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.
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];
}
}