진행 시간 표시 추가 및 231~250 스테이지 체크포인트 반영

This commit is contained in:
JiWoong Sul
2025-11-25 17:17:40 +09:00
parent 422e78d2db
commit ed84c9d9e8
10 changed files with 478 additions and 1304 deletions

3
AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# 에이전트 가이드
- 모든 대화는 한글로만 진행한다.

View File

@@ -1,989 +0,0 @@
// 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 <seed> [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<LevelBandConfig> _bands;
private readonly bool _trace;
private readonly HashSet<string> _seenLayouts = new();
public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList<LevelBandConfig> bands)
{
_rng = rng;
_tuning = tuning;
_bands = bands;
_trace = Environment.GetEnvironmentVariable("NEKOBAN_DEBUG") == "1";
}
public List<GeneratedLevel> 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<GeneratedLevel>();
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<string, int>() : 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<string, int>? 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<string> 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<string[]> ShapeMasks { get; init; } = new();
public List<string[]>? 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<int> 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<string> ToLines()
{
var lines = new List<string>(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<int> goals, out int[] boxes, out int player)
{
goals = new HashSet<int>();
boxes = Array.Empty<int>();
player = -1;
var candidates = Walkable(canvas).ToList();
var minRequired = boxCount + 2;
if (candidates.Count < minRequired)
{
return false;
}
Shuffle(candidates, rng);
var softGoals = new List<int>();
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<int>(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<int>());
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<int> FloodFrom(int start, GridCanvas canvas, int[] boxes)
{
var visited = new HashSet<int>();
if (IsSolid(canvas.Get(start)) || Array.BinarySearch(boxes, start) >= 0) return visited;
var queue = new Queue<int>();
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<int> 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<int> 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<T>(IList<T> 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<int> 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<SolverState>(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<int> 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<int> goals)
{
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 queue = new Queue<SolverState>();
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<SolverState, (SolverState Parent, int Dir)> 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<int> ReachableWithoutPushing(int start, int[] boxes)
{
var visited = new bool[_board.Size];
var queue = new Queue<int>();
var output = new List<int>();
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<int> 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<SolverState>
{
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);

View File

@@ -161,8 +161,9 @@ internal static class Program
var rng = new Random(seed);
var levelBands = LoadLevelBands();
var totalWatch = Stopwatch.StartNew();
var status = new StatusReporter();
var generator = new LevelGenerator(rng, Tuning, levelBands, status);
var generator = new LevelGenerator(rng, Tuning, levelBands, status, totalWatch);
var startId = levelBands.Min(b => b.StartId);
var endId = levelBands.Max(b => b.EndId);
@@ -208,7 +209,6 @@ internal static class Program
}
}
var totalWatch = Stopwatch.StartNew();
var levels = generator.BuildRange(startId, endId, SaveCheckpoint);
totalWatch.Stop();
@@ -458,13 +458,15 @@ internal sealed class LevelGenerator
private readonly HashSet<string> _seenLayouts = new();
private readonly HashSet<string> _seenPatterns = new();
private readonly StatusReporter _status;
private readonly Stopwatch _totalWatch;
public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList<LevelBandConfig> bands, StatusReporter status)
public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList<LevelBandConfig> bands, StatusReporter status, Stopwatch totalWatch)
{
_rng = rng;
_tuning = tuning;
_bands = bands;
_status = status;
_totalWatch = totalWatch;
_trace = Environment.GetEnvironmentVariable("NEKOBAN_DEBUG") == "1";
}
@@ -483,7 +485,8 @@ internal sealed class LevelGenerator
for (var id = startId; id <= endId; id++)
{
var band = ResolveBand(id);
var level = BuildSingle(id, band);
var levelStopwatch = Stopwatch.StartNew();
var level = BuildSingle(id, band, levelStopwatch);
level = TrimLevel(level);
output.Add(level);
onCheckpoint?.Invoke(output, id);
@@ -502,7 +505,7 @@ internal sealed class LevelGenerator
return new PocketSettings(min, max, radius);
}
private GeneratedLevel BuildSingle(int id, LevelBandConfig band)
private GeneratedLevel BuildSingle(int id, LevelBandConfig band, Stopwatch levelStopwatch)
{
// 완화 단계를 순차적으로 적용한다. (총 5단계 + 비상 1단계)
var stages = new List<RelaxStage>
@@ -546,26 +549,7 @@ internal sealed class LevelGenerator
c.MinBoxDistance = Math.Max(1, c.MinBoxDistance - 1);
c.MinWallDistance = Math.Max(0, c.MinWallDistance - 1);
return c;
}),
new RelaxStage("완화6-최종", bandConfig =>
{
var c = CloneBand(bandConfig);
c.BoxCountLow = 1;
c.BoxCountHigh = Math.Max(1, c.BoxCountHigh - 2);
c.MinAllowedPushes = 1;
c.MinAllowedTurns = 0;
c.MinAllowedBranching = 0;
c.MinGoalDistance = Math.Max(1, c.MinGoalDistance - 2);
c.MinBoxDistance = Math.Max(1, c.MinBoxDistance - 2);
c.MinWallDistance = 0;
c.ReverseDepthScale = Math.Max(1.0, c.ReverseDepthScale * 2.8);
c.ReverseBreadthScale = Math.Max(1.0, c.ReverseBreadthScale * 2.8);
c.MaskPadMin = -2;
c.MaskPadMax = -2;
c.ShapeMasks = MaskLibrary.Medium.Concat(MaskLibrary.Microban).ToList();
c.ShapeMasksExpanded = null;
return c;
}, OverrideRelaxSteps: 6)
})
};
// 밴드 강등 시도: 현재 밴드 -> 직전 10레벨 밴드 -> 직전 20레벨 밴드
@@ -584,10 +568,10 @@ internal sealed class LevelGenerator
}
// 시드 변조 재시도 + 완화 단계 루프
const int seedRetries = 5;
foreach (var bandCandidate in bandCandidates)
var retry = 0;
while (true)
{
for (var retry = 0; retry < seedRetries; retry++)
foreach (var bandCandidate in bandCandidates)
{
var jitterSeed = _rng.Next() ^ (id * 7919) ^ (retry * 104729);
var localRng = new Random(jitterSeed);
@@ -596,18 +580,17 @@ internal sealed class LevelGenerator
{
var bandForStage = stage.Adjust(bandCandidate);
var relaxOverride = stage.OverrideRelaxSteps;
if (TryBuildStage(id, bandForStage, stage.Label, localRng, relaxOverride, out var level))
if (TryBuildStage(id, bandForStage, stage.Label, localRng, relaxOverride, levelStopwatch, out var level))
{
return level;
}
}
}
retry++;
}
throw new InvalidOperationException($"레벨 {id} 생성 실패 (모든 완화 단계 시도됨)");
}
private bool TryBuildStage(int id, LevelBandConfig band, string stageLabel, Random rng, int? overrideRelaxSteps, out GeneratedLevel level)
private bool TryBuildStage(int id, LevelBandConfig band, string stageLabel, Random rng, int? overrideRelaxSteps, Stopwatch levelStopwatch, out GeneratedLevel level)
{
var failReasons = _trace ? new Dictionary<string, int>() : null;
var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban;
@@ -631,16 +614,16 @@ internal sealed class LevelGenerator
var reverseDepth = Math.Max(16, (int)Math.Round(_tuning.ReverseSearchMaxDepth * depthScale));
var reverseBreadth = Math.Max(200, (int)Math.Round(_tuning.ReverseSearchBreadth * breadthScale));
var stopwatch = Stopwatch.StartNew();
var stageStopwatch = Stopwatch.StartNew();
var attempts = 0;
while (attempts < attemptsLimit &&
stopwatch.ElapsedMilliseconds < timeLimit)
stageStopwatch.ElapsedMilliseconds < timeLimit)
{
attempts++;
// 진행 상태가 살아 있음을 보여주기 위해, 1초 단위 점(.) 애니메이션을 출력한다.
// 예) "레벨 214 [기본] 생성중.", "..", "..." 식으로 순환.
_status.Show($"레벨 {id} [{stageLabel}] 생성중", withDots: true);
_status.ShowProgress($"레벨 {id} [{stageLabel}] 생성중", levelStopwatch.Elapsed, _totalWatch.Elapsed);
var mask = MaskLibrary.CreateVariant(
MaskLibrary.PickRandom(rng, band.ShapeMasksExpanded),
rng,
@@ -734,7 +717,7 @@ internal sealed class LevelGenerator
_seenPatterns.Add(patternKey);
}
_status.Show($"레벨 {id} [{stageLabel}] 생성완료 ({StatusReporter.FormatDuration(stopwatch.Elapsed)})");
_status.Show($"레벨 {id} [{stageLabel}] 생성완료 ({StatusReporter.FormatDuration(levelStopwatch.Elapsed)})");
level = new GeneratedLevel
{
Id = id,

View File

@@ -22,7 +22,8 @@ dotnet run -- --trim <입력 json> [출력 json] [startId] [endId]
- `startId/endId`가 없으면 `levelbalance.json`에 정의된 밴드 범위를 사용합니다.
- 검증 모드에서는 지정한 JSON의 레벨을 솔버로 풀어보고, 성공/실패와 함께 `moves/ pushes/ turns`를 리포트합니다.
- 트림 모드는 지정한 범위(id)만 외곽의 '0' 패딩을 걷어내서 저장합니다(기본 출력 경로: `trimmed_output.json`).
- 진행 상태는 stderr에 한 줄로 갱신됩니다(벽 생성중/박스 배치중/검증중/생성완료).
- 생성 중 매 레벨 단위로 누적 체크포인트 JSON을 `stage_checkpoint.json`에 기록하며, 경로는 `NEKOBAN_CHECKPOINT` 환경변수로 바꿀 수 있습니다.
- 진행 상태는 stderr에 한 줄로 갱신되며 점(.) 애니메이션과 현재 레벨/전체 경과 시간이 함께 표시됩니다.
## 설정
- `levelbalance.json`: 레벨 밴드 설정(레벨 범위, 박스 수, 최소 푸시/턴/브랜칭, 마스크 세트/확장 범위, 역탐색 깊이/폭 스케일, 목표/박스 간 최소 거리, 주머니(포켓) 리라이팅). `maskSets`(micro/medium/large), `maskPadMin/max`, `reverseDepthScale/breadthScale`, `minAllowedBranching`, `minGoal/Box/WallDistance`, `pocketCarveMin/Max/Radius`를 밴드별로 조정해 난이도·길이감을 설계합니다.
@@ -60,3 +61,4 @@ dotnet run -- --trim <입력 json> [출력 json] [startId] [endId]
- 중복 레이아웃은 제거됩니다.
- 해법 패턴(푸시/턴/방향 런 요약) 해시를 이용해 유사 해법 레벨도 추가로 걸러냅니다.
- 더 느리게 돌려야 하면 `GenerationTuning`의 시도/시간 한도를 조정하세요.
- 밴드 강등/시드 재시도는 성공할 때까지 반복되므로, 오래 걸릴 때는 진행 메시지의 경과 시간을 참고해 중단 여부를 판단하세요.

View File

@@ -29,6 +29,13 @@ internal sealed class StatusReporter
Write(message, newline: true);
}
public void ShowProgress(string message, TimeSpan levelElapsed, TimeSpan totalElapsed)
{
_dotCounter++;
var progress = $"{message}{Dots()}({FormatDuration(levelElapsed)} / {FormatDuration(totalElapsed)})";
Write(progress, newline: false);
}
private void Write(string message, bool newline)
{
if (_lastMessage == message && !newline) return;
@@ -56,9 +63,13 @@ internal sealed class StatusReporter
public static string FormatDuration(TimeSpan elapsed)
{
if (elapsed.TotalHours >= 1)
{
return $"{(int)elapsed.TotalHours}h{elapsed.Minutes}m{elapsed.Seconds}s";
}
if (elapsed.TotalMinutes >= 1)
{
return $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s";
return $"{(int)elapsed.TotalMinutes}m{elapsed.Seconds}s";
}
return $"{elapsed.Seconds}s";
}

View File

@@ -1,277 +0,0 @@
[
{
"id": 3,
"grid": [
"00000000000",
"00000000000",
"00#######00",
"00##..###00",
"00#...$@#00",
"00#.....#00",
"00#...G.#00",
"00###.###00",
"00#######00",
"00000000000",
"00000000000"
],
"lowestPush": 4,
"pushLimit": 6
},
{
"id": 4,
"grid": [
"######0",
"#...##0",
"#...##0",
"#G$..#0",
"#.G$.#0",
"#.#@##0",
"######0"
],
"lowestPush": 4,
"pushLimit": 6
},
{
"id": 5,
"grid": [
"000000000",
"0#######0",
"0##...##0",
"0#@$..##0",
"0#.G...#0",
"0##$..##0",
"0##.G.##0",
"0#######0",
"000000000"
],
"lowestPush": 5,
"pushLimit": 7
},
{
"id": 6,
"grid": [
"######0",
"#...##0",
"#$.G##0",
"#G.$@#0",
"#..###0",
"#...##0",
"######0"
],
"lowestPush": 6,
"pushLimit": 9
},
{
"id": 7,
"grid": [
"000000000",
"0#######0",
"0###..##0",
"0#@$...#0",
"0#G....#0",
"0#.....#0",
"0###.###0",
"0#######0",
"000000000"
],
"lowestPush": 4,
"pushLimit": 6
},
{
"id": 8,
"grid": [
"######0",
"#...##0",
"#..$@#0",
"#.G..#0",
"#..$##0",
"#....#0",
"######0"
],
"lowestPush": 4,
"pushLimit": 6
},
{
"id": 9,
"grid": [
"######0",
"#..@##0",
"#.#$##0",
"#....#0",
"#....#0",
"#.G.##0",
"######0"
],
"lowestPush": 4,
"pushLimit": 6
},
{
"id": 10,
"grid": [
"#######",
"#@$GG.#",
"#.$...#",
"#.....#",
"#######",
"#######",
"0000000"
],
"lowestPush": 4,
"pushLimit": 6
},
{
"id": 11,
"grid": [
"######0",
"#..@##0",
"##$$##0",
"#.G..#0",
"#G...#0",
"#...##0",
"######0"
],
"lowestPush": 5,
"pushLimit": 7
},
{
"id": 12,
"grid": [
"0000000",
"0#####0",
"0#...#0",
"0#.$.#0",
"0#G.$#0",
"0#.#@#0",
"0#####0",
"0000000"
],
"lowestPush": 4,
"pushLimit": 6
},
{
"id": 13,
"grid": [
"######0",
"#...##0",
"#..###0",
"#..$@#0",
"#$.G##0",
"#...##0",
"######0"
],
"lowestPush": 7,
"pushLimit": 10
},
{
"id": 14,
"grid": [
"0######",
"0##...#",
"0##.$@#",
"0#.G..#",
"0##G$##",
"0##...#",
"0######"
],
"lowestPush": 5,
"pushLimit": 7
},
{
"id": 15,
"grid": [
"00000000000",
"00000000000",
"00#######00",
"00##...##00",
"00##.$.##00",
"00#@$.G.#00",
"00##..G##00",
"00##...##00",
"00#######00",
"00000000000",
"00000000000"
],
"lowestPush": 5,
"pushLimit": 7
},
{
"id": 16,
"grid": [
"0######",
"0##.G.#",
"0##$.G#",
"0#....#",
"0#@$.##",
"0##...#",
"0######"
],
"lowestPush": 8,
"pushLimit": 11
},
{
"id": 17,
"grid": [
"0######",
"0##...#",
"0#...##",
"0#....#",
"0##$.G#",
"0##@..#",
"0######"
],
"lowestPush": 4,
"pushLimit": 6
},
{
"id": 18,
"grid": [
"00000000000",
"00000000000",
"00#######00",
"00##...##00",
"00##G.$##00",
"00#.G...#00",
"00#...$.#00",
"00##..@##00",
"00#######00",
"00000000000",
"00000000000"
],
"lowestPush": 7,
"pushLimit": 10
},
{
"id": 19,
"grid": [
"000000000",
"0#######0",
"0##...##0",
"0##G..##0",
"0#.G.$@#0",
"0##.$.##0",
"0##...##0",
"0#######0",
"000000000"
],
"lowestPush": 5,
"pushLimit": 7
},
{
"id": 20,
"grid": [
"00000000000",
"00000000000",
"00#######00",
"00###@###00",
"00#..$..#00",
"00#...$.#00",
"00#.G.G.#00",
"00###.###00",
"00#######00",
"00000000000",
"00000000000"
],
"lowestPush": 4,
"pushLimit": 6
}
]

156
stage_231_240.json Normal file
View File

@@ -0,0 +1,156 @@
[
{
"id": 231,
"grid": [
"#########",
"#.@....##",
"#.$.....#",
"#.......#",
"#....G..#",
"#.#...#.#",
"#...#...#",
"##.#..###",
"###...###",
"#########"
],
"lowestPush": 5,
"pushLimit": 7
},
{
"id": 232,
"grid": [
"########",
"###..###",
"#.#..###",
"#.@....#",
"#.$.##.#",
"#....#.#",
"#.G##..#",
"#......#",
"###..###",
"###..###",
"########"
],
"lowestPush": 2,
"pushLimit": 4
},
{
"id": 233,
"grid": [
"#######",
"#.@..##",
"##$$#.#",
"#.G$..#",
"#...$G#",
"#.G...#",
"#######"
],
"lowestPush": 26,
"pushLimit": 36
},
{
"id": 234,
"grid": [
"##########",
"###@###.##",
"##.$#....#",
"##G.#.$.##",
"#.$.....##",
"#.G$######",
"#...#...##",
"##..#...##",
"##..#...##",
"###.######",
"##########"
],
"lowestPush": 25,
"pushLimit": 34
},
{
"id": 235,
"grid": [
"###########",
"####...#..#",
"##.$G#.$$.#",
"#.......$@#",
"#.G.#######",
"##G.#.#..##",
"##.#...#.##",
"##..#.#..##",
"###########",
"###########"
],
"lowestPush": 28,
"pushLimit": 38
},
{
"id": 236,
"grid": [
"########",
"####...#",
"##@$.GG#",
"##.$$..#",
"##.#.$G#",
"#..#...#",
"########"
],
"lowestPush": 27,
"pushLimit": 37
},
{
"id": 237,
"grid": [
"######",
"#.#..#",
"#@$.G#",
"#.#..#",
"######"
],
"lowestPush": 2,
"pushLimit": 4
},
{
"id": 238,
"grid": [
"#######",
"#.G...#",
"#.G$$G#",
"#...$.#",
"#.$#$G#",
"##@#..#",
"#######"
],
"lowestPush": 25,
"pushLimit": 34
},
{
"id": 239,
"grid": [
"###########",
"##.....####",
"##.$.#G$.##",
"##...G$G..#",
"#.##.$@#..#",
"##.####...#",
"##........#",
"##...#$...#",
"##...#....#",
"##.#####..#",
"###########"
],
"lowestPush": 25,
"pushLimit": 34
},
{
"id": 240,
"grid": [
"######",
"#.G..#",
"#..$@#",
"#....#",
"######"
],
"lowestPush": 2,
"pushLimit": 4
}
]

19
stage_241.json Normal file
View File

@@ -0,0 +1,19 @@
[
{
"id": 241,
"grid": [
"##########",
"#####..###",
"###....@.#",
"#.#.###$.#",
"#...#...G#",
"#G$.#.$..#",
"#.#.###..#",
"#.#.....##",
"###.#..###",
"##########"
],
"lowestPush": 7,
"pushLimit": 10
}
]

133
stage_242_250.json Normal file
View File

@@ -0,0 +1,133 @@
[
{
"id": 242,
"grid": [
"########",
"#..#.G.#",
"#..#.$.#",
"##.$$..#",
"##@$G.G#",
"####...#",
"########"
],
"lowestPush": 26,
"pushLimit": 36
},
{
"id": 243,
"grid": [
"######",
"#....#",
"#@$.$#",
"###$G#",
"###..#",
"######"
],
"lowestPush": 1,
"pushLimit": 3
},
{
"id": 244,
"grid": [
"########",
"##.....#",
"#@$.G..#",
"####.$G#",
"##.$$$.#",
"##.G.G.#",
"########"
],
"lowestPush": 27,
"pushLimit": 37
},
{
"id": 245,
"grid": [
"########",
"#....###",
"#...G###",
"#.$$.#.#",
"#.@###.#",
"#.#..#.#",
"#..###.#",
"#......#",
"###...##",
"###..###",
"########"
],
"lowestPush": 3,
"pushLimit": 5
},
{
"id": 246,
"grid": [
"##########",
"##.......#",
"##.#.....#",
"#.G#..#..#",
"#..#..#..#",
"##$####.##",
"##@.....##",
"####..####",
"###.....##",
"##########"
],
"lowestPush": 2,
"pushLimit": 4
},
{
"id": 247,
"grid": [
"#######",
"###@..#",
"##.$..#",
"##..$##",
"#.G$G.#",
"#..$..#",
"##..###",
"#######"
],
"lowestPush": 26,
"pushLimit": 36
},
{
"id": 248,
"grid": [
"#####",
"#..@#",
"#G$$#",
"#.$G#",
"#...#",
"#####"
],
"lowestPush": 2,
"pushLimit": 4
},
{
"id": 249,
"grid": [
"########",
"##.GG$.#",
"##..$@G#",
"####...#",
"#..$.$.#",
"##...###",
"########"
],
"lowestPush": 26,
"pushLimit": 36
},
{
"id": 250,
"grid": [
"######",
"#..@.#",
"###$$#",
"#....#",
"##.G.#",
"######"
],
"lowestPush": 2,
"pushLimit": 4
}
]

133
stage_checkpoint.json Normal file
View File

@@ -0,0 +1,133 @@
[
{
"id": 242,
"grid": [
"########",
"#..#.G.#",
"#..#.$.#",
"##.$$..#",
"##@$G.G#",
"####...#",
"########"
],
"lowestPush": 26,
"pushLimit": 36
},
{
"id": 243,
"grid": [
"######",
"#....#",
"#@$.$#",
"###$G#",
"###..#",
"######"
],
"lowestPush": 1,
"pushLimit": 3
},
{
"id": 244,
"grid": [
"########",
"##.....#",
"#@$.G..#",
"####.$G#",
"##.$$$.#",
"##.G.G.#",
"########"
],
"lowestPush": 27,
"pushLimit": 37
},
{
"id": 245,
"grid": [
"########",
"#....###",
"#...G###",
"#.$$.#.#",
"#.@###.#",
"#.#..#.#",
"#..###.#",
"#......#",
"###...##",
"###..###",
"########"
],
"lowestPush": 3,
"pushLimit": 5
},
{
"id": 246,
"grid": [
"##########",
"##.......#",
"##.#.....#",
"#.G#..#..#",
"#..#..#..#",
"##$####.##",
"##@.....##",
"####..####",
"###.....##",
"##########"
],
"lowestPush": 2,
"pushLimit": 4
},
{
"id": 247,
"grid": [
"#######",
"###@..#",
"##.$..#",
"##..$##",
"#.G$G.#",
"#..$..#",
"##..###",
"#######"
],
"lowestPush": 26,
"pushLimit": 36
},
{
"id": 248,
"grid": [
"#####",
"#..@#",
"#G$$#",
"#.$G#",
"#...#",
"#####"
],
"lowestPush": 2,
"pushLimit": 4
},
{
"id": 249,
"grid": [
"########",
"##.GG$.#",
"##..$@G#",
"####...#",
"#..$.$.#",
"##...###",
"########"
],
"lowestPush": 26,
"pushLimit": 36
},
{
"id": 250,
"grid": [
"######",
"#..@.#",
"###$$#",
"#....#",
"##.G.#",
"######"
],
"lowestPush": 2,
"pushLimit": 4
}
]