diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..afed358 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# 에이전트 가이드 + +- 모든 대화는 한글로만 진행한다. diff --git a/Program copy.cs.bak b/Program copy.cs.bak deleted file mode 100644 index 7a34614..0000000 --- a/Program copy.cs.bak +++ /dev/null @@ -1,989 +0,0 @@ -// Sokoban map generator (mask-based, reverse search from solved state) -// - Uses small hand-made masks (Microban/Novoban 스타일) to shape the outer walls. -// - Places goals/boxes in solved state, then pulls boxes away from goals (reverse of push) to guarantee solvability. -// - Run: `dotnet run > output.json` (defaults use band config). Optional: `dotnet run [startId] [endId]`. -// Legend: '#' wall, '.' floor, '0' void, 'G' goal, '$' box, '@' player. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text.Json; - -internal static class Program -{ - private static readonly LevelBandConfig[] LevelBands = - { - new LevelBandConfig - { - StartId = 3, - EndId = 20, - BoxCountLow = 1, - BoxCountHigh = 2, - MinAllowedPushes = 4, - MinAllowedTurns = 2, - ShapeMasks = MaskLibrary.Microban.Take(8).ToList() - }, - new LevelBandConfig - { - StartId = 21, - EndId = 40, - BoxCountLow = 1, - BoxCountHigh = 2, - MinAllowedPushes = 7, - MinAllowedTurns = 3, - ShapeMasks = MaskLibrary.Microban.ToList() - }, - new LevelBandConfig - { - StartId = 41, - EndId = 60, - BoxCountLow = 2, - BoxCountHigh = 3, - MinAllowedPushes = 9, - MinAllowedTurns = 4, - ShapeMasks = MaskLibrary.Microban.ToList() - } - }; - - private static readonly GenerationTuning Tuning = new GenerationTuning - { - MaxAttemptsPerLevel = 2000, - MaxMillisecondsPerLevel = 40_000, - PushLimitPadding = 2, - PushLimitScale = 0.35, - DynamicGrowthWindow = 12, - DynamicPushIncrement = 2, - ReverseSearchMaxDepth = 120, - ReverseSearchBreadth = 800, - ApplyMaskTransforms = true, - MaskWallJitter = 2 - }; - - private const int DefaultSeed = 12345; - - public static void Main(string[] args) - { - var seed = DefaultSeed; - if (args.Length > 0 && int.TryParse(args[0], out var parsedSeed)) - { - seed = parsedSeed; - } - - var rng = new Random(seed); - var generator = new LevelGenerator(rng, Tuning, LevelBands); - - var startId = LevelBands.Min(b => b.StartId); - var endId = LevelBands.Max(b => b.EndId); - - if (args.Length >= 2 && int.TryParse(args[1], out var requestedStart)) - { - startId = requestedStart; - } - - if (args.Length >= 3 && int.TryParse(args[2], out var requestedEnd)) - { - endId = requestedEnd; - } - - if (startId > endId) - { - (startId, endId) = (endId, startId); - } - - var levels = generator.BuildRange(startId, endId); - var options = new JsonSerializerOptions - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - Console.WriteLine(JsonSerializer.Serialize(levels, options)); - } -} - -internal sealed class LevelGenerator -{ - private readonly Random _rng; - private readonly GenerationTuning _tuning; - private readonly IReadOnlyList _bands; - private readonly bool _trace; - private readonly HashSet _seenLayouts = new(); - - public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList bands) - { - _rng = rng; - _tuning = tuning; - _bands = bands; - _trace = Environment.GetEnvironmentVariable("NEKOBAN_DEBUG") == "1"; - } - - public List BuildRange(int startId, int endId) - { - foreach (var band in _bands) - { - if (band.ShapeMasksExpanded == null || band.ShapeMasksExpanded.Count == 0) - { - var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban; - band.ShapeMasksExpanded = MaskLibrary.ExpandWithTransforms(baseMasks); - } - } - - var output = new List(); - for (var id = startId; id <= endId; id++) - { - var band = ResolveBand(id); - var level = BuildSingle(id, band); - output.Add(level); - } - - return output; - } - - private GeneratedLevel BuildSingle(int id, LevelBandConfig band) - { - var stopwatch = Stopwatch.StartNew(); - var attempts = 0; - var failReasons = _trace ? new Dictionary() : null; - - while (attempts < _tuning.MaxAttemptsPerLevel && - stopwatch.ElapsedMilliseconds < _tuning.MaxMillisecondsPerLevel) - { - attempts++; - - var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban; - var prepped = band.ShapeMasksExpanded ?? MaskLibrary.ExpandWithTransforms(baseMasks); - band.ShapeMasksExpanded ??= prepped; - var mask = MaskLibrary.CreateVariant( - MaskLibrary.PickRandom(_rng, prepped), - _rng, - _tuning.ApplyMaskTransforms, - _tuning.MaskWallJitter); - - var canvas = LayoutFactory.FromMask(mask, _rng, _tuning); - - var boxCount = _rng.Next(band.BoxCountLow, band.BoxCountHigh + 1); - if (boxCount <= 0) - { - boxCount = 1; - } - - if (!PiecePlacer.TryPlace(canvas, _rng, boxCount, out var goals, out var boxes, out var player)) - { - NoteFail(failReasons, "place"); - continue; - } - - var board = Board.FromCanvas(canvas); - var reverse = new ReverseSearch(board, _tuning.ReverseSearchMaxDepth, _tuning.ReverseSearchBreadth); - var startState = reverse.FindStartState(goals, band.MinAllowedPushes, _rng); - if (startState == null) - { - NoteFail(failReasons, "reverse_not_found"); - continue; - } - - var solver = new SokobanSolver(board); - var solve = solver.SolveDetailed(startState.Player, startState.Boxes, goals); - if (solve.IsFail) - { - NoteFail(failReasons, "verify_unsolvable"); - continue; - } - - if (solve.Pushes < band.MinAllowedPushes || solve.Turns < band.MinAllowedTurns || solve.Branching < band.MinAllowedBranching) - { - NoteFail(failReasons, "too_easy"); - continue; - } - - var pushLimit = solve.Pushes + Math.Max(_tuning.PushLimitPadding, - (int)Math.Ceiling(solve.Pushes * _tuning.PushLimitScale)); - - canvas.ClearDynamic(); - canvas.Overlay(goals, 'G'); - canvas.Overlay(startState.Boxes, '$'); - canvas.Set(startState.Player, '@'); - - // Final wall normalization; reject if tokens end up on the edge. - if (!LayoutFactory.NormalizeOuterWallsStrict(canvas, failOnTokens: true)) - { - NoteFail(failReasons, "boundary"); - continue; - } - - var lines = canvas.ToLines(); - var layoutKey = string.Join("|", lines); - if (_seenLayouts.Contains(layoutKey)) - { - NoteFail(failReasons, "duplicate"); - continue; - } - _seenLayouts.Add(layoutKey); - - return new GeneratedLevel - { - Id = id, - Grid = lines, - LowestPush = solve.Pushes, - PushLimit = pushLimit - }; - } - - if (_trace && failReasons != null) - { - Console.Error.WriteLine($"[trace] level {id} failed. Reasons:"); - foreach (var kv in failReasons.OrderByDescending(kv => kv.Value)) - { - Console.Error.WriteLine($" - {kv.Key}: {kv.Value}"); - } - } - - throw new InvalidOperationException($"레벨 {id} 생성 실패 (attempts {attempts})."); - } - - private LevelBandConfig ResolveBand(int id) - { - foreach (var band in _bands) - { - if (id >= band.StartId && id <= band.EndId) - { - return band; - } - } - - var last = _bands.Last(); - var delta = id - last.EndId; - var growthStep = Math.Max(1, delta / Math.Max(1, _tuning.DynamicGrowthWindow) + 1); - return new LevelBandConfig - { - StartId = id, - EndId = id, - BoxCountLow = last.BoxCountLow + growthStep / 2, - BoxCountHigh = last.BoxCountHigh + growthStep / 2, - MinAllowedPushes = last.MinAllowedPushes + growthStep * _tuning.DynamicPushIncrement, - ShapeMasks = last.ShapeMasks - }; - } - - private static void NoteFail(IDictionary? sink, string reason) - { - if (sink == null) return; - sink[reason] = sink.TryGetValue(reason, out var count) ? count + 1 : 1; - } -} - -internal sealed class GeneratedLevel -{ - public int Id { get; init; } - public List Grid { get; init; } = new(); - public int LowestPush { get; init; } - public int PushLimit { get; init; } -} - -internal sealed class LevelBandConfig -{ - public int StartId { get; init; } - public int EndId { get; init; } - public int BoxCountLow { get; init; } - public int BoxCountHigh { get; init; } - public int MinAllowedPushes { get; init; } - public int MinAllowedTurns { get; init; } = 0; - public int MinAllowedBranching { get; init; } = 0; - public List ShapeMasks { get; init; } = new(); - public List? ShapeMasksExpanded { get; set; } -} - -internal sealed class GenerationTuning -{ - public int MaxAttemptsPerLevel { get; init; } - public int MaxMillisecondsPerLevel { get; init; } - public int PushLimitPadding { get; init; } - public double PushLimitScale { get; init; } - public int DynamicGrowthWindow { get; init; } - public int DynamicPushIncrement { get; init; } - public int ReverseSearchMaxDepth { get; init; } - public int ReverseSearchBreadth { get; init; } - public bool ApplyMaskTransforms { get; init; } - public int MaskWallJitter { get; init; } - public int PocketCarveMin { get; init; } = 0; - public int PocketCarveMax { get; init; } = 0; - public int PocketMaxRadius { get; init; } = 1; -} - -internal sealed class GridCanvas -{ - private readonly char[] _cells; - - public GridCanvas(int width, int height) - { - Width = width; - Height = height; - _cells = Enumerable.Repeat('0', width * height).ToArray(); - } - - public int Width { get; } - public int Height { get; } - - public void Fill(char c) => Array.Fill(_cells, c); - - public void Set(int x, int y, char c) - { - if (!InBounds(x, y)) return; - _cells[y * Width + x] = c; - } - - public void Set(int index, char c) - { - if (index < 0 || index >= _cells.Length) return; - _cells[index] = c; - } - - public char Get(int x, int y) => _cells[y * Width + x]; - public char Get(int index) => _cells[index]; - - public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height; - - public void Overlay(IEnumerable indices, char c) - { - foreach (var idx in indices) - { - Set(idx, c); - } - } - - public void ClearDynamic() - { - for (var i = 0; i < _cells.Length; i++) - { - if (_cells[i] == '@' || _cells[i] == '$') - { - _cells[i] = '.'; - } - } - } - - public List ToLines() - { - var lines = new List(Height); - for (var y = 0; y < Height; y++) - { - lines.Add(new string(_cells, y * Width, Width)); - } - return lines; - } -} - -internal static class LayoutFactory -{ - public static GridCanvas FromMask(string[] mask, Random rng, GenerationTuning tuning) - { - var height = mask.Length; - var width = mask.Max(row => row.Length); - var canvas = new GridCanvas(width, height); - canvas.Fill('0'); - - for (var y = 0; y < height; y++) - { - var row = mask[y]; - for (var x = 0; x < row.Length; x++) - { - canvas.Set(x, y, row[x]); - } - } - - NormalizeOuterWalls(canvas); - - if (tuning.PocketCarveMax > 0) - { - AddPockets(canvas, rng, tuning); - NormalizeOuterWalls(canvas); - } - - return canvas; - } - - // Enforce: inside the bounding box of non-void tiles, edges are walls, interior voids become walls. - private static void NormalizeOuterWalls(GridCanvas canvas) - { - var w = canvas.Width; - var h = canvas.Height; - - var minX = w; - var minY = h; - var maxX = -1; - var maxY = -1; - - for (var y = 0; y < h; y++) - { - for (var x = 0; x < w; x++) - { - var c = canvas.Get(x, y); - if (c == '0') continue; - if (x < minX) minX = x; - if (y < minY) minY = y; - if (x > maxX) maxX = x; - if (y > maxY) maxY = y; - } - } - - if (maxX < minX || maxY < minY) - { - return; // nothing to normalize - } - - for (var y = minY; y <= maxY; y++) - { - for (var x = minX; x <= maxX; x++) - { - var isEdge = x == minX || x == maxX || y == minY || y == maxY; - if (isEdge) - { - canvas.Set(x, y, '#'); - } - else if (canvas.Get(x, y) == '0') - { - canvas.Set(x, y, '#'); - } - } - } - - // Outside bounding box remains void. - } - - public static bool NormalizeOuterWallsStrict(GridCanvas canvas, bool failOnTokens) - { - var w = canvas.Width; - var h = canvas.Height; - - var minX = w; - var minY = h; - var maxX = -1; - var maxY = -1; - - for (var y = 0; y < h; y++) - { - for (var x = 0; x < w; x++) - { - var c = canvas.Get(x, y); - if (c == '0') continue; - if (x < minX) minX = x; - if (y < minY) minY = y; - if (x > maxX) maxX = x; - if (y > maxY) maxY = y; - } - } - - if (maxX < minX || maxY < minY) - { - return false; - } - - for (var y = minY; y <= maxY; y++) - { - for (var x = minX; x <= maxX; x++) - { - var isEdge = x == minX || x == maxX || y == minY || y == maxY; - var c = canvas.Get(x, y); - if (isEdge) - { - if (failOnTokens && (c == 'G' || c == '$' || c == '@')) - { - return false; - } - canvas.Set(x, y, '#'); - } - else if (c == '0') - { - canvas.Set(x, y, '#'); - } - } - } - - return true; - } - - private static void AddPockets(GridCanvas canvas, Random rng, GenerationTuning tuning) - { - var pockets = rng.Next(tuning.PocketCarveMin, tuning.PocketCarveMax + 1); - for (var i = 0; i < pockets; i++) - { - var radius = rng.Next(1, tuning.PocketMaxRadius + 1); - var x = rng.Next(1, canvas.Width - 1); - var y = rng.Next(1, canvas.Height - 1); - for (var yy = y - radius; yy <= y + radius; yy++) - { - for (var xx = x - radius; xx <= x + radius; xx++) - { - if (!canvas.InBounds(xx, yy)) continue; - if (xx == 0 || yy == 0 || xx == canvas.Width - 1 || yy == canvas.Height - 1) continue; - var dx = xx - x; - var dy = yy - y; - if (dx * dx + dy * dy <= radius * radius) - { - if (canvas.Get(xx, yy) == '#') - { - canvas.Set(xx, yy, '.'); - } - } - } - } - } - } -} - -internal static class PiecePlacer -{ - public static bool TryPlace(GridCanvas canvas, Random rng, int boxCount, out HashSet goals, out int[] boxes, out int player) - { - goals = new HashSet(); - boxes = Array.Empty(); - player = -1; - - var candidates = Walkable(canvas).ToList(); - var minRequired = boxCount + 2; - if (candidates.Count < minRequired) - { - return false; - } - - Shuffle(candidates, rng); - - var softGoals = new List(); - foreach (var idx in candidates) - { - if (IsDeadCorner(canvas, idx)) - { - softGoals.Add(idx); - continue; - } - goals.Add(idx); - if (goals.Count == boxCount) break; - } - - if (goals.Count != boxCount) - { - foreach (var idx in softGoals) - { - goals.Add(idx); - if (goals.Count == boxCount) break; - } - } - - if (goals.Count != boxCount) return false; - - var goalSet = new HashSet(goals); - var nonGoals = candidates.Where(i => !goalSet.Contains(i)).ToList(); - Shuffle(nonGoals, rng); - - boxes = goals.ToArray(); - Array.Sort(boxes); - - if (nonGoals.Count == 0) return false; - - var reachable = FloodFrom(goalSet.First(), canvas, Array.Empty()); - if (goalSet.Any(g => !reachable.Contains(g))) return false; - - var playerOptions = nonGoals.Where(reachable.Contains).ToList(); - if (playerOptions.Count == 0) return false; - - player = playerOptions[rng.Next(playerOptions.Count)]; - return true; - } - - private static HashSet FloodFrom(int start, GridCanvas canvas, int[] boxes) - { - var visited = new HashSet(); - if (IsSolid(canvas.Get(start)) || Array.BinarySearch(boxes, start) >= 0) return visited; - - var queue = new Queue(); - visited.Add(start); - queue.Enqueue(start); - - while (queue.Count > 0) - { - var current = queue.Dequeue(); - foreach (var next in NeighborIndices(current, canvas.Width, canvas.Height)) - { - if (next < 0 || next >= canvas.Width * canvas.Height) continue; - if (visited.Contains(next)) continue; - - var tile = canvas.Get(next); - if (IsSolid(tile) || Array.BinarySearch(boxes, next) >= 0) continue; - - visited.Add(next); - queue.Enqueue(next); - } - } - - return visited; - } - - private static IEnumerable Walkable(GridCanvas canvas) - { - for (var i = 0; i < canvas.Width * canvas.Height; i++) - { - var tile = canvas.Get(i); - if (!IsSolid(tile)) - { - yield return i; - } - } - } - - private static IEnumerable NeighborIndices(int index, int width, int height) - { - var x = index % width; - var y = index / width; - - if (y > 0) yield return index - width; - if (y < height - 1) yield return index + width; - if (x > 0) yield return index - 1; - if (x < width - 1) yield return index + 1; - } - - private static bool IsDeadCorner(GridCanvas canvas, int idx) - { - var w = canvas.Width; - var h = canvas.Height; - var x = idx % w; - var y = idx / w; - - var solidNorth = y == 0 || IsSolid(canvas.Get(x, y - 1)); - var solidSouth = y == h - 1 || IsSolid(canvas.Get(x, y + 1)); - var solidWest = x == 0 || IsSolid(canvas.Get(x - 1, y)); - var solidEast = x == w - 1 || IsSolid(canvas.Get(x + 1, y)); - - var verticalBlocked = (solidNorth && solidSouth); - var horizontalBlocked = (solidWest && solidEast); - if (verticalBlocked || horizontalBlocked) return true; - - var cornerNW = solidNorth && solidWest; - var cornerNE = solidNorth && solidEast; - var cornerSW = solidSouth && solidWest; - var cornerSE = solidSouth && solidEast; - return cornerNW || cornerNE || cornerSW || cornerSE; - } - - private static bool IsSolid(char tile) => tile == '#' || tile == '0'; - - private static void Shuffle(IList list, Random rng) - { - for (var i = list.Count - 1; i > 0; i--) - { - var j = rng.Next(i + 1); - (list[i], list[j]) = (list[j], list[i]); - } - } -} - -internal sealed class Board -{ - private readonly bool[] _walls; - - private Board(int width, int height, bool[] walls) - { - Width = width; - Height = height; - _walls = walls; - } - - public int Width { get; } - public int Height { get; } - public int Size => Width * Height; - - public static Board FromCanvas(GridCanvas canvas) - { - var walls = new bool[canvas.Width * canvas.Height]; - for (var i = 0; i < walls.Length; i++) - { - var tile = canvas.Get(i); - walls[i] = tile == '#' || tile == '0'; - } - - return new Board(canvas.Width, canvas.Height, walls); - } - - public bool IsSolid(int index) => _walls[index]; -} - -internal sealed class ReverseSearch -{ - private readonly Board _board; - private readonly int _maxDepth; - private readonly int _breadth; - private readonly int[] _dirs; - - public ReverseSearch(Board board, int maxDepth, int breadth) - { - _board = board; - _maxDepth = maxDepth; - _breadth = breadth; - _dirs = new[] { -board.Width, board.Width, -1, 1 }; - } - - public ScrambledState? FindStartState(HashSet goals, int minPush, Random rng) - { - var boxes = goals.ToArray(); - Array.Sort(boxes); - - var playerStart = FirstFloorNotGoal(goals); - if (playerStart < 0) return null; - - var start = new SolverState(playerStart, boxes); - var queue = new Queue<(SolverState State, int Pushes)>(); - var visited = new HashSet(new StateComparer()) { start }; - queue.Enqueue((start, 0)); - - ScrambledState? best = null; - var bestPush = -1; - - while (queue.Count > 0 && visited.Count < _breadth) - { - var (state, pushes) = queue.Dequeue(); - if (pushes >= minPush && pushes > bestPush) - { - bestPush = pushes; - best = new ScrambledState(state.Player, state.Boxes); - } - - if (pushes >= _maxDepth) continue; - - foreach (var next in Expand(state)) - { - var nextPushes = pushes + (next.Item2 ? 1 : 0); - if (nextPushes > _maxDepth) continue; - - var nextState = next.Item1; - if (visited.Add(nextState)) - { - queue.Enqueue((nextState, nextPushes)); - } - } - } - - return best; - } - - private int FirstFloorNotGoal(HashSet goals) - { - for (var i = 0; i < _board.Size; i++) - { - if (_board.IsSolid(i)) continue; - if (goals.Contains(i)) continue; - return i; - } - return -1; - } - - // Returns (state, isPull) where isPull indicates a box move (counts as push) - private IEnumerable<(SolverState, bool)> Expand(SolverState state) - { - foreach (var dir in _dirs) - { - var step = state.Player + dir; - if (!IndexInBounds(step) || _board.IsSolid(step) || Array.BinarySearch(state.Boxes, step) >= 0) - { - // pull candidate? - var boxPos = state.Player + dir; - var behind = state.Player - dir; - if (!IndexInBounds(boxPos) || !IndexInBounds(behind)) continue; - var boxIndex = Array.BinarySearch(state.Boxes, boxPos); - if (boxIndex < 0) continue; - if (_board.IsSolid(behind) || Array.BinarySearch(state.Boxes, behind) >= 0) continue; - - var nextBoxes = state.Boxes.ToArray(); - nextBoxes[boxIndex] = state.Player; - Array.Sort(nextBoxes); - yield return (new SolverState(behind, nextBoxes), true); - continue; - } - - // player walk - yield return (new SolverState(step, state.Boxes), false); - } - } - - private bool IndexInBounds(int idx) => idx >= 0 && idx < _board.Size; -} - -internal sealed class SokobanSolver -{ - private readonly Board _board; - private readonly StateComparer _comparer = new(); - private readonly int[] _dirs; - - public SokobanSolver(Board board) - { - _board = board; - _dirs = new[] { -board.Width, board.Width, -1, 1 }; - } - - public SolveResult SolveDetailed(int player, int[] boxes, HashSet goals) - { - Array.Sort(boxes); - var start = new SolverState(player, boxes); - var visited = new HashSet(_comparer) { start }; - var parents = new Dictionary(_comparer); - var queue = new Queue(); - queue.Enqueue(start); - - while (queue.Count > 0) - { - var state = queue.Dequeue(); - if (IsSolved(state.Boxes, goals)) - { - return BuildResult(state, parents); - } - - foreach (var (next, dir) in Expand(state)) - { - if (visited.Add(next)) - { - parents[next] = (state, dir); - queue.Enqueue(next); - } - } - } - - return SolveResult.Fail; - } - - private IEnumerable<(SolverState State, int Dir)> Expand(SolverState state) - { - var reachable = ReachableWithoutPushing(state.Player, state.Boxes); - foreach (var pos in reachable) - { - foreach (var dir in _dirs) - { - var boxPos = pos + dir; - var landing = boxPos + dir; - if (!IndexInBounds(boxPos) || !IndexInBounds(landing)) continue; - - var boxIndex = Array.BinarySearch(state.Boxes, boxPos); - if (boxIndex < 0) continue; - if (_board.IsSolid(landing) || Array.BinarySearch(state.Boxes, landing) >= 0) continue; - - var nextBoxes = state.Boxes.ToArray(); - nextBoxes[boxIndex] = landing; - Array.Sort(nextBoxes); - yield return (new SolverState(boxPos, nextBoxes), dir); - } - } - } - - private SolveResult BuildResult(SolverState solved, Dictionary parents) - { - var pushes = 0; - var turns = 0; - var branching = 0; - - var path = new List<(SolverState State, int Dir)>(); - var current = solved; - while (parents.TryGetValue(current, out var entry)) - { - path.Add((current, entry.Dir)); - current = entry.Parent; - } - path.Reverse(); - - var prevDir = 0; - foreach (var (_, dir) in path) - { - pushes++; - if (prevDir != 0 && dir != prevDir) turns++; - prevDir = dir; - } - - // branching: count states along path that had more than one push option - current = solved; - while (parents.TryGetValue(current, out var entry2)) - { - var pushOptions = Expand(entry2.Parent).Count(); - if (pushOptions > 1) branching++; - current = entry2.Parent; - } - - return new SolveResult(pushes, turns, branching); - } - - private List ReachableWithoutPushing(int start, int[] boxes) - { - var visited = new bool[_board.Size]; - var queue = new Queue(); - var output = new List(); - - if (_board.IsSolid(start) || Array.BinarySearch(boxes, start) >= 0) return output; - - visited[start] = true; - queue.Enqueue(start); - output.Add(start); - - while (queue.Count > 0) - { - var current = queue.Dequeue(); - foreach (var dir in _dirs) - { - var next = current + dir; - if (!IndexInBounds(next)) continue; - if (visited[next]) continue; - if (_board.IsSolid(next) || Array.BinarySearch(boxes, next) >= 0) continue; - visited[next] = true; - queue.Enqueue(next); - output.Add(next); - } - } - - return output; - } - - private bool IsSolved(int[] boxes, HashSet goals) - { - foreach (var b in boxes) - { - if (!goals.Contains(b)) return false; - } - return true; - } - - private bool IndexInBounds(int idx) => idx >= 0 && idx < _board.Size; -} - -internal readonly record struct SolverState(int Player, int[] Boxes); - -internal readonly record struct SolveResult(int Pushes, int Turns, int Branching) -{ - public static SolveResult Fail => new(-1, -1, -1); - public bool IsFail => Pushes < 0; -} - -internal sealed class StateComparer : IEqualityComparer -{ - public bool Equals(SolverState x, SolverState y) - { - if (x.Player != y.Player) return false; - var a = x.Boxes; - var b = y.Boxes; - if (a.Length != b.Length) return false; - for (var i = 0; i < a.Length; i++) - { - if (a[i] != b[i]) return false; - } - return true; - } - - public int GetHashCode(SolverState obj) - { - var hash = obj.Player * 397 ^ obj.Boxes.Length; - unchecked - { - for (var i = 0; i < obj.Boxes.Length; i++) - { - hash = hash * 31 + obj.Boxes[i]; - } - } - return hash; - } -} - -internal sealed record ScrambledState(int Player, int[] Boxes); diff --git a/Program.cs b/Program.cs index 15f27e9..62f4361 100644 --- a/Program.cs +++ b/Program.cs @@ -161,8 +161,9 @@ internal static class Program var rng = new Random(seed); var levelBands = LoadLevelBands(); + var totalWatch = Stopwatch.StartNew(); var status = new StatusReporter(); - var generator = new LevelGenerator(rng, Tuning, levelBands, status); + var generator = new LevelGenerator(rng, Tuning, levelBands, status, totalWatch); var startId = levelBands.Min(b => b.StartId); var endId = levelBands.Max(b => b.EndId); @@ -208,7 +209,6 @@ internal static class Program } } - var totalWatch = Stopwatch.StartNew(); var levels = generator.BuildRange(startId, endId, SaveCheckpoint); totalWatch.Stop(); @@ -458,13 +458,15 @@ internal sealed class LevelGenerator private readonly HashSet _seenLayouts = new(); private readonly HashSet _seenPatterns = new(); private readonly StatusReporter _status; + private readonly Stopwatch _totalWatch; - public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList bands, StatusReporter status) + public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList bands, StatusReporter status, Stopwatch totalWatch) { _rng = rng; _tuning = tuning; _bands = bands; _status = status; + _totalWatch = totalWatch; _trace = Environment.GetEnvironmentVariable("NEKOBAN_DEBUG") == "1"; } @@ -483,7 +485,8 @@ internal sealed class LevelGenerator for (var id = startId; id <= endId; id++) { var band = ResolveBand(id); - var level = BuildSingle(id, band); + var levelStopwatch = Stopwatch.StartNew(); + var level = BuildSingle(id, band, levelStopwatch); level = TrimLevel(level); output.Add(level); onCheckpoint?.Invoke(output, id); @@ -502,7 +505,7 @@ internal sealed class LevelGenerator return new PocketSettings(min, max, radius); } - private GeneratedLevel BuildSingle(int id, LevelBandConfig band) + private GeneratedLevel BuildSingle(int id, LevelBandConfig band, Stopwatch levelStopwatch) { // 완화 단계를 순차적으로 적용한다. (총 5단계 + 비상 1단계) var stages = new List @@ -546,26 +549,7 @@ internal sealed class LevelGenerator c.MinBoxDistance = Math.Max(1, c.MinBoxDistance - 1); c.MinWallDistance = Math.Max(0, c.MinWallDistance - 1); return c; - }), - new RelaxStage("완화6-최종", bandConfig => - { - var c = CloneBand(bandConfig); - c.BoxCountLow = 1; - c.BoxCountHigh = Math.Max(1, c.BoxCountHigh - 2); - c.MinAllowedPushes = 1; - c.MinAllowedTurns = 0; - c.MinAllowedBranching = 0; - c.MinGoalDistance = Math.Max(1, c.MinGoalDistance - 2); - c.MinBoxDistance = Math.Max(1, c.MinBoxDistance - 2); - c.MinWallDistance = 0; - c.ReverseDepthScale = Math.Max(1.0, c.ReverseDepthScale * 2.8); - c.ReverseBreadthScale = Math.Max(1.0, c.ReverseBreadthScale * 2.8); - c.MaskPadMin = -2; - c.MaskPadMax = -2; - c.ShapeMasks = MaskLibrary.Medium.Concat(MaskLibrary.Microban).ToList(); - c.ShapeMasksExpanded = null; - return c; - }, OverrideRelaxSteps: 6) + }) }; // 밴드 강등 시도: 현재 밴드 -> 직전 10레벨 밴드 -> 직전 20레벨 밴드 @@ -584,10 +568,10 @@ internal sealed class LevelGenerator } // 시드 변조 재시도 + 완화 단계 루프 - const int seedRetries = 5; - foreach (var bandCandidate in bandCandidates) + var retry = 0; + while (true) { - for (var retry = 0; retry < seedRetries; retry++) + foreach (var bandCandidate in bandCandidates) { var jitterSeed = _rng.Next() ^ (id * 7919) ^ (retry * 104729); var localRng = new Random(jitterSeed); @@ -596,18 +580,17 @@ internal sealed class LevelGenerator { var bandForStage = stage.Adjust(bandCandidate); var relaxOverride = stage.OverrideRelaxSteps; - if (TryBuildStage(id, bandForStage, stage.Label, localRng, relaxOverride, out var level)) + if (TryBuildStage(id, bandForStage, stage.Label, localRng, relaxOverride, levelStopwatch, out var level)) { return level; } } } + retry++; } - - throw new InvalidOperationException($"레벨 {id} 생성 실패 (모든 완화 단계 시도됨)"); } - private bool TryBuildStage(int id, LevelBandConfig band, string stageLabel, Random rng, int? overrideRelaxSteps, out GeneratedLevel level) + private bool TryBuildStage(int id, LevelBandConfig band, string stageLabel, Random rng, int? overrideRelaxSteps, Stopwatch levelStopwatch, out GeneratedLevel level) { var failReasons = _trace ? new Dictionary() : null; var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban; @@ -631,16 +614,16 @@ internal sealed class LevelGenerator var reverseDepth = Math.Max(16, (int)Math.Round(_tuning.ReverseSearchMaxDepth * depthScale)); var reverseBreadth = Math.Max(200, (int)Math.Round(_tuning.ReverseSearchBreadth * breadthScale)); - var stopwatch = Stopwatch.StartNew(); + var stageStopwatch = Stopwatch.StartNew(); var attempts = 0; while (attempts < attemptsLimit && - stopwatch.ElapsedMilliseconds < timeLimit) + stageStopwatch.ElapsedMilliseconds < timeLimit) { attempts++; // 진행 상태가 살아 있음을 보여주기 위해, 1초 단위 점(.) 애니메이션을 출력한다. // 예) "레벨 214 [기본] 생성중.", "..", "..." 식으로 순환. - _status.Show($"레벨 {id} [{stageLabel}] 생성중", withDots: true); + _status.ShowProgress($"레벨 {id} [{stageLabel}] 생성중", levelStopwatch.Elapsed, _totalWatch.Elapsed); var mask = MaskLibrary.CreateVariant( MaskLibrary.PickRandom(rng, band.ShapeMasksExpanded), rng, @@ -734,7 +717,7 @@ internal sealed class LevelGenerator _seenPatterns.Add(patternKey); } - _status.Show($"레벨 {id} [{stageLabel}] 생성완료 ({StatusReporter.FormatDuration(stopwatch.Elapsed)})"); + _status.Show($"레벨 {id} [{stageLabel}] 생성완료 ({StatusReporter.FormatDuration(levelStopwatch.Elapsed)})"); level = new GeneratedLevel { Id = id, diff --git a/README.md b/README.md index dc37afd..ea0688e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ dotnet run -- --trim <입력 json> [출력 json] [startId] [endId] - `startId/endId`가 없으면 `levelbalance.json`에 정의된 밴드 범위를 사용합니다. - 검증 모드에서는 지정한 JSON의 레벨을 솔버로 풀어보고, 성공/실패와 함께 `moves/ pushes/ turns`를 리포트합니다. - 트림 모드는 지정한 범위(id)만 외곽의 '0' 패딩을 걷어내서 저장합니다(기본 출력 경로: `trimmed_output.json`). -- 진행 상태는 stderr에 한 줄로 갱신됩니다(벽 생성중/박스 배치중/검증중/생성완료). +- 생성 중 매 레벨 단위로 누적 체크포인트 JSON을 `stage_checkpoint.json`에 기록하며, 경로는 `NEKOBAN_CHECKPOINT` 환경변수로 바꿀 수 있습니다. +- 진행 상태는 stderr에 한 줄로 갱신되며 점(.) 애니메이션과 현재 레벨/전체 경과 시간이 함께 표시됩니다. ## 설정 - `levelbalance.json`: 레벨 밴드 설정(레벨 범위, 박스 수, 최소 푸시/턴/브랜칭, 마스크 세트/확장 범위, 역탐색 깊이/폭 스케일, 목표/박스 간 최소 거리, 주머니(포켓) 리라이팅). `maskSets`(micro/medium/large), `maskPadMin/max`, `reverseDepthScale/breadthScale`, `minAllowedBranching`, `minGoal/Box/WallDistance`, `pocketCarveMin/Max/Radius`를 밴드별로 조정해 난이도·길이감을 설계합니다. @@ -60,3 +61,4 @@ dotnet run -- --trim <입력 json> [출력 json] [startId] [endId] - 중복 레이아웃은 제거됩니다. - 해법 패턴(푸시/턴/방향 런 요약) 해시를 이용해 유사 해법 레벨도 추가로 걸러냅니다. - 더 느리게 돌려야 하면 `GenerationTuning`의 시도/시간 한도를 조정하세요. +- 밴드 강등/시드 재시도는 성공할 때까지 반복되므로, 오래 걸릴 때는 진행 메시지의 경과 시간을 참고해 중단 여부를 판단하세요. diff --git a/StatusReporter.cs b/StatusReporter.cs index 8ce2551..383cdfc 100644 --- a/StatusReporter.cs +++ b/StatusReporter.cs @@ -29,6 +29,13 @@ internal sealed class StatusReporter Write(message, newline: true); } + public void ShowProgress(string message, TimeSpan levelElapsed, TimeSpan totalElapsed) + { + _dotCounter++; + var progress = $"{message}{Dots()}({FormatDuration(levelElapsed)} / {FormatDuration(totalElapsed)})"; + Write(progress, newline: false); + } + private void Write(string message, bool newline) { if (_lastMessage == message && !newline) return; @@ -56,9 +63,13 @@ internal sealed class StatusReporter public static string FormatDuration(TimeSpan elapsed) { + if (elapsed.TotalHours >= 1) + { + return $"{(int)elapsed.TotalHours}h{elapsed.Minutes}m{elapsed.Seconds}s"; + } if (elapsed.TotalMinutes >= 1) { - return $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"; + return $"{(int)elapsed.TotalMinutes}m{elapsed.Seconds}s"; } return $"{elapsed.Seconds}s"; } diff --git a/stage.json b/stage.json deleted file mode 100644 index a7b5881..0000000 --- a/stage.json +++ /dev/null @@ -1,277 +0,0 @@ -[ - { - "id": 3, - "grid": [ - "00000000000", - "00000000000", - "00#######00", - "00##..###00", - "00#...$@#00", - "00#.....#00", - "00#...G.#00", - "00###.###00", - "00#######00", - "00000000000", - "00000000000" - ], - "lowestPush": 4, - "pushLimit": 6 - }, - { - "id": 4, - "grid": [ - "######0", - "#...##0", - "#...##0", - "#G$..#0", - "#.G$.#0", - "#.#@##0", - "######0" - ], - "lowestPush": 4, - "pushLimit": 6 - }, - { - "id": 5, - "grid": [ - "000000000", - "0#######0", - "0##...##0", - "0#@$..##0", - "0#.G...#0", - "0##$..##0", - "0##.G.##0", - "0#######0", - "000000000" - ], - "lowestPush": 5, - "pushLimit": 7 - }, - { - "id": 6, - "grid": [ - "######0", - "#...##0", - "#$.G##0", - "#G.$@#0", - "#..###0", - "#...##0", - "######0" - ], - "lowestPush": 6, - "pushLimit": 9 - }, - { - "id": 7, - "grid": [ - "000000000", - "0#######0", - "0###..##0", - "0#@$...#0", - "0#G....#0", - "0#.....#0", - "0###.###0", - "0#######0", - "000000000" - ], - "lowestPush": 4, - "pushLimit": 6 - }, - { - "id": 8, - "grid": [ - "######0", - "#...##0", - "#..$@#0", - "#.G..#0", - "#..$##0", - "#....#0", - "######0" - ], - "lowestPush": 4, - "pushLimit": 6 - }, - { - "id": 9, - "grid": [ - "######0", - "#..@##0", - "#.#$##0", - "#....#0", - "#....#0", - "#.G.##0", - "######0" - ], - "lowestPush": 4, - "pushLimit": 6 - }, - { - "id": 10, - "grid": [ - "#######", - "#@$GG.#", - "#.$...#", - "#.....#", - "#######", - "#######", - "0000000" - ], - "lowestPush": 4, - "pushLimit": 6 - }, - { - "id": 11, - "grid": [ - "######0", - "#..@##0", - "##$$##0", - "#.G..#0", - "#G...#0", - "#...##0", - "######0" - ], - "lowestPush": 5, - "pushLimit": 7 - }, - { - "id": 12, - "grid": [ - "0000000", - "0#####0", - "0#...#0", - "0#.$.#0", - "0#G.$#0", - "0#.#@#0", - "0#####0", - "0000000" - ], - "lowestPush": 4, - "pushLimit": 6 - }, - { - "id": 13, - "grid": [ - "######0", - "#...##0", - "#..###0", - "#..$@#0", - "#$.G##0", - "#...##0", - "######0" - ], - "lowestPush": 7, - "pushLimit": 10 - }, - { - "id": 14, - "grid": [ - "0######", - "0##...#", - "0##.$@#", - "0#.G..#", - "0##G$##", - "0##...#", - "0######" - ], - "lowestPush": 5, - "pushLimit": 7 - }, - { - "id": 15, - "grid": [ - "00000000000", - "00000000000", - "00#######00", - "00##...##00", - "00##.$.##00", - "00#@$.G.#00", - "00##..G##00", - "00##...##00", - "00#######00", - "00000000000", - "00000000000" - ], - "lowestPush": 5, - "pushLimit": 7 - }, - { - "id": 16, - "grid": [ - "0######", - "0##.G.#", - "0##$.G#", - "0#....#", - "0#@$.##", - "0##...#", - "0######" - ], - "lowestPush": 8, - "pushLimit": 11 - }, - { - "id": 17, - "grid": [ - "0######", - "0##...#", - "0#...##", - "0#....#", - "0##$.G#", - "0##@..#", - "0######" - ], - "lowestPush": 4, - "pushLimit": 6 - }, - { - "id": 18, - "grid": [ - "00000000000", - "00000000000", - "00#######00", - "00##...##00", - "00##G.$##00", - "00#.G...#00", - "00#...$.#00", - "00##..@##00", - "00#######00", - "00000000000", - "00000000000" - ], - "lowestPush": 7, - "pushLimit": 10 - }, - { - "id": 19, - "grid": [ - "000000000", - "0#######0", - "0##...##0", - "0##G..##0", - "0#.G.$@#0", - "0##.$.##0", - "0##...##0", - "0#######0", - "000000000" - ], - "lowestPush": 5, - "pushLimit": 7 - }, - { - "id": 20, - "grid": [ - "00000000000", - "00000000000", - "00#######00", - "00###@###00", - "00#..$..#00", - "00#...$.#00", - "00#.G.G.#00", - "00###.###00", - "00#######00", - "00000000000", - "00000000000" - ], - "lowestPush": 4, - "pushLimit": 6 - } -] diff --git a/stage_231_240.json b/stage_231_240.json new file mode 100644 index 0000000..ba1f00c --- /dev/null +++ b/stage_231_240.json @@ -0,0 +1,156 @@ +[ + { + "id": 231, + "grid": [ + "#########", + "#.@....##", + "#.$.....#", + "#.......#", + "#....G..#", + "#.#...#.#", + "#...#...#", + "##.#..###", + "###...###", + "#########" + ], + "lowestPush": 5, + "pushLimit": 7 + }, + { + "id": 232, + "grid": [ + "########", + "###..###", + "#.#..###", + "#.@....#", + "#.$.##.#", + "#....#.#", + "#.G##..#", + "#......#", + "###..###", + "###..###", + "########" + ], + "lowestPush": 2, + "pushLimit": 4 + }, + { + "id": 233, + "grid": [ + "#######", + "#.@..##", + "##$$#.#", + "#.G$..#", + "#...$G#", + "#.G...#", + "#######" + ], + "lowestPush": 26, + "pushLimit": 36 + }, + { + "id": 234, + "grid": [ + "##########", + "###@###.##", + "##.$#....#", + "##G.#.$.##", + "#.$.....##", + "#.G$######", + "#...#...##", + "##..#...##", + "##..#...##", + "###.######", + "##########" + ], + "lowestPush": 25, + "pushLimit": 34 + }, + { + "id": 235, + "grid": [ + "###########", + "####...#..#", + "##.$G#.$$.#", + "#.......$@#", + "#.G.#######", + "##G.#.#..##", + "##.#...#.##", + "##..#.#..##", + "###########", + "###########" + ], + "lowestPush": 28, + "pushLimit": 38 + }, + { + "id": 236, + "grid": [ + "########", + "####...#", + "##@$.GG#", + "##.$$..#", + "##.#.$G#", + "#..#...#", + "########" + ], + "lowestPush": 27, + "pushLimit": 37 + }, + { + "id": 237, + "grid": [ + "######", + "#.#..#", + "#@$.G#", + "#.#..#", + "######" + ], + "lowestPush": 2, + "pushLimit": 4 + }, + { + "id": 238, + "grid": [ + "#######", + "#.G...#", + "#.G$$G#", + "#...$.#", + "#.$#$G#", + "##@#..#", + "#######" + ], + "lowestPush": 25, + "pushLimit": 34 + }, + { + "id": 239, + "grid": [ + "###########", + "##.....####", + "##.$.#G$.##", + "##...G$G..#", + "#.##.$@#..#", + "##.####...#", + "##........#", + "##...#$...#", + "##...#....#", + "##.#####..#", + "###########" + ], + "lowestPush": 25, + "pushLimit": 34 + }, + { + "id": 240, + "grid": [ + "######", + "#.G..#", + "#..$@#", + "#....#", + "######" + ], + "lowestPush": 2, + "pushLimit": 4 + } +] \ No newline at end of file diff --git a/stage_241.json b/stage_241.json new file mode 100644 index 0000000..11c7f6a --- /dev/null +++ b/stage_241.json @@ -0,0 +1,19 @@ +[ + { + "id": 241, + "grid": [ + "##########", + "#####..###", + "###....@.#", + "#.#.###$.#", + "#...#...G#", + "#G$.#.$..#", + "#.#.###..#", + "#.#.....##", + "###.#..###", + "##########" + ], + "lowestPush": 7, + "pushLimit": 10 + } +] \ No newline at end of file diff --git a/stage_242_250.json b/stage_242_250.json new file mode 100644 index 0000000..eb14355 --- /dev/null +++ b/stage_242_250.json @@ -0,0 +1,133 @@ +[ + { + "id": 242, + "grid": [ + "########", + "#..#.G.#", + "#..#.$.#", + "##.$$..#", + "##@$G.G#", + "####...#", + "########" + ], + "lowestPush": 26, + "pushLimit": 36 + }, + { + "id": 243, + "grid": [ + "######", + "#....#", + "#@$.$#", + "###$G#", + "###..#", + "######" + ], + "lowestPush": 1, + "pushLimit": 3 + }, + { + "id": 244, + "grid": [ + "########", + "##.....#", + "#@$.G..#", + "####.$G#", + "##.$$$.#", + "##.G.G.#", + "########" + ], + "lowestPush": 27, + "pushLimit": 37 + }, + { + "id": 245, + "grid": [ + "########", + "#....###", + "#...G###", + "#.$$.#.#", + "#.@###.#", + "#.#..#.#", + "#..###.#", + "#......#", + "###...##", + "###..###", + "########" + ], + "lowestPush": 3, + "pushLimit": 5 + }, + { + "id": 246, + "grid": [ + "##########", + "##.......#", + "##.#.....#", + "#.G#..#..#", + "#..#..#..#", + "##$####.##", + "##@.....##", + "####..####", + "###.....##", + "##########" + ], + "lowestPush": 2, + "pushLimit": 4 + }, + { + "id": 247, + "grid": [ + "#######", + "###@..#", + "##.$..#", + "##..$##", + "#.G$G.#", + "#..$..#", + "##..###", + "#######" + ], + "lowestPush": 26, + "pushLimit": 36 + }, + { + "id": 248, + "grid": [ + "#####", + "#..@#", + "#G$$#", + "#.$G#", + "#...#", + "#####" + ], + "lowestPush": 2, + "pushLimit": 4 + }, + { + "id": 249, + "grid": [ + "########", + "##.GG$.#", + "##..$@G#", + "####...#", + "#..$.$.#", + "##...###", + "########" + ], + "lowestPush": 26, + "pushLimit": 36 + }, + { + "id": 250, + "grid": [ + "######", + "#..@.#", + "###$$#", + "#....#", + "##.G.#", + "######" + ], + "lowestPush": 2, + "pushLimit": 4 + } +] diff --git a/stage_checkpoint.json b/stage_checkpoint.json new file mode 100644 index 0000000..a48e860 --- /dev/null +++ b/stage_checkpoint.json @@ -0,0 +1,133 @@ +[ + { + "id": 242, + "grid": [ + "########", + "#..#.G.#", + "#..#.$.#", + "##.$$..#", + "##@$G.G#", + "####...#", + "########" + ], + "lowestPush": 26, + "pushLimit": 36 + }, + { + "id": 243, + "grid": [ + "######", + "#....#", + "#@$.$#", + "###$G#", + "###..#", + "######" + ], + "lowestPush": 1, + "pushLimit": 3 + }, + { + "id": 244, + "grid": [ + "########", + "##.....#", + "#@$.G..#", + "####.$G#", + "##.$$$.#", + "##.G.G.#", + "########" + ], + "lowestPush": 27, + "pushLimit": 37 + }, + { + "id": 245, + "grid": [ + "########", + "#....###", + "#...G###", + "#.$$.#.#", + "#.@###.#", + "#.#..#.#", + "#..###.#", + "#......#", + "###...##", + "###..###", + "########" + ], + "lowestPush": 3, + "pushLimit": 5 + }, + { + "id": 246, + "grid": [ + "##########", + "##.......#", + "##.#.....#", + "#.G#..#..#", + "#..#..#..#", + "##$####.##", + "##@.....##", + "####..####", + "###.....##", + "##########" + ], + "lowestPush": 2, + "pushLimit": 4 + }, + { + "id": 247, + "grid": [ + "#######", + "###@..#", + "##.$..#", + "##..$##", + "#.G$G.#", + "#..$..#", + "##..###", + "#######" + ], + "lowestPush": 26, + "pushLimit": 36 + }, + { + "id": 248, + "grid": [ + "#####", + "#..@#", + "#G$$#", + "#.$G#", + "#...#", + "#####" + ], + "lowestPush": 2, + "pushLimit": 4 + }, + { + "id": 249, + "grid": [ + "########", + "##.GG$.#", + "##..$@G#", + "####...#", + "#..$.$.#", + "##...###", + "########" + ], + "lowestPush": 26, + "pushLimit": 36 + }, + { + "id": 250, + "grid": [ + "######", + "#..@.#", + "###$$#", + "#....#", + "##.G.#", + "######" + ], + "lowestPush": 2, + "pushLimit": 4 + } +] \ No newline at end of file