Files
nekoban_map_generator/Program.cs

1471 lines
47 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;
using System.Text;
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,
MinAllowedBranching = 0,
MinGoalDistance = 2,
MinBoxDistance = 2,
MinWallDistance = 1,
ReverseDepthScale = 1.0,
ReverseBreadthScale = 1.0,
PocketCarveMin = 0,
PocketCarveMax = 1,
PocketMaxRadius = 1,
MaskPadMin = -1,
MaskPadMax = 1,
ShapeMasks = MaskLibrary.Microban.Take(8).ToList()
},
new LevelBandConfig
{
StartId = 21,
EndId = 40,
BoxCountLow = 1,
BoxCountHigh = 2,
MinAllowedPushes = 7,
MinAllowedTurns = 3,
MinAllowedBranching = 1,
MinGoalDistance = 3,
MinBoxDistance = 3,
MinWallDistance = 1,
ReverseDepthScale = 1.15,
ReverseBreadthScale = 1.2,
PocketCarveMin = 1,
PocketCarveMax = 2,
PocketMaxRadius = 2,
MaskPadMin = -1,
MaskPadMax = 2,
ShapeMasks = MaskLibrary.Microban.Concat(MaskLibrary.Medium).ToList()
},
new LevelBandConfig
{
StartId = 41,
EndId = 60,
BoxCountLow = 2,
BoxCountHigh = 3,
MinAllowedPushes = 9,
MinAllowedTurns = 4,
MinAllowedBranching = 2,
MinGoalDistance = 3,
MinBoxDistance = 3,
MinWallDistance = 1,
ReverseDepthScale = 1.35,
ReverseBreadthScale = 1.4,
PocketCarveMin = 1,
PocketCarveMax = 3,
PocketMaxRadius = 2,
MaskPadMin = 0,
MaskPadMax = 2,
ShapeMasks = MaskLibrary.Medium.Concat(MaskLibrary.Large).ToList()
}
};
private static readonly GenerationTuning Tuning = new GenerationTuning
{
MaxAttemptsPerLevel = 10_000,
MaxMillisecondsPerLevel = 200_000,
RelaxationSteps = 3,
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 status = new StatusReporter();
var generator = new LevelGenerator(rng, Tuning, levelBands, status);
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 totalWatch = Stopwatch.StartNew();
var levels = generator.BuildRange(startId, endId);
totalWatch.Stop();
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
Console.WriteLine(JsonSerializer.Serialize(levels, options));
var duration = StatusReporter.FormatDuration(totalWatch.Elapsed);
Console.Error.Write($"\r레벨 {startId}~레벨 {endId} 생성완료 ({duration}) \n");
Console.Error.WriteLine($"총 소요시간 {duration}");
}
private static LevelBandConfig[] LoadLevelBands()
{
try
{
if (File.Exists(LevelBalancePath))
{
var json = File.ReadAllText(LevelBalancePath);
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var config = JsonSerializer.Deserialize<LevelBalanceFile>(json, options);
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 = ResolveMasks(input);
var padMin = Math.Min(input.MaskPadMin, input.MaskPadMax);
var padMax = Math.Max(input.MaskPadMin, input.MaskPadMax);
return new LevelBandConfig
{
StartId = input.StartId,
EndId = input.EndId,
BoxCountLow = input.BoxCountLow,
BoxCountHigh = input.BoxCountHigh,
MinAllowedPushes = input.MinAllowedPushes,
MinAllowedTurns = input.MinAllowedTurns,
MinAllowedBranching = input.MinAllowedBranching,
MinGoalDistance = input.MinGoalDistance > 0 ? input.MinGoalDistance : 1,
MinBoxDistance = input.MinBoxDistance > 0 ? input.MinBoxDistance : 1,
MinWallDistance = input.MinWallDistance >= 0 ? input.MinWallDistance : 0,
ReverseDepthScale = input.ReverseDepthScale > 0 ? input.ReverseDepthScale : 1.0,
ReverseBreadthScale = input.ReverseBreadthScale > 0 ? input.ReverseBreadthScale : 1.0,
PocketCarveMin = input.PocketCarveMin,
PocketCarveMax = input.PocketCarveMax,
PocketMaxRadius = input.PocketMaxRadius,
MaskPadMin = padMin,
MaskPadMax = padMax,
ShapeMasks = masks
};
}
private static List<string[]> ResolveMasks(LevelBandJson input)
{
var resolved = new List<string[]>();
var seen = new HashSet<string>();
var maskSets = input.MaskSets ?? new List<string>();
var hasExplicitSets = maskSets.Count > 0;
if (hasExplicitSets)
{
foreach (var raw in maskSets)
{
var name = raw?.Trim().ToLowerInvariant();
switch (name)
{
case "micro":
case "small":
AddMasks(resolved, seen, MaskLibrary.Microban, input.MaskTake);
break;
case "medium":
AddMasks(resolved, seen, MaskLibrary.Medium);
break;
case "large":
AddMasks(resolved, seen, MaskLibrary.Large);
break;
}
}
}
if (resolved.Count == 0)
{
AddMasks(resolved, seen, MaskLibrary.Microban, input.MaskTake);
}
return resolved;
}
private static void AddMasks(List<string[]> sink, HashSet<string> seen, List<string[]> source, int take = 0)
{
var picked = take > 0 ? source.Take(take) : source;
foreach (var mask in picked)
{
var key = string.Join("\n", mask);
if (seen.Add(key))
{
sink.Add(mask);
}
}
}
}
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();
private readonly HashSet<string> _seenPatterns = new();
private readonly StatusReporter _status;
public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList<LevelBandConfig> bands, StatusReporter status)
{
_rng = rng;
_tuning = tuning;
_bands = bands;
_status = status;
_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, band.MaskPadMin, band.MaskPadMax);
}
}
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 PocketSettings ResolvePockets(LevelBandConfig band)
{
var min = band.PocketCarveMin >= 0 ? band.PocketCarveMin : _tuning.PocketCarveMin;
var max = band.PocketCarveMax >= 0 ? band.PocketCarveMax : _tuning.PocketCarveMax;
var radius = band.PocketMaxRadius > 0 ? band.PocketMaxRadius : _tuning.PocketMaxRadius;
if (max < min) max = min;
if (radius < 1) radius = 1;
return new PocketSettings(min, max, radius);
}
private GeneratedLevel BuildSingle(int id, LevelBandConfig band)
{
var failReasons = _trace ? new Dictionary<string, int>() : null;
var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban;
band.ShapeMasksExpanded ??= MaskLibrary.ExpandWithTransforms(baseMasks, band.MaskPadMin, band.MaskPadMax);
var pockets = ResolvePockets(band);
for (var relax = 0; relax < _tuning.RelaxationSteps; relax++)
{
var attemptsLimit = _tuning.MaxAttemptsPerLevel + relax * 2000;
var timeLimit = _tuning.MaxMillisecondsPerLevel + relax * 20_000;
var minPush = Math.Max(1, band.MinAllowedPushes - relax * 1);
var minTurns = Math.Max(0, band.MinAllowedTurns - relax * 1);
var minBranch = Math.Max(0, band.MinAllowedBranching - relax * 1);
var minGoalDistance = Math.Max(1, band.MinGoalDistance - relax);
var minBoxDistance = Math.Max(1, band.MinBoxDistance - relax);
var minWallDistance = Math.Max(0, band.MinWallDistance - relax);
var depthScale = band.ReverseDepthScale * (1.0 + 0.2 * relax);
var breadthScale = band.ReverseBreadthScale * (1.0 + 0.25 * relax);
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 attempts = 0;
_status.Show($"레벨 {id} 생성중...");
while (attempts < attemptsLimit &&
stopwatch.ElapsedMilliseconds < timeLimit)
{
attempts++;
var mask = MaskLibrary.CreateVariant(
MaskLibrary.PickRandom(_rng, band.ShapeMasksExpanded),
_rng,
_tuning.ApplyMaskTransforms,
_tuning.MaskWallJitter);
var canvas = LayoutFactory.FromMask(mask, _rng, _tuning, pockets);
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;
}
if (!LayoutFilters.CheckSpacing(goals, canvas.Width, canvas.Height, minGoalDistance, minWallDistance))
{
NoteFail(failReasons, "goal_spacing");
continue;
}
var board = Board.FromCanvas(canvas);
var reverse = new ReverseSearch(board, reverseDepth, reverseBreadth);
var startState = reverse.FindStartState(goals, minPush, _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 < minPush || solve.Turns < minTurns || solve.Branching < minBranch)
{
NoteFail(failReasons, "too_easy");
continue;
}
if (!LayoutFilters.CheckSpacing(startState.Boxes, board.Width, board.Height, minBoxDistance, minWallDistance))
{
NoteFail(failReasons, "box_spacing");
continue;
}
if (_tuning.FilterCornerDeadlocks && LayoutFilters.HasCornerDeadlock(startState.Boxes, goals, board))
{
NoteFail(failReasons, "corner_deadlock");
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);
var patternKey = solve.PatternSignature;
if (!string.IsNullOrEmpty(patternKey))
{
if (_seenPatterns.Contains(patternKey))
{
NoteFail(failReasons, "pattern_duplicate");
continue;
}
_seenPatterns.Add(patternKey);
}
_status.Show($"레벨 {id} 생성완료 ({StatusReporter.FormatDuration(stopwatch.Elapsed)})");
return new GeneratedLevel
{
Id = id,
Grid = lines,
LowestPush = solve.Pushes,
PushLimit = pushLimit
};
}
}
_status.Show($"레벨 {id} 생성 실패");
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 {_tuning.MaxAttemptsPerLevel})");
}
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,
MinAllowedTurns = last.MinAllowedTurns,
MinAllowedBranching = last.MinAllowedBranching,
MinGoalDistance = last.MinGoalDistance,
MinBoxDistance = last.MinBoxDistance,
MinWallDistance = last.MinWallDistance,
ReverseDepthScale = last.ReverseDepthScale,
ReverseBreadthScale = last.ReverseBreadthScale,
PocketCarveMin = last.PocketCarveMin,
PocketCarveMax = last.PocketCarveMax,
PocketMaxRadius = last.PocketMaxRadius,
MaskPadMin = last.MaskPadMin,
MaskPadMax = last.MaskPadMax,
ShapeMasks = last.ShapeMasks,
ShapeMasksExpanded = last.ShapeMasksExpanded
};
}
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 int MinGoalDistance { get; init; } = 1;
public int MinBoxDistance { get; init; } = 1;
public int MinWallDistance { get; init; } = 0;
public double ReverseDepthScale { get; init; } = 1.0;
public double ReverseBreadthScale { get; init; } = 1.0;
public int PocketCarveMin { get; init; } = -1;
public int PocketCarveMax { get; init; } = -1;
public int PocketMaxRadius { get; init; } = -1;
public int MaskPadMin { get; init; } = -1;
public int MaskPadMax { get; init; } = 1;
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 MinGoalDistance { get; set; } = 1;
public int MinBoxDistance { get; set; } = 1;
public int MinWallDistance { get; set; } = 0;
public double ReverseDepthScale { get; set; } = 1.0;
public double ReverseBreadthScale { get; set; } = 1.0;
public int PocketCarveMin { get; set; } = -1;
public int PocketCarveMax { get; set; } = -1;
public int PocketMaxRadius { get; set; } = -1;
public int MaskPadMin { get; set; } = -1;
public int MaskPadMax { get; set; } = 1;
public List<string> MaskSets { get; set; } = new();
public int MaskTake { get; set; } = 0;
}
internal sealed class GenerationTuning
{
public int MaxAttemptsPerLevel { get; init; }
public int MaxMillisecondsPerLevel { get; init; }
public int RelaxationSteps { get; init; } = 3;
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;
public bool FilterCornerDeadlocks { get; init; } = true;
}
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 readonly record struct PocketSettings(int Min, int Max, int Radius);
internal static class LayoutFactory
{
public static GridCanvas FromMask(string[] mask, Random rng, GenerationTuning tuning, PocketSettings pockets)
{
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);
var pocketMin = pockets.Min >= 0 ? pockets.Min : tuning.PocketCarveMin;
var pocketMax = pockets.Max >= 0 ? pockets.Max : tuning.PocketCarveMax;
var pocketRadius = pockets.Radius > 0 ? pockets.Radius : tuning.PocketMaxRadius;
if (pocketMax > 0)
{
AddPockets(canvas, rng, pocketMin, pocketMax, pocketRadius);
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, int pocketMin, int pocketMax, int pocketRadius)
{
var pockets = rng.Next(pocketMin, pocketMax + 1);
for (var i = 0; i < pockets; i++)
{
var radius = rng.Next(1, pocketRadius + 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;
}
var signature = SolutionSignature.Build(path.Select(p => p.Dir), pushes, turns, branching, _board.Width);
return new SolveResult(pushes, turns, branching, signature);
}
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, string PatternSignature)
{
public static SolveResult Fail => new(-1, -1, -1, string.Empty);
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);
internal static class SolutionSignature
{
public static string Build(IEnumerable<int> dirs, int pushes, int turns, int branching, int boardWidth)
{
var dirList = dirs.ToList();
if (pushes <= 0 || dirList.Count == 0) return string.Empty;
var chars = dirList.Select(d => DirToChar(d, boardWidth)).Where(c => c != '?').ToList();
if (chars.Count == 0) return string.Empty;
var lenBin = pushes / 5;
var turnBin = turns / 2;
var branchBin = branching / 2;
var dirMix = DirectionMix(chars, pushes);
var runSig = RunSignature(chars, 8);
var turnSig = TurnPattern(chars, 12);
return $"{lenBin}:{turnBin}:{branchBin}:{dirMix}:{runSig}:{turnSig}";
}
private static string DirectionMix(List<char> chars, int pushes)
{
var counts = new Dictionary<char, int> { ['U'] = 0, ['D'] = 0, ['L'] = 0, ['R'] = 0 };
foreach (var c in chars)
{
if (counts.ContainsKey(c)) counts[c]++;
}
var bins = new List<int>(4);
foreach (var key in new[] { 'U', 'D', 'L', 'R' })
{
var ratio = (double)counts[key] / Math.Max(1, pushes);
var bucket = (int)Math.Round(ratio * 5);
bins.Add(Math.Clamp(bucket, 0, 5));
}
return string.Join("", bins);
}
private static string RunSignature(List<char> chars, int maxParts)
{
if (chars.Count == 0) return string.Empty;
var parts = new List<string>();
var current = chars[0];
var count = 1;
void Flush()
{
if (parts.Count >= maxParts) return;
parts.Add($"{current}{BucketLength(count)}");
}
for (var i = 1; i < chars.Count; i++)
{
if (chars[i] == current)
{
count++;
}
else
{
Flush();
current = chars[i];
count = 1;
}
}
Flush();
var truncated = parts.Take(maxParts).ToList();
if (parts.Count > maxParts)
{
truncated.Add($"+{parts.Count - maxParts}");
}
return string.Join("", truncated);
}
private static string TurnPattern(List<char> chars, int maxParts)
{
if (chars.Count < 2) return string.Empty;
var turns = new List<char>();
for (var i = 1; i < chars.Count; i++)
{
turns.Add(RelativeTurn(chars[i - 1], chars[i]));
}
var sampled = turns.Take(maxParts).ToList();
if (turns.Count > maxParts) sampled.Add('+');
return new string(sampled.ToArray());
}
private static char RelativeTurn(char prev, char next)
{
var dirIndex = new Dictionary<char, int> { ['U'] = 0, ['R'] = 1, ['D'] = 2, ['L'] = 3 };
if (!dirIndex.ContainsKey(prev) || !dirIndex.ContainsKey(next)) return '?';
var delta = (dirIndex[next] - dirIndex[prev] + 4) % 4;
return delta switch
{
0 => 'S', // straight
1 => 'R',
3 => 'L',
2 => 'B',
_ => '?'
};
}
private static char BucketLength(int len)
{
if (len <= 1) return '1';
if (len == 2) return '2';
if (len <= 4) return '4';
if (len <= 6) return '6';
if (len <= 9) return '9';
if (len <= 12) return 'c';
return 'm';
}
private static char DirToChar(int dir, int width)
{
if (dir == -width) return 'U';
if (dir == width) return 'D';
if (dir == -1) return 'L';
if (dir == 1) return 'R';
return '?';
}
}
internal static class LayoutFilters
{
public static bool CheckSpacing(IEnumerable<int> indices, int width, int height, int minDistance, int minWallDistance)
{
var arr = indices as int[] ?? indices.ToArray();
if (arr.Length == 0) return true;
if (minDistance > 1 && arr.Length > 1 && MinPairwiseDistance(arr, width) < minDistance)
{
return false;
}
if (minWallDistance > 0 && MinDistanceToWall(arr, width, height) < minWallDistance)
{
return false;
}
return true;
}
public static int MinPairwiseDistance(IReadOnlyList<int> indices, int width)
{
if (indices.Count <= 1) return int.MaxValue;
var min = int.MaxValue;
for (var i = 0; i < indices.Count; i++)
{
for (var j = i + 1; j < indices.Count; j++)
{
var dist = Manhattan(indices[i], indices[j], width);
if (dist < min) min = dist;
}
}
return min;
}
private static int Manhattan(int a, int b, int width)
{
var ax = a % width;
var ay = a / width;
var bx = b % width;
var by = b / width;
return Math.Abs(ax - bx) + Math.Abs(ay - by);
}
public static int MinDistanceToWall(IReadOnlyList<int> indices, int width, int height)
{
if (indices.Count == 0) return int.MaxValue;
var min = int.MaxValue;
foreach (var idx in indices)
{
var x = idx % width;
var y = idx / width;
var dist = Math.Min(Math.Min(x, width - 1 - x), Math.Min(y, height - 1 - y));
if (dist < min) min = dist;
}
return min;
}
public static bool HasCornerDeadlock(IEnumerable<int> boxes, HashSet<int> goals, Board board)
{
foreach (var box in boxes)
{
if (goals.Contains(box)) continue;
if (IsCorner(box, board)) return true;
}
return false;
}
private static bool IsCorner(int idx, Board board)
{
var w = board.Width;
var h = board.Height;
var x = idx % w;
var y = idx / w;
// Out of bounds safety
if (x < 0 || y < 0 || x >= w || y >= h) return false;
bool Solid(int xx, int yy)
{
if (xx < 0 || yy < 0 || xx >= w || yy >= h) return true;
return board.IsSolid(yy * w + xx);
}
var north = Solid(x, y - 1);
var south = Solid(x, y + 1);
var west = Solid(x - 1, y);
var east = Solid(x + 1, y);
var cornerNW = north && west;
var cornerNE = north && east;
var cornerSW = south && west;
var cornerSE = south && east;
return cornerNW || cornerNE || cornerSW || cornerSE;
}
}