레벨 검증 모드 추가 및 해법 통계 출력
This commit is contained in:
241
Program.cs
241
Program.cs
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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에 한 줄로 갱신됩니다(벽 생성중/박스 배치중/검증중/생성완료).
|
||||
|
||||
## 설정
|
||||
|
||||
Reference in New Issue
Block a user