// 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);