Files
nekoban_map_generator/Program.cs
JiWoong Sul e81218576d 신규등록
2025-11-22 01:08:44 +09:00

1052 lines
32 KiB
C#

// 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.IO;
using System.Linq;
using System.Text.Json;
internal static class Program
{
private static readonly string LevelBalancePath = "levelbalance.json";
private static readonly LevelBandConfig[] DefaultLevelBands =
{
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 levelBands = LoadLevelBands();
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));
}
private static LevelBandConfig[] LoadLevelBands()
{
try
{
if (File.Exists(LevelBalancePath))
{
var json = File.ReadAllText(LevelBalancePath);
var config = JsonSerializer.Deserialize<LevelBalanceFile>(json);
if (config?.Bands != null && config.Bands.Count > 0)
{
return config.Bands.Select(ConvertBand).ToArray();
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[warn] Failed to read {LevelBalancePath}: {ex.Message}. Falling back to default bands.");
}
return DefaultLevelBands;
}
private static LevelBandConfig ConvertBand(LevelBandJson input)
{
var masks = MaskLibrary.Microban;
if (input.MaskTake > 0)
{
masks = masks.Take(input.MaskTake).ToList();
}
return new LevelBandConfig
{
StartId = input.StartId,
EndId = input.EndId,
BoxCountLow = input.BoxCountLow,
BoxCountHigh = input.BoxCountHigh,
MinAllowedPushes = input.MinAllowedPushes,
MinAllowedTurns = input.MinAllowedTurns,
MinAllowedBranching = input.MinAllowedBranching,
ShapeMasks = masks.ToList()
};
}
}
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 LevelBalanceFile
{
public List<LevelBandJson> Bands { get; set; } = new();
}
internal sealed class LevelBandJson
{
public int StartId { get; set; }
public int EndId { get; set; }
public int BoxCountLow { get; set; }
public int BoxCountHigh { get; set; }
public int MinAllowedPushes { get; set; }
public int MinAllowedTurns { get; set; }
public int MinAllowedBranching { get; set; }
public int MaskTake { get; set; } = 0;
}
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);