Game/DailyLevelGenerator.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace BlockParty;
/// <summary>One generated daily challenge: the level plus the daily-only rules around it.</summary>
public sealed class DailyDef
{
public string DailyId { get; init; }
public LevelDef Level { get; init; }
/// <summary>Allowed attempts for the day; null = unlimited (the common case).</summary>
public int? MaxAttempts { get; init; }
/// <summary>Which obstacle template the day rolled (for daily_dump / debugging).</summary>
public string TemplateId { get; init; }
/// <summary>Human-readable twist tags for editor diagnostics and daily_dump.</summary>
public IReadOnlyList<string> Modifiers { get; init; }
}
/// <summary>Generate-once cache in front of <see cref="DailyLevelGenerator"/>. Deliberately never
/// touches the <see cref="Levels"/> registry — daily levels are transient, regenerated from the id.</summary>
public static class DailyLevels
{
private static readonly Dictionary<string, DailyDef> _cache = new();
public static DailyDef Get( string dailyId )
{
if ( string.IsNullOrEmpty( dailyId ) ) return null;
if ( _cache.TryGetValue( dailyId, out var def ) )
return WithMusic( dailyId, def );
// Refuse to generate while the template library isn't up. At boot the mounted filesystem
// can lag (see DailyTemplates.EnsureLoaded); a def generated before then is the WRONG layout
// for the day — playing it would burn an attempt on (and submit a permanently-desynced
// replay to) the shared board. Every caller treats null as "not playable/previewable yet",
// and IsLoaded retries the scan, so this self-heals on a later access.
if ( !DailyTemplates.IsLoaded )
return null;
def = DailyLevelGenerator.Generate( dailyId );
_cache[dailyId] = def;
return def;
}
// The song comes from a filesystem scan that can lag at boot like the templates do, but it's
// cosmetic — rather than hold the whole def out of the cache over it, a cached day with no song
// yet picks one up on a later access (before anything plays it).
private static DailyDef WithMusic( string dailyId, DailyDef def )
{
if ( def?.Level is not null && def.Level.Music is null )
def.Level.Music = DailyMusic.For( dailyId );
return def;
}
public static string LevelIdFor( string dailyId ) => $"daily-{dailyId}";
/// <summary>Parse a "daily-yyyyMMdd" level id back to its daily id.</summary>
public static bool TryParseDailyLevelId( string levelId, out string dailyId )
{
dailyId = null;
if ( levelId is null || !levelId.StartsWith( "daily-", StringComparison.Ordinal ) ) return false;
var id = levelId["daily-".Length..];
if ( !DailyChallenge.TryParseId( id, out _ ) ) return false;
dailyId = id;
return true;
}
/// <summary>Drop cached defs (hotload hygiene while iterating on the generator/config).</summary>
public static void ClearCache() => _cache.Clear();
public static void Remove( string dailyId ) => _cache.Remove( dailyId ?? "" );
}
/// <summary>
/// Deterministically generates each day's unique daily-challenge level from the day's seed.
///
/// DETERMINISM CONTRACT: for a given id the output must be byte-identical across machines, builds
/// and runs within one LEADERBOARD_VERSION — every player plays the same layout and stored replays
/// regenerate it. All draws come from one <see cref="Random"/> seeded by
/// <see cref="DailyChallenge.GenSeedFor"/>, in a FIXED pipeline order (append new draws at the end
/// only), from plain arrays (never dictionary iteration). Any change to this file, the tables in
/// <see cref="DailyGenConfig"/>, or <see cref="LevelCosmeticsRandomizer"/>'s roll methods changes
/// every past and future day — such changes ship together with a LEADERBOARD_VERSION bump (the
/// daily seed folds the version in, which re-rolls the per-day boards; old daily replays retire
/// via the level-hash gate, since the regenerated layout no longer matches their recorded
/// <see cref="RunData.LevelHash"/>).
/// </summary>
public static class DailyLevelGenerator
{
/// <summary>The seed-dependent choices that shape a day, rolled before the level is built so the
/// anti-repetition pass can compare/adjust against yesterday's plan cheaply.</summary>
private sealed class DayPlan
{
public Random Rng;
public List<DailyModifier> Modifiers = new();
public ObstacleTemplate Template;
public bool Mirrored;
public string ForcedCharacterId;
public int? MaxAttempts;
public bool Has( DailyModifier m ) => Modifiers.Contains( m );
}
public static DailyDef Generate( string dailyId )
{
var plan = RollPlan( dailyId );
// Anti-repetition, BEST-EFFORT BY DESIGN: if yesterday's RAW roll used the same
// template or forced the same character, deterministically re-pick so consecutive days don't
// feel samey. Comparing against yesterday's raw roll (not its final, post-adjustment plan)
// means a day whose collision was itself rerolled can still be repeated — deliberately
// accepted: sweeps show that slips through roughly once every couple of YEARS, and full
// correctness needs a fingerprint chain over all history (hidden state, and it makes
// EarliestId affect generation). Each day stays a pure function of exactly two seeds.
var yesterday = RollPlan( DailyChallenge.AddDays( dailyId, -1 ) );
if ( plan.Template.Id == yesterday.Template.Id )
{
plan.Template = PickTemplate( plan.Rng, excludeId: plan.Template.Id ) ?? plan.Template;
plan.Mirrored &= plan.Template.AllowMirrorX;
}
if ( plan.ForcedCharacterId is not null && plan.ForcedCharacterId == yesterday.ForcedCharacterId )
plan.ForcedCharacterId = PickForcedCharacter( plan.Rng, excludeId: plan.ForcedCharacterId ) ?? plan.ForcedCharacterId;
return Build( dailyId, plan );
}
// ── plan (budget → modifiers → template → character → attempts) ─────────────────────────────────
private static DayPlan RollPlan( string dailyId )
{
var plan = new DayPlan { Rng = new Random( DailyChallenge.GenSeedFor( dailyId ) ) };
var rng = plan.Rng;
int budget = WeightedPick( rng, DailyGenConfig.SpiceBudgetWeights, e => e.Weight ).Points;
var candidates = new List<SpiceModifier>( DailyGenConfig.Modifiers );
while ( budget > 0 )
{
var available = candidates.Where( m => m.Cost <= budget && !ExcludedByDrawn( plan, m.Id ) ).ToList();
if ( available.Count == 0 ) break;
var pick = WeightedPick( rng, available, m => m.Weight );
plan.Modifiers.Add( pick.Id );
candidates.Remove( pick );
budget -= pick.Cost;
}
plan.Template = PickTemplate( rng, excludeId: null ) ?? DailyGenConfig.FallbackTemplate;
if ( plan.Template.AllowMirrorX && rng.Next( 2 ) == 1 )
plan.Mirrored = true;
if ( rng.Next( 100 ) < DailyGenConfig.ForcedCharacterChancePercent )
plan.ForcedCharacterId = PickForcedCharacter( rng, excludeId: null );
if ( rng.Next( 100 ) < DailyGenConfig.LimitedAttemptsChancePercent )
plan.MaxAttempts = WeightedPick( rng, DailyGenConfig.AttemptWeights, e => e.Weight ).Attempts;
return plan;
}
private static bool ExcludedByDrawn( DayPlan plan, DailyModifier candidate )
{
foreach ( var (a, b) in DailyGenConfig.ExclusiveModifiers )
{
if ( candidate == a && plan.Has( b ) ) return true;
if ( candidate == b && plan.Has( a ) ) return true;
}
return false;
}
private static ObstacleTemplate PickTemplate( Random rng, string excludeId )
{
var pool = DailyTemplates.All
.Where( t => t.Id != excludeId && t.Weight > 0 )
.ToList();
return pool.Count == 0 ? null : WeightedPick( rng, pool, t => t.Weight );
}
private static string PickForcedCharacter( Random rng, string excludeId )
{
var pool = DailyGenConfig.CharacterWeights
.Where( e => e.CharacterId != excludeId && e.Weight > 0 && Characters.TryGet( e.CharacterId, out _ ) )
.ToList();
return pool.Count == 0 ? null : WeightedPick( rng, pool, e => e.Weight ).CharacterId;
}
// ── build (spikes → count → types → phase starts → cadence → cosmetics) ─────────────────────────
private static DailyDef Build( string dailyId, DayPlan plan )
{
var rng = plan.Rng;
var template = plan.Template;
var obstacles = template.Obstacles.Select( r => plan.Mirrored ? MirrorRect( r ) : r ).ToArray();
var spawnPositions = template.SpawnPositions.Select( p => plan.Mirrored ? MirrorPoint( p ) : p ).ToArray();
var playerSpawns = template.PlayerSpawns.Select( p => plan.Mirrored ? MirrorPoint( p ) : p ).ToArray();
// Template-authored pinned blocks ride along (positions follow their spawn slot; pre-pressed
// sides swap Left/Right on a mirrored day).
var spawnPins = template.SpawnPins.Select( pin => pin is null ? null : new PinnedBlock
{
Type = pin.Type,
Phase = pin.Phase,
PressedSides = plan.Mirrored ? pin.PressedSides.Select( MirrorSide ).ToArray() : pin.PressedSides,
} ).ToArray();
int pinnedCount = spawnPins.Count( p => p is not null );
var level = new LevelDef
{
Id = DailyLevels.LevelIdFor( dailyId ),
Name = $"DAILY {DailyChallenge.DisplayDate( dailyId )}",
Obstacles = obstacles,
SpawnPositions = spawnPositions,
SpawnPins = pinnedCount > 0 ? spawnPins : null,
PlayerSpawns = playerSpawns,
VisionBlockers = template.VisionObstacles.Select( i => obstacles[i] ).ToArray(),
Fences = template.FenceObstacles.Select( i => obstacles[i] ).ToArray(),
Glass = template.GlassObstacles.Select( i => obstacles[i] ).ToArray(),
// Coins ride along with the layout (mirror-aware). NO rng draws here — coins must not
// shift any existing day's generation.
Coins = template.Coins.Select( p => plan.Mirrored ? MirrorPoint( p ) : p ).ToArray(),
// The day's song is hashed from the date, NOT drawn from the rng (see DailyMusic): songs
// can be added to Assets/music without shifting a single generation draw. Null = the
// library hasn't mounted yet; DailyLevels.Get fills it in on a later access.
Music = DailyMusic.For( dailyId ),
ForcedCharacterId = plan.ForcedCharacterId,
ShuffleTypes = true,
};
var tags = new List<string>();
// Spikes — AuthoredSpikes templates always spike every authored surface (that's layout, not a
// twist: no rng draws, no tag). Everything else rolls each curated candidate independently
// every day, walls first then obstacle faces, in listed order.
{
var candidates = new List<(int Obstacle, Direction Side)>();
foreach ( var side in template.SpikeableWalls )
candidates.Add( (-1, plan.Mirrored ? MirrorSide( side ) : side) );
foreach ( var (obstacle, sides) in template.SpikeableObstacleSides )
foreach ( var side in sides )
candidates.Add( (obstacle, plan.Mirrored ? MirrorSide( side ) : side) );
var picked = candidates;
if ( !template.AuthoredSpikes )
{
picked = new List<(int Obstacle, Direction Side)>();
foreach ( var candidate in candidates )
if ( rng.Next( 100 ) < DailyGenConfig.SpikeChancePercent )
picked.Add( candidate );
if ( picked.Count > 0 )
tags.Add( "SPIKES" );
}
level.SpikedWalls = picked.Where( p => p.Obstacle < 0 ).Select( p => p.Side ).ToArray();
level.SpikedObstacleSides = picked
.Where( p => p.Obstacle >= 0 )
.GroupBy( p => p.Obstacle )
.OrderBy( g => g.Key )
.Select( g => new ObstacleSpikeSpec( obstacles[g.Key], g.Select( p => p.Side ).ToArray() ) )
.ToArray();
}
// Block count, nudged by the count modifiers and clamped to the template's FREE spawn slots
// (pinned blocks always spawn on top of this count from their own slots). The twist tags only
// show when the nudge survived the clamp (a full spawn grid can swallow an ExtraBlocks roll —
// advertising it would lie to the hub).
int freeSlots = spawnPositions.Length - pinnedCount;
int baseCount = WeightedPick( rng, DailyGenConfig.BlockCountWeights, e => e.Weight ).Count;
int count = baseCount;
if ( plan.Has( DailyModifier.ExtraBlocks ) ) count++;
if ( plan.Has( DailyModifier.FewerBlocks ) ) count--;
count = Math.Clamp( count, Math.Min( 2, freeSlots ), freeSlots );
int unmodified = Math.Clamp( baseCount, Math.Min( 2, freeSlots ), freeSlots );
if ( count > unmodified ) tags.Add( "EXTRA BLOCK" );
if ( count < unmodified ) tags.Add( "FEWER BLOCKS" );
level.Blocks = RollBlockTypes( rng, count, plan.Has( DailyModifier.Duplicates ), spawnPins, tags );
if ( plan.Has( DailyModifier.PhaseStart ) )
{
var starts = new BlockStart[level.Blocks.Count];
int phased = Math.Min( rng.Next( 1, 3 ), starts.Length );
var slots = Enumerable.Range( 0, starts.Length ).ToList();
for ( int i = 0; i < phased; i++ )
{
int slot = slots[rng.Next( slots.Count )];
slots.Remove( slot );
starts[slot] = new BlockStart { Phase = 1 };
}
level.BlockStarts = starts;
tags.Add( "HEAD START" );
}
if ( plan.ForcedCharacterId is not null )
tags.Add( $"PLAYS AS {(Characters.TryGet( plan.ForcedCharacterId, out var c ) ? c.Name : plan.ForcedCharacterId).ToUpperInvariant()}" );
if ( plan.MaxAttempts is int attempts )
tags.Add( attempts == 1 ? "1 ATTEMPT" : $"{attempts} ATTEMPTS" );
// Cosmetics last, so gameplay draws stay aligned if the cosmetic rolls ever gain draws.
// Alternate playfield: template-authored rects win; otherwise an independent cosmetic roll
// paints a generated region (see BuildAltLayout) in a second pattern+colour.
var altRects = template.AlternatePlayfieldRects.Select( r => plan.Mirrored ? MirrorRect( r ) : r ).ToArray();
if ( altRects.Length == 0 && rng.Next( 100 ) < DailyGenConfig.AltPlayfieldChancePercent )
altRects = RollAltPlayfieldRects( rng );
bool alternate = altRects.Length > 0;
if ( alternate )
{
level.AlternatePlayfieldEnabled = true;
level.AlternatePlayfieldRects = altRects;
}
var colorRoll = LevelCosmeticsRandomizer.RollColors( rng, alternate, useThreeBgColors: false );
colorRoll.ApplyTo( level );
// Ambient particles: rolled every day so the draw count stays constant, applied only when the
// enable gate wins; the tint anchors to the day's palette hue.
var particleRoll = LevelCosmeticsRandomizer.RollBackgroundParticles(
rng, colorRoll.Hue, DailyGenConfig.BackgroundParticlesChancePercent / 100f );
if ( particleRoll.Enabled )
particleRoll.ApplyTo( level );
// Pattern LAST: its draw count varies (generated Custom tiles), so nothing may follow it.
LevelCosmeticsRandomizer.RollPattern( rng, alternate ).ApplyTo( level );
return new DailyDef
{
DailyId = dailyId,
Level = level,
MaxAttempts = plan.MaxAttempts,
TemplateId = plan.Mirrored ? $"{template.Id}-mirrored" : template.Id,
Modifiers = tags,
};
}
/// <summary>Weighted draws without replacement; drawing a type also removes its
/// <see cref="DailyGenConfig.ForbiddenPairs"/> partners from the pool. Template-pinned types are
/// pre-seeded as if already drawn (no random copy of a pinned type; forbidden partners of pinned
/// types never appear). On a Duplicates day the last slot repeats one already-drawn
/// (duplicate-eligible) type instead.</summary>
private static List<BlockType> RollBlockTypes( Random rng, int count, bool allowDuplicate,
IReadOnlyList<PinnedBlock> pins, List<string> tags )
{
var pool = DailyGenConfig.TypeWeights.ToList();
foreach ( var pin in pins )
{
if ( pin is null ) continue;
pool.RemoveAll( e => e.Type == pin.Type );
foreach ( var (a, b) in DailyGenConfig.ForbiddenPairs )
{
if ( pin.Type == a ) pool.RemoveAll( e => e.Type == b );
else if ( pin.Type == b ) pool.RemoveAll( e => e.Type == a );
}
}
var blocks = new List<BlockType>();
int distinct = allowDuplicate ? count - 1 : count;
for ( int i = 0; i < distinct && pool.Count > 0; i++ )
{
var pick = WeightedPick( rng, pool, e => e.Weight ).Type;
blocks.Add( pick );
pool.RemoveAll( e => e.Type == pick );
foreach ( var (a, b) in DailyGenConfig.ForbiddenPairs )
{
if ( pick == a ) pool.RemoveAll( e => e.Type == b );
else if ( pick == b ) pool.RemoveAll( e => e.Type == a );
}
}
if ( allowDuplicate )
{
var eligible = blocks.Where( t => !DailyGenConfig.DuplicateBannedTypes.Contains( t ) ).ToList();
if ( eligible.Count > 0 )
{
blocks.Add( eligible[rng.Next( eligible.Count )] );
tags.Add( "DUPLICATE" );
}
else if ( pool.Count > 0 )
{
blocks.Add( WeightedPick( rng, pool, e => e.Weight ).Type );
}
}
return blocks;
}
/// <summary>A generated alternate-playfield region: one of <see cref="BuildAltLayout"/>'s families, then a 50/50
/// horizontal and a 50/50 vertical mirror, so every asymmetric layout appears in up to four
/// orientations. Edges snap to an 8-unit grid off the inner wall face. Purely cosmetic (the
/// alternate pattern/colour are rolled with the cosmetics), so no gameplay-fairness constraints
/// apply; layouts never overlap their own rects (two renderers on one spot would z-fight).</summary>
private static RectF[] RollAltPlayfieldRects( Random rng )
{
bool mirrorX = rng.Next( 2 ) == 0, mirrorY = rng.Next( 2 ) == 0;
// Drop degenerates BEFORE mirroring: a mirror flips an inverted rect into a valid-looking one.
var rects = BuildAltLayout( rng.Next( AltLayoutCount ), rng ).Where( r => r.Width > 0f && r.Height > 0f ).ToArray();
for ( int i = 0; i < rects.Length; i++ )
{
if ( mirrorX ) rects[i] = MirrorRect( rects[i] );
if ( mirrorY ) rects[i] = MirrorRectY( rects[i] );
}
return rects;
}
private const float AltLo = Arena.WALL_SIZE, AltHi = Arena.WIDTH - Arena.WALL_SIZE, AltMid = Arena.WIDTH / 2f;
/// <summary>Snap a coordinate to the 8-unit grid anchored at the inner wall face.</summary>
private static float AltSnap( float v ) => Math.Clamp( AltLo + MathF.Round( ( v - AltLo ) / 8f ) * 8f, AltLo, AltHi );
/// <summary>A random grid-snapped length in [min, max] (multiples of 8).</summary>
private static float AltLen( Random rng, int min, int max ) => min + rng.Next( ( max - min ) / 8 + 1 ) * 8;
/// <summary>A random grid-snapped start so that [start, start + length] fits inside the field.</summary>
private static float AltStart( Random rng, float length ) => AltLo + rng.Next( (int)( ( AltHi - AltLo - length ) / 8 ) + 1 ) * 8;
private static RectF AltBox( float left, float bottom, float right, float top )
=> new( AltSnap( left ), AltSnap( bottom ), AltSnap( right ), AltSnap( top ) );
/// <summary>How many alt-region layout families <see cref="BuildAltLayout"/> offers.</summary>
private const int AltLayoutCount = 24;
/// <summary>One alt-region layout family, in bottom-left-biased geometry; the roller adds the
/// mirrors, so a family only needs ONE handedness.
/// A switch, NOT an array of lambdas: s&box remaps delegates across a hotload, and a lambda
/// parked in a static field has no counterpart in the reloaded assembly (it throws "Unable to find
/// matching substitution for a lambda method" when called).</summary>
private static RectF[] BuildAltLayout( int layout, Random rng )
{
switch ( layout )
{
case 0: // Horizontal band.
{
float h = AltLen( rng, 40, 104 ), b = AltStart( rng, h );
return new[] { new RectF( AltLo, b, AltHi, b + h ) };
}
case 1: // Vertical band.
{
float w = AltLen( rng, 40, 104 ), l = AltStart( rng, w );
return new[] { new RectF( l, AltLo, l + w, AltHi ) };
}
case 2: // Centred box.
{
float w = AltLen( rng, 80, 144 ), h = AltLen( rng, 80, 144 );
return new[] { AltBox( AltMid - w / 2f, AltMid - h / 2f, AltMid + w / 2f, AltMid + h / 2f ) };
}
case 3: // Free-floating box anywhere.
{
float w = AltLen( rng, 40, 120 ), h = AltLen( rng, 40, 120 );
float l = AltStart( rng, w ), b = AltStart( rng, h );
return new[] { new RectF( l, b, l + w, b + h ) };
}
case 4: // One half (left; mirrors give right / bottom / top).
return rng.Next( 2 ) == 0
? new[] { new RectF( AltLo, AltLo, AltMid, AltHi ) }
: new[] { new RectF( AltLo, AltLo, AltHi, AltMid ) };
case 5: // Opposite quadrants.
return new[] { new RectF( AltLo, AltLo, AltMid, AltMid ), new RectF( AltMid, AltMid, AltHi, AltHi ) };
case 6: // Three quadrants (an L of three).
return new[] { new RectF( AltLo, AltLo, AltMid, AltHi ), new RectF( AltMid, AltLo, AltHi, AltMid ) };
case 7: // Corner box.
{
float w = AltLen( rng, 48, 128 ), h = AltLen( rng, 48, 128 );
return new[] { new RectF( AltLo, AltLo, AltLo + w, AltLo + h ) };
}
case 8: // Two boxes in adjacent corners (bottom pair).
{
float w = AltLen( rng, 40, 96 ), h = AltLen( rng, 40, 112 );
return new[] { new RectF( AltLo, AltLo, AltLo + w, AltLo + h ), new RectF( AltHi - w, AltLo, AltHi, AltLo + h ) };
}
case 9: // Two boxes in opposite corners, unequal sizes.
{
float w1 = AltLen( rng, 40, 104 ), h1 = AltLen( rng, 40, 104 ), w2 = AltLen( rng, 40, 104 ), h2 = AltLen( rng, 40, 104 );
return new[] { new RectF( AltLo, AltLo, AltLo + w1, AltLo + h1 ), new RectF( AltHi - w2, AltHi - h2, AltHi, AltHi ) };
}
case 10: // Border frame (four non-overlapping strips).
{
float t = AltLen( rng, 8, 32 );
return new[]
{
new RectF( AltLo, AltLo, AltHi, AltLo + t ), new RectF( AltLo, AltHi - t, AltHi, AltHi ),
new RectF( AltLo, AltLo + t, AltLo + t, AltHi - t ), new RectF( AltHi - t, AltLo + t, AltHi, AltHi - t ),
};
}
case 11: // Picture frame: a hollow box outline somewhere in the field.
{
float w = AltLen( rng, 96, 176 ), h = AltLen( rng, 96, 176 ), t = AltLen( rng, 8, 24 );
float l = AltStart( rng, w ), b = AltStart( rng, h );
return new[]
{
new RectF( l, b, l + w, b + t ), new RectF( l, b + h - t, l + w, b + h ),
new RectF( l, b + t, l + t, b + h - t ), new RectF( l + w - t, b + t, l + w, b + h - t ),
};
}
case 12: // Target: border frame plus a centre box.
{
float t = AltLen( rng, 8, 24 ), c = AltLen( rng, 40, 96 );
return new[]
{
new RectF( AltLo, AltLo, AltHi, AltLo + t ), new RectF( AltLo, AltHi - t, AltHi, AltHi ),
new RectF( AltLo, AltLo + t, AltLo + t, AltHi - t ), new RectF( AltHi - t, AltLo + t, AltHi, AltHi - t ),
AltBox( AltMid - c / 2f, AltMid - c / 2f, AltMid + c / 2f, AltMid + c / 2f ),
};
}
case 13: // Plus / cross (vertical bar + two horizontal stubs, no overlap).
{
float t = AltLen( rng, 24, 64 ), cx = AltSnap( AltMid + ( rng.Next( 5 ) - 2 ) * 16 ), cy = AltSnap( AltMid + ( rng.Next( 5 ) - 2 ) * 16 );
float l = cx - t / 2f, r = cx + t / 2f, b = cy - t / 2f, tp = cy + t / 2f;
return new[] { new RectF( l, AltLo, r, AltHi ), new RectF( AltLo, b, l, tp ), new RectF( r, b, AltHi, tp ) };
}
case 14: // L: a bottom band plus a left band.
{
float t = AltLen( rng, 24, 72 ), u = AltLen( rng, 24, 72 );
return new[] { new RectF( AltLo, AltLo, AltHi, AltLo + t ), new RectF( AltLo, AltLo + t, AltLo + u, AltHi ) };
}
case 15: // Evenly spaced parallel bands (2–4), horizontal or vertical.
{
int n = rng.Next( 2, 5 );
float span = ( AltHi - AltLo ) / n;
float t = AltLen( rng, 16, (int)( span / 2f ) / 8 * 8 );
bool horizontal = rng.Next( 2 ) == 0;
var rects = new RectF[n];
for ( int i = 0; i < n; i++ )
{
float a = AltSnap( AltLo + span * i + ( span - t ) / 2f ), z = AltSnap( a + t );
rects[i] = horizontal ? new RectF( AltLo, a, AltHi, z ) : new RectF( a, AltLo, z, AltHi );
}
return rects;
}
case 16: // Pillars: two symmetric side bands. The inset is capped so the pair can meet but never overlap.
{
float w = AltLen( rng, 24, 72 );
int maxInset = (int)MathF.Max( 0f, MathF.Floor( ( ( AltHi - AltLo ) / 2f - w ) / 8f ) * 8f );
float inset = AltLen( rng, 0, Math.Min( 48, maxInset ) );
bool vertical = rng.Next( 2 ) == 0;
return vertical
? new[] { new RectF( AltLo + inset, AltLo, AltLo + inset + w, AltHi ), new RectF( AltHi - inset - w, AltLo, AltHi - inset, AltHi ) }
: new[] { new RectF( AltLo, AltLo + inset, AltHi, AltLo + inset + w ), new RectF( AltLo, AltHi - inset - w, AltHi, AltHi - inset ) };
}
case 17: // Big checkerboard: 3x3 or 4x4 cells, alternating.
{
int n = rng.Next( 2 ) == 0 ? 3 : 4;
float cell = ( AltHi - AltLo ) / n;
var rects = new List<RectF>();
for ( int y = 0; y < n; y++ )
for ( int x = 0; x < n; x++ )
if ( ( x + y ) % 2 == 0 )
rects.Add( AltBox( AltLo + cell * x, AltLo + cell * y, AltLo + cell * ( x + 1 ), AltLo + cell * ( y + 1 ) ) );
return rects.ToArray();
}
case 18: // Diagonal chain of boxes, bottom-left to top-right.
{
int n = rng.Next( 3, 5 );
float cell = ( AltHi - AltLo ) / n, shrink = AltLen( rng, 0, 16 );
var rects = new RectF[n];
for ( int i = 0; i < n; i++ )
rects[i] = AltBox( AltLo + cell * i + shrink, AltLo + cell * i + shrink, AltLo + cell * ( i + 1 ) - shrink, AltLo + cell * ( i + 1 ) - shrink );
return rects;
}
case 19: // Staircase filling one corner: full-width bottom step, each step above shorter.
{
int n = rng.Next( 3, 6 );
float step = AltSnap( ( AltHi - AltLo ) / n ) - AltLo;
var rects = new RectF[n];
for ( int i = 0; i < n; i++ )
rects[i] = AltBox( AltLo, AltLo + step * i, AltHi - step * i, AltLo + step * ( i + 1 ) );
return rects;
}
case 20: // Scattered boxes: 2–4 cells of a 3x3 grid, each inset a little.
{
int count = rng.Next( 2, 5 );
float cell = ( AltHi - AltLo ) / 3f, inset = AltLen( rng, 0, 16 );
var picks = Enumerable.Range( 0, 9 ).OrderBy( _ => rng.Next() ).Take( count );
return picks.Select( i => AltBox( AltLo + cell * ( i % 3 ) + inset, AltLo + cell * ( i / 3 ) + inset,
AltLo + cell * ( i % 3 + 1 ) - inset, AltLo + cell * ( i / 3 + 1 ) - inset ) ).ToArray();
}
case 21: // Half plus a thin strip along the far edge.
{
float t = AltLen( rng, 8, 32 );
return new[] { new RectF( AltLo, AltLo, AltMid, AltHi ), new RectF( AltHi - t, AltLo, AltHi, AltHi ) };
}
case 22: // Split band: a horizontal band with a gap in the middle.
{
float h = AltLen( rng, 32, 96 ), b = AltStart( rng, h ), gap = AltLen( rng, 24, 80 );
return new[] { new RectF( AltLo, b, AltSnap( AltMid - gap / 2f ), b + h ), new RectF( AltSnap( AltMid + gap / 2f ), b, AltHi, b + h ) };
}
default: // Nested corner squares (two concentric quarter-frames in one corner).
{
float s = AltLen( rng, 96, 160 ), t = AltLen( rng, 16, 32 );
return new[]
{
new RectF( AltLo, AltLo + s - t, AltLo + s, AltLo + s ), new RectF( AltLo + s - t, AltLo, AltLo + s, AltLo + s - t ),
new RectF( AltLo, AltLo, AltLo + s - 2 * t, AltLo + s - 2 * t ),
};
}
}
}
private static T WeightedPick<T>( Random rng, IReadOnlyList<T> entries, Func<T, int> weight )
{
int total = 0;
for ( int i = 0; i < entries.Count; i++ )
total += Math.Max( 0, weight( entries[i] ) );
int roll = rng.Next( total );
for ( int i = 0; i < entries.Count; i++ )
{
roll -= Math.Max( 0, weight( entries[i] ) );
if ( roll < 0 ) return entries[i];
}
return entries[^1];
}
// Float-weight variant (templates). Consumes exactly one rng draw, same as the int version.
private static T WeightedPick<T>( Random rng, IReadOnlyList<T> entries, Func<T, float> weight )
{
double total = 0;
for ( int i = 0; i < entries.Count; i++ )
total += Math.Max( 0f, weight( entries[i] ) );
double roll = rng.NextDouble() * total;
for ( int i = 0; i < entries.Count; i++ )
{
roll -= Math.Max( 0f, weight( entries[i] ) );
if ( roll < 0 ) return entries[i];
}
return entries[^1];
}
// Shared with daily_validate_templates so the validator checks the exact mirrored geometry.
internal static RectF MirrorRect( RectF r ) => new( Arena.WIDTH - r.Right, r.Bottom, Arena.WIDTH - r.Left, r.Top );
internal static RectF MirrorRectY( RectF r ) => new( r.Left, Arena.HEIGHT - r.Top, r.Right, Arena.HEIGHT - r.Bottom );
internal static Vector2 MirrorPoint( Vector2 p ) => new( Arena.WIDTH - p.x, p.y );
internal static Direction MirrorSide( Direction side ) => side switch
{
Direction.Left => Direction.Right,
Direction.Right => Direction.Left,
_ => side,
};
}