레벨 생성 완화 및 체크포인트 강화
This commit is contained in:
239
Program.cs
239
Program.cs
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@ internal sealed class StatusReporter
|
||||
{
|
||||
if (_lastMessage == message && !newline) return;
|
||||
var now = DateTime.UtcNow;
|
||||
if (!newline && (now - _lastWriteTime).TotalMilliseconds < 200)
|
||||
// 너무 자주 찍으면 콘솔이 쓸데없이 지저분해지므로,
|
||||
// 진행 중 상태는 최소 1초 간격으로만 업데이트한다.
|
||||
if (!newline && (now - _lastWriteTime).TotalMilliseconds < 1000)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
2309
levelbalance.json
2309
levelbalance.json
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user