Weapons/Flechette.cs

A static utility implementing the "flechette" weapon node. It breaks a bullet impact into multiple short-range fragment traces that apply damage via Health.OnDamage, ignoring the body that caused the split and tagging shard damage as "flechette" (and optionally "nopay"). It also exposes Reach and a console command to inspect/change reach.

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

namespace NZombies;

/// <summary>
/// FLECHETTE (`t5_flechette`) — a round that comes apart inside the first zombie it hits and
/// sprays the rest of the pack with fragments.
///
/// ⛔ IT IS A CROWD NODE AND IT IS SUPPOSED TO BE USELESS ALONE. Seven shards at a third of the
/// damage each is up to x2.31 in a packed group and EXACTLY NOTHING against one zombie in an open
/// room — every shard flies off and finds nothing. That shape is the whole design: the ceiling is
/// enormous, and the player only reaches it by standing somewhere frightening.
///
/// ⛔ THE SHARDS CANNOT SPLIT AGAIN, BY CONSTRUCTION RATHER THAN BY A FLAG. The split is fired
/// from `HitScan`, and a shard is not a `HitScan` bullet — it is a trace resolved in this file that
/// calls `Health.OnDamage` directly. There is no path from a shard back to the code that would
/// split it, so "seven, once" needs no depth counter and cannot be broken by a later edit that
/// forgets one.
///
/// ⚠️ ONCE PER PELLET, ON THE FIRST ZOMBIE — not once per body a penetrating round crosses. A
/// rifle round through five zombies splits at the first of them, for 7 shards, not 35. The caller
/// holds that latch (`split` in `HitScan.Shoot`, which is per pellet) for the same reason the
/// explosive node holds `blasted` beside it.
///
/// ⚠️ SO A SHOTGUN REALLY DOES GET ONE SPLIT PER PELLET, and that is the honest reading of "a
/// bullet splits into 7": a pellet is a bullet. It is also why <see cref="Reach"/> is short — see
/// its note; sixteen splits of seven short traces is a broadphase query each, not a map-length
/// sweep each.
///
/// ⚠️ ROUTED THROUGH `Health.OnDamage`, NOT `Health.Apply`, following `TechBlast.Detonate`'s note
/// verbatim: `Apply` is local-only and takes no weapon, so shard damage would neither reach the
/// host from a client nor attribute the kill to the gun that earned it. `OnDamage` is the one
/// chokepoint that relays, scales and attributes.
///
/// ⚠️ WHICH ALSO MEANS THE SHARDS OBEY `ShotPoints`. They are asked the same question every
/// pierced body is asked and carry the same `nopay` tag when refused, so a node that touches seven
/// extra zombies does not also become a points engine — the economy guard was built for exactly
/// this multiplication and it costs one call per shard to stay inside it.
/// </summary>
public static class Flechette
{
	/// <summary>
	/// How far a shard flies before it gives up, in units. 250.
	///
	/// ⛔ SHORT ON PURPOSE, AND FOR TWO SEPARATE REASONS. A fragment that travels the length of
	/// the map is not a fragment, and — the one that decides the number — seven full-range sweeps
	/// per pellet is the cost shape `Weapon.TraceRange` exists to stop. A 16-pellet shotgun asks
	/// for 112 traces on one trigger pull; at 250 units each of them is a small local query that
	/// broadphase culling answers almost for free, and at 999999 it would be 112 sweeps that
	/// narrow-phase against every zombie in the level.
	///
	/// ⚠️ IT IS ALSO THE NODE'S REAL RANGE STAT. Zombies have to be within about six metres of
	/// the one you shot for any of this to happen, which is what makes it a horde node rather than
	/// a damage node.
	/// </summary>
	public static float Reach { get => _reach ?? 250f; set => _reach = value; }
	static float? _reach;

	/// <summary>How many draws to spend finding a uniform direction before settling. 8.</summary>
	/// <remarks>
	/// ⚠️ SAME REJECTION SAMPLER AS `HitScanBulletInfo.Deflect`, and deliberately not shared with
	/// it: that one folds in a hemisphere test against a surface normal, which a shard has no use
	/// for. Two short loops that agree is better than one parameterised loop that has to be read
	/// twice to see which half applies.
	/// </remarks>
	const int DirectionTries = 8;

	/// <summary>The tag every shard carries, so the damage can be told apart downstream.</summary>
	public const string Tag = "flechette";

	// ⚠️ ONE REUSED LIST RATHER THAN A NEW ONE PER SPLIT. A split runs to completion inside one
	// pellet's resolution and never overlaps another, and this is the damage path — where
	// INSTRUCTIONS.md already records an array-per-hit costing frames.
	static readonly List<GameObject> _ignore = new();

	/// <summary>
	/// A bullet just hit a zombie. Break it up.
	/// </summary>
	/// <param name="splitOn">
	/// The zombie that caused the split. ⚠️ THE SHARDS IGNORE IT, which is the requested rule and
	/// is also the only thing standing between this node and seven free hits on a body that is
	/// already being shot: every shard starts INSIDE that zombie's hitbox, so without the ignore
	/// most of them would resolve against it a unit later and the "spray" would be a x3.31 single
	/// target multiplier.
	/// </param>
	/// <param name="at">Where the bullet struck — the origin every shard flies from.</param>
	/// <param name="damage">What the bullet dealt, before the share is taken.</param>
	public static void Split( SWB.Base.Weapon weapon, GameObject splitOn, Vector3 at, float damage )
	{
		if ( !weapon.IsValid() || damage <= 0f ) return;

		var shards = (int)TechEffects.Factor( weapon, "t5_flechette", 0f );
		if ( shards < 1 ) return;

		var share = TechEffects.Mag( weapon, "t5_flechette", "share", 0f );
		if ( share <= 0f ) return;

		var each = damage * share;
		var attacker = weapon.Owner?.GameObject;
		var root = ZombieAI.RootOf( splitOn );

		_ignore.Clear();
		if ( root.IsValid() ) _ignore.Add( root );

		for ( var i = 0; i < shards; i++ )
		{
			var dir = Scatter();
			var tr = weapon.TraceBullet( at, at + dir * Reach, extraIgnoreGOs: _ignore );
			if ( !tr.Hit ) continue;

			// ⛔ THE TRACE IS NOT TAG-FILTERED TO ZOMBIES, AND THAT IS WHAT MAKES WALLS STOP
			// SHARDS. A `.WithTag("zombie")` sweep would be cheaper and would also spray the
			// room on the other side of the wall you are standing behind — a node that shoots
			// through geometry reads as a bug, and it would be one.
			var hit = ZombieAI.RootOf( tr.GameObject );
			if ( !hit.IsValid() ) continue;

			var hp = hit.Components.Get<Health>( FindMode.EverythingInSelf );
			if ( hp is null || hp.IsDead ) continue;

			// ⚠️ ASKED EXACTLY ONCE PER SHARD, because it MUTATES — see `ShotPoints.Pays`. The
			// answer rides the damage as a tag, the same carrier the bullet's own verdict uses.
			// ⚠️ INSIDE ITS BULLET'S PELLET, so the gun's ammo-mod upgrades come with it (2026-10-05): a shard spends Midas III's
			// wider cap and pays Scrapper III's salvage as any hit of that bullet does. Leech III has already healed for it.
			var pays = ShotPoints.Pays( hit );

			hp.OnDamage( new SWB.Shared.DamageInfo
			{
				Attacker = attacker,
				Weapon = weapon.GameObject,
				Damage = each,
				Position = tr.HitPosition,
				Origin = at,

				// ⚠️ NO `head` TAG EVER, however the shard landed. A fragment is not aimed, so
				// it earns neither the head damage product nor the 100-point award — the same
				// ruling `BouncyRounds` makes, for the same reason. It also saves the per-shard
				// `Hitbox.Tags.TryGetAll().ToArray()` that `HitScan` already flags as a
				// gen0 pressure source, seven times per pellet.
				Tags = pays ? [Tag] : [Tag, "nopay"],
			} );
		}
	}

	/// <summary>
	/// Where one shard goes: a uniformly random direction over the whole sphere.
	///
	/// ⛔ THE WHOLE SPHERE, NOT A FORWARD CONE, AND IN THIS GAME THAT IS NOT A WASTE. The obvious
	/// objection is that half the shards fly back past the shooter — true, and in nZombies the
	/// space behind you is where the horde is. A forward cone would make the node strongest when
	/// firing into an empty room and weakest when surrounded, which is backwards for a fragment
	/// spray.
	///
	/// ⚠️ REJECTION-SAMPLED IN THE CUBE RATHER THAN `Vector3.Random`, for the reason
	/// `GlobalHandling`'s spread note gives: whether that property returns a unit vector or a point
	/// in a cube is engine behaviour this project has decided not to assume, and the difference is
	/// between uniform and biased toward the eight corners.
	/// </summary>
	static Vector3 Scatter()
	{
		for ( var i = 0; i < DirectionTries; i++ )
		{
			var v = new Vector3(
				Game.Random.Float( -1f, 1f ),
				Game.Random.Float( -1f, 1f ),
				Game.Random.Float( -1f, 1f ) );

			// ⚠️ BOTH ENDS TESTED. Longer than 1 is outside the ball and biases toward the
			// corners; near zero cannot be normalised at all.
			var len = v.Length;
			if ( len > 1f || len < 0.0001f ) continue;

			return v / len;
		}

		// ⚠️ EIGHT CONSECUTIVE REJECTIONS IS A 2.7-IN-10000 EVENT (the ball fills 52% of the
		// cube), and the cost of hitting it is one shard flying straight up. Not worth a ninth try.
		return Vector3.Up;
	}

	/// <summary>
	/// `nz_flechette [reach]` — how far a shard looks, and what the node is worth.
	///
	/// ⚠️ THE SHARD COUNT AND SHARE ARE NOT SETTABLE HERE. They belong to the catalogue, which
	/// `nz_tech` prints and `nz_tech_amp` scales; a second place to set them is the drift this
	/// project has already fixed twice.
	/// </summary>
	[ConCmd( "nz_flechette" )]
	public static void Cmd( float reach = -1f )
	{
		if ( reach > 0f ) Reach = reach;

		var node = WeaponTech.Find( "t5_flechette" );
		var shards = node is null ? 0 : (int)node.Factor;
		var share = WeaponTech.MagOf( "t5_flechette", "share", 0f );

		Log.Info( $"[nz-flechette] {shards} shards at x{share:0.##} each, {Reach:0} units of reach" );
		Log.Info( $"[nz-flechette]   all seven landing is x{1f + shards * share:0.00} the bullet;"
			+ " none landing is x1.00" );
		Log.Info( "[nz-flechette]   one split per PELLET, on the first zombie it hits, and the"
			+ " shards ignore that zombie" );
	}
}