레벨 검증 모드 추가 및 해법 통계 출력
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)
|
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;
|
var seed = DefaultSeed;
|
||||||
if (args.Length > 0 && int.TryParse(args[0], out var parsedSeed))
|
if (args.Length > 0 && int.TryParse(args[0], out var parsedSeed))
|
||||||
{
|
{
|
||||||
@@ -167,6 +197,70 @@ internal static class Program
|
|||||||
Console.Error.WriteLine($"총 소요시간 {duration}");
|
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()
|
private static LevelBandConfig[] LoadLevelBands()
|
||||||
{
|
{
|
||||||
try
|
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
|
internal sealed class GeneratedLevel
|
||||||
{
|
{
|
||||||
public int Id { get; init; }
|
public int Id { get; init; }
|
||||||
@@ -1248,7 +1431,7 @@ internal sealed class SokobanSolver
|
|||||||
Array.Sort(boxes);
|
Array.Sort(boxes);
|
||||||
var start = new SolverState(player, boxes);
|
var start = new SolverState(player, boxes);
|
||||||
var visited = new HashSet<SolverState>(_comparer) { start };
|
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>();
|
var queue = new Queue<SolverState>();
|
||||||
queue.Enqueue(start);
|
queue.Enqueue(start);
|
||||||
|
|
||||||
@@ -1260,11 +1443,11 @@ internal sealed class SokobanSolver
|
|||||||
return BuildResult(state, parents);
|
return BuildResult(state, parents);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (next, dir) in Expand(state))
|
foreach (var (next, dir, moveCost) in Expand(state))
|
||||||
{
|
{
|
||||||
if (visited.Add(next))
|
if (visited.Add(next))
|
||||||
{
|
{
|
||||||
parents[next] = (state, dir);
|
parents[next] = (state, dir, moveCost);
|
||||||
queue.Enqueue(next);
|
queue.Enqueue(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1273,11 +1456,13 @@ internal sealed class SokobanSolver
|
|||||||
return SolveResult.Fail;
|
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);
|
var reachable = ReachableWithDistances(state.Player, state.Boxes);
|
||||||
foreach (var pos in reachable)
|
foreach (var kv in reachable)
|
||||||
{
|
{
|
||||||
|
var pos = kv.Key;
|
||||||
|
var walk = kv.Value;
|
||||||
foreach (var dir in _dirs)
|
foreach (var dir in _dirs)
|
||||||
{
|
{
|
||||||
var boxPos = pos + dir;
|
var boxPos = pos + dir;
|
||||||
@@ -1291,30 +1476,33 @@ internal sealed class SokobanSolver
|
|||||||
var nextBoxes = state.Boxes.ToArray();
|
var nextBoxes = state.Boxes.ToArray();
|
||||||
nextBoxes[boxIndex] = landing;
|
nextBoxes[boxIndex] = landing;
|
||||||
Array.Sort(nextBoxes);
|
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 pushes = 0;
|
||||||
var turns = 0;
|
var turns = 0;
|
||||||
var branching = 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;
|
var current = solved;
|
||||||
while (parents.TryGetValue(current, out var entry))
|
while (parents.TryGetValue(current, out var entry))
|
||||||
{
|
{
|
||||||
path.Add((current, entry.Dir));
|
path.Add((current, entry.Dir, entry.MoveCost));
|
||||||
current = entry.Parent;
|
current = entry.Parent;
|
||||||
}
|
}
|
||||||
path.Reverse();
|
path.Reverse();
|
||||||
|
|
||||||
var prevDir = 0;
|
var prevDir = 0;
|
||||||
foreach (var (_, dir) in path)
|
foreach (var (_, dir, moveCost) in path)
|
||||||
{
|
{
|
||||||
pushes++;
|
pushes++;
|
||||||
|
moves += moveCost;
|
||||||
if (prevDir != 0 && dir != prevDir) turns++;
|
if (prevDir != 0 && dir != prevDir) turns++;
|
||||||
prevDir = dir;
|
prevDir = dir;
|
||||||
}
|
}
|
||||||
@@ -1329,20 +1517,18 @@ internal sealed class SokobanSolver
|
|||||||
}
|
}
|
||||||
|
|
||||||
var signature = SolutionSignature.Build(path.Select(p => p.Dir), pushes, turns, branching, _board.Width);
|
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 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);
|
queue.Enqueue(start);
|
||||||
output.Add(start);
|
|
||||||
|
|
||||||
while (queue.Count > 0)
|
while (queue.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -1351,15 +1537,22 @@ internal sealed class SokobanSolver
|
|||||||
{
|
{
|
||||||
var next = current + dir;
|
var next = current + dir;
|
||||||
if (!IndexInBounds(next)) continue;
|
if (!IndexInBounds(next)) continue;
|
||||||
if (visited[next]) continue;
|
if (distances[next] != int.MaxValue) continue;
|
||||||
if (_board.IsSolid(next) || Array.BinarySearch(boxes, next) >= 0) continue;
|
if (_board.IsSolid(next) || Array.BinarySearch(boxes, next) >= 0) continue;
|
||||||
visited[next] = true;
|
distances[next] = distances[current] + 1;
|
||||||
queue.Enqueue(next);
|
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)
|
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 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;
|
public bool IsFail => Pushes < 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,15 @@
|
|||||||
```bash
|
```bash
|
||||||
dotnet build
|
dotnet build
|
||||||
dotnet run -- <seed> [startId] [endId] > output.json
|
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 -- 12345 3 20 > stage.json`
|
||||||
|
- 예: `dotnet run -- --verify stage.json 210 220`
|
||||||
- `seed`가 없으면 기본값(12345)을 사용합니다.
|
- `seed`가 없으면 기본값(12345)을 사용합니다.
|
||||||
- `startId/endId`가 없으면 `levelbalance.json`에 정의된 밴드 범위를 사용합니다.
|
- `startId/endId`가 없으면 `levelbalance.json`에 정의된 밴드 범위를 사용합니다.
|
||||||
|
- 검증 모드에서는 지정한 JSON의 레벨을 솔버로 풀어보고, 성공/실패와 함께 `moves/ pushes/ turns`를 리포트합니다.
|
||||||
- 진행 상태는 stderr에 한 줄로 갱신됩니다(벽 생성중/박스 배치중/검증중/생성완료).
|
- 진행 상태는 stderr에 한 줄로 갱신됩니다(벽 생성중/박스 배치중/검증중/생성완료).
|
||||||
|
|
||||||
## 설정
|
## 설정
|
||||||
|
|||||||
Reference in New Issue
Block a user