Buyables/LooseChange.cs

Component that awards a small one-time points reward when a player crouches at a perk machine. It tracks which machines have had their coin claimed (keyed by perk id and rounded position), awards points, plays a sound, broadcasts a network claim, and exposes a console command to report and optionally reset/adjust the award.

NetworkingFile Access
using Sandbox;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// Crouch at a perk machine and find someone's loose change: 100 points, once per machine per game, to
/// whoever gets there first.
/// </summary>
///
/// ⛔ ONE COIN PER MACHINE, FOR THE WHOLE LOBBY (user, 2026-09-27: *"we should be able to do it once per
/// machine"*; asked who, *"first player per machine"*). It was ONE coin for the whole game — *"only once
/// per game, only one player per game"* — so four players at four machines found one between them. Now
/// every machine has its own: six machines, six finds, each to whoever crouched there first.
///
/// ⚠️ A STATIC IS NOT SYNCED BY ANYTHING, so each claim is broadcast WITH THE MACHINE IT WAS AT. Without
/// that every machine would keep its own idea of which coins were still there and each client would award
/// its own player — the "one each" this is not.
///
/// ⚠️ A MACHINE IS KNOWN BY ITS PERK AND WHERE IT STANDS (<see cref="KeyOf"/>), which is the same on
/// every machine because each builds its perk machines from the same config. A GameObject id is not:
/// those differ per machine.
///
/// ⚠️ THE RESIDUAL RACE IS ACCEPTED AND NAMED. Two players crouching at the SAME machine inside one
/// network round trip both see it unclaimed and both award. Closing it properly needs the host to
/// arbitrate — a request message and a grant message — and the prize is 100 points, which is less than a
/// single zombie is worth by round 5. Documented rather than engineered away.
public sealed class LooseChange : Component
{
	/// <summary>What it pays. One flat award, not scaled by round.</summary>
	///
	/// ⚠️ DELIBERATELY SMALL AND FLAT. It is a wink at whoever thought to crouch at a vending
	/// machine, not an economy. Scaling it by round would make finding it late worth more than
	/// finding it early, which rewards not looking.
	public static int Award { get; set; } = 100;

	/// <summary>
	/// Whose coin has been found this game: machine (<see cref="KeyOf"/>) → who found it.
	/// </summary>
	///
	/// ⚠️ AN EMPTY ACCUMULATOR, NOT A TABLE WITH CONTENT IN ITS INITIALISER (INSTRUCTIONS.md §1). A hotload
	/// carries it across, which is what should happen: a code edit mid-game must not put the coins back.
	static readonly Dictionary<string, string> _found = new();

	/// <summary>Every coin found this game, as (machine, who). A snapshot, safe to iterate while claiming.</summary>
	public static KeyValuePair<string, string>[] Found => _found.ToArray();

	/// <summary>Has this machine's coin been found?</summary>
	public static bool IsClaimed( string machine ) => machine is not null && _found.ContainsKey( machine );

	/// <summary>
	/// The name a machine's coin is kept under: its perk and its position, e.g. "jugg@-120,340,64".
	/// Null for a machine with no spot.
	/// </summary>
	///
	/// ⚠️ ROUNDED TO WHOLE UNITS, so the same spot reads the same everywhere — the position comes from the
	/// config every machine was handed, but a float printed to its last digit is a comparison waiting to
	/// fail on a rounding difference.
	public static string KeyOf( PerkMachine machine )
	{
		var spot = machine.IsValid() ? machine.Spot : null;
		if ( spot is null ) return null;

		var p = spot.Position;
		return $"{spot.PerkId}@{p.x:0},{p.y:0},{p.z:0}";
	}

	/// <summary>New game: every machine's coin is back.</summary>
	///
	/// ⛔ CALLED FROM THE NEW-GAME RESETS TOO — `RoundManager.StartGame` on the host, `NZNet.NewGameWorld` on each client
	/// (2026-09-29). The note here used to say `PerkMachineManager.Rebuild` "already runs when the machines are laid out for a new
	/// game"; it runs when a CONFIG is shown, and no new game does that, so after a game over no coin ever came back. User: *"perk
	/// machines do not reset the change you can get by crouching"*. Rebuild still calls it, for a config loaded mid-session.
	public static void ResetForNewGame() => _found.Clear();

	/// <summary>Mark one machine's coin found, on this machine. What the broadcast lands on.</summary>
	public static void ApplyClaim( string who, string machine )
	{
		if ( string.IsNullOrEmpty( machine ) ) return;
		_found[machine] = who ?? "";
	}

	protected override void OnUpdate()
	{
		// ⛔ THE MACHINE THAT OWNS THE BODY. Every machine holds a copy of every player, and a
		// proxy's crouch would otherwise let one player's ducking award points on somebody else's
		// screen — and spend that perk machine's coin doing it.
		if ( Networking.IsActive && !PlayerPresence.Mine( GameObject ) ) return;

		var player = Components.Get<NZPlayer>( FindMode.EverythingInSelf );
		if ( !player.IsValid() || player.IsDown ) return;

		var c = Components.Get<PlayerController>( FindMode.EverythingInSelf );
		if ( !c.IsValid() || !c.IsDucking ) return;

		// ⚠️ THE MACHINE'S OWN USE RANGE, via `PerkMachine.Near`, so "in front of a perk machine"
		// means the same distance here as it does for buying the perk. A second radius would drift
		// from that one and be wrong in a way nobody could see.
		var machine = PerkMachine.Near( player.WorldPosition );
		if ( !machine.IsValid() ) return;

		var key = KeyOf( machine );
		if ( key is null || IsClaimed( key ) ) return;

		Claim( player, machine, key );
	}

	void Claim( NZPlayer player, PerkMachine machine, string key )
	{
		var who = player.GameObject?.Name ?? "someone";

		// ⚠️ CLAIMED LOCALLY FIRST, THEN TOLD. The award below is on this machine either way, and
		// setting the flag before the message goes out closes the window where this same player's
		// next frame awards a second time.
		ApplyClaim( who, key );

		if ( Networking.IsActive )
			NZNet.LooseChangeClaimed( who, key );

		player.AddPoints( Award );

		// ⛔ THE MONEY SOUND, NOT THE BONUS-POINTS ANNOUNCER (2026-09-27): *"they should use a standard money earn
		// sound"*. The announcer said a powerup had dropped, and said it to everybody. This is the cash register every
		// spend plays (`NZPlayer.TrySpend`), the one Black Ops plays for these same points, and only the finder hears
		// it: this runs on their own machine, and the cue is 2D.
		NZSound.Play( NZSound.Purchase );

		Log.Info( $"[nz-change] {who} found {Award} points of loose change under the"
			+ $" {machine.Perk?.Name ?? "perk"} machine — {Left()} of {PerkMachine.All.Count} machine(s)"
			+ " still have theirs" );
	}

	/// <summary>How many standing machines still have their coin.</summary>
	static int Left() => PerkMachine.All.Count( m => m.IsValid() && !IsClaimed( KeyOf( m ) ) );

	/// <summary>`nz_loose_change [points]` — every machine's coin, and put them all back.</summary>
	///
	/// ⚠️ IT CAN UNCLAIM, because testing a once-per-game thing otherwise costs a whole game.
	[ConCmd( "nz_loose_change" )]
	public static void Report( int points = 0 )
	{
		if ( points > 0 ) Award = points;

		Log.Info( $"[nz-change] worth {Award} points, once per perk machine per game"
			+ $" · {Left()} of {PerkMachine.All.Count} machine(s) still have theirs" );

		foreach ( var m in PerkMachine.All )
		{
			if ( !m.IsValid() ) continue;

			var key = KeyOf( m );
			Log.Info( $"[nz-change]   {m.Perk?.Name ?? "?",-21} "
				+ (key is not null && _found.TryGetValue( key, out var who )
					? $"found by {who}"
					: "still there") );
		}

		if ( _found.Count > 0 )
		{
			ResetForNewGame();
			Log.Info( "[nz-change] put back — crouch at any perk machine to find its coin again" );
		}
	}
}