Weapons/PrismaChain.cs

Static PrismaChain utility for the NZombies game that implements the Prisma weapons resonance chain mechanic. It applies and propagates a timed "resonance" status to zombies, handles bursts on death that re-infect nearby zombies with the remaining time and carried damage, exposes tuning properties and console commands for debugging and testing.

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

namespace NZombies;

/// <summary>
/// The Prisma's resonance chain — one ten-second fuse, handed from corpse to corpse.
///
/// A round does its damage and leaves the `resonance` status behind. If the victim dies while it
/// is still burning, the body bursts and **whatever is left of the ten seconds** jumps to every
/// zombie nearby. Those carry the same remainder, and pass on whatever is left of it when they
/// die. The chain ends when the clock does, not when it runs out of victims.
///
/// ⛔ ONE DEADLINE, NOT A DURATION PER ZOMBIE, AND EVERYTHING DEPENDS ON THAT. If each spread
/// handed on a fresh ten seconds the chain would be unkillable: one shot into a horde would
/// re-seed itself faster than it expired and never stop. What is passed on is the REMAINDER, so
/// the whole lineage shares a single wall-clock budget — the design as asked for: *"so over these
/// 10 seconds when a zombie dies with this effect it spreads it in a radius until the 10 seconds
/// are gone"*.
///
/// ⚠️ THE REMAINDER IS NOT DIVIDED AMONG THE NEW VICTIMS. Each one gets the full remaining time.
/// That is what makes it a wonder weapon rather than a damage-over-time round: the chain grows
/// in WIDTH while shrinking in TIME, so a shot into a crowd is spectacular and a shot into an
/// empty room is one dead zombie.
///
/// ⚠️ AND IT CANNOT RUN AWAY. Every link shares the deadline, so the worst case is bounded by ten
/// seconds of spreading no matter how many zombies are in the room — there is no arrangement of
/// bodies that extends it.
///
/// ⚠️ RE-INFECTION KEEPS THE LONGER FUSE, which falls out of `StatusEffects` refreshing with a
/// `MathF.Max` on `Until`. Two chains crossing cannot shorten each other, and a zombie caught by
/// the same burst twice is not counted twice.
/// </summary>
public static class PrismaChain
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does
	// not re-run. INSTRUCTIONS.md §1.

	static string _weapon;
	/// <summary>Which weapon carries the chain, by `ClassName`.</summary>
	public static string Weapon { get => _weapon ?? "nz_prisma"; set => _weapon = value; }

	static float? _seconds;
	/// <summary>How long one fuse lasts, from the shot that started it. 10s.</summary>
	public static float Seconds { get => _seconds ?? 10f; set => _seconds = value; }

	static float? _radius;
	/// <summary>How far a burst reaches when a carrier dies. 260u.</summary>
	public static float Radius { get => _radius ?? 260f; set => _radius = value; }

	static int? _maxPerBurst;
	/// <summary>
	/// How many zombies one burst may infect. 12.
	/// </summary>
	///
	/// ⚠️ A BUDGET, NOT A BALANCE KNOB. The chain is already bounded in time; this bounds the
	/// per-frame COST of a burst going off in the middle of a forty-zombie horde, where the
	/// radius query and twelve status applications all land on one tick.
	public static int MaxPerBurst { get => _maxPerBurst ?? 12; set => _maxPerBurst = value; }

	static float? _minPass;
	/// <summary>
	/// Below this much time left, a death does not bother bursting. 0.35s.
	/// </summary>
	///
	/// ⛔ WITHOUT IT THE CHAIN ENDS IN A FLURRY OF EMPTY EXPLOSIONS. The last fraction of a second
	/// infects zombies that die of nothing, each throwing its own burst — a lot of noise and light
	/// for damage too small to kill anything.
	public static float MinPass { get => _minPass ?? 0.35f; set => _minPass = value; }

	static float? _specialEvery;
	/// <summary>
	/// How often the fuse ticks on a variant with a `ResonanceShare` — the napalm, the shrieker,
	/// Brutus and Oberon. 1s.
	/// </summary>
	///
	/// ⚠️ THE SHARE IS DATA, THE CLOCK IS NOT. How hard each special burns is its `.zvar`'s business
	/// (`ZombieVariant.ResonanceShare`, 10% on all four); how often is the weapon's, and "once per
	/// second" was the ask.
	public static float SpecialEvery { get => _specialEvery ?? 1f; set => _specialEvery = value; }

	/// <summary>The burst's colour — whatever blue the weapon's effects are drawn in.</summary>
	///
	/// ⛔ IT DEFERS TO `PrismaFx.Tint` RATHER THAN HOLDING ITS OWN COPY. It used to carry the same
	/// measured figure written out a second time, which meant retuning the blue moved the muzzle
	/// and the tracer and left every chain burst the old colour — a difference you only notice in
	/// the one situation the weapon exists for, a burst going off in a crowd.
	///
	/// ⚠️ SO `nz_prisma_fx_blue` MOVES THIS TOO, which is the point: one weapon, one blue.
	public static Color Colour => PrismaFx.Tint;

	// ══ the fuse ═════════════════════════════════════════════════════════════

	/// <summary>
	/// A round from the chain weapon hit a zombie. Light the fuse.
	/// </summary>
	///
	/// ⚠️ IT DOES NOT ADD DAMAGE. The shot's own damage has already been applied by the time this
	/// runs; the status is the whole of what this contributes, and the weapon's punch is tuned on
	/// the weapon where the rest of its numbers live.
	public static void OnHit( in TechEffects.TechRef tech, GameObject zombie, float damage )
	{
		if ( !zombie.IsValid() ) return;
		if ( !Fired( tech ) ) return;

		Infect( zombie, tech.Player?.GameObject, MathF.Max( 0.1f, Seconds ), damage );
	}

	/// <summary>
	/// Light, or refresh, the fuse on one zombie — and carry the weapon's damage with it.
	/// </summary>
	///
	/// ⛔ THE DAMAGE TRAVELS WITH THE FUSE, NOT JUST THE CLOCK. A special caught by a burst was never
	/// shot, so "10% of the weapon's damage" has to be the damage of the shot that STARTED the chain.
	/// Every link carries it forward, the way it carries what is left of the ten seconds.
	///
	/// ⚠️ A VARIANT WITH A `ResonanceShare` TICKS THAT SHARE OF IT ONCE A SECOND instead of the rule's
	/// 9% of max health every quarter second — asked for as *"the wonder weapon DoT should not affect
	/// napalms, shriekers, brutus and oberon the same way, instead it deals 10% of the weapon's damage
	/// once per second"*. Everything else about the fuse is the same: it glows, it spreads, it bursts.
	///
	/// ⚠️ NO DAMAGE KNOWN MEANS NO TICK ON A SPECIAL, not the proportional one. Falling back to 9% of
	/// max health is exactly the behaviour this replaces; zero is the safe way to be wrong.
	static void Infect( GameObject zombie, GameObject source, float seconds, float damage )
	{
		var ai = zombie.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
		var share = ai?.Variant?.ResonanceShare;

		// ⛔ BASALT'S BEAST IN ITS FIGHT TAKES NOTHING FROM THE FUSE ITSELF — *"the 10s debuff deals no damage to it, however
		// while it has that debuff it takes full damage"*: the fuse burns on him as on anything, glowing and spreading, and
		// ticks nothing; what it does is let every other hit land whole (`HexPlatforms.BossCap`)
		if ( HexPlatforms.IsFightBoss( ai ) ) share = 0f;
		// ⛔ AND ANY OBERON, IN THE FIGHT OR OUT OF IT (2026-10-07, the user: *"make it so the prisma is unable to damage the boss oberon, only applying the debuff that does not damage but allows it to get more damaged"*): the fuse burns on him and
		// deals him nothing (`Spares`)
		if ( ai.IsValid() && Spares( ai.GameObject ) ) share = 0f;

		if ( share is float s )
			StatusEffects.Apply( zombie, "resonance", source, seconds: seconds,
				tickDamage: MathF.Max( 0f, s ) * MathF.Max( 0f, damage ),
				tickEvery: MathF.Max( 0.05f, SpecialEvery ), carry: damage );
		else
			StatusEffects.Apply( zombie, "resonance", source, seconds: seconds, carry: damage );
	}

	/// <summary>
	/// A zombie died. If it was carrying a fuse with time on it, burst and hand that time on.
	/// </summary>
	///
	/// ⛔ CALLED FROM `ZombieAI.Die`, BEFORE THE STATUS IS TORN DOWN. Reading the remainder after
	/// the component has been cleaned up gives zero, and the chain would stop at the first link
	/// while looking exactly like a chain that was working.
	///
	/// ⚠️ THE VICTIM IS EXCLUDED BY NAME, not by "it is dying" — a corpse still answers
	/// `IsValid` on the frame it dies, and re-infecting it would put a fuse on something that can
	/// never burst again and silently swallow the rest of the chain.
	public static void OnDied( GameObject zombie )
	{
		if ( !zombie.IsValid() ) return;

		var left = StatusEffects.Remaining( zombie, "resonance" );
		if ( left <= MinPass || left == float.MaxValue ) return;

		// ⚠️ READ NOW, WITH THE REMAINDER: after the status is torn down both answers are 0.
		Burst( zombie.WorldPosition, left, zombie, StatusEffects.CarriedBy( zombie, "resonance" ) );
	}

	/// <summary>Throw the light and infect what is standing in it.</summary>
	static void Burst( Vector3 at, float left, GameObject skip, float damage )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		// ⚠️ THE RING IS DRAWN AT THE RADIUS THAT ACTUALLY INFECTS, so what you see is what was
		// caught — the same rule the leap's shockwave and the hole's vortex follow.
		ShockRing.FireShared( at, Radius, Colour );
		NZSound.PlayShared( "nz.pop.thunderwall.shoot", at );

		var caught = ZombieAI.All
			.Where( z => z.IsValid() && z.GameObject.IsValid() )
			.Where( z => z.GameObject != skip )
			.Where( z => z.State != ZombieState.Dead )
			.Where( z => at.Distance( z.WorldPosition ) <= Radius )
			.OrderBy( z => at.Distance( z.WorldPosition ) )
			.Take( Math.Max( 1, MaxPerBurst ) )
			.ToList();

		foreach ( var z in caught )
			Infect( z.GameObject, null, left, damage );

		if ( ChainDebug )
			Log.Info( $"[nz-prisma] burst at {at} — {left:0.00}s left, caught {caught.Count}" );
	}

	/// <summary>
	/// Did this shot come from the chain weapon.
	/// </summary>
	///
	/// ⛔ OFF THE TECH REF, WHICH COSTS NOTHING, AND THAT MATTERS BECAUSE THIS RUNS ON EVERY
	/// BULLET IN THE GAME. `Health.OnDamage` already resolves `FiredBy( damage )` once per hit;
	/// reading the prefab path off it is a string compare, where resolving the player and then
	/// their weapon would be two `Components.Get` with descendant search per pellet of every
	/// shotgun in the pack.
	///
	/// ⚠️ THE PREFAB PATH, NOT `tech.Weapon`. That field is the live component and weapons do
	/// not replicate — it is null on the machine that is not holding the gun, which is exactly
	/// the machine the host resolves damage on. The path is a string and travels.
	public static bool Fired( in TechEffects.TechRef tech )
		=> !string.IsNullOrEmpty( tech.Prefab )
			&& tech.Prefab.Contains( Weapon, StringComparison.OrdinalIgnoreCase );

	/// <summary>
	/// Does this weapon deal the same damage wherever it lands.
	/// </summary>
	///
	/// ⛔ IT SUPPRESSES THE GAME'S HEADSHOT BONUS AND THE PART MULTIPLIER BOTH. `HeadMultiplier`
	/// and `LimbMultiplier` on the ShootInfo are already 1, but those are only the WEAPON's own
	/// scaling — `Health` applies a base x2.5 for a headshot on top, plus Death Perception,
	/// Deadshot, Deadeye and `t2_headshot`, none of which the prefab can switch off. A flat
	/// weapon has to be flat against all of them.
	///
	/// ⚠️ IT REUSES THE `t4_bodyshot` PATH rather than adding a branch, which is the same move
	/// `ImmuneToHeadshotBonus` made: that node already means "no headshot bonus", so the concept
	/// and its consequences are established.
	public static bool FlatDamage( in TechEffects.TechRef tech ) => Fired( tech );

	/// <summary>
	/// IS THIS ONE THE PRISMA NEVER HURTS? Oberon (2026-10-07, the user: *"make it so the prisma is unable to damage the boss oberon, only applying the debuff that does not damage but allows it to get more damaged"*).
	/// Its round lights the fuse on him and deals him nothing — no hit, no ammo mod, no Napalm ignite off it, no fuse tick
	/// (`Health.OnDamage`, `Infect`) — and the fuse is what lets every OTHER gun's hit land whole in basalt's fight
	/// (`HexPlatforms.BossCap`: a tenth of a hit without it).
	/// </summary>
	public static bool Spares( GameObject victim )
		=> victim.IsValid() && victim.Components.Get<OberonBoss>( FindMode.EverythingInSelfAndAncestors ).IsValid();

	// ══ diagnostics ══════════════════════════════════════════════════════════

	static bool? _debug;
	/// <summary>`nz_prisma_debug 1` — one line per burst.</summary>
	public static bool ChainDebug { get => _debug ?? false; set => _debug = value; }

	/// <summary>`nz_prisma` — what the chain is set to, and how many fuses are burning.</summary>
	[ConCmd( "nz_prisma" )]
	public static void Report()
	{
		var lit = ZombieAI.All
			.Count( z => z.IsValid() && z.GameObject.IsValid()
				&& StatusEffects.Has( z.GameObject, "resonance" ) );

		Log.Info( $"[nz-prisma] chain on '{Weapon}' · {Seconds:0.#}s fuse"
			+ $" · {Radius:0}u burst · up to {MaxPerBurst} per burst"
			+ $" · stops under {MinPass:0.##}s" );

		Log.Info( $"[nz-prisma]   {lit} zombie(s) burning right now" );

		// ⚠️ READ OFF THE LOADED VARIANTS, not restated here: the share is each `.zvar`'s own.
		var specials = SpecialEnemies.Names
			.Select( n => (n, v: SpecialEnemies.VariantFor( n )) )
			.Where( x => x.v?.ResonanceShare is float )
			.Select( x => $"{x.n} {x.v.ResonanceShare.Value * 100f:0.#}%" )
			.ToArray();

		Log.Info( specials.Length == 0
			? "[nz-prisma]   no variant has a ResonanceShare — every zombie burns the ordinary fuse"
			: $"[nz-prisma]   on {string.Join( " · ", specials )} of the weapon's damage"
				+ $" every {SpecialEvery:0.##}s instead" );
	}

	/// <summary>
	/// `nz_prisma_set &lt;key&gt; &lt;value&gt;` — retune the chain.
	/// </summary>
	[ConCmd( "nz_prisma_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "seconds": Seconds = value; break;
			case "radius": Radius = value; break;
			case "maxperburst": MaxPerBurst = (int)value; break;
			case "minpass": MinPass = value; break;
			case "specialevery": SpecialEvery = value; break;
			case "debug": ChainDebug = value > 0.5f; break;

			default:
				Log.Info( "[nz-prisma] nz_prisma_set <seconds|radius|maxperburst|minpass|specialevery|debug> <v>" );
				return;
		}

		Log.Info( $"[nz-prisma] {key} = {value:0.###}" );
		Report();
	}

	/// <summary>
	/// `nz_prisma_test [seconds]` — light a fuse on the nearest zombie, without firing.
	/// </summary>
	///
	/// ⚠️ IT EXISTS BECAUSE THE CHAIN IS ONLY INTERESTING IN A CROWD, and getting a crowd, a full
	/// magazine and a clear view at once is most of the work of testing it.
	///
	/// ⚠️ `damage` IS WHAT A SPECIAL'S TICK IS A SHARE OF — 4000 is the unpacked Prisma's figure;
	/// pass a packed one (10800 for MK1) to see a packed burn on a Brutus.
	[ConCmd( "nz_prisma_test" )]
	public static void TestCmd( float seconds = -1f, float damage = 4000f )
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-prisma] no player" ); return; }

		var z = ZombieAI.All
			.Where( q => q.IsValid() && q.GameObject.IsValid() && q.State != ZombieState.Dead )
			.OrderBy( q => p.WorldPosition.Distance( q.WorldPosition ) )
			.FirstOrDefault();

		if ( z is null ) { Log.Warning( "[nz-prisma] no zombie to light" ); return; }

		var life = seconds > 0f ? seconds : Seconds;
		Infect( z.GameObject, p.GameObject, life, damage );

		Log.Info( $"[nz-prisma] lit {z.GameObject.Name} for {life:0.##}s, carrying {damage:0} damage"
			+ $" at {p.WorldPosition.Distance( z.WorldPosition ):0}u" );
	}
}