레벨 생성 완화 및 체크포인트 강화

This commit is contained in:
JiWoong Sul
2025-11-24 17:13:39 +09:00
parent 8c351c5c05
commit c8cbb7f8ac
3 changed files with 2491 additions and 61 deletions

View File

@@ -84,8 +84,8 @@ internal static class Program
private static readonly GenerationTuning Tuning = new GenerationTuning
{
MaxAttemptsPerLevel = 10_000,
MaxMillisecondsPerLevel = 200_000,
MaxAttemptsPerLevel = 100_000,
MaxMillisecondsPerLevel = 2_000_000,
RelaxationSteps = 3,
PushLimitPadding = 2,
PushLimitScale = 0.35,
@@ -130,14 +130,36 @@ 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,
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<GeneratedLevel> snapshot, int lastId)
{
if (!checkpointEnabled) return;
// 10단위(또는 마지막 레벨)마다 누적 JSON을 디스크에 기록한다.
if (lastId % 10 != 0 && lastId != endId) return;
try
{
var json = JsonSerializer.Serialize(snapshot, options);
File.WriteAllText(checkpointPath, json);
status.ShowLine($"체크포인트 저장: 레벨 {lastId}까지 -> {checkpointPath}");
}
catch (Exception ex)
{
status.ShowLine($"[warn] 체크포인트 저장 실패({checkpointPath}): {ex.Message}");
}
}
var totalWatch = Stopwatch.StartNew();
var levels = generator.BuildRange(startId, endId, SaveCheckpoint);
totalWatch.Stop();
Console.WriteLine(JsonSerializer.Serialize(levels, options));
var duration = StatusReporter.FormatDuration(totalWatch.Elapsed);
@@ -265,7 +287,7 @@ internal sealed class LevelGenerator
_trace = Environment.GetEnvironmentVariable("NEKOBAN_DEBUG") == "1";
}
public List<GeneratedLevel> BuildRange(int startId, int endId)
public List<GeneratedLevel> BuildRange(int startId, int endId, Action<List<GeneratedLevel>, int>? onCheckpoint = null)
{
foreach (var band in _bands)
{
@@ -282,6 +304,7 @@ internal sealed class LevelGenerator
var band = ResolveBand(id);
var level = BuildSingle(id, band);
output.Add(level);
onCheckpoint?.Invoke(output, id);
}
return output;
@@ -298,13 +321,120 @@ internal sealed class LevelGenerator
}
private GeneratedLevel BuildSingle(int id, LevelBandConfig band)
{
// 완화 단계를 순차적으로 적용한다. (총 5단계 + 비상 1단계)
var stages = new List<RelaxStage>
{
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;
}),
new RelaxStage("완화6-최종", bandConfig =>
{
var c = CloneBand(bandConfig);
c.BoxCountLow = 1;
c.BoxCountHigh = Math.Max(1, c.BoxCountHigh - 2);
c.MinAllowedPushes = 1;
c.MinAllowedTurns = 0;
c.MinAllowedBranching = 0;
c.MinGoalDistance = Math.Max(1, c.MinGoalDistance - 2);
c.MinBoxDistance = Math.Max(1, c.MinBoxDistance - 2);
c.MinWallDistance = 0;
c.ReverseDepthScale = Math.Max(1.0, c.ReverseDepthScale * 2.8);
c.ReverseBreadthScale = Math.Max(1.0, c.ReverseBreadthScale * 2.8);
c.MaskPadMin = -2;
c.MaskPadMax = -2;
c.ShapeMasks = MaskLibrary.Medium.Concat(MaskLibrary.Microban).ToList();
c.ShapeMasksExpanded = null;
return c;
}, OverrideRelaxSteps: 6)
};
// 밴드 강등 시도: 현재 밴드 -> 직전 10레벨 밴드 -> 직전 20레벨 밴드
var bandCandidates = new List<LevelBandConfig> { 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);
}
}
// 시드 변조 재시도 + 완화 단계 루프
const int seedRetries = 5;
foreach (var bandCandidate in bandCandidates)
{
for (var retry = 0; retry < seedRetries; retry++)
{
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, out var level))
{
return level;
}
}
}
}
throw new InvalidOperationException($"레벨 {id} 생성 실패 (모든 완화 단계 시도됨)");
}
private bool TryBuildStage(int id, LevelBandConfig band, string stageLabel, Random rng, int? overrideRelaxSteps, out GeneratedLevel level)
{
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 relaxSteps = overrideRelaxSteps ?? _tuning.RelaxationSteps;
for (var relax = 0; relax < relaxSteps; relax++)
{
var attemptsLimit = _tuning.MaxAttemptsPerLevel + relax * 2000;
var timeLimit = _tuning.MaxMillisecondsPerLevel + relax * 20_000;
@@ -321,24 +451,26 @@ internal sealed class LevelGenerator
var stopwatch = Stopwatch.StartNew();
var attempts = 0;
_status.Show($"레벨 {id} 생성중...");
while (attempts < attemptsLimit &&
stopwatch.ElapsedMilliseconds < timeLimit)
{
attempts++;
// 진행 상태가 살아 있음을 보여주기 위해, 1초 단위 점(.) 애니메이션을 출력한다.
// 예) "레벨 214 [기본] 생성중.", "..", "..." 식으로 순환.
_status.Show($"레벨 {id} [{stageLabel}] 생성중", withDots: true);
var mask = MaskLibrary.CreateVariant(
MaskLibrary.PickRandom(_rng, band.ShapeMasksExpanded),
_rng,
MaskLibrary.PickRandom(rng, band.ShapeMasksExpanded),
rng,
_tuning.ApplyMaskTransforms,
_tuning.MaskWallJitter);
var canvas = LayoutFactory.FromMask(mask, _rng, _tuning, pockets);
var canvas = LayoutFactory.FromMask(mask, rng, _tuning, pockets);
var boxCount = _rng.Next(band.BoxCountLow, band.BoxCountHigh + 1);
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))
if (!PiecePlacer.TryPlace(canvas, rng, boxCount, out var goals, out var boxes, out var player))
{
NoteFail(failReasons, "place");
continue;
@@ -352,7 +484,7 @@ internal sealed class LevelGenerator
var board = Board.FromCanvas(canvas);
var reverse = new ReverseSearch(board, reverseDepth, reverseBreadth);
var startState = reverse.FindStartState(goals, minPush, _rng);
var startState = reverse.FindStartState(goals, minPush, rng);
if (startState == null)
{
NoteFail(failReasons, "reverse_not_found");
@@ -393,7 +525,7 @@ internal sealed class LevelGenerator
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");
@@ -420,30 +552,61 @@ internal sealed class LevelGenerator
_seenPatterns.Add(patternKey);
}
_status.Show($"레벨 {id} 생성완료 ({StatusReporter.FormatDuration(stopwatch.Elapsed)})");
return new GeneratedLevel
_status.Show($"레벨 {id} [{stageLabel}] 생성완료 ({StatusReporter.FormatDuration(stopwatch.Elapsed)})");
level = new GeneratedLevel
{
Id = id,
Grid = lines,
LowestPush = solve.Pushes,
PushLimit = pushLimit
};
return true;
}
}
_status.Show($"레벨 {id} 생성 실패");
_status.Show($"레벨 {id} [{stageLabel}] 생성 실패");
if (_trace && failReasons != null)
{
Console.Error.WriteLine($"[trace] level {id} failed. Reasons:");
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}");
}
}
throw new InvalidOperationException($"레벨 {id} 생성 실패 (attempts {_tuning.MaxAttemptsPerLevel})");
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,
ShapeMasks = src.ShapeMasks.ToList(),
ShapeMasksExpanded = src.ShapeMasksExpanded?.ToList()
};
}
private sealed record RelaxStage(string Label, Func<LevelBandConfig, LevelBandConfig> Adjust, int? OverrideRelaxSteps = null);
private LevelBandConfig ResolveBand(int id)
{
foreach (var band in _bands)
@@ -498,24 +661,24 @@ internal sealed class GeneratedLevel
internal sealed class LevelBandConfig
{
public int StartId { get; init; }
public int EndId { get; init; }
public int BoxCountLow { get; init; }
public int BoxCountHigh { get; init; }
public int MinAllowedPushes { get; init; }
public int MinAllowedTurns { get; init; } = 0;
public int MinAllowedBranching { get; init; } = 0;
public int MinGoalDistance { get; init; } = 1;
public int MinBoxDistance { get; init; } = 1;
public int MinWallDistance { get; init; } = 0;
public double ReverseDepthScale { get; init; } = 1.0;
public double ReverseBreadthScale { get; init; } = 1.0;
public int PocketCarveMin { get; init; } = -1;
public int PocketCarveMax { get; init; } = -1;
public int PocketMaxRadius { get; init; } = -1;
public int MaskPadMin { get; init; } = -1;
public int MaskPadMax { get; init; } = 1;
public List<string[]> ShapeMasks { get; init; } = new();
public 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 List<string[]> ShapeMasks { get; set; } = new();
public List<string[]>? ShapeMasksExpanded { get; set; }
}