EasterEgg/EggInteractable.cs

Abstract component for an Easter-egg interactable, implementing shared state and rules used by concrete triggers (pressable, shootable, etc.). It manages progress counting, cooldown/round lockouts, timed availability, visibility toggling, group coordination via EggGroups, and provides availability text and failure handling.

File AccessNetworking
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// THE SHARED BODY OF AN EASTER-EGG INTERACTABLE — the conditions every one of them carries,
/// in one place.
///
/// ⛔ THIS EXISTS BECAUSE THE SECOND INTERACTABLE WOULD OTHERWISE HAVE COPIED THE FIRST. The
/// spec files the Shootable as "same conditions as Pressable" minus the hold, so building it
/// standalone meant a second copy of the repeat count, the overshoot rule, the retry lockout,
/// the round reset, the timed delay, the group wiring and the vanish-on-completion. Two copies
/// of a condition engine is exactly the shape `INSTRUCTIONS.md` §3 records diverging: one of
/// them gets the fix. There are seven more interactables in the spec.
///
/// ⚠️ WHAT STAYS IN THE SUBCLASS IS THE TRIGGER, and only that. A pressable is triggered by
/// the use key and can be held; a shootable is triggered by a bullet and knows what fired it.
/// Everything between "something happened" and "the step moved" is here.
/// </summary>
public abstract class EggInteractable : Component, IEggInteractable
{
	/// <summary>The config row, as the shared fields. Each subclass returns its own typed
	/// spot; every condition below is read through this.</summary>
	public abstract EggSpot Config { get; }

	/// <summary>The step this carries — <see cref="IEggInteractable"/>.</summary>
	public EggStep Step => Config?.Step;

	/// <summary>Seconds to finish the step once started — <see cref="IEggInteractable"/>.</summary>
	public float TimeWindow => Config?.TimeWindow ?? 0f;

	/// <summary>
	/// The child object holding the model and the collider — everything a player can see or
	/// bump into or shoot. Set by the manager at build time.
	///
	/// ⚠️ ONE OBJECT, NOT A RENDERER AND A COLLIDER SEPARATELY. Switching the child off takes
	/// both with it, so a finished interactable cannot be seen, shot, or walked into — one
	/// that is invisible but still solid is worse than one that never vanished.
	/// </summary>
	[Property] public GameObject Visual { get; set; }

	/// <summary>Triggers banked toward <see cref="EggSpot.RepeatCount"/>.</summary>
	public int Progress { get; protected set; }

	/// <summary>Seconds left on a failed-attempt cooldown. Only used under
	/// <see cref="EggRetry.Seconds"/>.</summary>
	public float Cooldown { get; private set; }

	/// <summary>Locked out until the round turns, after failing under
	/// <see cref="EggRetry.NextRound"/>.
	///
	/// ⚠️ A FLAG, NOT A HUGE `Cooldown` VALUE. Expressing "until the next round" as a number of
	/// seconds would need a guess at how long a round lasts, and would be wrong every time the
	/// players cleared one faster or slower than the guess.</summary>
	public bool LockedUntilRound { get; private set; }

	/// <summary>When this step's Required flags first became satisfied — the clock the Timed
	/// condition counts from. Negative = they never have been.</summary>
	protected float AvailableSince = -1f;

	// ⚠️ REGISTERED WITH THE GROUP COORDINATOR IN THE BASE, so a new interactable joins the
	// step system by existing rather than by remembering to. A member that outlived its
	// GameObject would leave a group that could never finish.
	protected override void OnEnabled() => EggGroups.Register( this );
	protected override void OnDisabled() => EggGroups.Forget( this );

	protected override void OnUpdate()
	{
		if ( Config is null ) return;

		TickVisibility();

		// ⚠️ EVERY MEMBER CALLS IT AND THE COORDINATOR RUNS IT ONCE. There is no component
		// behind `EggGroups`, so the step clock has to be driven by whatever is alive — and the
		// things that are alive are its own members.
		EggGroups.Tick();

		var dt = Time.Delta;

		if ( Cooldown > 0f ) Cooldown = MathF.Max( 0f, Cooldown - dt );

		// ⚠️ THE TIMED CLOCK STARTS WHEN `Required` IS FIRST SATISFIED, not when the map loads.
		// "Unlocks X seconds after its Required flags are set" is only meaningful measured from
		// that moment; counting from load would make a step gated behind an hour of play
		// available the instant its flags landed.
		var gated = EggFlags.AllSet( Step.Required ) && !EggFlags.AnySet( Step.Excluded );

		if ( gated && AvailableSince < 0f ) AvailableSince = Time.Now;
		else if ( !gated ) AvailableSince = -1f;

		OnTick( dt );
	}

	/// <summary>Per-frame work only this kind of interactable has — the pressable's hold. Most
	/// have none.</summary>
	protected virtual void OnTick( float dt ) { }

	/// <summary>
	/// A completed step is GONE from the world — its parts stop being there once the step is
	/// done.
	///
	/// ⛔ STATE-DRIVEN EVERY FRAME, NOT AN EVENT ON COMPLETION. The same choice the damage wall
	/// was rebuilt around: hiding on a "step completed" callback means every other way the state
	/// can move — a group reset, `Fail` with RevertToPrevious, `nz_egg_reset`, a config rebuild
	/// — needs its own matching un-hide, and the one that gets forgotten leaves a part missing
	/// from a map with nothing to explain it. Reading the state instead means one rule that
	/// cannot fall out of step.
	///
	/// ⚠️ A GROUP VANISHES TOGETHER FOR FREE, because every member completes in the same call.
	///
	/// ⚠️ CREATIVE KEEPS THEM. A mapper testing their own egg still has to see the parts to move
	/// or delete them, and `ShowAuthoringVisuals` is off in preview, so a preview still shows
	/// what a player would see.
	/// </summary>
	protected void TickVisibility()
	{
		if ( !Visual.IsValid() ) return;

		var show = !Step.Completed || NZGame.ShowAuthoringVisuals;
		if ( Visual.Enabled != show ) Visual.Enabled = show;
	}

	/// <summary>
	/// Why this cannot be triggered right now, or empty when it can. Answers for the prompt and
	/// for the trigger, so a prompt can never offer what the trigger then refuses.
	/// </summary>
	public string Unavailable( NZPlayer player ) => Unavailable( player, HeldWeapon( player ) );

	/// <summary>
	/// The same question, told which weapon to judge.
	///
	/// ⛔ THE WEAPON IS A PARAMETER BECAUSE THE TWO TRIGGERS MEAN DIFFERENT THINGS BY IT. A
	/// pressable asks about the gun in your hands right now; a shootable asks about the gun the
	/// BULLET came from, which with a fast switch is not the same object. Resolving it inside
	/// would silently make the shootable read the wrong one.
	/// </summary>
	public virtual string Unavailable( NZPlayer player, SWB.Base.Weapon weapon )
	{
		if ( !player.IsValid() || Config is null ) return "no player";

		// ⛔ A COMPLETED STEP REFUSES, IT DOES NOT RETURN "". Empty means "go ahead" to the
		// trigger, and this line used to return it — so a finished button could be pressed for
		// ever, answering "DONE — set flag_c" every time while setting nothing. It went
		// unnoticed because `EggStep.Complete` early-outs, so the only symptom was a lie in the
		// reply. `UsePrompt` tests `Completed` itself before asking, so the prompt still shows
		// nothing on a finished one.
		if ( Step.Completed ) return "Already done";

		// ⚠️ A SATISFIED MEMBER SAYS SO RATHER THAN GOING SILENT. It has been done correctly and
		// is waiting on the rest of its step; without this it reads exactly like something that
		// stopped working, and the player's own correct input is what broke it.
		if ( Step.Satisfied )
			return $"Done — waiting on the rest of step {Step.StepNumber}";

		// ⚠️ THE DOOR FLAG IS CHECKED HERE, NOT IN `EggStep.IsAvailable` — see `EggSpot.Link`.
		// It is the General pool, which the spec keeps out of the egg's own inputs, so it sits
		// at the interaction layer beside the perk and weapon checks.
		//
		// ⚠️ IT GETS ITS OWN WORD. "Locked" and "Nothing to do here" look identical from in
		// front of an unresponsive object but are different causes — one is waiting on a door
		// being bought, the other on the egg reaching this step.
		if ( !DoorLinks.IsOpen( Config.Link ) ) return "Locked";

		if ( !Step.IsAvailable() ) return "Nothing to do here";

		if ( Cooldown > 0f ) return $"Wait {Cooldown:0.0}s";

		// ⚠️ SAYS THE ROUND, NOT A TIME. There is no number to show — that is the whole point of
		// the setting — and a spinner counting down to nothing would be worse than the truth.
		if ( LockedUntilRound ) return "Wait for the next round";

		if ( Config.TimedDelay > 0f )
		{
			if ( AvailableSince < 0f ) return "Nothing to do here";

			var waited = Time.Now - AvailableSince;
			if ( waited < Config.TimedDelay )
				return $"Wait {Config.TimedDelay - waited:0.0}s";
		}

		if ( !string.IsNullOrWhiteSpace( Config.RequiredPerk ) && !player.HasPerk( Config.RequiredPerk ) )
			return $"Needs {PerkRegistry.All.FirstOrDefault( p => p.Id == Config.RequiredPerk )?.Name ?? Config.RequiredPerk}";

		if ( !string.IsNullOrWhiteSpace( Config.RequiredWeapon ) && !Matches( weapon, Config.RequiredWeapon ) )
			return $"Needs {Config.RequiredWeapon}";

		return "";
	}

	/// <summary>The weapon in the player's hands, or null.</summary>
	protected static SWB.Base.Weapon HeldWeapon( NZPlayer player )
	{
		var held = player.IsValid()
			? player.Components.Get<NZInventory>( FindMode.EverythingInSelf )?.Active
			: null;

		return held.IsValid() ? held.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf ) : null;
	}

	/// <summary>Does this weapon answer to the name a mapper typed?
	///
	/// ⚠️ MATCHED ON THE CLASS NAME, CASE-INSENSITIVELY AND BY SUBSTRING, so "awm" matches
	/// "nz_awm" and a mapper does not have to know the prefab prefix.</summary>
	protected static bool Matches( SWB.Base.Weapon weapon, string wanted )
	{
		var cls = weapon.IsValid() ? weapon.ClassName : null;

		return !string.IsNullOrEmpty( cls )
			&& cls.Contains( wanted, StringComparison.OrdinalIgnoreCase );
	}

	/// <summary>
	/// Count a trigger that landed and decide what it means.
	///
	/// ⛔ OVERSHOOT FAILS, IT DOES NOT CAP. The spec: "Overshooting past N invalidates the
	/// current attempt, it does not cap at max." Clamping would make a repeat-count target
	/// impossible to get wrong, which is the entire puzzle.
	/// </summary>
	protected string Bank()
	{
		NZSound.Play( NZSound.Purchase, WorldPosition );

		var need = Math.Max( 1, Config.RepeatCount );
		Progress++;

		if ( Progress > need ) return FailAttempt( $"overshot ({Progress}/{need})" );

		// ⛔ THE STEP CLOCK STARTS HERE, ON A TRIGGER THAT COUNTED — not on the first member to
		// finish. With four statues of three presses each, the first of those twelve presses is
		// what starts the step; anything later would give a free run-up.
		EggGroups.NoteProgress( this );

		if ( Progress < need )
			return $"{Progress}/{need}";

		Progress = 0;

		// ⛔ THE GROUP DECIDES, NOT THE PART. `EggGroups.Satisfy` completes an ungrouped step on
		// the spot and holds a grouped one until its last sibling lands — no interactable should
		// know which of those it is, or grouping would be re-implemented in every one of them.
		var fired = EggGroups.Satisfy( this );

		if ( !fired )
		{
			var left = EggGroups.InGroup( Step.StepNumber ).Count( m => !m.Step.Satisfied );

			return $"DONE — waiting on {left} more in step {Step.StepNumber}";
		}

		var rewards = Step.Reward.Count > 0 ? string.Join( ", ", Step.Reward ) : "no flag";

		return $"DONE — set {rewards}"
			+ ( string.IsNullOrWhiteSpace( Step.GeneralReward )
				? "" : $" · opened {Step.GeneralReward}" )
			+ ( Step.StepNumber > 0 ? $" · step {Step.StepNumber} complete" : "" );
	}

	/// <summary>Fail the current attempt: zero the progress, start the retry lockout, and let
	/// the step's own OnFail decide whether prior steps re-open.</summary>
	protected string FailAttempt( string why )
	{
		Progress = 0;
		ClearOwnProgress();

		// ⛔ ONE OR THE OTHER, NEVER BOTH. `EggRetry` exists so a mapper answers "how is a
		// failure locked out" once; setting a clock as well under NextRound would make it
		// retry-able the moment the clock ran out, silently ignoring the choice.
		if ( Config.Retry == EggRetry.NextRound )
		{
			LockedUntilRound = true;
			Cooldown = 0f;
		}
		else
		{
			Cooldown = MathF.Max( 0f, Config.CooldownSeconds );
			LockedUntilRound = false;
		}

		Step.Fail();

		return $"FAILED — {why}"
			+ ( LockedUntilRound ? " · retry next round" : "" )
			+ ( Cooldown > 0f ? $" · retry in {Cooldown:0.#}s" : "" );
	}

	/// <summary>Fail this member on the group's behalf — <see cref="IEggInteractable"/>. It goes
	/// through the same `FailAttempt` a wrong trigger does, so the retry lockout and
	/// `OnFail: RevertToPrevious` behave identically however the failure arrived.</summary>
	public void Fail( string why ) => FailAttempt( why );

	/// <summary>Zero everything this holds. Returns true if there was anything to zero.</summary>
	public bool ClearProgress()
	{
		var had = Progress > 0 || LockedUntilRound || HasOwnProgress;

		Progress = 0;
		ClearOwnProgress();

		// ⚠️ THE LOCK GOES TOO. A group being wound back is being handed to the players again
		// from scratch; leaving one member still serving a lockout would make the reset look
		// like it had missed one.
		LockedUntilRound = false;
		return had;
	}

	/// <summary>Partial state only this kind holds — the pressable's hold timer.</summary>
	protected virtual bool HasOwnProgress => false;

	/// <summary>Drop that state.</summary>
	protected virtual void ClearOwnProgress() { }

	/// <summary>One line for the group diagnostics.</summary>
	public abstract string Describe();

	/// <summary>
	/// Round rollover, for every interactable at once.
	///
	/// ⚠️ CALLED FROM `RoundManager.BeginRound`, beside `AmmoBox.OnRoundStart`. The spec says
	/// "resets at END of round"; the start of the next one is the same boundary and is where
	/// this project already has a hook, so there is no second notion of when a round turns over.
	///
	/// ⚠️ IT IS NOT A `Fail`. Progress goes to 0 and the step stays available — running out of
	/// round is not the same as triggering wrong, and calling Fail would fire RevertToPrevious
	/// and unwind earlier steps for simply taking too long.
	///
	/// ⛔ IT WALKS `EggGroups.All`, NOT A PER-TYPE LIST. Every interactable registers there by
	/// existing, so a new kind is covered by the round rules the day it is written rather than
	/// the day somebody remembers to add a second hook in `RoundManager`.
	/// </summary>
	public static void OnRoundStart()
	{
		int zeroed = 0, unlocked = 0;

		foreach ( var i in EggGroups.All.OfType<EggInteractable>().ToList() )
		{
			if ( !i.IsValid() || i.Config is null ) continue;

			// ⛔ THE ROUND LOCK LIFTS FOR EVERYONE, BEFORE THE `ResetOnRound` TEST. It is not
			// part of the Round condition — it is the retry rule, and one whose mapper never
			// ticked "Reset each round" would otherwise stay locked out of the egg for ever
			// after a single wrong trigger.
			if ( i.LockedUntilRound )
			{
				i.LockedUntilRound = false;
				unlocked++;
			}

			if ( !i.Config.ResetOnRound || i.Step.Completed ) continue;
			if ( i.ClearProgress() ) zeroed++;
		}

		if ( unlocked > 0 )
			Log.Info( $"[nz-ee] {unlocked} interactable(s) came off their round lockout" );

		if ( zeroed > 0 )
			Log.Info( $"[nz-ee] {zeroed} interactable(s) reset on the round turn" );
	}
}