Game/DailyDebugCommands.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sandbox;
namespace BlockParty;
/// <summary>
/// TEMP DEBUG console commands for evaluating the daily-challenge generator: play any past/future
/// day without touching progress, sweep-dump generation summaries to eyeball distributions, and
/// validate the curated obstacle templates.
/// </summary>
public static class DailyDebugCommands
{
/// <summary>Play any day's generated daily (past or future). Never marks progress, counts an
/// attempt, or submits — see <see cref="GameStage.IsDebugRun"/>.</summary>
[ConCmd( "daily_play" )]
public static void PlayCmd( string dailyId = "" )
{
if ( !Game.IsEditor ) return;
var gm = GameManager.Instance;
if ( gm is null )
{
Log.Warning( "[BlockParty] daily_play: no GameManager in the scene." );
return;
}
dailyId = string.IsNullOrWhiteSpace( dailyId ) ? DailyChallenge.TodayId : dailyId.Trim();
if ( !DailyChallenge.TryParseId( dailyId, out _ ) )
{
Log.Warning( $"[BlockParty] daily_play: '{dailyId}' is not a yyyyMMdd day id." );
return;
}
// Regenerate fresh so config/generator edits under iteration are picked up.
DailyLevels.Remove( dailyId );
var daily = DailyLevels.Get( dailyId );
Log.Info( $"[BlockParty] daily_play {dailyId}: {Describe( daily )}" );
gm.StartDailyChallengeDebug( dailyId );
}
/// <summary>Dump one summary line per day plus aggregate distribution tables, to evaluate how the
/// generator behaves over a date range. Usage: daily_dump [startId=today] [count=30].</summary>
[ConCmd( "daily_dump" )]
public static void DumpCmd( string startId = "", int count = 30 )
{
if ( !Game.IsEditor ) return;
startId = string.IsNullOrWhiteSpace( startId ) ? DailyChallenge.TodayId : startId.Trim();
if ( !DailyChallenge.TryParseId( startId, out _ ) )
{
Log.Warning( $"[BlockParty] daily_dump: '{startId}' is not a yyyyMMdd day id." );
return;
}
count = Math.Clamp( count, 1, 3650 );
var sb = new StringBuilder();
var typeCounts = new Dictionary<BlockType, int>();
var templateCounts = new Dictionary<string, int>();
var attemptCounts = new Dictionary<string, int>();
var modifierHistogram = new Dictionary<int, int>();
var songCounts = new Dictionary<string, int>();
var songLastDay = new Dictionary<string, int>();
var patternCounts = new Dictionary<PlayfieldPattern, int>();
var customTiles = new HashSet<string>();
int altDays = 0, oneSlotCustomDays = 0, bothCustomDays = 0, noAltCustomDays = 0, anyCustomDays = 0, bgSplitDays = 0;
int forcedDays = 0, spikeDays = 0, totalBlocks = 0;
int minSongGap = int.MaxValue, closeRepeats = 0;
string id = startId;
for ( int i = 0; i < count; i++, id = DailyChallenge.AddDays( id, 1 ) )
{
var daily = DailyLevelGenerator.Generate( id ); // bypass the cache: always current code/config
sb.AppendLine( $"{id} {Describe( daily )}" );
foreach ( var t in daily.Level.Blocks )
{
typeCounts[t] = typeCounts.GetValueOrDefault( t ) + 1;
totalBlocks++;
}
templateCounts[daily.TemplateId] = templateCounts.GetValueOrDefault( daily.TemplateId ) + 1;
string attempts = daily.MaxAttempts?.ToString() ?? "inf";
attemptCounts[attempts] = attemptCounts.GetValueOrDefault( attempts ) + 1;
modifierHistogram[daily.Modifiers.Count] = modifierHistogram.GetValueOrDefault( daily.Modifiers.Count ) + 1;
string song = daily.Level.Music ?? "-";
songCounts[song] = songCounts.GetValueOrDefault( song ) + 1;
if ( songLastDay.TryGetValue( song, out int lastDay ) )
{
int gap = i - lastDay;
minSongGap = Math.Min( minSongGap, gap );
if ( gap <= DailyMusic.RepeatWindowDays ) closeRepeats++;
}
songLastDay[song] = i;
if ( daily.Level.ForcedCharacterId is not null ) forcedDays++;
if ( daily.Level.SpikedWalls.Count > 0 || daily.Level.SpikedObstacleSides.Count > 0 ) spikeDays++;
// Pattern usage, split by whether the day had an alternate region (the two slots roll
// Custom on different odds — see LevelCosmeticsRandomizer.CustomPatternChance).
var lvl = daily.Level;
bool hasAlt = lvl.AlternatePlayfieldEnabled && lvl.AlternatePlayfieldRects.Count > 0;
bool mainCustom = lvl.PlayfieldPattern == PlayfieldPattern.Custom;
bool altCustom = hasAlt && lvl.AlternatePlayfieldPattern == PlayfieldPattern.Custom;
patternCounts[lvl.PlayfieldPattern] = patternCounts.GetValueOrDefault( lvl.PlayfieldPattern ) + 1;
if ( lvl.BackgroundBlockColors is { Count: 3 } ) bgSplitDays++; // daily gen only sets 3 for the split scheme
if ( hasAlt )
{
altDays++;
patternCounts[lvl.AlternatePlayfieldPattern] = patternCounts.GetValueOrDefault( lvl.AlternatePlayfieldPattern ) + 1;
if ( mainCustom && altCustom ) bothCustomDays++;
else if ( mainCustom || altCustom ) oneSlotCustomDays++;
}
else if ( mainCustom ) noAltCustomDays++;
if ( mainCustom || altCustom ) anyCustomDays++;
// Distinct generated tiles, to confirm the families really vary day to day.
if ( mainCustom && lvl.PlayfieldPatternRows is { Count: > 0 } mainRows )
customTiles.Add( string.Join( "/", mainRows ) );
if ( altCustom && lvl.AlternatePlayfieldPatternRows is { Count: > 0 } altRows )
customTiles.Add( string.Join( "/", altRows ) );
}
sb.AppendLine( $"── {count} days from {startId} ──" );
sb.AppendLine( "templates: " + string.Join( " ", templateCounts.OrderByDescending( kv => kv.Value ).Select( kv => $"{kv.Key}:{kv.Value}" ) ) );
sb.AppendLine( "attempts: " + string.Join( " ", attemptCounts.OrderBy( kv => kv.Key ).Select( kv => $"{kv.Key}:{kv.Value}" ) ) );
sb.AppendLine( "twists: " + string.Join( " ", modifierHistogram.OrderBy( kv => kv.Key ).Select( kv => $"{kv.Key}x:{kv.Value}" ) ) );
sb.AppendLine( $"forced character days: {forcedDays} spike days: {spikeDays}" );
sb.AppendLine( $"songs: {songCounts.Count} of {DailyMusic.Pool.Count} used " +
$"closest repeat: {(minSongGap == int.MaxValue ? "never" : $"{minSongGap} days")} " +
$"repeats within {DailyMusic.RepeatWindowDays} days: {closeRepeats} " +
$"most played: {string.Join( " ", songCounts.OrderByDescending( kv => kv.Value ).ThenBy( kv => kv.Key ).Take( 5 ).Select( kv => $"{kv.Key}:{kv.Value}" ) )}" );
int noAltDays = count - altDays;
string Pct( int n, int of ) => of > 0 ? $"{n * 100f / of:0.#}%" : "-";
sb.AppendLine( "patterns (both slots): " + string.Join( " ",
patternCounts.OrderByDescending( kv => kv.Value ).Select( kv => $"{kv.Key}:{kv.Value}" ) ) );
sb.AppendLine( $"custom: {anyCustomDays} of {count} days distinct generated tiles: {customTiles.Count}" );
sb.AppendLine( $" alt-playfield days {altDays}: one slot {oneSlotCustomDays} ({Pct( oneSlotCustomDays, altDays )}, " +
$"target {LevelCosmeticsRandomizer.CustomPatternChance:P0}) both {bothCustomDays} " +
$"({Pct( bothCustomDays, altDays )}, target {LevelCosmeticsRandomizer.BothCustomPatternChance:P0})" );
sb.AppendLine( $" no-alt days {noAltDays}: custom {noAltCustomDays} ({Pct( noAltCustomDays, noAltDays )}, " +
$"target {LevelCosmeticsRandomizer.SoloCustomPatternChance:P0})" );
sb.AppendLine( $" split background blocks: {bgSplitDays} ({Pct( bgSplitDays, altDays )} of alt days, " +
$"target {LevelCosmeticsRandomizer.ComplementaryAltPlayfieldChance * LevelCosmeticsRandomizer.AltBackgroundSplitChance:P0})" );
sb.AppendLine( "block types (% of all blocks): " + string.Join( " ",
typeCounts.OrderByDescending( kv => kv.Value ).Select( kv => $"{kv.Key}:{kv.Value * 100 / Math.Max( 1, totalBlocks )}%" ) ) );
Log.Info( $"[BlockParty] daily_dump {startId} x{count}\n{sb}" );
}
/// <summary>Sanity-check every obstacle template from Assets/daily-templates: spawn centres in
/// bounds, no spawn footprint or player spawn intersecting an obstacle, no overlapping spawn
/// footprints, valid spike references. Every rule is pure geometry under an x-reflection, so the
/// mirrored variant can never disagree with the authored one and isn't checked separately.</summary>
[ConCmd( "daily_validate_templates" )]
public static void ValidateTemplatesCmd()
{
if ( !Game.IsEditor ) return;
DailyTemplates.Reload();
int errors = 0;
var all = new List<ObstacleTemplate>( DailyTemplates.All );
foreach ( var template in all )
{
errors += Validate( template );
}
if ( errors == 0 )
Log.Info( $"[BlockParty] daily_validate_templates: {all.Count} templates OK." );
else
Log.Warning( $"[BlockParty] daily_validate_templates: {errors} problem(s) found (see above)." );
}
/// <summary>Drop all cached generated dailies and rescan the template and music folders (hotload
/// hygiene while iterating on the generator/templates).</summary>
[ConCmd( "daily_clear_cache" )]
public static void ClearCacheCmd()
{
if ( !Game.IsEditor ) return;
DailyLevels.ClearCache();
DailyTemplates.Reload();
DailyMusic.Reload();
Log.Info( "[BlockParty] Cleared the generated-daily cache and reloaded the template + song libraries." );
}
/// <summary>Toggle hub paging into future/pre-earliest days, to preview upcoming dailies in place.</summary>
[ConCmd( "daily_debug_nav" )]
public static void DebugNavCmd()
{
if ( !Game.IsEditor ) return;
DailyChallengeStage.DebugNavigation = !DailyChallengeStage.DebugNavigation;
Log.Info( $"[BlockParty] Daily debug navigation {(DailyChallengeStage.DebugNavigation ? "ON — the hub can page into future days" : "off")}." );
}
internal static string Describe( DailyDef daily )
{
if ( daily is null ) return "(no def — daily templates not loaded yet)";
var level = daily.Level;
string blocks = string.Join( ",", level.Blocks );
string spikes = "-";
if ( level.SpikedWalls.Count > 0 || level.SpikedObstacleSides.Count > 0 )
{
var parts = level.SpikedWalls.Select( s => $"wall:{s}" )
.Concat( level.SpikedObstacleSides.Select( s => $"obst:{string.Join( "+", s.Sides )}" ) );
spikes = string.Join( " ", parts );
}
string mods = daily.Modifiers.Count > 0 ? string.Join( " | ", daily.Modifiers ) : "-";
string pins = level.PinnedCount > 0 ? $" pins={level.PinnedCount}" : "";
string alt = level.AlternatePlayfieldEnabled && level.AlternatePlayfieldRects.Count > 0
? $" alt{level.AlternatePlayfieldRects.Count}=" + DescribePattern( level.AlternatePlayfieldPattern,
level.AlternatePlayfieldCellScale, level.AlternatePlayfieldPatternRows )
: "";
return $"seed={DailyChallenge.SeedFor( daily.DailyId )} template={daily.TemplateId} " +
$"blocks={level.Blocks.Count}[{blocks}]{pins} spikes={spikes} " +
$"char={level.ForcedCharacterId ?? "-"} attempts={daily.MaxAttempts?.ToString() ?? "inf"} " +
$"song={level.Music ?? "-"} " +
$"pattern={DescribePattern( level.PlayfieldPattern, level.PlayfieldCellScale, level.PlayfieldPatternRows )}{alt}" +
$"{( level.BackgroundBlockColors is { Count: 3 } ? " bg=split" : "" )} mods=[{mods}]";
}
/// <summary>One pattern slot as "Name:cellScale", plus the tile art for a generated Custom (top row
/// FIRST so the text reads the way the pattern draws; '#' = the dark colour).</summary>
internal static string DescribePattern( PlayfieldPattern pattern, int cellScale, IReadOnlyList<string> rows )
{
string text = $"{pattern}:{cellScale}";
if ( pattern != PlayfieldPattern.Custom || rows is not { Count: > 0 } ) return text;
int width = rows.Max( r => r?.Length ?? 0 );
string art = string.Join( "/", rows.Reverse().Select( r => ( r ?? "" ).Replace( '1', '#' ).Replace( '0', '.' ) ) );
return $"{text}({width}x{rows.Count}){art}";
}
private static int Validate( ObstacleTemplate template )
{
string name = template.Id;
int errors = 0;
void Fail( string message ) { Log.Warning( $"[BlockParty] template '{name}': {message}" ); errors++; }
var obstacles = template.Obstacles;
var spawns = template.SpawnPositions;
var players = template.PlayerSpawns;
const float blockSize = 40f; // matches Block.BLOCK_SIZE (private there)
const float half = blockSize / 2f;
float min = Arena.WALL_SIZE + half, max = Arena.WIDTH - Arena.WALL_SIZE - half;
float ceiling = Arena.HEIGHT - Arena.WALL_SIZE;
float playerHalfW = Player.COLLISION_SIZE.x / 2f;
static string R( RectF r ) => $"[{r.Left},{r.Bottom},{r.Right},{r.Top}]";
bool IsFence( int i ) => template.FenceObstacles.Contains( i );
bool IsGlass( int i ) => template.GlassObstacles.Contains( i );
foreach ( var rect in obstacles )
if ( rect.Left < 0 || rect.Bottom < 0 || rect.Right > Arena.WIDTH || rect.Top > Arena.HEIGHT || rect.Left >= rect.Right || rect.Bottom >= rect.Top )
Fail( $"obstacle {R( rect )} is degenerate or outside the arena" );
for ( int i = 0; i < spawns.Length; i++ )
{
var s = spawns[i];
if ( s.x < min || s.x > max || s.y < min || s.y > max )
Fail( $"spawn {s} centre outside the valid range [{min},{max}]" );
// Glass is solid only to players — a block may sit in it. Fences ARE block-solid.
for ( int o = 0; o < obstacles.Length; o++ )
if ( !IsGlass( o ) && Overlaps( s.x - half, s.y - half, s.x + half, s.y + half, obstacles[o] ) )
Fail( $"spawn {s} footprint intersects block-solid obstacle {R( obstacles[o] )}" );
for ( int j = i + 1; j < spawns.Length; j++ )
if ( Math.Abs( s.x - spawns[j].x ) < blockSize && Math.Abs( s.y - spawns[j].y ) < blockSize )
Fail( $"spawns {s} and {spawns[j]} footprints overlap" );
foreach ( var player in players )
{
if ( Math.Abs( s.x - player.x ) < half && Math.Abs( s.y - player.y ) < half )
Fail( $"spawn {s} footprint contains player spawn {player}" );
}
}
if ( players.Length == 0 )
Fail( "no player spawns" );
// Fences are solid only to blocks — a player may spawn inside one. Glass IS player-solid.
foreach ( var player in players )
for ( int o = 0; o < obstacles.Length; o++ )
if ( !IsFence( o ) && player.x > obstacles[o].Left && player.x < obstacles[o].Right && player.y > obstacles[o].Bottom && player.y < obstacles[o].Top )
Fail( $"player spawn {player} is inside player-solid obstacle {R( obstacles[o] )}" );
// Wrap-character (WRAITH) spawn trace. A wrap character passes through everything but glass:
// it sinks into any obstacle under it and emerges from the opposite face (chaining across flush
// rects), falls out of the floor and re-enters at the ceiling, already falling. Emerging INTO a
// spawn block flush with an exit face crushes; a spikeable exit face (the ceiling, an obstacle
// underside) kills. Rolled slots may be empty on a given day, so only a PINNED block stops the
// trace; "spikeable" covers rolled (live some days) and authored spikes alike.
bool ceilingSpikeable = template.SpikeableWalls.Contains( Direction.Up );
bool SideSpikeable( int o, Direction side )
=> template.SpikeableObstacleSides.Any( e => e.Obstacle == o && e.Sides is not null && e.Sides.Contains( side ) );
foreach ( var player in players )
{
float colL = player.x - playerHalfW, colR = player.x + playerHalfW;
bool InColumn( float left, float right ) => right > colL && left < colR;
float h = player.y - Player.COLLISION_SIZE.y / 2f;
bool wrapped = false;
bool exitFace = false; // h is a face the wrapper EMERGES from (obstacle underside / ceiling), not air
for ( int guard = 0; guard < 64; guard++ )
{
// The highest thing in the column at or below h. A block tied with an obstacle wins: a
// block flush with the face being exited is exactly the crush.
float best = float.NegativeInfinity; int bestOb = -1, bestSpawn = -1;
for ( int o = 0; o < obstacles.Length; o++ )
{
var r = obstacles[o];
if ( IsFence( o ) || !InColumn( r.Left, r.Right ) || r.Top > h + 0.5f || r.Top <= best ) continue;
best = r.Top; bestOb = o; bestSpawn = -1;
}
for ( int i = 0; i < spawns.Length; i++ )
{
float top = spawns[i].y + half;
if ( !InColumn( spawns[i].x - half, spawns[i].x + half ) || top > h + 0.5f || top < best ) continue;
best = top; bestSpawn = i; bestOb = -1;
}
if ( bestSpawn >= 0 )
{
var sp = spawns[bestSpawn];
if ( exitFace && best >= h - 0.5f )
{
Fail( $"spawn {sp} is flush under the {(wrapped ? "ceiling / obstacle" : "obstacle")} exit face in player spawn {player}'s column — a wrap character emerging there is crushed on spawn" );
break;
}
if ( bestSpawn < template.SpawnPins.Length && template.SpawnPins[bestSpawn] is not null )
break; // pinned: always there to land on
h = sp.y - half; // rolled: may be absent that day, keep falling (through air)
exitFace = false;
continue;
}
if ( bestOb >= 0 )
{
if ( IsGlass( bestOb ) )
break; // lands on glass
if ( SideSpikeable( bestOb, Direction.Down ) )
{
Fail( $"obstacle {R( obstacles[bestOb] )} in player spawn {player}'s column has a spikeable underside — a wrap character sinking through it is spiked on the exit face" );
break;
}
h = obstacles[bestOb].Bottom; // emerge from the underside
exitFace = true;
continue;
}
// Nothing below: out through the floor, in at the ceiling (once — a second pass loops).
if ( wrapped )
break;
wrapped = true;
h = ceiling;
exitFace = true;
if ( ceilingSpikeable )
{
Fail( $"ceiling is spikeable and nothing stops player spawn {player}'s fall — a wrap character falling out the bottom emerges into ceiling spikes" );
break;
}
}
}
foreach ( var (obstacle, sides) in template.SpikeableObstacleSides )
{
if ( obstacle < 0 || obstacle >= template.Obstacles.Length )
Fail( $"spikeable obstacle index {obstacle} out of range" );
if ( sides is null || sides.Length == 0 || sides.Contains( Direction.None ) )
Fail( $"spikeable obstacle {obstacle} has invalid sides" );
}
if ( template.SpikeableWalls.Contains( Direction.None ) )
Fail( "spikeable walls contain None" );
foreach ( var index in template.VisionObstacles )
if ( index < 0 || index >= template.Obstacles.Length )
Fail( $"vision obstacle index {index} out of range" );
foreach ( var index in template.FenceObstacles )
{
if ( index < 0 || index >= template.Obstacles.Length )
Fail( $"fence obstacle index {index} out of range" );
// Fences can't be spiked (nothing lethal to a player who passes through them) and can't
// also block vision — the editor enforces both; a hand-edited template might not.
if ( template.VisionObstacles.Contains( index ) )
Fail( $"obstacle {index} is flagged both fence and vision" );
foreach ( var (obstacle, _) in template.SpikeableObstacleSides )
if ( obstacle == index )
Fail( $"fence obstacle {index} is listed as spikeable" );
}
foreach ( var index in template.GlassObstacles )
{
if ( index < 0 || index >= template.Obstacles.Length )
Fail( $"glass obstacle index {index} out of range" );
// Glass CAN be spiked (a player-only hazard), but the fence/vision flags are exclusive.
if ( template.VisionObstacles.Contains( index ) )
Fail( $"obstacle {index} is flagged both glass and vision" );
if ( template.FenceObstacles.Contains( index ) )
Fail( $"obstacle {index} is flagged both glass and fence" );
}
// Coins: inside the walls, and never buried in a player-solid obstacle (a normal obstacle or
// glass makes the coin uncollectable; a fence is fine — players pass through fences).
var coins = template.Coins;
const float coinHalfW = Coin.ART_W / 2f, coinHalfH = Coin.ART_H / 2f;
foreach ( var coin in coins )
{
if ( coin.x < Arena.WALL_SIZE + coinHalfW || coin.x > Arena.WIDTH - Arena.WALL_SIZE - coinHalfW
|| coin.y < Arena.WALL_SIZE + coinHalfH || coin.y > Arena.HEIGHT - Arena.WALL_SIZE - coinHalfH )
Fail( $"coin {coin} is outside the arena walls" );
for ( int i = 0; i < obstacles.Length; i++ )
{
if ( template.FenceObstacles.Contains( i ) )
continue;
if ( Overlaps( coin.x - coinHalfW, coin.y - coinHalfH, coin.x + coinHalfW, coin.y + coinHalfH, obstacles[i] ) )
Fail( $"coin {coin} intersects player-solid obstacle {R( obstacles[i] )} (unreachable)" );
}
}
// Pins align index-wise with spawns; a longer pin list points at nothing.
if ( template.SpawnPins.Length > template.SpawnPositions.Length )
Fail( $"{template.SpawnPins.Length} pin slots but only {template.SpawnPositions.Length} spawns" );
// Random block counts clamp to the FREE (unpinned) spawn slots; pinned blocks read as authored
// geometry (enclosed, phase-started turrets etc.), not rolled blocks. The win only fires when
// a block REACHES max phase: no rollable slot and no pin still below max means nothing can
// ever get there (Build clamps the count to 0) — an unwinnable daily.
int freeSlots = spawns.Length - template.SpawnPins.Count( p => p is not null );
if ( freeSlots <= 0 && !template.SpawnPins.Any( p => p is not null && p.Phase < Block.NUM_PHASES - 1 ) )
Fail( "no unpinned spawn slots and no pin below max phase — no block can ever reach max phase (unwinnable)" );
else if ( freeSlots <= 0 )
Log.Warning( $"[BlockParty] template '{name}': no unpinned spawn positions — nothing is rolled, only the pinned blocks play" );
return errors;
}
private static bool Overlaps( float left, float bottom, float right, float top, RectF rect )
=> left < rect.Right && right > rect.Left && bottom < rect.Top && top > rect.Bottom;
}