Manager for easter-egg step groups. Tracks all live IEggInteractable members, starts and enforces per-group or per-member time windows, handles satisfaction/completion, round resets, and diagnostics/logging.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Anything that can be a member of an easter-egg step.
///
/// ⚠️ THE INTERFACE EXISTS SO THE COORDINATOR IS NOT ABOUT BUTTONS. A step group in the
/// spec mixes interactable types freely — four statues today, a statue and a shootable
/// tomorrow — so the thing that decides "have all four landed" must not know what a
/// pressable is. `Pressable` is simply the first implementer.
/// </summary>
public interface IEggInteractable
{
/// <summary>The step this carries. Null while it has no config row.</summary>
EggStep Step { get; }
/// <summary>Zero whatever partial progress this holds — presses, a hold timer, a kill
/// count. Returns true if there was anything to zero, so the coordinator can stay quiet
/// on a group that had not been touched.</summary>
bool ClearProgress();
/// <summary>
/// Seconds allowed to finish the whole step once anyone starts it. 0 = no limit.
///
/// ⚠️ ON THE INTERACTABLE, NOT ON `EggStep`, because it is a CONDITION — the same place
/// the press count and the hold live. The coordinator reads it through this interface so it
/// never has to know what kind of thing it is asking.
/// </summary>
float TimeWindow { get; }
/// <summary>Fail this member: zero its progress and start whatever retry lockout it
/// carries. Called on every member when a step runs out of time.</summary>
void Fail( string why );
/// <summary>One line for the diagnostics: what and where.</summary>
string Describe();
}
/// <summary>
/// THE STEP-NUMBER COORDINATOR — the spec's Group ID, made real.
///
/// A step number is the easter egg's own step counter: every placeable carrying the same
/// number is one step of the egg, and they stand or fall together. From
/// `Docs/EASTER_EGG_TOOLSET.md`:
///
/// - all members must be completed correctly **within the same round** to count;
/// - if any member is incomplete at the round turn, **all** of them reset, however far
/// along the others were;
/// - the downstream effect fires only once every member is satisfied at the same time,
/// and reward flags stay per-step.
///
/// ⛔ SATISFIED AND COMPLETED ARE TWO DIFFERENT THINGS, and that split is the whole
/// mechanism. A grouped member that has been pressed correctly is SATISFIED — it stops
/// accepting input and waits. Nothing is written to the flag pool until the last member
/// joins it, at which point every member COMPLETES and every member's rewards fire. Before
/// that moment the egg has not moved, so a group that times out can be wound back without
/// having to un-set flags other steps may already have read.
///
/// ⚠️ AN UNGROUPED STEP (number 0) TAKES THE OLD PATH EXACTLY — satisfy and complete in one
/// motion. Grouping is opt-in, and a lone button must not wait for a group of one.
/// </summary>
public static class EggGroups
{
// ⚠️ MEMBERSHIP IS BY LIVE COMPONENT, not by config row. A rebuild destroys and recreates
// every interactable, and a registry keyed on config would then hold members that are no
// longer in the world — a group that could never complete because one of its four
// statues had been deleted an hour ago.
static readonly List<IEggInteractable> _all = new();
public static void Register( IEggInteractable who )
{
if ( who is not null && !_all.Contains( who ) ) _all.Add( who );
}
public static void Forget( IEggInteractable who )
{
_all.Remove( who );
_soloDeadline.Remove( who );
}
// ⛔ TWO DICTIONARIES, ONE RULE. A step is identified by its GROUP NUMBER when it has
// one and by the interactable itself when it does not, so the clock has two homes — but
// both start the same way, expire in the same tick and fail down the same path, and keeping
// them in one file is what stops those three from drifting apart.
static readonly Dictionary<int, float> _groupDeadline = new();
static readonly Dictionary<IEggInteractable, float> _soloDeadline = new();
// ⚠️ FRAME-GUARDED. Every member calls Tick from its own OnUpdate — there is no
// coordinator component to hang an update on — so without this a four-button step would
// run the expiry check four times a frame. Stamping `Time.Now` is the same guard
// `DamageNumbers` uses to batch a frame's hits.
static float _tickedAt = -1f;
/// <summary>Every live member, skipping anything without a config row yet.</summary>
public static IEnumerable<IEggInteractable> All
=> _all.Where( m => m is not null && m.Step is not null );
/// <summary>The members of one group.</summary>
public static IEnumerable<IEggInteractable> InGroup( int number )
=> All.Where( m => m.Step.StepNumber == number );
/// <summary>The step numbers actually in use, ascending.</summary>
public static IEnumerable<int> Numbers
=> All.Where( m => m.Step.StepNumber > 0 )
.Select( m => m.Step.StepNumber ).Distinct().OrderBy( n => n );
/// <summary>
/// Something happened on this member that counts as starting the step — a first press, a
/// completed hold. Starts the clock if there is one and it is not already running.
///
/// ⛔ THE CLOCK STARTS ON THE FIRST PROGRESS, NOT THE FIRST COMPLETED MEMBER. Four
/// statues that each need three presses are started by the first of those twelve presses —
/// waiting until a whole statue was finished would hand the player a free run-up on the one
/// they happened to start with, and the spec's short window ("effectively forces multiple
/// players to press together") would not force anything.
///
/// ⚠️ IDEMPOTENT. Every later press asks again and is ignored while a deadline stands;
/// restarting the clock on each press is exactly the bug that would make a long window
/// impossible to run out of.
/// </summary>
public static void NoteProgress( IEggInteractable who )
{
var step = who?.Step;
if ( step is null ) return;
if ( step.StepNumber > 0 )
{
if ( _groupDeadline.ContainsKey( step.StepNumber ) ) return;
var window = WindowFor( step.StepNumber );
if ( window <= 0f ) return;
_groupDeadline[step.StepNumber] = Time.Now + window;
Log.Info( $"[nz-ee] step {step.StepNumber} started — {window:0.#}s to finish it" );
return;
}
if ( _soloDeadline.ContainsKey( who ) || who.TimeWindow <= 0f ) return;
_soloDeadline[who] = Time.Now + who.TimeWindow;
Log.Info( $"[nz-ee] {who.Describe()} started — {who.TimeWindow:0.#}s to finish it" );
}
/// <summary>
/// The window a group runs on: the LARGEST any member carries.
///
/// ⛔ THE LARGEST, AND IT WARNS WHEN THEY DISAGREE. Members are meant to be authored
/// alike; when they are not, taking the smallest would let one button left at 0 silently
/// switch the whole puzzle's timer off, which is invisible from inside the game. Taking the
/// largest makes the mistake show up as a puzzle that is too easy — which somebody notices
/// — and the warning names the numbers either way.
/// </summary>
static float WindowFor( int number )
{
var windows = InGroup( number ).Select( m => m.TimeWindow ).ToList();
if ( windows.Count == 0 ) return 0f;
var biggest = windows.Max();
if ( biggest > 0f && windows.Distinct().Count() > 1 )
Log.Warning( $"[nz-ee] step {number} has members with different time limits "
+ $"({string.Join( ", ", windows.Select( w => w.ToString( "0.#" ) ) )}) — "
+ $"using {biggest:0.#}s. Set them all the same." );
return biggest;
}
/// <summary>Seconds left on this member's step, or -1 when no clock is running. For the
/// prompt and the diagnostics.</summary>
public static float Remaining( IEggInteractable who )
{
var step = who?.Step;
if ( step is null ) return -1f;
var found = step.StepNumber > 0
? _groupDeadline.TryGetValue( step.StepNumber, out var d )
: _soloDeadline.TryGetValue( who, out d );
return found ? MathF.Max( 0f, d - Time.Now ) : -1f;
}
/// <summary>
/// Expire any step that has run out of time. Called from every member's OnUpdate.
///
/// ⚠️ THE WHOLE STEP FAILS, NOT JUST THE MEMBER THAT WAS SLOW. That is what a step is:
/// the members that were already done lose their progress too and every one of them starts
/// its own retry lockout, so "you were too slow" costs the same wait wherever you got to.
/// </summary>
public static void Tick()
{
if ( _tickedAt == Time.Now ) return;
_tickedAt = Time.Now;
if ( _groupDeadline.Count > 0 )
{
foreach ( var number in _groupDeadline.Where( kv => Time.Now >= kv.Value )
.Select( kv => kv.Key ).ToList() )
{
_groupDeadline.Remove( number );
var members = InGroup( number ).ToList();
foreach ( var m in members ) m.Fail( "ran out of time" );
Log.Info( $"[nz-ee] step {number} RAN OUT OF TIME — all {members.Count} "
+ "members failed together" );
}
}
if ( _soloDeadline.Count == 0 ) return;
foreach ( var who in _soloDeadline.Where( kv => Time.Now >= kv.Value )
.Select( kv => kv.Key ).ToList() )
{
_soloDeadline.Remove( who );
who.Fail( "ran out of time" );
Log.Info( $"[nz-ee] {who.Describe()} RAN OUT OF TIME" );
}
}
/// <summary>
/// One member has done its job. Returns true if that fired the rewards.
///
/// ⚠️ THE MEMBER IS SATISFIED EVEN WHEN THE GROUP IS NOT, so it stops accepting presses
/// immediately. Without that, a player who finished their statue could keep pressing it
/// while waiting for a team-mate and overshoot their own repeat count — failing a step
/// they had already got right.
/// </summary>
public static bool Satisfy( IEggInteractable who )
{
var step = who?.Step;
if ( step is null ) return false;
if ( step.StepNumber <= 0 )
{
step.Complete();
_soloDeadline.Remove( who );
return true;
}
step.Satisfy();
var members = InGroup( step.StepNumber ).ToList();
var waiting = members.Count( m => !m.Step.Satisfied );
if ( waiting > 0 )
{
Log.Info( $"[nz-ee] step {step.StepNumber}: {members.Count - waiting}/{members.Count}"
+ $" done, waiting on {waiting}" );
return false;
}
// ⚠️ EVERY MEMBER'S OWN REWARD FIRES, not one shared one. The spec: "Reward flags
// remain per-step (no longer required to match across the group)" — four statues can
// each set their own flag, and something downstream can require all four.
foreach ( var m in members )
m.Step.Complete();
// ⚠️ THE CLOCK STOPS WHEN THE STEP LANDS, and it has to be removed rather than left
// to expire harmlessly: `Tick` would otherwise fail a step that is already complete.
_groupDeadline.Remove( step.StepNumber );
Log.Info( $"[nz-ee] step {step.StepNumber} COMPLETE — all {members.Count} members landed" );
return true;
}
/// <summary>
/// The round turned. Any group that is not fully complete loses everything.
///
/// ⛔ THIS IGNORES THE PER-STEP "Reset each round" SETTING, deliberately. Round-bounding
/// is not a condition a grouped step opts into — it is what a group MEANS: "all members
/// completed correctly within the same round". A group whose members individually opted
/// out of round reset would be a group with no deadline, which is not a group at all.
///
/// ⚠️ A COMPLETED GROUP IS LEFT ALONE. Its rewards have fired and other steps may already
/// be gated on them; resetting it would unwind the egg from behind.
/// </summary>
public static void OnRoundStart()
{
foreach ( var number in Numbers.ToList() )
{
var members = InGroup( number ).ToList();
if ( members.Count == 0 ) continue;
if ( members.All( m => m.Step.Completed ) ) continue;
// ⚠️ THE CLOCK GOES WITH THE PROGRESS. A group wound back at the round turn is
// being handed over from scratch; a deadline still standing from last round would
// expire on a group nobody had started.
_groupDeadline.Remove( number );
var touched = 0;
foreach ( var m in members )
{
if ( m.Step.ClearProgress() ) touched++;
if ( m.ClearProgress() ) touched++;
}
// ⚠️ SILENT WHEN NOTHING HAD BEEN STARTED. A group nobody has touched this round
// is reset every round for the whole match, and logging that would bury the one
// line that matters — the round a nearly-finished group ran out of time.
if ( touched > 0 )
Log.Info( $"[nz-ee] step {number} ran out of round — all {members.Count}"
+ " members reset together" );
}
}
/// <summary>Group id, how many are satisfied, how many there are, and whether it has
/// fired. For `nz_egg_groups`.</summary>
public static IEnumerable<(int Number, int Satisfied, int Total, bool Complete)> Snapshot()
{
foreach ( var n in Numbers )
{
var members = InGroup( n ).ToList();
yield return (n, members.Count( m => m.Step.Satisfied ), members.Count,
members.Count > 0 && members.All( m => m.Step.Completed ));
}
}
}