swb_base/Weapon.Marked.cs

Partial Weapon class extension that implements the "Marked" mechanic (t3_marked). It tracks a per-weapon streak on a single zombie across trigger pulls, computes a damage multiplier based on a growth factor from TechEffects, and exposes a console command to print the current streak and target for the local weapon.

Reflection
using Sandbox;

namespace SWB.Base;

/// <summary>
/// MARKED (`t3_marked`) — staying on one zombie makes every shot hit harder.
///
/// ⛔ THE STREAK LIVES ON THE WEAPON, NOT ON THE ZOMBIE, and that is what makes the rule
/// "if I shoot a different zombie it returns to base" cheap to honour. A status on the target
/// would have to be found, refreshed, expired and cleaned up on every corpse; a remembered
/// target and a count is two fields that die with the weapon.
///
/// ⚠️ AND IT IS PER WEAPON RATHER THAN PER PLAYER, because the node is bought per weapon. A
/// player carrying a marked rifle and an unmarked SMG keeps one streak, on the gun that paid
/// for it, and switching weapons cannot launder a stack from one onto the other.
///
/// ⚠️ NO STATIC STATE, SO IT IS MULTIPLAYER-SAFE BY CONSTRUCTION. `HitScan` runs on the
/// shooter, the fields are instance fields on that shooter's weapon, and two players shooting
/// the same zombie keep entirely separate streaks — which is the right answer: the reward is
/// for YOUR aim, not for the zombie having been shot a lot.
/// </summary>
public partial class Weapon
{
	/// <summary>The zombie the streak is currently running on.</summary>
	GameObject _markedTarget;

	/// <summary>How many CONSECUTIVE shots have landed on it. 0 is the first hit.</summary>
	int _markedStreak;

	/// <summary>The trigger pull the streak last counted, so pellets cannot each advance it.</summary>
	int _markedShot = -1;

	/// <summary>
	/// Trigger pulls, counted once each.
	///
	/// ⛔ `shotId` COULD NOT DO THIS JOB. It is a fresh Guid PER PELLET — its own comment says
	/// so, "matching `shotId`'s scope exactly" beside the per-pellet penetration budget — so
	/// counting on it would advance the streak eight times for one shotgun blast and make this
	/// node eight times better on shotguns than on everything else. This increments once in
	/// `Shoot`, whatever leaves the barrel.
	/// </summary>
	int _shotSeq;

	/// <summary>Called once per trigger pull, before any pellet is traced.</summary>
	public void BeginMarkedShot() => _shotSeq++;

	/// <summary>
	/// The damage multiplier this bullet earns, and the streak bookkeeping that produces it.
	///
	/// ⚠️ ADDITIVE ON THE BASE, NOT COMPOUNDING. Hit 1 is x1.00, hit 2 x1.10, hit 3 x1.20 —
	/// `1 + growth * streak`. Compounding was the other reading of "+10% per shot" and it is a
	/// different node entirely: with no cap, 800 rpm held on one zombie reaches x3.4 in one
	/// second and x45 in three, where this reaches x2.3 and x4.6.
	///
	/// ⚠️ ONLY THE FIRST ZOMBIE OF THE TRIGGER PULL MOVES THE STREAK, which is the requested
	/// rule and also what makes it survive penetration: a round through five bodies advances
	/// the count once, by the body it touched first. Later pellets of the same shot read the
	/// answer without changing it, so a shotgun blast is one step whether one pellet lands or
	/// all of them.
	///
	/// ⚠️ A MISS CHANGES NOTHING. Only a zombie resets the streak, so hitting a wall, a barrel
	/// or a team-mate between shots leaves it standing — the node rewards staying ON a target
	/// rather than never missing.
	///
	/// ⚠️ THE WHOLE BULLET IS SCALED, not just the body that carries the mark. It is one
	/// buffed round; splitting the bonus at the first body would make a penetrating shot weaker
	/// the deeper it went, on top of `PenetrationDamageMult` already doing exactly that.
	/// </summary>
	public float MarkedFactor( GameObject hit )
	{
		var growth = NZombies.TechEffects.Factor( this, "t3_marked", 0f );
		if ( growth <= 0f ) return 1f;

		var body = NZombies.ZombieAI.RootOf( hit );

		// ⚠️ Not a zombie: no mark, no reset, and the running streak still applies to this
		// bullet — it is the same shot that would have hit one.
		if ( !body.IsValid() )
			return 1f + growth * _markedStreak;

		// Already counted this trigger pull — answer, do not advance.
		if ( _markedShot != _shotSeq )
		{
			_markedShot = _shotSeq;

			if ( body == _markedTarget )
				_markedStreak++;
			else
			{
				_markedTarget = body;
				_markedStreak = 0;
			}
		}

		return 1f + growth * _markedStreak;
	}

	/// <summary>
	/// `nz_marked` — what the streak is doing right now, on the weapon in your hands.
	///
	/// ⚠️ A READOUT RATHER THAN A SETTER. The magnitude belongs to the catalogue
	/// (`WeaponTech.Tier3`), which `nz_tech` prints; a second place to set it is the drift this
	/// project has already fixed twice.
	/// </summary>
	[ConCmd( "nz_marked" )]
	public static void MarkedCmd()
	{
		var player = NZombies.NZPlayer.Local;
		var wep = player?.Components.Get<Weapon>( FindMode.EverythingInSelfAndDescendants );

		if ( !wep.IsValid() )
		{
			Log.Info( "[nz-marked] nothing in hand" );
			return;
		}

		var growth = NZombies.TechEffects.Factor( wep, "t3_marked", 0f );

		if ( growth <= 0f )
		{
			Log.Info( $"[nz-marked] '{wep.ClassName}' does not have Marked" );
			return;
		}

		Log.Info( $"[nz-marked] +{growth * 100f:0.#}% per consecutive hit, no cap" );
		Log.Info( $"[nz-marked]   streak {_StreakOf( wep )} -> x{1f + growth * _StreakOf( wep ):0.00}"
			+ $"   target {(wep._markedTarget.IsValid() ? wep._markedTarget.Name : "none")}" );
	}

	static int _StreakOf( Weapon w ) => w._markedStreak;
}