// 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 [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.Json.Serialization; 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 = 100_000, MaxMillisecondsPerLevel = 2_000_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) { // 트리밍 모드: --trim <입력 json> [출력 json] [startId] [endId] if (args.Length > 0 && args[0].Equals("--trim", StringComparison.OrdinalIgnoreCase)) { if (args.Length < 2) { Console.Error.WriteLine("사용법: dotnet run -- --trim <입력 json> [출력 json] [startId] [endId]"); return; } var input = args[1]; var output = args.Length >= 3 && !args[2].StartsWith("--", StringComparison.Ordinal) ? args[2] : "trimmed_output.json"; var argOffset = args.Length >= 3 && !args[2].StartsWith("--", StringComparison.Ordinal) ? 3 : 2; int? trimStart = null; int? trimEnd = null; if (args.Length > argOffset && int.TryParse(args[argOffset], out var ts)) trimStart = ts; if (args.Length > argOffset + 1 && int.TryParse(args[argOffset + 1], out var te)) trimEnd = te; RunTrim(input, output, trimStart, trimEnd); return; } // 검증 모드: --verify [endId] if (args.Length > 0 && args[0].Equals("--verify", StringComparison.OrdinalIgnoreCase)) { if (args.Length < 3 || !int.TryParse(args[2], out var verifyStart)) { Console.Error.WriteLine("사용법: dotnet run -- --verify [endId]"); return; } var verifyEnd = verifyStart; if (args.Length >= 4 && int.TryParse(args[3], out var parsedEnd)) { verifyEnd = parsedEnd; } RunVerification(args[1], verifyStart, verifyEnd); return; } // 전체 검증 모드: --verify-all if (args.Length > 0 && args[0].Equals("--verify-all", StringComparison.OrdinalIgnoreCase)) { if (args.Length < 2) { Console.Error.WriteLine("사용법: dotnet run -- --verify-all "); return; } RunVerification(args[1], int.MinValue, int.MaxValue); return; } 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 totalWatch = Stopwatch.StartNew(); var status = new StatusReporter(); var generator = new LevelGenerator(rng, Tuning, levelBands, status, totalWatch); 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 options = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var checkpointPath = Environment.GetEnvironmentVariable("NEKOBAN_CHECKPOINT"); // 비워 있으면 기본 경로를 사용한다. (쉘 리다이렉션과 별개로 누적 체크포인트를 보관) checkpointPath = string.IsNullOrWhiteSpace(checkpointPath) ? "stage_checkpoint.json" : checkpointPath; var checkpointEnabled = !string.IsNullOrWhiteSpace(checkpointPath); void SaveCheckpoint(List snapshot, int lastId, TimeSpan levelElapsed) { if (!checkpointEnabled) return; // 매 레벨마다(또는 마지막 레벨) 누적 JSON을 디스크에 기록한다. try { var json = JsonSerializer.Serialize(snapshot, options); File.WriteAllText(checkpointPath, json); status.ShowLine($"체크포인트 저장: 레벨 {lastId}까지 ({StatusReporter.FormatDuration(levelElapsed)}) -> {checkpointPath}"); } catch (Exception ex) { status.ShowLine($"[warn] 체크포인트 저장 실패({checkpointPath}): {ex.Message}"); } } var levels = generator.BuildRange(startId, endId, SaveCheckpoint); totalWatch.Stop(); 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 void RunVerification(string jsonPath, int startId, int endId) { var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; if (!File.Exists(jsonPath)) { Console.Error.WriteLine($"파일을 찾을 수 없습니다: {jsonPath}"); return; } List? levels; try { var json = File.ReadAllText(jsonPath); levels = JsonSerializer.Deserialize>(json, options); } catch (Exception ex) { Console.Error.WriteLine($"[verify] JSON을 읽거나 파싱하는 데 실패했습니다: {ex.Message}"); return; } if (levels == null || levels.Count == 0) { Console.Error.WriteLine("[verify] 레벨 데이터가 없습니다."); return; } if (startId > endId) { (startId, endId) = (endId, startId); } var targets = levels.Where(l => l.Id >= startId && l.Id <= endId) .OrderBy(l => l.Id) .ToList(); if (targets.Count == 0) { Console.Error.WriteLine($"[verify] 지정 범위에 레벨이 없습니다. 범위: {startId}~{endId}"); return; } var ok = 0; foreach (var level in targets) { var result = LevelVerifier.Verify(level); if (result.Success) { ok++; Console.WriteLine($"[OK] id {level.Id} | moves {result.Moves}, pushes {result.Pushes}, turns {result.Turns}"); } else { Console.WriteLine($"[FAIL] id {level.Id} | {result.ErrorMessage}"); } } Console.WriteLine($"[verify] 완료: 성공 {ok}/{targets.Count} (범위 {startId}~{endId})"); } private static void RunTrim(string inputPath, string outputPath, int? startId, int? endId) { var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; if (!File.Exists(inputPath)) { Console.Error.WriteLine($"파일을 찾을 수 없습니다: {inputPath}"); return; } List? levels; try { var json = File.ReadAllText(inputPath); levels = JsonSerializer.Deserialize>(json, options); } catch (Exception ex) { Console.Error.WriteLine($"[trim] JSON을 읽거나 파싱하는 데 실패했습니다: {ex.Message}"); return; } if (levels == null || levels.Count == 0) { Console.Error.WriteLine("[trim] 레벨 데이터가 없습니다."); return; } if (startId.HasValue && endId.HasValue && startId > endId) { (startId, endId) = (endId, startId); } var trimmed = new List(levels.Count); foreach (var level in levels) { if (startId.HasValue && level.Id < startId.Value) { trimmed.Add(level); continue; } if (endId.HasValue && level.Id > endId.Value) { trimmed.Add(level); continue; } var newGrid = LevelTrimmer.Trim(level.Grid); trimmed.Add(new GeneratedLevel { Id = level.Id, Grid = newGrid, LowestPush = level.LowestPush, PushLimit = level.PushLimit, MoveCount = level.MoveCount }); } try { var json = JsonSerializer.Serialize(trimmed, options); File.WriteAllText(outputPath, json); Console.WriteLine($"[trim] 완료: {inputPath} -> {outputPath} (대상 id {startId ?? int.MinValue}~{endId ?? int.MaxValue})"); } catch (Exception ex) { Console.Error.WriteLine($"[trim] 저장 실패: {ex.Message}"); } } private static LevelBandConfig[] LoadLevelBands() { try { if (File.Exists(LevelBalancePath)) { var json = File.ReadAllText(LevelBalancePath); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var config = JsonSerializer.Deserialize(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, ApplyTransforms = input.ApplyTransforms }; } private static List ResolveMasks(LevelBandJson input) { var resolved = new List(); var seen = new HashSet(); var maskSets = input.MaskSets ?? new List(); 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; case "tall8": AddMasks(resolved, seen, MaskLibrary.Tall8); break; case "tall9": AddMasks(resolved, seen, MaskLibrary.Tall9); break; case "tall10": AddMasks(resolved, seen, MaskLibrary.Tall10); break; case "tall11": AddMasks(resolved, seen, MaskLibrary.Tall11); break; case "tall12": AddMasks(resolved, seen, MaskLibrary.Tall12); break; } } } if (resolved.Count == 0) { AddMasks(resolved, seen, MaskLibrary.Microban, input.MaskTake); } return resolved; } private static void AddMasks(List sink, HashSet seen, List 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 _bands; private readonly bool _trace; private readonly HashSet _seenLayouts = new(); private readonly HashSet _seenPatterns = new(); private readonly StatusReporter _status; private readonly Stopwatch _totalWatch; public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList bands, StatusReporter status, Stopwatch totalWatch) { _rng = rng; _tuning = tuning; _bands = bands; _status = status; _totalWatch = totalWatch; _trace = Environment.GetEnvironmentVariable("NEKOBAN_DEBUG") == "1"; } public List BuildRange(int startId, int endId, Action, int, TimeSpan>? onCheckpoint = null) { 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, band.ApplyTransforms); } } var output = new List(); for (var id = startId; id <= endId; id++) { var band = ResolveBand(id); var levelStopwatch = Stopwatch.StartNew(); var level = BuildSingle(id, band, levelStopwatch); level = TrimLevel(level); levelStopwatch.Stop(); output.Add(level); onCheckpoint?.Invoke(output, id, levelStopwatch.Elapsed); } 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, Stopwatch levelStopwatch) { // 완화 단계를 순차적으로 적용한다. (총 5단계 + 비상 1단계) var stages = new List { new RelaxStage("기본", b => CloneBand(b)), new RelaxStage("완화1-임계치", bandConfig => { var c = CloneBand(bandConfig); c.MinAllowedPushes = Math.Max(1, c.MinAllowedPushes - 3); c.MinAllowedTurns = Math.Max(0, c.MinAllowedTurns - 1); c.MinAllowedBranching = Math.Max(0, c.MinAllowedBranching - 1); return c; }), new RelaxStage("완화2-마스크확장", bandConfig => { var c = CloneBand(bandConfig); c.MaskPadMin = -2; c.MaskPadMax = -2; c.ShapeMasks = MaskLibrary.Medium.Concat(MaskLibrary.Large).ToList(); c.ShapeMasksExpanded = null; // 변형/패딩을 다시 계산하도록 초기화 return c; }), new RelaxStage("완화3-역탐색확대", bandConfig => { var c = CloneBand(bandConfig); c.ReverseDepthScale = Math.Max(1.0, c.ReverseDepthScale * 1.25); c.ReverseBreadthScale = Math.Max(1.0, c.ReverseBreadthScale * 1.25); return c; }), new RelaxStage("완화4-박스감소", bandConfig => { var c = CloneBand(bandConfig); c.BoxCountLow = Math.Max(1, c.BoxCountLow - 1); c.BoxCountHigh = Math.Max(c.BoxCountLow, c.BoxCountHigh - 1); return c; }), new RelaxStage("완화5-간격완화", bandConfig => { var c = CloneBand(bandConfig); c.MinGoalDistance = Math.Max(1, c.MinGoalDistance - 1); c.MinBoxDistance = Math.Max(1, c.MinBoxDistance - 1); c.MinWallDistance = Math.Max(0, c.MinWallDistance - 1); return c; }) }; // 밴드 강등 시도: 현재 밴드 -> 직전 10레벨 밴드 -> 직전 20레벨 밴드 var bandCandidates = new List { band }; var minStartId = _bands.Min(b => b.StartId); foreach (var delta in new[] { 10, 20 }) { var fallbackId = Math.Max(minStartId, id - delta); if (fallbackId < id) { var fb = CloneBand(ResolveBand(fallbackId)); fb.StartId = band.StartId; fb.EndId = band.EndId; bandCandidates.Add(fb); } } // 시드 변조 재시도 + 완화 단계 루프 var retry = 0; while (true) { foreach (var bandCandidate in bandCandidates) { var jitterSeed = _rng.Next() ^ (id * 7919) ^ (retry * 104729); var localRng = new Random(jitterSeed); foreach (var stage in stages) { var bandForStage = stage.Adjust(bandCandidate); var relaxOverride = stage.OverrideRelaxSteps; if (TryBuildStage(id, bandForStage, stage.Label, localRng, relaxOverride, levelStopwatch, out var level)) { return level; } } } retry++; } } private bool TryBuildStage(int id, LevelBandConfig band, string stageLabel, Random rng, int? overrideRelaxSteps, Stopwatch levelStopwatch, out GeneratedLevel level) { var failReasons = _trace ? new Dictionary() : null; var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban; band.ShapeMasksExpanded ??= MaskLibrary.ExpandWithTransforms(baseMasks, band.MaskPadMin, band.MaskPadMax, band.ApplyTransforms); var pockets = ResolvePockets(band); var relaxSteps = overrideRelaxSteps ?? _tuning.RelaxationSteps; for (var relax = 0; relax < relaxSteps; 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 stageStopwatch = Stopwatch.StartNew(); var attempts = 0; while (attempts < attemptsLimit && stageStopwatch.ElapsedMilliseconds < timeLimit) { attempts++; // 진행 상태가 살아 있음을 보여주기 위해, 1초 단위 점(.) 애니메이션을 출력한다. // 예) "레벨 214 [기본] 생성중.", "..", "..." 식으로 순환. _status.ShowProgress($"레벨 {id} [{stageLabel}] 생성중", levelStopwatch.Elapsed, _totalWatch.Elapsed); var mask = MaskLibrary.CreateVariant( MaskLibrary.PickRandom(rng, band.ShapeMasksExpanded), rng, _tuning.ApplyMaskTransforms && band.ApplyTransforms, _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, '@'); // 최종 외벽 정규화; 토큰이 가장자리에 닿으면 실패 처리. 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} [{stageLabel}] 생성완료 ({StatusReporter.FormatDuration(levelStopwatch.Elapsed)})"); level = new GeneratedLevel { Id = id, Grid = lines, LowestPush = solve.Pushes, PushLimit = pushLimit, MoveCount = solve.Moves }; return true; } } _status.Show($"레벨 {id} [{stageLabel}] 생성 실패"); if (_trace && failReasons != null) { Console.Error.WriteLine($"[trace] level {id} stage {stageLabel} failed. Reasons:"); foreach (var kv in failReasons.OrderByDescending(kv => kv.Value)) { Console.Error.WriteLine($" - {kv.Key}: {kv.Value}"); } } level = default!; return false; } private static LevelBandConfig CloneBand(LevelBandConfig src) { // 완화 단계마다 원본 밴드를 손상시키지 않도록 복사본을 만든다. return new LevelBandConfig { StartId = src.StartId, EndId = src.EndId, BoxCountLow = src.BoxCountLow, BoxCountHigh = src.BoxCountHigh, MinAllowedPushes = src.MinAllowedPushes, MinAllowedTurns = src.MinAllowedTurns, MinAllowedBranching = src.MinAllowedBranching, MinGoalDistance = src.MinGoalDistance, MinBoxDistance = src.MinBoxDistance, MinWallDistance = src.MinWallDistance, ReverseDepthScale = src.ReverseDepthScale, ReverseBreadthScale = src.ReverseBreadthScale, PocketCarveMin = src.PocketCarveMin, PocketCarveMax = src.PocketCarveMax, PocketMaxRadius = src.PocketMaxRadius, MaskPadMin = src.MaskPadMin, MaskPadMax = src.MaskPadMax, ApplyTransforms = src.ApplyTransforms, ShapeMasks = src.ShapeMasks.ToList(), ShapeMasksExpanded = src.ShapeMasksExpanded?.ToList() }; } private sealed record RelaxStage(string Label, Func Adjust, int? OverrideRelaxSteps = null); 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? sink, string reason) { if (sink == null) return; sink[reason] = sink.TryGetValue(reason, out var count) ? count + 1 : 1; } // 생성된 레벨을 자동으로 트림하여 외곽 void(0) 패딩을 제거한다. private static GeneratedLevel TrimLevel(GeneratedLevel level) { var trimmed = LevelTrimmer.Trim(level.Grid); return new GeneratedLevel { Id = level.Id, Grid = trimmed, LowestPush = level.LowestPush, PushLimit = level.PushLimit, MoveCount = level.MoveCount }; } } internal static class LevelTrimmer { // 그리드에서 바깥쪽이 전부 '0'인 행/열을 제거해 압축한다. public static List Trim(List grid) { if (grid.Count == 0) return grid; var height = grid.Count; var width = grid.Max(r => r.Length); if (width == 0) return grid; // 패딩하여 동일 너비로 정규화 var padded = grid.Select(r => r.PadRight(width, '0')).ToArray(); // 유지할 행/열 계산 var keepRows = Enumerable.Range(0, height) .Where(y => padded[y].Any(ch => ch != '0')) .ToList(); var keepCols = Enumerable.Range(0, width) .Where(x => padded.Any(row => row[x] != '0')) .ToList(); if (keepRows.Count == 0 || keepCols.Count == 0) return grid; var trimmed = new List(keepRows.Count); foreach (var y in keepRows) { var chars = keepCols.Select(x => padded[y][x]).ToArray(); trimmed.Add(new string(chars)); } return trimmed; } } internal readonly record struct ParsedLevel(Board Board, int Player, int[] Boxes, HashSet Goals); internal readonly record struct VerificationResult(int Id, bool Success, int? Moves, int? Pushes, int? Turns, string? ErrorMessage); internal static class LevelVerifier { public static VerificationResult Verify(GeneratedLevel level) { if (!TryParseLevel(level, out var parsed, out var error)) { return new VerificationResult(level.Id, false, null, null, null, error ?? "parse_error"); } var solver = new SokobanSolver(parsed.Board); var solve = solver.SolveDetailed(parsed.Player, parsed.Boxes, parsed.Goals); if (solve.IsFail) { return new VerificationResult(level.Id, false, null, null, null, "unsolvable"); } return new VerificationResult(level.Id, true, solve.Moves, solve.Pushes, solve.Turns, null); } private static bool TryParseLevel(GeneratedLevel level, out ParsedLevel parsed, out string? error) { parsed = default; error = null; if (level.Grid == null || level.Grid.Count == 0) { error = "grid_missing"; return false; } var height = level.Grid.Count; var width = level.Grid.Max(row => row.Length); var canvas = new GridCanvas(width, height); var goals = new HashSet(); var boxes = new List(); var player = -1; for (var y = 0; y < height; y++) { var row = level.Grid[y]; for (var x = 0; x < width; x++) { var c = x < row.Length ? row[x] : '0'; if (c == '\0') c = '0'; canvas.Set(x, y, c); var idx = y * width + x; switch (c) { case 'G': goals.Add(idx); break; case '$': boxes.Add(idx); break; case '@': player = idx; break; } } } if (player < 0) { error = "player_not_found"; return false; } if (boxes.Count == 0) { error = "no_boxes"; return false; } if (goals.Count == 0) { error = "no_goals"; return false; } var board = Board.FromCanvas(canvas); parsed = new ParsedLevel(board, player, boxes.ToArray(), goals); return true; } } internal sealed class GeneratedLevel { [JsonPropertyOrder(0)] public int Id { get; init; } [JsonPropertyOrder(1)] public List Grid { get; init; } = new(); [JsonPropertyOrder(2)] public int LowestPush { get; init; } [JsonPropertyOrder(3)] public int PushLimit { get; init; } [JsonPropertyOrder(4)] public int MoveCount { get; init; } } internal sealed class LevelBandConfig { 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; } = 0; public int MinAllowedBranching { get; set; } = 0; 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 bool ApplyTransforms { get; set; } = true; public List ShapeMasks { get; set; } = new(); public List? ShapeMasksExpanded { get; set; } } internal sealed class LevelBalanceFile { public List 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 bool ApplyTransforms { get; set; } = true; public List 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 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 ToLines() { var lines = new List(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 goals, out int[] boxes, out int player) { goals = new HashSet(); boxes = Array.Empty(); player = -1; var candidates = Walkable(canvas).ToList(); var minRequired = boxCount + 2; if (candidates.Count < minRequired) { return false; } Shuffle(candidates, rng); var softGoals = new List(); 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(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()); 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 FloodFrom(int start, GridCanvas canvas, int[] boxes) { var visited = new HashSet(); if (IsSolid(canvas.Get(start)) || Array.BinarySearch(boxes, start) >= 0) return visited; var queue = new Queue(); 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 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 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(IList 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 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(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 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 goals) { Array.Sort(boxes); var start = new SolverState(player, boxes); var visited = new HashSet(_comparer) { start }; var parents = new Dictionary(_comparer); var queue = new Queue(); queue.Enqueue(start); while (queue.Count > 0) { var state = queue.Dequeue(); if (IsSolved(state.Boxes, goals)) { return BuildResult(state, parents); } foreach (var (next, dir, moveCost) in Expand(state)) { if (visited.Add(next)) { parents[next] = (state, dir, moveCost); queue.Enqueue(next); } } } return SolveResult.Fail; } private IEnumerable<(SolverState State, int Dir, int MoveCost)> Expand(SolverState state) { var reachable = ReachableWithDistances(state.Player, state.Boxes); foreach (var kv in reachable) { var pos = kv.Key; var walk = kv.Value; 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); var moveCost = checked(walk + 1); // 걷기 + 푸시 1회 yield return (new SolverState(boxPos, nextBoxes), dir, moveCost); } } } private SolveResult BuildResult(SolverState solved, Dictionary parents) { var pushes = 0; var turns = 0; var branching = 0; var moves = 0; var path = new List<(SolverState State, int Dir, int MoveCost)>(); var current = solved; while (parents.TryGetValue(current, out var entry)) { path.Add((current, entry.Dir, entry.MoveCost)); current = entry.Parent; } path.Reverse(); var prevDir = 0; foreach (var (_, dir, moveCost) in path) { pushes++; moves += moveCost; 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, moves, signature); } private Dictionary ReachableWithDistances(int start, int[] boxes) { var distances = Enumerable.Repeat(int.MaxValue, _board.Size).ToArray(); var queue = new Queue(); if (_board.IsSolid(start) || Array.BinarySearch(boxes, start) >= 0) return new Dictionary(); distances[start] = 0; queue.Enqueue(start); while (queue.Count > 0) { var current = queue.Dequeue(); foreach (var dir in _dirs) { var next = current + dir; if (!IndexInBounds(next)) continue; if (distances[next] != int.MaxValue) continue; if (_board.IsSolid(next) || Array.BinarySearch(boxes, next) >= 0) continue; distances[next] = distances[current] + 1; queue.Enqueue(next); } } var result = new Dictionary(); for (var i = 0; i < distances.Length; i++) { if (distances[i] != int.MaxValue) { result[i] = distances[i]; } } return result; } private bool IsSolved(int[] boxes, HashSet 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, int Moves, string PatternSignature) { public static SolveResult Fail => new(-1, -1, -1, -1, string.Empty); public bool IsFail => Pushes < 0; } internal sealed class StateComparer : IEqualityComparer { 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 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 chars, int pushes) { var counts = new Dictionary { ['U'] = 0, ['D'] = 0, ['L'] = 0, ['R'] = 0 }; foreach (var c in chars) { if (counts.ContainsKey(c)) counts[c]++; } var bins = new List(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 chars, int maxParts) { if (chars.Count == 0) return string.Empty; var parts = new List(); 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 chars, int maxParts) { if (chars.Count < 2) return string.Empty; var turns = new List(); 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 { ['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 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 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 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 boxes, HashSet 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; } }