EasterEgg/EggGroups.cs

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.

File Access
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 ));
		}
	}
}