EasterEgg/BuildParts.cs

Static utility managing the three build parts for the Prisma wonder weapon. It defines part names, models, masks and provides methods to query, take, clear, reset and broadcast the team-held and map-found masks, with network-aware send/apply logic.

Networking
using System.Linq;
using Sandbox;

namespace NZombies;

/// <summary>
/// The Prisma's three build parts — what they are, and who is carrying which.
///
/// ⛔ POOLED FOR THE TEAM, NOT CARRIED PER PLAYER — CHANGED 2026-09-22 AND THE OPPOSITE OF WHAT
/// SHIPPED. *"I want parts to be global, when one player picks it up it's as if all players have
/// that piece — so if each of 3 players picks up a piece, any of them can build the weapon, but
/// after one builds the weapon, all players lose the pieces."* Three players can now split the
/// hunt, which is what makes it a co-op objective rather than three separate ones.
///
/// ⚠️ TWO MASKS, NOT ONE, AND THE SECOND IS WHY THE MAP DOES NOT RESTOCK ITSELF. `_held` is what
/// the team is carrying and is emptied at the bench; `_found` is which parts have been taken off
/// the map and is emptied only by a new game. Keying the world objects on `_held` alone would make
/// every collected part REAPPEAR the moment somebody built the weapon.
///
/// ⚠️ STATIC, WHICH FOR GAMEPLAY STATE MEANS IT HAS TO BE SENT. Nothing syncs a static by itself:
/// `Take` broadcasts, and `NZNet.PushState` hands both masks to anyone who joins late — the same
/// two-part arrangement `DoorLinks` needs and for the same reason.
/// </summary>
public static class BuildParts
{
	public const int Count = 3;

	/// <summary>The weapon the three parts add up to.</summary>
	public const string WeaponPrefab = "prefabs/weapons/nz_prisma.prefab";

	/// <summary>What to call it on screen.</summary>
	public const string WeaponName = "Prisma";

	/// <summary>
	/// The models, baked by `Tools/make_prisma_buildparts.py`.
	/// </summary>
	///
	/// ⚠️ INDEX 0 IS PART 1. Parts are numbered from one everywhere a person sees them — the tool,
	/// the console, the prompt — because "part 0" reads as a bug report. `Model( n )` does the
	/// subtraction in one place so nothing else has to remember.
	static readonly string[] _models =
	{
		"weapons/prisma/build/prisma_build_1.vmdl",
		"weapons/prisma/build/prisma_build_2.vmdl",
		"weapons/prisma/build/prisma_build_3.vmdl",
	};

	/// <summary>
	/// What each part is called.
	/// </summary>
	///
	/// ⚠️ NAMED FOR WHAT THEY LOOK LIKE, not "part 1 / 2 / 3". The whole point of the missing-parts
	/// message is that you can go and look for the one you are short of, and a number tells you
	/// nothing about what to search for.
	static readonly string[] _names = { "shell", "core", "claws" };

	/// <summary>
	/// Is this prefab the wonder weapon?
	/// </summary>
	///
	/// ⚠️ ONE PREDICATE, READ BY THE THREE SYSTEMS THAT HAVE TO TREAT IT DIFFERENTLY — rarity, tech
	/// and ammo mods. Each of them testing the prefab string itself would be three chances to spell
	/// it differently and three places to miss when the weapon changes.
	///
	/// ⚠️ ORDINAL AND CASE-INSENSITIVE, because a prefab path arrives from config, from a
	/// `WeaponSource` component and from the console, and those have never agreed about case.
	public static bool IsWonderWeapon( string prefab )
		=> !string.IsNullOrEmpty( prefab )
			&& string.Equals( prefab, WeaponPrefab, System.StringComparison.OrdinalIgnoreCase );

	public static bool Valid( int part ) => part >= 1 && part <= Count;

	public static string Model( int part )
		=> Valid( part ) ? _models[part - 1] : "";

	public static string Name( int part )
		=> Valid( part ) ? _names[part - 1] : $"part {part}";

	/// <summary>The bit this part occupies on the player's carried mask.</summary>
	public static int Bit( int part ) => Valid( part ) ? 1 << (part - 1) : 0;

	/// <summary>Every part carried, i.e. done.</summary>
	public static int All => (1 << Count) - 1;

	// ══ what the TEAM has ════════════════════════════════════════════════════

	static int _held;
	static int _found;

	/// <summary>What the team is carrying, as a mask. For the network layer.</summary>
	public static int HeldMask => _held;

	/// <summary>Which parts have been taken off the map, as a mask. For the network layer.</summary>
	public static int FoundMask => _found;

	public static bool Has( int part ) => Valid( part ) && (_held & Bit( part )) != 0;

	public static bool HasAll() => (_held & All) == All;

	public static int Held() => Enumerable.Range( 1, Count ).Count( Has );

	/// <summary>Has this part already been lifted off the map?</summary>
	///
	/// ⚠️ SEPARATE FROM `Has`, AND ONLY THIS ONE DECIDES WHAT IS VISIBLE. After a build the team
	/// holds nothing, but the parts were still taken — keying the world objects on `Has` would lay
	/// all three back out on the map the instant the weapon was finished.
	public static bool Found( int part ) => Valid( part ) && (_found & Bit( part )) != 0;

	/// <summary>
	/// Pick a part up, for everyone. False if the team already had it.
	/// </summary>
	///
	/// ⚠️ IT RETURNS WHETHER ANYTHING CHANGED so the caller can stay quiet about a part picked up
	/// twice, which happens whenever two of the same part are placed on one map.
	public static bool Take( int part )
	{
		if ( !Valid( part ) || Has( part ) ) return false;

		// ⛔ ON A CLIENT THIS DECIDES NOTHING. A client ASKS (`BuildPart.Collect` → `NZNet.BuildPartTakeAsk`) and the host's
		// answer reaches it with everyone else (the co-op audit, 2026-09-27).
		if ( Networking.IsActive && !NZGame.IsHost ) return false;

		Send( _held | Bit( part ), _found | Bit( part ), merge: true );
		return true;
	}

	/// <summary>Spend the set at the bench. The map is NOT restocked.</summary>
	public static void Clear() => Send( 0, _found, merge: false );

	/// <summary>New game: the team carries nothing and the map is whole again.</summary>
	public static void Reset() => Send( 0, 0, merge: false );

	/// <summary>
	/// Apply a change here and tell every other machine.
	/// </summary>
	///
	/// ⛔ BROADCAST BY WHOEVER DID IT, HOST OR NOT. A client walks over a part and collects it
	/// locally; routing that through the host first would need a second message and a round trip
	/// before their own screen agreed. `TradeTableState` already establishes the pattern — an
	/// idempotent set with no sender guard, applied locally by the sender first.
	///
	/// ⚠️ `merge` IS NOT DECORATION. A pickup must OR, or two players collecting different parts in
	/// the same instant each broadcast a mask that does not know about the other and the later
	/// message erases the earlier pickup. A clear must ASSIGN, or it would never clear anything.
	///
	/// ⛔ AND ONLY THE HOST TELLS ANYONE — THE NOTE ABOVE WAS THE BUG (the co-op audit, 2026-09-27). "Broadcast by whoever
	/// did it" meant a client's own `Reset`, which runs whenever its world is rebuilt — a join's config, a new game — told
	/// every machine the team held nothing: a join wiped the team's parts. A client now asks the host for a pickup
	/// (`NZNet.BuildPartTakeAsk`), and applies only what the host sends; its own `Reset` stays on its own machine.
	static void Send( int held, int found, bool merge )
	{
		ApplyMask( held, found, merge );

		if ( Networking.IsActive && NZGame.IsHost )
			NZNet.BuildPartsState( held, found, merge );
	}

	/// <summary>Set the masks locally. What the network message lands on.</summary>
	public static void ApplyMask( int held, int found, bool merge )
	{
		_held = merge ? _held | (held & All) : held & All;
		_found = merge ? _found | (found & All) : found & All;
	}

	/// <summary>
	/// "core and claws" / "claws" — what they still need, in words.
	/// </summary>
	///
	/// ⚠️ IT LISTS THEM RATHER THAN COUNTING THEM. "You are missing 2 pieces" sends a player back
	/// out to search for something they cannot describe; naming them means they can recognise the
	/// one they walked past.
	public static string Missing()
	{
		var want = Enumerable.Range( 1, Count ).Where( n => !Has( n ) ).Select( Name ).ToList();

		if ( want.Count == 0 ) return "";
		if ( want.Count == 1 ) return want[0];

		return string.Join( ", ", want.Take( want.Count - 1 ) ) + " and " + want[^1];
	}
}