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,33 +287,56 @@ 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 failReasons = _trace ? new Dictionary<string, int>() : null;
var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban;
band.ShapeMasksExpanded ??= MaskLibrary.ExpandWithTransforms(baseMasks, band.MaskPadMin, band.MaskPadMax);
var pockets = ResolvePockets(band);
for (var relax = 0; relax < _tuning.RelaxationSteps; relax++)
{
var attemptsLimit = _tuning.MaxAttemptsPerLevel + relax * 2000;
var timeLimit = _tuning.MaxMillisecondsPerLevel + relax * 20_000;
var minPush = Math.Max(1, band.MinAllowedPushes - relax * 1);
var minTurns = Math.Max(0, band.MinAllowedTurns - relax * 1);
var minBranch = Math.Max(0, band.MinAllowedBranching - relax * 1);
var minGoalDistance = Math.Max(1, band.MinGoalDistance - relax);
var minBoxDistance = Math.Max(1, band.MinBoxDistance - relax);
var minWallDistance = Math.Max(0, band.MinWallDistance - relax);
var depthScale = band.ReverseDepthScale * (1.0 + 0.2 * relax);
var breadthScale = band.ReverseBreadthScale * (1.0 + 0.25 * relax);
var reverseDepth = Math.Max(16, (int)Math.Round(_tuning.ReverseSearchMaxDepth * depthScale));
var reverseBreadth = Math.Max(200, (int)Math.Round(_tuning.ReverseSearchBreadth * breadthScale));
var stopwatch = Stopwatch.StartNew();
var attempts = 0;
var failReasons = _trace ? new Dictionary<string, int>() : null;
_status.Show($"레벨 {id} 생성중...");
while (attempts < _tuning.MaxAttemptsPerLevel &&
stopwatch.ElapsedMilliseconds < _tuning.MaxMillisecondsPerLevel)
while (attempts < attemptsLimit &&
stopwatch.ElapsedMilliseconds < timeLimit)
{
attempts++;
var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban;
var prepped = band.ShapeMasksExpanded ?? MaskLibrary.ExpandWithTransforms(baseMasks);
band.ShapeMasksExpanded ??= prepped;
var mask = MaskLibrary.CreateVariant(
MaskLibrary.PickRandom(_rng, prepped),
MaskLibrary.PickRandom(_rng, band.ShapeMasksExpanded),
_rng,
_tuning.ApplyMaskTransforms,
_tuning.MaskWallJitter);
var canvas = LayoutFactory.FromMask(mask, _rng, _tuning);
var canvas = LayoutFactory.FromMask(mask, _rng, _tuning, pockets);
var boxCount = _rng.Next(band.BoxCountLow, band.BoxCountHigh + 1);
if (boxCount <= 0)
{
boxCount = 1;
}
if (boxCount <= 0) boxCount = 1;
if (!PiecePlacer.TryPlace(canvas, _rng, boxCount, out var goals, out var boxes, out var player))
{
@@ -219,9 +344,15 @@ internal sealed class LevelGenerator
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, _tuning.ReverseSearchMaxDepth, _tuning.ReverseSearchBreadth);
var startState = reverse.FindStartState(goals, band.MinAllowedPushes, _rng);
var reverse = new ReverseSearch(board, reverseDepth, reverseBreadth);
var startState = reverse.FindStartState(goals, minPush, _rng);
if (startState == null)
{
NoteFail(failReasons, "reverse_not_found");
@@ -236,12 +367,24 @@ internal sealed class LevelGenerator
continue;
}
if (solve.Pushes < band.MinAllowedPushes || solve.Turns < band.MinAllowedTurns || solve.Branching < band.MinAllowedBranching)
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));
@@ -266,6 +409,18 @@ internal sealed class LevelGenerator
}
_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,
@@ -274,7 +429,9 @@ internal sealed class LevelGenerator
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;
}
}

View File

@@ -16,9 +16,9 @@ dotnet run -- <seed> [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 -- <seed> [startId] [endId] > output.json
## 메모
- 외벽은 항상 `#`로 둘러지며, `0`은 외부에서만 사용됩니다.
- 중복 레이아웃은 제거됩니다.
- 해법 패턴(푸시/턴/방향 런 요약) 해시를 이용해 유사 해법 레벨도 추가로 걸러냅니다.
- 더 느리게 돌려야 하면 `GenerationTuning`의 시도/시간 한도를 조정하세요.

63
StatusReporter.cs Normal file
View File

@@ -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";
}
}

View File

@@ -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
}
]

View File

@@ -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<string[]> 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<string[]> 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<string[]> masks)
{
if (masks.Count == 0) throw new System.InvalidOperationException("No masks provided.");
return masks[rng.Next(masks.Count)];
}
public static List<string[]> ExpandWithTransforms(IEnumerable<string[]> baseMasks, bool includeScaled = true)
public static List<string[]> ExpandWithTransforms(IEnumerable<string[]> baseMasks, int padMin = -1, int padMax = 1)
{
var seen = new HashSet<string>();
var output = new List<string[]>();
foreach (var mask in baseMasks)
{
var seeds = includeScaled ? ScaleVariants(mask) : new List<string[]> { 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<string[]> ScaleVariants(string[] mask)
private static List<string[]> ScaleVariants(string[] mask, int padMin, int padMax)
{
var scaled = new List<string[]>();
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);
var padded = Pad(mask, 1);
if (padded != null) scaled.Add(padded);
var trimmed = Pad(mask, -1);
if (trimmed != null) scaled.Add(trimmed);
addedBase = true;
continue;
}
var padded = Pad(mask, delta);
if (padded != null)
{
scaled.Add(padded);
}
}
if (!addedBase)
{
scaled.Insert(0, mask);
}
return scaled;
}