From 629b74888c449a277854970aef07782a87412ce4 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 24 Nov 2025 17:37:20 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A0=88=EB=B2=A8=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=95=B4?= =?UTF-8?q?=EB=B2=95=20=ED=86=B5=EA=B3=84=20=EC=B6=9C=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Program.cs | 241 +++++++++++++++++++++++++++++++++++++++++++++++------ README.md | 5 ++ 2 files changed, 222 insertions(+), 24 deletions(-) diff --git a/Program.cs b/Program.cs index ff42121..8128f0d 100644 --- a/Program.cs +++ b/Program.cs @@ -101,6 +101,36 @@ internal static class Program public static void Main(string[] args) { + // 검증 모드: --verify [endId] + if (args.Length > 0 && args[0].Equals("--verify", StringComparison.OrdinalIgnoreCase)) + { + if (args.Length < 3 || !int.TryParse(args[2], out var verifyStart)) + { + Console.Error.WriteLine("사용법: dotnet run -- --verify [endId]"); + return; + } + var verifyEnd = verifyStart; + if (args.Length >= 4 && int.TryParse(args[3], out var parsedEnd)) + { + verifyEnd = parsedEnd; + } + + RunVerification(args[1], verifyStart, verifyEnd); + return; + } + + // 전체 검증 모드: --verify-all + if (args.Length > 0 && args[0].Equals("--verify-all", StringComparison.OrdinalIgnoreCase)) + { + if (args.Length < 2) + { + Console.Error.WriteLine("사용법: dotnet run -- --verify-all "); + return; + } + RunVerification(args[1], int.MinValue, int.MaxValue); + return; + } + var seed = DefaultSeed; if (args.Length > 0 && int.TryParse(args[0], out var parsedSeed)) { @@ -167,6 +197,70 @@ internal static class Program Console.Error.WriteLine($"총 소요시간 {duration}"); } + private static void RunVerification(string jsonPath, int startId, int endId) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + if (!File.Exists(jsonPath)) + { + Console.Error.WriteLine($"파일을 찾을 수 없습니다: {jsonPath}"); + return; + } + + List? levels; + try + { + var json = File.ReadAllText(jsonPath); + levels = JsonSerializer.Deserialize>(json, options); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[verify] JSON을 읽거나 파싱하는 데 실패했습니다: {ex.Message}"); + return; + } + + if (levels == null || levels.Count == 0) + { + Console.Error.WriteLine("[verify] 레벨 데이터가 없습니다."); + return; + } + + if (startId > endId) + { + (startId, endId) = (endId, startId); + } + + var targets = levels.Where(l => l.Id >= startId && l.Id <= endId) + .OrderBy(l => l.Id) + .ToList(); + + if (targets.Count == 0) + { + Console.Error.WriteLine($"[verify] 지정 범위에 레벨이 없습니다. 범위: {startId}~{endId}"); + return; + } + + var ok = 0; + foreach (var level in targets) + { + var result = LevelVerifier.Verify(level); + if (result.Success) + { + ok++; + Console.WriteLine($"[OK] id {level.Id} | moves {result.Moves}, pushes {result.Pushes}, turns {result.Turns}"); + } + else + { + Console.WriteLine($"[FAIL] id {level.Id} | {result.ErrorMessage}"); + } + } + + Console.WriteLine($"[verify] 완료: 성공 {ok}/{targets.Count} (범위 {startId}~{endId})"); + } + private static LevelBandConfig[] LoadLevelBands() { try @@ -651,6 +745,95 @@ internal sealed class LevelGenerator } } +internal readonly record struct ParsedLevel(Board Board, int Player, int[] Boxes, HashSet Goals); + +internal readonly record struct VerificationResult(int Id, bool Success, int? Moves, int? Pushes, int? Turns, string? ErrorMessage); + +internal static class LevelVerifier +{ + public static VerificationResult Verify(GeneratedLevel level) + { + if (!TryParseLevel(level, out var parsed, out var error)) + { + return new VerificationResult(level.Id, false, null, null, null, error ?? "parse_error"); + } + + var solver = new SokobanSolver(parsed.Board); + var solve = solver.SolveDetailed(parsed.Player, parsed.Boxes, parsed.Goals); + if (solve.IsFail) + { + return new VerificationResult(level.Id, false, null, null, null, "unsolvable"); + } + + return new VerificationResult(level.Id, true, solve.Moves, solve.Pushes, solve.Turns, null); + } + + private static bool TryParseLevel(GeneratedLevel level, out ParsedLevel parsed, out string? error) + { + parsed = default; + error = null; + + if (level.Grid == null || level.Grid.Count == 0) + { + error = "grid_missing"; + return false; + } + + var height = level.Grid.Count; + var width = level.Grid.Max(row => row.Length); + var canvas = new GridCanvas(width, height); + var goals = new HashSet(); + var boxes = new List(); + var player = -1; + + for (var y = 0; y < height; y++) + { + var row = level.Grid[y]; + for (var x = 0; x < width; x++) + { + var c = x < row.Length ? row[x] : '0'; + if (c == '\0') c = '0'; + canvas.Set(x, y, c); + var idx = y * width + x; + switch (c) + { + case 'G': + goals.Add(idx); + break; + case '$': + boxes.Add(idx); + break; + case '@': + player = idx; + break; + } + } + } + + if (player < 0) + { + error = "player_not_found"; + return false; + } + + if (boxes.Count == 0) + { + error = "no_boxes"; + return false; + } + + if (goals.Count == 0) + { + error = "no_goals"; + return false; + } + + var board = Board.FromCanvas(canvas); + parsed = new ParsedLevel(board, player, boxes.ToArray(), goals); + return true; + } +} + internal sealed class GeneratedLevel { public int Id { get; init; } @@ -1248,7 +1431,7 @@ internal sealed class SokobanSolver Array.Sort(boxes); var start = new SolverState(player, boxes); var visited = new HashSet(_comparer) { start }; - var parents = new Dictionary(_comparer); + var parents = new Dictionary(_comparer); var queue = new Queue(); queue.Enqueue(start); @@ -1260,11 +1443,11 @@ internal sealed class SokobanSolver return BuildResult(state, parents); } - foreach (var (next, dir) in Expand(state)) + foreach (var (next, dir, moveCost) in Expand(state)) { if (visited.Add(next)) { - parents[next] = (state, dir); + parents[next] = (state, dir, moveCost); queue.Enqueue(next); } } @@ -1273,11 +1456,13 @@ internal sealed class SokobanSolver return SolveResult.Fail; } - private IEnumerable<(SolverState State, int Dir)> Expand(SolverState state) + private IEnumerable<(SolverState State, int Dir, int MoveCost)> Expand(SolverState state) { - var reachable = ReachableWithoutPushing(state.Player, state.Boxes); - foreach (var pos in reachable) + var reachable = ReachableWithDistances(state.Player, state.Boxes); + foreach (var kv in reachable) { + var pos = kv.Key; + var walk = kv.Value; foreach (var dir in _dirs) { var boxPos = pos + dir; @@ -1291,30 +1476,33 @@ internal sealed class SokobanSolver var nextBoxes = state.Boxes.ToArray(); nextBoxes[boxIndex] = landing; Array.Sort(nextBoxes); - yield return (new SolverState(boxPos, nextBoxes), dir); + var moveCost = checked(walk + 1); // 걷기 + 푸시 1회 + yield return (new SolverState(boxPos, nextBoxes), dir, moveCost); } } } - private SolveResult BuildResult(SolverState solved, Dictionary parents) + private SolveResult BuildResult(SolverState solved, Dictionary parents) { var pushes = 0; var turns = 0; var branching = 0; + var moves = 0; - var path = new List<(SolverState State, int Dir)>(); + var path = new List<(SolverState State, int Dir, int MoveCost)>(); var current = solved; while (parents.TryGetValue(current, out var entry)) { - path.Add((current, entry.Dir)); + path.Add((current, entry.Dir, entry.MoveCost)); current = entry.Parent; } path.Reverse(); var prevDir = 0; - foreach (var (_, dir) in path) + foreach (var (_, dir, moveCost) in path) { pushes++; + moves += moveCost; if (prevDir != 0 && dir != prevDir) turns++; prevDir = dir; } @@ -1329,20 +1517,18 @@ internal sealed class SokobanSolver } var signature = SolutionSignature.Build(path.Select(p => p.Dir), pushes, turns, branching, _board.Width); - return new SolveResult(pushes, turns, branching, signature); + return new SolveResult(pushes, turns, branching, moves, signature); } - private List ReachableWithoutPushing(int start, int[] boxes) + private Dictionary ReachableWithDistances(int start, int[] boxes) { - var visited = new bool[_board.Size]; + var distances = Enumerable.Repeat(int.MaxValue, _board.Size).ToArray(); var queue = new Queue(); - var output = new List(); - if (_board.IsSolid(start) || Array.BinarySearch(boxes, start) >= 0) return output; + if (_board.IsSolid(start) || Array.BinarySearch(boxes, start) >= 0) return new Dictionary(); - visited[start] = true; + distances[start] = 0; queue.Enqueue(start); - output.Add(start); while (queue.Count > 0) { @@ -1351,15 +1537,22 @@ internal sealed class SokobanSolver { var next = current + dir; if (!IndexInBounds(next)) continue; - if (visited[next]) continue; + if (distances[next] != int.MaxValue) continue; if (_board.IsSolid(next) || Array.BinarySearch(boxes, next) >= 0) continue; - visited[next] = true; + distances[next] = distances[current] + 1; queue.Enqueue(next); - output.Add(next); } } - return output; + var result = new Dictionary(); + for (var i = 0; i < distances.Length; i++) + { + if (distances[i] != int.MaxValue) + { + result[i] = distances[i]; + } + } + return result; } private bool IsSolved(int[] boxes, HashSet goals) @@ -1376,9 +1569,9 @@ internal sealed class SokobanSolver internal readonly record struct SolverState(int Player, int[] Boxes); -internal readonly record struct SolveResult(int Pushes, int Turns, int Branching, string PatternSignature) +internal readonly record struct SolveResult(int Pushes, int Turns, int Branching, int Moves, string PatternSignature) { - public static SolveResult Fail => new(-1, -1, -1, string.Empty); + public static SolveResult Fail => new(-1, -1, -1, -1, string.Empty); public bool IsFail => Pushes < 0; } diff --git a/README.md b/README.md index 07bb8dc..4bdd328 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,15 @@ ```bash dotnet build dotnet run -- [startId] [endId] > output.json +# 개별/범위 검증: 생성된 JSON이 풀리는지 확인 +dotnet run -- --verify [endId] +dotnet run -- --verify-all ``` - 예: `dotnet run -- 12345 3 20 > stage.json` +- 예: `dotnet run -- --verify stage.json 210 220` - `seed`가 없으면 기본값(12345)을 사용합니다. - `startId/endId`가 없으면 `levelbalance.json`에 정의된 밴드 범위를 사용합니다. +- 검증 모드에서는 지정한 JSON의 레벨을 솔버로 풀어보고, 성공/실패와 함께 `moves/ pushes/ turns`를 리포트합니다. - 진행 상태는 stderr에 한 줄로 갱신됩니다(벽 생성중/박스 배치중/검증중/생성완료). ## 설정