레벨 검증 모드 추가 및 해법 통계 출력

This commit is contained in:
JiWoong Sul
2025-11-24 17:37:20 +09:00
parent c8cbb7f8ac
commit 629b74888c
2 changed files with 222 additions and 24 deletions

View File

@@ -101,6 +101,36 @@ internal static class Program
public static void Main(string[] args)
{
// 검증 모드: --verify <json 경로> <id> [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 <json경로> <id> [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 <json 경로>
if (args.Length > 0 && args[0].Equals("--verify-all", StringComparison.OrdinalIgnoreCase))
{
if (args.Length < 2)
{
Console.Error.WriteLine("사용법: dotnet run -- --verify-all <json경로>");
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<GeneratedLevel>? levels;
try
{
var json = File.ReadAllText(jsonPath);
levels = JsonSerializer.Deserialize<List<GeneratedLevel>>(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<int> 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<int>();
var boxes = new List<int>();
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<SolverState>(_comparer) { start };
var parents = new Dictionary<SolverState, (SolverState Parent, int Dir)>(_comparer);
var parents = new Dictionary<SolverState, (SolverState Parent, int Dir, int MoveCost)>(_comparer);
var queue = new Queue<SolverState>();
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<SolverState, (SolverState Parent, int Dir)> parents)
private SolveResult BuildResult(SolverState solved, Dictionary<SolverState, (SolverState Parent, int Dir, int MoveCost)> 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<int> ReachableWithoutPushing(int start, int[] boxes)
private Dictionary<int, int> ReachableWithDistances(int start, int[] boxes)
{
var visited = new bool[_board.Size];
var distances = Enumerable.Repeat(int.MaxValue, _board.Size).ToArray();
var queue = new Queue<int>();
var output = new List<int>();
if (_board.IsSolid(start) || Array.BinarySearch(boxes, start) >= 0) return output;
if (_board.IsSolid(start) || Array.BinarySearch(boxes, start) >= 0) return new Dictionary<int, int>();
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<int, int>();
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<int> 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;
}

View File

@@ -9,10 +9,15 @@
```bash
dotnet build
dotnet run -- <seed> [startId] [endId] > output.json
# 개별/범위 검증: 생성된 JSON이 풀리는지 확인
dotnet run -- --verify <json경로> <id> [endId]
dotnet run -- --verify-all <json경로>
```
- 예: `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에 한 줄로 갱신됩니다(벽 생성중/박스 배치중/검증중/생성완료).
## 설정