feat: expand masks, structural filters, and solution similarity dedupe

This commit is contained in:
JiWoong Sul
2025-11-22 02:13:14 +09:00
parent a3b6dc0ce4
commit 8c351c5c05
5 changed files with 796 additions and 117 deletions

View File

@@ -10,6 +10,7 @@ using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text;
internal static class Program
{
@@ -24,6 +25,17 @@ internal static class Program
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
@@ -34,7 +46,18 @@ internal static class Program
BoxCountHigh = 2,
MinAllowedPushes = 7,
MinAllowedTurns = 3,
ShapeMasks = MaskLibrary.Microban.ToList()
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
{
@@ -44,14 +67,26 @@ internal static class Program
BoxCountHigh = 3,
MinAllowedPushes = 9,
MinAllowedTurns = 4,
ShapeMasks = MaskLibrary.Microban.ToList()
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 = 2000,
MaxMillisecondsPerLevel = 40_000,
MaxAttemptsPerLevel = 10_000,
MaxMillisecondsPerLevel = 200_000,
RelaxationSteps = 3,
PushLimitPadding = 2,
PushLimitScale = 0.35,
DynamicGrowthWindow = 12,
@@ -74,7 +109,8 @@ internal static class Program
var rng = new Random(seed);
var levelBands = LoadLevelBands();
var generator = new LevelGenerator(rng, Tuning, levelBands);
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);
@@ -94,7 +130,9 @@ internal static class Program
(startId, endId) = (endId, startId);
}
var totalWatch = Stopwatch.StartNew();
var levels = generator.BuildRange(startId, endId);
totalWatch.Stop();
var options = new JsonSerializerOptions
{
WriteIndented = true,
@@ -102,6 +140,9 @@ internal static class Program
};
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()
@@ -111,7 +152,8 @@ internal static class Program
if (File.Exists(LevelBalancePath))
{
var json = File.ReadAllText(LevelBalancePath);
var config = JsonSerializer.Deserialize<LevelBalanceFile>(json);
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();
@@ -128,11 +170,9 @@ internal static class Program
private static LevelBandConfig ConvertBand(LevelBandJson input)
{
var masks = MaskLibrary.Microban;
if (input.MaskTake > 0)
{
masks = masks.Take(input.MaskTake).ToList();
}
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,
@@ -142,9 +182,68 @@ internal static class Program
MinAllowedPushes = input.MinAllowedPushes,
MinAllowedTurns = input.MinAllowedTurns,
MinAllowedBranching = input.MinAllowedBranching,
ShapeMasks = masks.ToList()
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
@@ -154,12 +253,15 @@ internal sealed class LevelGenerator
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)
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";
}
@@ -170,7 +272,7 @@ internal sealed class LevelGenerator
if (band.ShapeMasksExpanded == null || band.ShapeMasksExpanded.Count == 0)
{
var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban;
band.ShapeMasksExpanded = MaskLibrary.ExpandWithTransforms(baseMasks);
band.ShapeMasksExpanded = MaskLibrary.ExpandWithTransforms(baseMasks, band.MaskPadMin, band.MaskPadMax);
}
}
@@ -185,96 +287,151 @@ internal sealed class LevelGenerator
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 stopwatch = Stopwatch.StartNew();
var attempts = 0;
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);
while (attempts < _tuning.MaxAttemptsPerLevel &&
stopwatch.ElapsedMilliseconds < _tuning.MaxMillisecondsPerLevel)
for (var relax = 0; relax < _tuning.RelaxationSteps; relax++)
{
attempts++;
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 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 stopwatch = Stopwatch.StartNew();
var attempts = 0;
_status.Show($"레벨 {id} 생성중...");
var canvas = LayoutFactory.FromMask(mask, _rng, _tuning);
var boxCount = _rng.Next(band.BoxCountLow, band.BoxCountHigh + 1);
if (boxCount <= 0)
while (attempts < attemptsLimit &&
stopwatch.ElapsedMilliseconds < timeLimit)
{
boxCount = 1;
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
};
}
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
};
}
_status.Show($"레벨 {id} 생성 실패");
if (_trace && failReasons != null)
{
Console.Error.WriteLine($"[trace] level {id} failed. Reasons:");
@@ -284,7 +441,7 @@ internal sealed class LevelGenerator
}
}
throw new InvalidOperationException($"레벨 {id} 생성 실패 (attempts {attempts}).");
throw new InvalidOperationException($"레벨 {id} 생성 실패 (attempts {_tuning.MaxAttemptsPerLevel})");
}
private LevelBandConfig ResolveBand(int id)
@@ -307,7 +464,20 @@ internal sealed class LevelGenerator
BoxCountLow = last.BoxCountLow + growthStep / 2,
BoxCountHigh = last.BoxCountHigh + growthStep / 2,
MinAllowedPushes = last.MinAllowedPushes + growthStep * _tuning.DynamicPushIncrement,
ShapeMasks = last.ShapeMasks
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
};
}
@@ -335,6 +505,16 @@ internal sealed class LevelBandConfig
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; }
}
@@ -353,6 +533,17 @@ internal sealed class LevelBandJson
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;
}
@@ -360,6 +551,7 @@ 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; }
@@ -371,6 +563,7 @@ internal sealed class GenerationTuning
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
@@ -436,9 +629,11 @@ internal sealed class GridCanvas
}
}
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)
public static GridCanvas FromMask(string[] mask, Random rng, GenerationTuning tuning, PocketSettings pockets)
{
var height = mask.Length;
var width = mask.Max(row => row.Length);
@@ -456,9 +651,13 @@ internal static class LayoutFactory
NormalizeOuterWalls(canvas);
if (tuning.PocketCarveMax > 0)
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, tuning);
AddPockets(canvas, rng, pocketMin, pocketMax, pocketRadius);
NormalizeOuterWalls(canvas);
}
@@ -565,12 +764,12 @@ internal static class LayoutFactory
return true;
}
private static void AddPockets(GridCanvas canvas, Random rng, GenerationTuning tuning)
private static void AddPockets(GridCanvas canvas, Random rng, int pocketMin, int pocketMax, int pocketRadius)
{
var pockets = rng.Next(tuning.PocketCarveMin, tuning.PocketCarveMax + 1);
var pockets = rng.Next(pocketMin, pocketMax + 1);
for (var i = 0; i < pockets; i++)
{
var radius = rng.Next(1, tuning.PocketMaxRadius + 1);
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++)
@@ -966,7 +1165,8 @@ internal sealed class SokobanSolver
current = entry2.Parent;
}
return new SolveResult(pushes, turns, branching);
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)
@@ -1013,9 +1213,9 @@ internal sealed class SokobanSolver
internal readonly record struct SolverState(int Player, int[] Boxes);
internal readonly record struct SolveResult(int Pushes, int Turns, int Branching)
internal readonly record struct SolveResult(int Pushes, int Turns, int Branching, string PatternSignature)
{
public static SolveResult Fail => new(-1, -1, -1);
public static SolveResult Fail => new(-1, -1, -1, string.Empty);
public bool IsFail => Pushes < 0;
}
@@ -1049,3 +1249,222 @@ internal sealed class StateComparer : IEqualityComparer<SolverState>
}
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;
}
}