From 8c351c5c050d6a4c22561d65c46b31fa6f503ca7 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Sat, 22 Nov 2025 02:13:14 +0900 Subject: [PATCH] feat: expand masks, structural filters, and solution similarity dedupe --- Program.cs | 627 ++++++++++++++++++++++++++++++++++++++-------- README.md | 7 +- StatusReporter.cs | 63 +++++ levelbalance.json | 37 ++- mask_library.cs | 179 ++++++++++++- 5 files changed, 796 insertions(+), 117 deletions(-) create mode 100644 StatusReporter.cs diff --git a/Program.cs b/Program.cs index f58da2a..f466f03 100644 --- a/Program.cs +++ b/Program.cs @@ -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(json); + 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(); @@ -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 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; + } + } + } + + 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 @@ -154,12 +253,15 @@ internal sealed class LevelGenerator private readonly IReadOnlyList _bands; private readonly bool _trace; private readonly HashSet _seenLayouts = new(); + private readonly HashSet _seenPatterns = new(); + private readonly StatusReporter _status; - public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList bands) + public LevelGenerator(Random rng, GenerationTuning tuning, IReadOnlyList 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() : 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 ShapeMasks { get; init; } = new(); public List? 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 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 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 } 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; + } +} diff --git a/README.md b/README.md index 62a79b1..07bb8dc 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ dotnet run -- [startId] [endId] > output.json - 진행 상태는 stderr에 한 줄로 갱신됩니다(벽 생성중/박스 배치중/검증중/생성완료). ## 설정 -- `levelbalance.json`: 레벨 밴드 설정(레벨 범위, 박스 수, 최소 푸시/턴/브랜칭, 사용할 마스크 개수). 이 파일을 수정해 난이도/범위를 조정합니다. -- `mask_library.cs`: Microban/Novoban 스타일의 외곽 마스크 모음. 회전/반전/스케일 변형과 소규모 벽 지터가 자동 적용됩니다. 마스크를 추가해 형태 다양성을 늘릴 수 있습니다. -- `Program.cs`: 생성 튜닝(`GenerationTuning`)에 시도 횟수, 시간 한도, 역탐색 깊이/폭, 외벽 정규화 등이 정의되어 있습니다. +- `levelbalance.json`: 레벨 밴드 설정(레벨 범위, 박스 수, 최소 푸시/턴/브랜칭, 마스크 세트/확장 범위, 역탐색 깊이/폭 스케일, 목표/박스 간 최소 거리, 주머니(포켓) 리라이팅). `maskSets`(micro/medium/large), `maskPadMin/max`, `reverseDepthScale/breadthScale`, `minAllowedBranching`, `minGoal/Box/WallDistance`, `pocketCarveMin/Max/Radius`를 밴드별로 조정해 난이도·길이감을 설계합니다. +- `mask_library.cs`: Microban/Novoban 스타일의 외곽 마스크 모음 + 중형/대형 마스크. 회전/반전/스케일 변형과 소규모 벽 지터가 자동 적용됩니다. 마스크를 추가해 형태 다양성을 늘릴 수 있습니다. +- `Program.cs`: 생성 튜닝(`GenerationTuning`)에 시도 횟수, 시간 한도, 릴랙스 단계 수, 역탐색 기본 깊이/폭, 외벽 정규화, 코너 데드락 필터 옵션 등이 정의되어 있습니다. ## 출력 형식 `output.json`은 다음 형태의 배열입니다. @@ -49,4 +49,5 @@ dotnet run -- [startId] [endId] > output.json ## 메모 - 외벽은 항상 `#`로 둘러지며, `0`은 외부에서만 사용됩니다. - 중복 레이아웃은 제거됩니다. +- 해법 패턴(푸시/턴/방향 런 요약) 해시를 이용해 유사 해법 레벨도 추가로 걸러냅니다. - 더 느리게 돌려야 하면 `GenerationTuning`의 시도/시간 한도를 조정하세요. diff --git a/StatusReporter.cs b/StatusReporter.cs new file mode 100644 index 0000000..6bd6a59 --- /dev/null +++ b/StatusReporter.cs @@ -0,0 +1,63 @@ +using System; +using System.Diagnostics; +using System.Text; + +internal sealed class StatusReporter +{ + private int _dotCounter = 0; + private string _lastMessage = string.Empty; + private DateTime _lastWriteTime = DateTime.MinValue; + + public void Show(string message, bool withDots = false) + { + if (withDots) + { + _dotCounter++; + message = $"{message}{Dots()}"; + } + Write(message, newline: false); + } + + public string Dots() + { + var dots = (_dotCounter % 3) + 1; + return new string('.', dots); + } + + public void ShowLine(string message) + { + Write(message, newline: true); + } + + private void Write(string message, bool newline) + { + if (_lastMessage == message && !newline) return; + var now = DateTime.UtcNow; + if (!newline && (now - _lastWriteTime).TotalMilliseconds < 200) + { + return; + } + _lastWriteTime = now; + var pad = string.Empty; + _lastMessage = message; + var output = $"\r\u001b[K{message}{pad}"; + if (newline) + { + Console.Error.WriteLine(output); + _lastMessage = string.Empty; + } + else + { + Console.Error.Write(output); + } + } + + public static string FormatDuration(TimeSpan elapsed) + { + if (elapsed.TotalMinutes >= 1) + { + return $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"; + } + return $"{elapsed.Seconds}s"; + } +} diff --git a/levelbalance.json b/levelbalance.json index 014a936..fbc11c1 100644 --- a/levelbalance.json +++ b/levelbalance.json @@ -8,6 +8,17 @@ "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, + "maskSets": ["micro"], "maskTake": 8 }, { @@ -17,7 +28,18 @@ "boxCountHigh": 2, "minAllowedPushes": 7, "minAllowedTurns": 3, - "minAllowedBranching": 0, + "minAllowedBranching": 1, + "minGoalDistance": 3, + "minBoxDistance": 3, + "minWallDistance": 1, + "reverseDepthScale": 1.15, + "reverseBreadthScale": 1.2, + "pocketCarveMin": 1, + "pocketCarveMax": 2, + "pocketMaxRadius": 2, + "maskPadMin": -1, + "maskPadMax": 2, + "maskSets": ["micro", "medium"], "maskTake": 0 }, { @@ -27,7 +49,18 @@ "boxCountHigh": 3, "minAllowedPushes": 9, "minAllowedTurns": 4, - "minAllowedBranching": 0, + "minAllowedBranching": 2, + "minGoalDistance": 3, + "minBoxDistance": 3, + "minWallDistance": 1, + "reverseDepthScale": 1.35, + "reverseBreadthScale": 1.4, + "pocketCarveMin": 1, + "pocketCarveMax": 3, + "pocketMaxRadius": 2, + "maskPadMin": 0, + "maskPadMax": 2, + "maskSets": ["medium", "large"], "maskTake": 0 } ] diff --git a/mask_library.cs b/mask_library.cs index 26784be..85bcd11 100644 --- a/mask_library.cs +++ b/mask_library.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; @@ -209,19 +210,161 @@ internal static class MaskLibrary } }; + // 중형(약 11~13) 마스크: Microban보다 넓은 홀과 복도 길이를 제공. + public static readonly List Medium = new() + { + new[]{ + "00000000000", + "00#######00", + "00#.....#00", + "0##.###.##0", + "0#..#.#..#0", + "0#.......#0", + "0#..#.#..#0", + "0##.###.##0", + "00#.....#00", + "00#######00", + "00000000000" + }, + new[]{ + "0000000000000", + "000#######000", + "00##.....##00", + "00#..###..#00", + "0##.......##0", + "0#..#...#..#0", + "0##.......##0", + "00#..###..#00", + "00##.....##00", + "000#######000", + "0000000000000" + }, + new[]{ + "000000000000", + "00########00", + "00#......#00", + "0##.####.##0", + "0#..#..#..#0", + "0#..#..#..#0", + "0##.####.##0", + "00#......#00", + "00###..###00", + "000#....#000", + "000######000", + "000000000000" + }, + new[]{ + "000000000000", + "000######000", + "000#....#000", + "0###.##.###0", + "0#......#.#0", + "0#.##.##..#0", + "0#..#.....#0", + "0###.##.###0", + "000#....#000", + "000######000", + "000000000000" + }, + new[]{ + "0000000000000", + "000#######000", + "000#.....#000", + "0###.###.###0", + "0#...#.#...#0", + "0#...#.#...#0", + "0###.###.###0", + "000#.....#000", + "000#######000", + "0000000000000" + } + }; + + // 대형(약 15~16) 마스크: 입구/포켓이 늘어나고, 복도 길이가 길다. + public static readonly List Large = new() + { + new[]{ + "000000000000000", + "000#########000", + "000#.......#000", + "00##.#####.##00", + "00#..#...#..#00", + "0##..#...#..##0", + "0#...#...#...#0", + "0#...#####...#0", + "0#...........#0", + "0##..#...#..##0", + "00#..#...#..#00", + "00##.#####.##00", + "000#.......#000", + "000#########000", + "000000000000000" + }, + new[]{ + "000000000000000", + "000#########000", + "000#.......#000", + "00##.#####.##00", + "00#..#...#..#00", + "0##..#.#.#..##0", + "0#...##.##...#0", + "0#...#...#...#0", + "0#...##.##...#0", + "0##..#.#.#..##0", + "00#..#...#..#00", + "00##.#####.##00", + "000#.......#000", + "000#########000", + "000000000000000" + }, + new[]{ + "0000000000000000", + "0000#########000", + "0000#.......#000", + "00###.#####.###0", + "00#..#.....#..#0", + "0##..##...##..##", + "0#....#...#....#", + "0#.#..#####..#.#", + "0#....#...#....#", + "0##..##...##..##", + "00#..#.....#..#0", + "00###.#####.###0", + "0000#.......#000", + "0000#########000", + "0000000000000000" + }, + new[]{ + "000000000000000", + "000##########00", + "000#........#00", + "00##.######.#00", + "00#..#....#.#00", + "0##..#....#..##", + "0#...#.##.#...#", + "0#...#....#...#", + "0##..#....#..##", + "00#..#....#.#00", + "00##.######.#00", + "000#........#00", + "000##########00", + "000000000000000" + } + }; + public static string[] PickRandom(Random rng, List masks) { if (masks.Count == 0) throw new System.InvalidOperationException("No masks provided."); return masks[rng.Next(masks.Count)]; } - public static List ExpandWithTransforms(IEnumerable baseMasks, bool includeScaled = true) + public static List ExpandWithTransforms(IEnumerable baseMasks, int padMin = -1, int padMax = 1) { var seen = new HashSet(); var output = new List(); foreach (var mask in baseMasks) { - var seeds = includeScaled ? ScaleVariants(mask) : new List { mask }; + var seeds = ScaleVariants(mask, padMin, padMax); foreach (var seed in seeds) { foreach (var variant in Variants(seed)) @@ -345,14 +488,34 @@ internal static class MaskLibrary } } - private static List ScaleVariants(string[] mask) + private static List ScaleVariants(string[] mask, int padMin, int padMax) { var scaled = new List(); - scaled.Add(mask); - var padded = Pad(mask, 1); - if (padded != null) scaled.Add(padded); - var trimmed = Pad(mask, -1); - if (trimmed != null) scaled.Add(trimmed); + var from = Math.Min(padMin, padMax); + var to = Math.Max(padMin, padMax); + var addedBase = false; + + for (var delta = from; delta <= to; delta++) + { + if (delta == 0) + { + scaled.Add(mask); + addedBase = true; + continue; + } + + var padded = Pad(mask, delta); + if (padded != null) + { + scaled.Add(padded); + } + } + + if (!addedBase) + { + scaled.Insert(0, mask); + } + return scaled; }