레벨 생성 완화 및 체크포인트 강화
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
|
private static readonly GenerationTuning Tuning = new GenerationTuning
|
||||||
{
|
{
|
||||||
MaxAttemptsPerLevel = 10_000,
|
MaxAttemptsPerLevel = 100_000,
|
||||||
MaxMillisecondsPerLevel = 200_000,
|
MaxMillisecondsPerLevel = 2_000_000,
|
||||||
RelaxationSteps = 3,
|
RelaxationSteps = 3,
|
||||||
PushLimitPadding = 2,
|
PushLimitPadding = 2,
|
||||||
PushLimitScale = 0.35,
|
PushLimitScale = 0.35,
|
||||||
@@ -130,14 +130,36 @@ internal static class Program
|
|||||||
(startId, endId) = (endId, startId);
|
(startId, endId) = (endId, startId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalWatch = Stopwatch.StartNew();
|
|
||||||
var levels = generator.BuildRange(startId, endId);
|
|
||||||
totalWatch.Stop();
|
|
||||||
var options = new JsonSerializerOptions
|
var options = new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
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));
|
Console.WriteLine(JsonSerializer.Serialize(levels, options));
|
||||||
var duration = StatusReporter.FormatDuration(totalWatch.Elapsed);
|
var duration = StatusReporter.FormatDuration(totalWatch.Elapsed);
|
||||||
@@ -265,7 +287,7 @@ internal sealed class LevelGenerator
|
|||||||
_trace = Environment.GetEnvironmentVariable("NEKOBAN_DEBUG") == "1";
|
_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)
|
foreach (var band in _bands)
|
||||||
{
|
{
|
||||||
@@ -282,6 +304,7 @@ internal sealed class LevelGenerator
|
|||||||
var band = ResolveBand(id);
|
var band = ResolveBand(id);
|
||||||
var level = BuildSingle(id, band);
|
var level = BuildSingle(id, band);
|
||||||
output.Add(level);
|
output.Add(level);
|
||||||
|
onCheckpoint?.Invoke(output, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
@@ -298,13 +321,120 @@ internal sealed class LevelGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
private GeneratedLevel BuildSingle(int id, LevelBandConfig band)
|
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 failReasons = _trace ? new Dictionary<string, int>() : null;
|
||||||
var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban;
|
var baseMasks = band.ShapeMasks.Count > 0 ? band.ShapeMasks : MaskLibrary.Microban;
|
||||||
band.ShapeMasksExpanded ??= MaskLibrary.ExpandWithTransforms(baseMasks, band.MaskPadMin, band.MaskPadMax);
|
band.ShapeMasksExpanded ??= MaskLibrary.ExpandWithTransforms(baseMasks, band.MaskPadMin, band.MaskPadMax);
|
||||||
var pockets = ResolvePockets(band);
|
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 attemptsLimit = _tuning.MaxAttemptsPerLevel + relax * 2000;
|
||||||
var timeLimit = _tuning.MaxMillisecondsPerLevel + relax * 20_000;
|
var timeLimit = _tuning.MaxMillisecondsPerLevel + relax * 20_000;
|
||||||
@@ -321,24 +451,26 @@ internal sealed class LevelGenerator
|
|||||||
|
|
||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
var attempts = 0;
|
var attempts = 0;
|
||||||
_status.Show($"레벨 {id} 생성중...");
|
|
||||||
|
|
||||||
while (attempts < attemptsLimit &&
|
while (attempts < attemptsLimit &&
|
||||||
stopwatch.ElapsedMilliseconds < timeLimit)
|
stopwatch.ElapsedMilliseconds < timeLimit)
|
||||||
{
|
{
|
||||||
attempts++;
|
attempts++;
|
||||||
|
// 진행 상태가 살아 있음을 보여주기 위해, 1초 단위 점(.) 애니메이션을 출력한다.
|
||||||
|
// 예) "레벨 214 [기본] 생성중.", "..", "..." 식으로 순환.
|
||||||
|
_status.Show($"레벨 {id} [{stageLabel}] 생성중", withDots: true);
|
||||||
var mask = MaskLibrary.CreateVariant(
|
var mask = MaskLibrary.CreateVariant(
|
||||||
MaskLibrary.PickRandom(_rng, band.ShapeMasksExpanded),
|
MaskLibrary.PickRandom(rng, band.ShapeMasksExpanded),
|
||||||
_rng,
|
rng,
|
||||||
_tuning.ApplyMaskTransforms,
|
_tuning.ApplyMaskTransforms,
|
||||||
_tuning.MaskWallJitter);
|
_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 (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");
|
NoteFail(failReasons, "place");
|
||||||
continue;
|
continue;
|
||||||
@@ -352,7 +484,7 @@ internal sealed class LevelGenerator
|
|||||||
|
|
||||||
var board = Board.FromCanvas(canvas);
|
var board = Board.FromCanvas(canvas);
|
||||||
var reverse = new ReverseSearch(board, reverseDepth, reverseBreadth);
|
var reverse = new ReverseSearch(board, reverseDepth, reverseBreadth);
|
||||||
var startState = reverse.FindStartState(goals, minPush, _rng);
|
var startState = reverse.FindStartState(goals, minPush, rng);
|
||||||
if (startState == null)
|
if (startState == null)
|
||||||
{
|
{
|
||||||
NoteFail(failReasons, "reverse_not_found");
|
NoteFail(failReasons, "reverse_not_found");
|
||||||
@@ -393,7 +525,7 @@ internal sealed class LevelGenerator
|
|||||||
canvas.Overlay(startState.Boxes, '$');
|
canvas.Overlay(startState.Boxes, '$');
|
||||||
canvas.Set(startState.Player, '@');
|
canvas.Set(startState.Player, '@');
|
||||||
|
|
||||||
// Final wall normalization; reject if tokens end up on the edge.
|
// 최종 외벽 정규화; 토큰이 가장자리에 닿으면 실패 처리.
|
||||||
if (!LayoutFactory.NormalizeOuterWallsStrict(canvas, failOnTokens: true))
|
if (!LayoutFactory.NormalizeOuterWallsStrict(canvas, failOnTokens: true))
|
||||||
{
|
{
|
||||||
NoteFail(failReasons, "boundary");
|
NoteFail(failReasons, "boundary");
|
||||||
@@ -420,30 +552,61 @@ internal sealed class LevelGenerator
|
|||||||
_seenPatterns.Add(patternKey);
|
_seenPatterns.Add(patternKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
_status.Show($"레벨 {id} 생성완료 ({StatusReporter.FormatDuration(stopwatch.Elapsed)})");
|
_status.Show($"레벨 {id} [{stageLabel}] 생성완료 ({StatusReporter.FormatDuration(stopwatch.Elapsed)})");
|
||||||
return new GeneratedLevel
|
level = new GeneratedLevel
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Grid = lines,
|
Grid = lines,
|
||||||
LowestPush = solve.Pushes,
|
LowestPush = solve.Pushes,
|
||||||
PushLimit = pushLimit
|
PushLimit = pushLimit
|
||||||
};
|
};
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_status.Show($"레벨 {id} 생성 실패");
|
_status.Show($"레벨 {id} [{stageLabel}] 생성 실패");
|
||||||
if (_trace && failReasons != null)
|
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))
|
foreach (var kv in failReasons.OrderByDescending(kv => kv.Value))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($" - {kv.Key}: {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)
|
private LevelBandConfig ResolveBand(int id)
|
||||||
{
|
{
|
||||||
foreach (var band in _bands)
|
foreach (var band in _bands)
|
||||||
@@ -498,24 +661,24 @@ internal sealed class GeneratedLevel
|
|||||||
|
|
||||||
internal sealed class LevelBandConfig
|
internal sealed class LevelBandConfig
|
||||||
{
|
{
|
||||||
public int StartId { get; init; }
|
public int StartId { get; set; }
|
||||||
public int EndId { get; init; }
|
public int EndId { get; set; }
|
||||||
public int BoxCountLow { get; init; }
|
public int BoxCountLow { get; set; }
|
||||||
public int BoxCountHigh { get; init; }
|
public int BoxCountHigh { get; set; }
|
||||||
public int MinAllowedPushes { get; init; }
|
public int MinAllowedPushes { get; set; }
|
||||||
public int MinAllowedTurns { get; init; } = 0;
|
public int MinAllowedTurns { get; set; } = 0;
|
||||||
public int MinAllowedBranching { get; init; } = 0;
|
public int MinAllowedBranching { get; set; } = 0;
|
||||||
public int MinGoalDistance { get; init; } = 1;
|
public int MinGoalDistance { get; set; } = 1;
|
||||||
public int MinBoxDistance { get; init; } = 1;
|
public int MinBoxDistance { get; set; } = 1;
|
||||||
public int MinWallDistance { get; init; } = 0;
|
public int MinWallDistance { get; set; } = 0;
|
||||||
public double ReverseDepthScale { get; init; } = 1.0;
|
public double ReverseDepthScale { get; set; } = 1.0;
|
||||||
public double ReverseBreadthScale { get; init; } = 1.0;
|
public double ReverseBreadthScale { get; set; } = 1.0;
|
||||||
public int PocketCarveMin { get; init; } = -1;
|
public int PocketCarveMin { get; set; } = -1;
|
||||||
public int PocketCarveMax { get; init; } = -1;
|
public int PocketCarveMax { get; set; } = -1;
|
||||||
public int PocketMaxRadius { get; init; } = -1;
|
public int PocketMaxRadius { get; set; } = -1;
|
||||||
public int MaskPadMin { get; init; } = -1;
|
public int MaskPadMin { get; set; } = -1;
|
||||||
public int MaskPadMax { get; init; } = 1;
|
public int MaskPadMax { get; set; } = 1;
|
||||||
public List<string[]> ShapeMasks { get; init; } = new();
|
public List<string[]> ShapeMasks { get; set; } = new();
|
||||||
public List<string[]>? ShapeMasksExpanded { get; set; }
|
public List<string[]>? ShapeMasksExpanded { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ internal sealed class StatusReporter
|
|||||||
{
|
{
|
||||||
if (_lastMessage == message && !newline) return;
|
if (_lastMessage == message && !newline) return;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
if (!newline && (now - _lastWriteTime).TotalMilliseconds < 200)
|
// 너무 자주 찍으면 콘솔이 쓸데없이 지저분해지므로,
|
||||||
|
// 진행 중 상태는 최소 1초 간격으로만 업데이트한다.
|
||||||
|
if (!newline && (now - _lastWriteTime).TotalMilliseconds < 1000)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
2307
levelbalance.json
2307
levelbalance.json
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user