Game/DailyTemplates.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace BlockParty;
/// <summary>The on-disk JSON shape of one daily-challenge obstacle template
/// (Assets/daily-templates/*.json — the file name is the template id). Same primitive conventions
/// as <see cref="LevelJson"/>: enums as names, points as [x,y], rects as [left,bottom,right,top].
/// Authored from the level editor (Save Template) and hand-tunable (Weight/AllowMirrorX).</summary>
public sealed class ObstacleTemplateJson
{
public float Weight { get; set; } = 1f;
public bool AllowMirrorX { get; set; }
/// <summary>When set, spiked surfaces are always spiked exactly as authored instead of rolled
/// per-side each day (see <see cref="ObstacleTemplate.AuthoredSpikes"/>).</summary>
public bool AuthoredSpikes { get; set; }
public float[][] Obstacles { get; set; }
/// <summary>Indices into <see cref="Obstacles"/> whose rect blocks line of sight.</summary>
public int[] Vision { get; set; }
/// <summary>Indices into <see cref="Obstacles"/> flagged as fences (solid only to blocks). Their
/// colour is NOT stored here — templates are layout-only, and the fence colour rolls with the
/// rest of the day's palette (see <see cref="LevelCosmeticsRandomizer.RollColors"/>).</summary>
public int[] Fences { get; set; }
/// <summary>Indices into <see cref="Obstacles"/> flagged as GLASS (solid only to players).
/// Layout-only like <see cref="Fences"/> — the glass colour rolls with the day's palette.</summary>
public int[] Glass { get; set; }
public float[][] Spawns { get; set; }
/// <summary>Single player spawn [x,y] (the common case).</summary>
public float[] Player { get; set; }
/// <summary>Candidate player spawns [[x,y],...] — written instead of <see cref="Player"/> when
/// there's more than one; the run picks one from the run Rng.</summary>
public float[][] Players { get; set; }
public string[] SpikeableWalls { get; set; }
public ObstacleSpikeJson[] SpikeableObstacleSides { get; set; }
public float[][] AltRects { get; set; }
/// <summary>Pinned blocks (sparse: spawn index + type/phase/pressed) — always spawn on this
/// template's days, independent of the rolled block count.</summary>
public PinJson[] Pins { get; set; }
/// <summary>Collectible coin centres [[x,y],...]. Null/omitted = no coins.</summary>
public float[][] Coins { get; set; }
}
/// <summary>
/// The daily generator's obstacle-template library, loaded from <c>Assets/daily-templates/*.json</c>
/// (sorted by id). IMPORTANT: the file set AND content are generation inputs — adding, renaming,
/// editing or deleting a template changes every past and future generated day, so template changes
/// ship with a LEADERBOARD_VERSION bump like any generator change (see <see cref="DailyLevelGenerator"/>).
/// Templates are authored in the level editor (Save/Load Template in the inspector) and validated
/// by the daily_validate_templates ConCmd.
/// </summary>
public static class DailyTemplates
{
public const string DirName = "daily-templates";
private static List<ObstacleTemplate> _templates;
/// <summary>Every loaded template, sorted by id (deterministic generation order).</summary>
public static IReadOnlyList<ObstacleTemplate> All
{
get
{
EnsureLoaded();
return _templates ?? (IReadOnlyList<ObstacleTemplate>)Array.Empty<ObstacleTemplate>();
}
}
public static ObstacleTemplate Find( string id )
=> string.IsNullOrWhiteSpace( id ) ? null : All.FirstOrDefault( t => t.Id == id );
/// <summary>Drop the loaded set so the next access rescans the mounted folder (hotload hygiene).</summary>
public static void Reload() => _templates = null;
/// <summary>Whether a scan has actually found templates (attempts a load, so polling here
/// self-heals once the mount lands). While false the library may just not be mounted yet, so
/// dailies must not be generated at all (see <see cref="DailyLevels.Get"/>).</summary>
public static bool IsLoaded { get { EnsureLoaded(); return _templates is not null; } }
/// <summary>Hot-insert/replace a just-saved template so it's usable immediately — the mounted
/// asset filesystem can lag behind the disk write (mirrors <c>Levels.ApplySavedLevel</c>).</summary>
public static void ApplySaved( ObstacleTemplate template )
{
if ( template is null || string.IsNullOrEmpty( template.Id ) )
return;
EnsureLoaded();
_templates ??= new List<ObstacleTemplate>();
_templates.RemoveAll( t => t.Id == template.Id );
_templates.Add( template );
_templates.Sort( ( a, b ) => string.CompareOrdinal( a.Id, b.Id ) );
}
/// <summary>Hot-remove a just-deleted template so the browser stops offering it immediately — the
/// mounted asset filesystem can lag behind the disk delete (the counterpart of <see cref="ApplySaved"/>).</summary>
public static void ApplyDeleted( string id )
{
if ( string.IsNullOrEmpty( id ) )
return;
_templates?.RemoveAll( t => t.Id == id );
}
// Latch the library only once a scan actually FINDS templates, so a boot-before-mount race can't
// latch an empty or partial library — mirrors the
// content-based `Levels._loaded` retry for the same race. An empty scan retries on the next access.
private static void EnsureLoaded()
{
if ( _templates is not null )
return;
try
{
if ( !FileSystem.Mounted.DirectoryExists( DirName ) )
return;
var loaded = new List<ObstacleTemplate>();
foreach ( var name in FileSystem.Mounted.FindFile( DirName, "*.json" ) )
{
var path = $"{DirName}/{name}";
try
{
var json = Json.Deserialize<ObstacleTemplateJson>( FileSystem.Mounted.ReadAllText( path ) );
var id = name.EndsWith( ".json", StringComparison.OrdinalIgnoreCase ) ? name[..^".json".Length] : name;
loaded.Add( FromJson( id, json ) );
}
catch ( Exception ex )
{
Log.Warning( $"[BlockParty] daily template '{path}' skipped: {ex.Message}" );
}
}
if ( loaded.Count == 0 )
{
// Folder visible but nothing loaded: either the mount is mid-race (retry silently) or
// every template file is gone/broken (a real authoring problem — the warnings above /
// daily_validate_templates cover the broken-file case).
return;
}
loaded.Sort( ( a, b ) => string.CompareOrdinal( a.Id, b.Id ) );
_templates = loaded;
}
catch ( Exception ex )
{
Log.Warning( $"[BlockParty] scanning '{DirName}': {ex.Message}" );
}
}
// ── JSON ↔ runtime/editor conversions ────────────────────────────────────────────────────────────
public static ObstacleTemplate FromJson( string id, ObstacleTemplateJson json )
{
var spawns = (json.Spawns ?? Array.Empty<float[]>()).Select( Point ).ToArray();
// Sparse pins → aligned-with-spawns array (nulls = free slots).
var pins = new PinnedBlock[spawns.Length];
foreach ( var pin in json.Pins ?? Array.Empty<PinJson>() )
{
if ( pin is null || pin.Spawn < 0 || pin.Spawn >= pins.Length ) continue;
if ( !Enum.TryParse<BlockType>( pin.Type, true, out var type ) ) continue;
pins[pin.Spawn] = new PinnedBlock
{
Type = type,
Phase = Math.Clamp( pin.Phase, 0, Block.NUM_PHASES - 1 ),
PressedSides = (pin.Pressed ?? Array.Empty<string>()).Select( ParseDirection ).Where( d => d != Direction.None ).ToArray(),
};
}
return new()
{
Id = id,
Weight = Math.Max( 0f, json.Weight ),
AllowMirrorX = json.AllowMirrorX,
AuthoredSpikes = json.AuthoredSpikes,
Obstacles = (json.Obstacles ?? Array.Empty<float[]>()).Select( Rect ).ToArray(),
VisionObstacles = json.Vision ?? Array.Empty<int>(),
FenceObstacles = json.Fences ?? Array.Empty<int>(),
GlassObstacles = json.Glass ?? Array.Empty<int>(),
SpawnPositions = spawns,
SpawnPins = pins.Any( p => p is not null ) ? pins : Array.Empty<PinnedBlock>(),
PlayerSpawns = json.Players is { Length: > 0 } list
? list.Select( Point ).ToArray()
: new[] { json.Player is { Length: 2 } p ? new Vector2( p[0], p[1] ) : new Vector2( 16, 40 ) },
SpikeableWalls = (json.SpikeableWalls ?? Array.Empty<string>()).Select( ParseDirection ).Where( d => d != Direction.None ).ToArray(),
SpikeableObstacleSides = (json.SpikeableObstacleSides ?? Array.Empty<ObstacleSpikeJson>())
.Select( s => (s.Obstacle, (s.Sides ?? Array.Empty<string>()).Select( ParseDirection ).Where( d => d != Direction.None ).ToArray()) )
.ToArray(),
AlternatePlayfieldRects = (json.AltRects ?? Array.Empty<float[]>()).Select( Rect ).ToArray(),
Coins = (json.Coins ?? Array.Empty<float[]>()).Select( Point ).ToArray(),
};
}
/// <summary>Build the on-disk shape from an editor level. The editor's spiked walls/faces are
/// spike CANDIDATES rolled per-side each day, unless the template's AuthoredSpikes toggle
/// makes them spiked exactly as authored on every day.</summary>
public static ObstacleTemplateJson BuildJson( EditorLevel level ) => new()
{
Obstacles = level.Obstacles.Select( Arr ).ToArray(),
Vision = level.VisionObstacles.OrderBy( i => i ).ToArray(),
Fences = level.FenceObstacles.OrderBy( i => i ).ToArray(),
Glass = level.GlassObstacles.OrderBy( i => i ).ToArray(),
Spawns = level.SpawnPositions.Select( Arr ).ToArray(),
Player = level.PlayerSpawns.Count == 1 ? Arr( level.PlayerSpawns[0] ) : null,
Players = level.PlayerSpawns.Count > 1 ? level.PlayerSpawns.Select( Arr ).ToArray() : null,
SpikeableWalls = level.SpikedWalls.Select( d => d.ToString() ).ToArray(),
SpikeableObstacleSides = level.SpikedObstacleSides
.OrderBy( s => s.Obstacle )
.Select( s => new ObstacleSpikeJson { Obstacle = s.Obstacle, Sides = s.Sides.Select( d => d.ToString() ).ToArray() } )
.ToArray(),
AltRects = level.AlternatePlayfieldRects.Select( Arr ).ToArray(),
Coins = level.Coins.Count > 0 ? level.Coins.Select( Arr ).ToArray() : null,
Pins = Enumerable.Range( 0, level.SpawnPositions.Count )
.Where( i => level.SpawnPin( i ) is not null )
.Select( i => new PinJson
{
Spawn = i,
Type = level.SpawnPin( i ).Type.ToString(),
Phase = level.SpawnPin( i ).Phase,
Pressed = level.SpawnPin( i ).Pressed.Count > 0 ? level.SpawnPin( i ).Pressed.Select( d => d.ToString() ).ToArray() : null,
} )
.ToArray(),
};
/// <summary>Replace an editor level's LAYOUT with a template's: obstacles, vision flags, spawn
/// grid, player spawn, spikes (candidates become the editor's spiked walls/faces) and alternate
/// rects. Blocks, cosmetics, id/name etc. are left untouched so a template can seed a map level.</summary>
public static void ApplyToEditor( ObstacleTemplate template, EditorLevel level )
{
level.Obstacles = new List<RectF>( template.Obstacles );
level.VisionObstacles = new List<int>( template.VisionObstacles );
level.FenceObstacles = new List<int>( template.FenceObstacles );
level.GlassObstacles = new List<int>( template.GlassObstacles );
level.SpikedWalls = new List<Direction>( template.SpikeableWalls );
level.SpikedObstacleSides = template.SpikeableObstacleSides.Select( s =>
{
var spike = new ObstacleSpike { Obstacle = s.Obstacle };
spike.Sides.AddRange( s.Sides );
return spike;
} ).ToList();
// Every per-slot list must be cleared together with SpawnPositions: AddSpawn APPENDS to all of
// them, so leaving one populated shifts it out of step and the old level's entries end up read
// as the template's. Templates carry no start direction / turn-mode authoring (daily levels
// don't use them), so both simply reset to their defaults.
level.SpawnPositions.Clear();
level.SpawnPins.Clear();
level.SpawnAbilityTimers.Clear();
level.SpawnDirections.Clear();
level.SpawnTurnModes.Clear();
for ( int i = 0; i < template.SpawnPositions.Length; i++ )
{
level.AddSpawn( template.SpawnPositions[i] );
var pin = i < template.SpawnPins.Length ? template.SpawnPins[i] : null;
if ( pin is not null )
level.SetSpawnPin( i, new EditorBlock( pin.Type ) { Phase = pin.Phase, Pressed = pin.PressedSides.ToList() } );
}
level.PlayerSpawns = template.PlayerSpawns.Length > 0
? new List<Vector2>( template.PlayerSpawns )
: new List<Vector2> { new( 16, 40 ) };
// The alt playfield is part of the LAYOUT, so it follows the template both ways: a template
// without alt rects switches it back off, or the previous level's toggle would leave the
// editor claiming a second pattern this template never had.
level.AlternatePlayfieldRects = new List<RectF>( template.AlternatePlayfieldRects );
level.AlternatePlayfieldEnabled = level.AlternatePlayfieldRects.Count > 0;
level.Coins = new List<Vector2>( template.Coins );
}
/// <summary>Kebab-case a user-typed template name into a file/id-safe slug.</summary>
public static string SanitizeId( string name )
{
var chars = (name ?? "").Trim().ToLowerInvariant()
.Select( c => char.IsLetterOrDigit( c ) ? c : '-' )
.ToArray();
var id = new string( chars );
while ( id.Contains( "--" ) ) id = id.Replace( "--", "-" );
return id.Trim( '-' );
}
private static RectF Rect( float[] a ) => a is { Length: 4 }
? new RectF( a[0], a[1], a[2], a[3] )
: throw new FormatException( "rect must be [left,bottom,right,top]" );
private static Vector2 Point( float[] a ) => a is { Length: 2 }
? new Vector2( a[0], a[1] )
: throw new FormatException( "point must be [x,y]" );
private static float[] Arr( RectF r ) => new[] { r.Left, r.Bottom, r.Right, r.Top };
private static float[] Arr( Vector2 p ) => new[] { p.x, p.y };
private static Direction ParseDirection( string name )
=> Enum.TryParse<Direction>( name, ignoreCase: true, out var d ) ? d : Direction.None;
}