Weapons/TarPit.cs

Component that spawns and manages a tar pit game object. It creates a visual pit, periodically applies a slowing status to nearby zombies (per-machine), handles lifetime, bubbling sounds, upgrade-adjusted radius/lifetime/speed/linger, and console commands to inspect and retune values.

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

namespace NZombies;

/// <summary>
/// Tar Pit — leaves a pool of tar where the zombie was hit; zombies in it wade at 40% speed.
///
/// | | value |
/// |---|---|
/// | proc | **15%** per hit, **8s** cooldown |
/// | pool | **140u**, lives **8s**, at the zombie you hit — I **220u**, II **12s**; V, Tar Flood: **440u** for **24s** |
/// | slow | the `tar` status — **×0.4** speed while inside, and a moment after (III: **5s** after); IV, Thick Tar: **×0.2** |
/// | damage | **none** |
///
/// ⚠️ THE USER (2026-10-04): *"make it spawn a circle like in radioactive decay, but make it tar colored"*. So the pool is
/// Radioactive Decay's pit visual (`PitVisual`) in a `Tar` style: dark fumes hugging the floor, a dark edge, two slow
/// ripples crawling across — and no glow, because light cannot be dark. Its size is the fallout's, 140u.
///
/// ⛔ EVERY MACHINE SLOWS ITS OWN COPY, AND NOTHING ELSE IS SENT. The pool is announced once (`NZNet.WorldFx`), each machine
/// builds its own, and each puts `tar` on the zombies standing in it there (`StatusEffects.ApplyHere`). The HOST'S copy is
/// the one the AI obeys (`ZombieAI.TickStatusSpeed`); every other copy is only the tint. Relaying the status would have
/// been a message per zombie per refresh per machine — and a refresh does not travel anyway, so a client's own pool would
/// have slowed the host's zombies for the first instant only.
///
/// ⚠️ THE SLOW IS SHORT AND KEPT TOPPED UP (`Hold`), so it ends a moment after a zombie wades out rather than when the pool
/// does. The 0.4 lives on the status rule, retuned with `nz_status`; IV's 0.2 is this file's (`ThickSpeed`).
///
/// ⚠️ 40% IS FLOORED BY THE NAVMESH AGENT: `ZombieAI.AgentSpeed` lifts any non-zero speed to 42 u/s, below which the agent
/// stops moving. A sprinter (≈220 u/s) wades at ≈90; a walker (55) only drops to 42.
///
/// ⚠️ ITS THREE UPGRADES (2026-10-05, `AMMO_MODS.md` "Upgrades"): I Wide Pool, 220u (`WideRadius`); II Long Spill, 12s
/// (`LongLifetime`); III Clinging Tar, the slow lingering 5s after a zombie leaves (`Cling`). Each is the OWNER'S level, read
/// when the pool is poured, by every machine for its own copy — see `_reach`.
///
/// ⚠️ TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"), read the same way. IV Thick Tar: ×0.2 inside (`ThickSpeed`),
/// handed in with each top-up — one shared `tar` rule slows every pool, so the rule keeps the base ×0.4 for everyone else's. V Tar
/// Flood, the user's (23:34): *"V Pool lasts longer and is 2x bigger"* — twice I's reach, 440u, and twice II's life, 24s
/// ("longer" named no length; `FloodRadius`, `FloodLifetime`). With the 8s cooldown up to three pools can lie at once. The navmesh
/// floor below still holds, and III's 5s cling carries the ×0.2 with it (unless it wades into a plain pool: `Tick`).
/// </summary>
public sealed class TarPit : Component
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.

	static float? _radius;
	/// <summary>How wide the pool is. 140u, the fallout's.</summary>
	public static float Radius { get => _radius ?? 140f; set => _radius = value; }

	static float? _lifetime;
	/// <summary>How long the pool lasts. 8s.</summary>
	public static float Lifetime { get => _lifetime ?? 8f; set => _lifetime = value; }

	static float? _hold;
	/// <summary>
	/// How long one application of the slow lasts. 0.35s — topped up every tick while a zombie is in the pool, so this is
	/// how long it lingers once it wades out.
	/// </summary>
	public static float Hold { get => _hold ?? 0.35f; set => _hold = value; }

	// ── the upgrades (2026-10-05) ────────────────────────────────────────────
	//
	// ⚠️ EACH UPGRADED VALUE IS ITS OWN TUNABLE BESIDE THE BASE ONE, and the `…For` below (four since IV) are the only places
	// that choose between them (§3).

	/// <summary>The mod's id, for its upgrade level (`AmmoModUpgrades.Level`).</summary>
	const string ModId = "tarpit";

	static float? _wideRadius;
	/// <summary>How wide the pool is with I, Wide Pool. 220u (140).</summary>
	public static float WideRadius { get => _wideRadius ?? 220f; set => _wideRadius = value; }

	static float? _longLifetime;
	/// <summary>How long the pool lasts with II, Long Spill. 12s (8).</summary>
	public static float LongLifetime { get => _longLifetime ?? 12f; set => _longLifetime = value; }

	static float? _cling;
	/// <summary>
	/// How long the slow lingers past its last top-up with III, Clinging Tar. 5s (`Hold`, 0.35).
	///
	/// ⚠️ THE USER (2026-10-05 01:12): *"make it so III makes any zombie that steps on it slow for 5 seconds after it
	/// leaves the pit still"*. It is `Hold` made long, nothing new: the slow is topped up every tick while a zombie is in the
	/// pool, so it ends 5s after the last one — after the zombie wades out, or after the pool dries up under it. The navmesh
	/// agent's floor (42 u/s) still applies, so a walker barely shows it.
	/// </summary>
	public static float Cling { get => _cling ?? 5f; set => _cling = value; }

	// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ──
	//
	// ⚠️ THE SAME SHAPE: each its own tunable beside the one it replaces (§3, §1), and the `…For` below ask the HIGHEST level first,
	// since a level-V owner has I and II too.

	static float? _thickSpeed;
	/// <summary>
	/// How fast a zombie wades with IV, Thick Tar. ×0.2 (the `tar` rule's ×0.4).
	///
	/// ⚠️ PER APPLICATION, NOT ON THE RULE (`StatusEffects.ApplyHere`'s `speedScale`): the rule is shared by every pool, so a
	/// level-IV owner's pool hands its own factor in with each top-up, and the rule keeps the base for everyone else's.
	///
	/// ⚠️ FLOORED AT 0.01 (`SpeedFor`): a 0 handed to `ApplyHere` means "the rule's own", so `nz_tarpit_set thick 0` would have
	/// given back ×0.4, not a stop. The navmesh agent's floor (42 u/s, `ZombieAI.AgentSpeed`) holds a walker up whatever this says.
	/// </summary>
	public static float ThickSpeed { get => _thickSpeed ?? 0.2f; set => _thickSpeed = value; }

	static float? _floodRadius;
	/// <summary>How wide the pool is with V, Tar Flood. 440u (I's 220) — four times the ground it covers.</summary>
	public static float FloodRadius { get => _floodRadius ?? 440f; set => _floodRadius = value; }

	static float? _floodLifetime;
	/// <summary>How long the pool lasts with V, Tar Flood. 24s (II's 12).</summary>
	public static float FloodLifetime { get => _floodLifetime ?? 24f; set => _floodLifetime = value; }

	/// <summary>
	/// This player's pool radius: <see cref="FloodRadius"/> at level V, <see cref="WideRadius"/> from I, <see cref="Radius"/> below.
	/// </summary>
	public static float RadiusFor( NZPlayer player )
		=> AmmoModUpgrades.Has( player, ModId, 5 ) ? FloodRadius
			: AmmoModUpgrades.Has( player, ModId, 1 ) ? WideRadius : Radius;

	/// <summary>
	/// This player's pool life: <see cref="FloodLifetime"/> at level V, <see cref="LongLifetime"/> from II, <see cref="Lifetime"/>
	/// below.
	/// </summary>
	public static float LifetimeFor( NZPlayer player )
		=> AmmoModUpgrades.Has( player, ModId, 5 ) ? FloodLifetime
			: AmmoModUpgrades.Has( player, ModId, 2 ) ? LongLifetime : Lifetime;

	/// <summary>How long this player's slow lingers: <see cref="Cling"/> at level III, <see cref="Hold"/> below it.</summary>
	public static float HoldFor( NZPlayer player ) => AmmoModUpgrades.Has( player, ModId, 3 ) ? Cling : Hold;

	/// <summary>
	/// How fast this player's pool lets a zombie wade: <see cref="ThickSpeed"/> from level IV (never below 0.01, see it), the
	/// `tar` rule's own ×0.4 below it.
	/// </summary>
	public static float SpeedFor( NZPlayer player )
		=> AmmoModUpgrades.Has( player, ModId, 4 ) ? MathF.Max( 0.01f, ThickSpeed ) : SpeedOf();

	/// <summary>The status that is the slow. Its ×0.4 is on the rule (`StatusEffects`); IV's ×0.2 goes in per application.</summary>
	public const string Status = "tar";

	/// <summary>The pool's gloop — basalt's lava, five takes picked at random.</summary>
	const string Gloop = "nz.basalt.sfx.lava_gloop";

	/// <summary>How often the pool checks who is in it. 10 Hz.</summary>
	const float TickEvery = 0.1f;

	// ══ live state ═══════════════════════════════════════════════════════════

	public NZPlayer Owner { get; set; }

	TimeUntil _dies;
	TimeUntil _nextTick;
	TimeUntil _nextGloop;

	/// <summary>
	/// THIS pool's radius and how long its slow lingers — the owner's `RadiusFor` and `HoldFor`, read when it is poured
	/// (2026-10-05).
	///
	/// ⛔ EVERY MACHINE READS THE OWNER'S LEVEL FOR ITS OWN COPY, and the announcement carries none. Each copy slows its own
	/// zombies and the HOST'S is the one the AI obeys, so a host copy at the base numbers would cancel a client's upgrades.
	/// The levels are synced (`NZPlayer.AmmoUpgradeNet`); only a copy whose owner `NZNet.WorldFx` could not find falls back to
	/// the base numbers.
	///
	/// ⚠️ NULLABLE (§1): a pool alive across a hotload has neither, and keeps the base numbers.
	///
	/// ⚠️ AND ITS SPEED (2026-10-06, Thick Tar): the owner's `SpeedFor`, kept the same way — so below IV it is the `tar` rule's
	/// factor as it stood when the pool was poured, and `nz_status` retunes the next pool, not one already lying there.
	/// </summary>
	float? _reach;
	float? _linger;
	float? _speed;

	float Reach => _reach ?? Radius;
	float Linger => _linger ?? Hold;
	float Speed => _speed ?? SpeedOf();

	/// <summary>How many zombies this pool has caught, on this machine.</summary>
	public int Caught { get; private set; }

	readonly System.Collections.Generic.HashSet<Guid> _seen = new();

	/// <summary>
	/// Pour a pool at <paramref name="at"/> — the zombie hit, on the shooter's machine; the announced point everywhere else.
	///
	/// ⚠️ A POINT, NOT THE ZOMBIE, so a machine the zombie has already died on still builds the pool where it was.
	/// </summary>
	public static void Spawn( NZPlayer player, Vector3 at, bool announce = true )
	{
		// ⚠️ EVERY MACHINE BUILDS ITS OWN; EACH SLOWS ITS OWN COPY. See the header and `NZNet.WorldFx`.
		if ( announce && Networking.IsActive && Connection.Local is not null )
			NZNet.WorldFx( Connection.Local.Id.ToString(),
				NZPlayers.OwnerOf( player.IsValid() ? player.GameObject : null ),
				(int)NZNet.FxKind.TarPit, at, Guid.Empty );

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var go = scene.CreateObject();

		go.Name = "nz_tar_pit";
		go.WorldPosition = at;

		// ⛔ THIS MACHINE'S OWN (INSTRUCTIONS §39): each machine builds its pool from the announcement, so a copy in a joiner's
		// snapshot would only stand there frozen.
		go.NetworkMode = NetworkMode.Never;

		var pit = go.Components.Create<TarPit>();

		// ⚠️ THE OWNER'S UPGRADES ARE READ HERE, ONCE, and kept by the pool (2026-10-05): a level bought while it lies there
		// changes the next pool, not this one.
		var life = LifetimeFor( player );

		pit.Owner = player;
		pit._reach = RadiusFor( player );
		pit._linger = HoldFor( player );
		pit._speed = SpeedFor( player );
		pit._dies = MathF.Max( 0.2f, life );
		pit._nextGloop = Game.Random.Float( 0.8f, 1.6f );

		// ⛔ THE VISUAL SNAPS THE POOL TO THE FLOOR, AND IT GOES ON BEFORE THE FIRST TICK — the slowing circle and the one on
		// screen must be the same circle (see `RadioactiveDecay.Spawn`).
		PitVisual.Attach( go, pit.Reach, PitVisual.Style.Tar, life );

		Sound.Play( Gloop, go.WorldPosition );

		// ⚠️ A SECOND, TIMER-BASED CLEANUP, as every pit has: it fires even if this component never updates.
		SWB.Shared.GameObjectExtensions.DestroyAsync( go, MathF.Max( 0.2f, life ) );

		if ( announce )
			Log.Info( $"[nz-ammo] TAR PIT — {pit.Reach:0}u for {life:0.#}s · ×{pit.Speed:0.##} speed inside"
				+ $" · lingers {pit.Linger:0.##}s · level {AmmoModUpgrades.Level( player, ModId )}" );

		// ⚠️ IT CATCHES ON THE FRAME IT LANDS: the zombie that was shot is standing in it.
		pit.Tick();
	}

	/// <summary>The `tar` rule's own speed factor: the base slow, below IV (`SpeedFor`), and the logs'.</summary>
	static float SpeedOf() => StatusEffects.Rules.TryGetValue( Status, out var r ) ? r.SpeedScale : 1f;

	/// <summary>Top up the slow on every zombie standing in the pool. EVERY MACHINE, on its own copies.</summary>
	void Tick()
	{
		var reach = MathF.Max( 0f, Reach );
		var at = WorldPosition;

		// ⚠️ CLINGING TAR IS THIS NUMBER (2026-10-05): `Linger` is 5s at level III, and `StatusEffects.Add` keeps the LATER
		// expiry on a refresh, so a zombie in the pool sits at 5s left and keeps 5s once it is out. Two pools overlapping keep
		// the longer of their two lingers.
		var hold = MathF.Max( TickEvery * 2f, Linger );
		var source = Owner.IsValid() ? Owner.GameObject : null;

		// ⚠️ WHERE POOLS OVERLAP, THE SLOWEST WINS (2026-10-06, Thick Tar). One `tar` status holds one speed and every top-up
		// replaces it (`StatusEffects.Add`), so a level-IV pool and a plain one over the same zombie would flip it between ×0.2 and
		// ×0.4 at their two 10 Hz ticks — and the AI re-paths on every change (`ZombieAI.TickStatusSpeed`). Each pool hands on the
		// slowest factor of all the pools on this machine the zombie stands in, so whichever tops up last says the same thing.
		//
		// ⚠️ ONLY THE POOLS IT STANDS IN: a zombie still clinging to a Thick Tar pool's ×0.2 (III) that wades into a plain one takes
		// the plain one's ×0.4 from its first top-up, for the rest of that cling too — one status keeps one speed, with no expiry per pool.
		var others = Scene.GetAllComponents<TarPit>().Where( p => p != this && p.IsValid() ).ToList();

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || !z.GameObject.IsValid() || z.State == ZombieState.Dead ) continue;
			if ( at.Distance( z.WorldPosition ) > reach ) continue;

			var speed = Speed;

			foreach ( var p in others )
				if ( p.Speed < speed && p.WorldPosition.Distance( z.WorldPosition ) <= MathF.Max( 0f, p.Reach ) )
					speed = p.Speed;

			// ⚠️ THE SPEED GOES IN WITH EVERY TOP-UP (2026-10-06): the rule's own below IV, Thick Tar's ×0.2 from it (`SpeedFor`).
			StatusEffects.ApplyHere( z.GameObject, Status, source, seconds: hold, speedScale: speed );

			if ( _seen.Add( z.GameObject.Id ) ) Caught++;
		}
	}

	protected override void OnUpdate()
	{
		// ⚠️ EXPIRY FIRST, ON EVERY MACHINE — `RadioactiveDecay` records the leak that came of putting it behind a gate.
		if ( _dies )
		{
			GameObject.Destroy();
			return;
		}

		if ( _nextTick )
		{
			_nextTick = TickEvery;
			Tick();
		}

		// ⚠️ THE POOL BUBBLES: a gloop now and then somewhere in it, while it lasts.
		if ( _nextGloop )
		{
			_nextGloop = Game.Random.Float( 1.4f, 2.6f );

			var a = Game.Random.Float( 0f, MathF.PI * 2f );
			var r = Reach * MathF.Sqrt( Game.Random.Float( 0f, 1f ) ) * 0.8f;

			Sound.Play( Gloop, WorldPosition + new Vector3( MathF.Cos( a ) * r, MathF.Sin( a ) * r, 4f ) );
		}
	}

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

	/// <summary>`nz_tarpit` — the resolved numbers and the live pools on this machine.</summary>
	[ConCmd( "nz_tarpit" )]
	public static void Report()
	{
		var live = Game.ActiveScene?.GetAllComponents<TarPit>().ToList()
			?? new System.Collections.Generic.List<TarPit>();

		var mod = AmmoMods.Find( "tarpit" );
		var tarred = ZombieAI.All.Count( x => x.IsValid() && StatusEffects.Has( x.GameObject, Status ) );

		Log.Info( $"[nz-ammo] TAR PIT · {(mod?.Chance ?? 0f) * 100f:0.#}% per hit, {mod?.Cooldown ?? 0f:0.#}s cooldown"
			+ $" · {Radius:0}u for {Lifetime:0.#}s · ×{SpeedOf():0.##} speed inside (topped up {Hold:0.##}s)"
			+ $" · {live.Count} live · {tarred} zombie(s) tarred here" );

		// ⚠️ THE UPGRADES (2026-10-05; IV and V 2026-10-06), and where your own level puts them.
		var me = NZPlayer.Local;

		Log.Info( $"[nz-ammo]   upgrades: I {WideRadius:0}u · II {LongLifetime:0.#}s · III lingers {Cling:0.#}s"
			+ $" · IV ×{ThickSpeed:0.##} speed · V {FloodRadius:0}u for {FloodLifetime:0.#}s" );

		if ( me.IsValid() )
			Log.Info( $"[nz-ammo]   yours at level {AmmoModUpgrades.Level( me, ModId )}: {RadiusFor( me ):0}u for"
				+ $" {LifetimeFor( me ):0.#}s, ×{SpeedFor( me ):0.##} speed, lingers {HoldFor( me ):0.##}s" );

		foreach ( var p in live )
			Log.Info( $"[nz-ammo]   pool at {p.WorldPosition} · {p.Reach:0}u, ×{p.Speed:0.##} speed, lingers {p.Linger:0.##}s"
				+ $" · caught {p.Caught}" );
	}

	/// <summary>
	/// `nz_tarpit_set &lt;radius|life|hold|wide|long|cling|thick|flood|floodlife&gt; &lt;value&gt;` — retune the pool; from `wide`
	/// on they are the upgrades' (IV and V from `thick`, 2026-10-06). The base speed: `nz_status`.
	/// </summary>
	[ConCmd( "nz_tarpit_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "radius": Radius = value; break;
			case "life": Lifetime = value; break;
			case "hold": Hold = value; break;
			case "wide": WideRadius = value; break;
			case "long": LongLifetime = value; break;
			case "cling": Cling = value; break;
			case "thick": ThickSpeed = value; break;
			case "flood": FloodRadius = value; break;
			case "floodlife": FloodLifetime = value; break;

			default:
				Log.Info( "[nz-ammo] nz_tarpit_set <radius|life|hold|wide|long|cling|thick|flood|floodlife> <value>"
					+ " — the base speed is the `tar` status rule's, IV's is `thick`" );
				return;
		}

		Report();
	}
}