Pool and roll logic for the "Chimera" weapon node. Reads weapon prefab JSONs, extracts numeric axes (clip, damage, rpm, firing mode, falloff, recoil, reload, pellets), caches donors, and produces a ChimeraRoll composed of eight independent draws (with replacement). Also includes a console command to sample the pool and print diagnostics.
using Sandbox;
using SWB.Base;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace NZombies;
/// <summary>
/// One Chimera draw: eight axes, each taken from a DIFFERENT randomly chosen weapon.
///
/// ⚠️ ELEVEN FIELDS BUT EIGHT AXES. Two axes are bundles and must not be split:
/// the three `Falloff*` fields come from one donor (a start without its matching end
/// is not a range curve, it is two halves of two different guns), and `RecoilUp` +
/// `SpreadAddHipFire` come from one donor (recoil and accuracy travel together —
/// drawing them apart produces a gun that kicks like an AWM and groups like a MAC11,
/// which reads as a bug rather than as a gamble).
///
/// ⚠️ `FiringMode` IS A STRING, deliberately: this record is data read off a prefab,
/// and `FiringType` has no neutral value to mean "not rolled". Null means the mode
/// axis produced nothing; the consumer parses it with `Enum.TryParse<FiringType>`
/// and leaves the weapon's authored mode alone when that fails.
/// </summary>
public record ChimeraRoll( int ClipSize, float Damage, int Rpm, string FiringMode,
float FalloffStart, float FalloffEnd, float FalloffMultiplier,
float RecoilUp, float SpreadAddHipFire, float ReloadTime, int Bullets,
float RecoilVerticalMult, float RecoilHorizontalMult, float RecoilKick, float RecoilAutoControl );
/// <summary>
/// The donor pool Chimera draws from: every weapon in the game, read straight off its
/// prefab.
///
/// ⛔ THE ROSTER COMES FROM `WeaponLibrary.All`, NOT FROM `WeaponClassStats`. That is
/// the whole implementation risk of this node and the wrong choice is the tempting one:
/// `WeaponClassStats` enumerates `Assets/weapons/manifest.json` and nothing else, and
/// that file is HAND-AUTHORED — a weapon whose prefab exists but whose manifest entry
/// was forgotten is invisible to it. `WeaponLibrary.All` reads the manifest AND THEN
/// sweeps `ResourceLibrary.GetAll<PrefabFile>()` for every `prefabs/weapons/*.prefab`
/// the manifest missed, so a newly ported weapon joins the pool with no edit here. The
/// mystery box rolls from the same list, which keeps "can the box give it to me" and
/// "can Chimera draw from it" one answer.
///
/// ⚠️ THE STATS COME OFF THE PREFAB JSON, because neither the manifest nor
/// `WeaponClassStats` carries all the axes — fire mode is a quoted string that the
/// panel's number scanner skips entirely, and the three `Falloff*` fields are absent
/// from it. Reading the prefab rather than a live component is also the only option:
/// exactly one weapon exists in the scene at a time, so the other thirty have nothing
/// live to measure. See `ReadPrefab`.
///
/// ⚠️ READ ONCE AND CACHED, invalidated by <see cref="Invalidate"/> — reading 31+ files
/// per roll would be absurd, and reading them once at startup would miss anything
/// hot-loaded. `WeaponLibrary.Reload()` is the precedent and this hangs off it.
///
/// ⛔ NO FLOOR, NO CEILING, NO RE-ROLL. Both tails are the feature. The per-axis filters
/// below exclude UNAUTHORED axes only — never unlucky ones. Do not add a clamp later
/// "for balance"; the catalogue records that capping was considered and rejected.
///
/// ⛔ AND THE RANGE AXIS IS ARITHMETICALLY REAL BUT PRACTICALLY INVISIBLE. Measured, not
/// guessed: all 31 prefabs author `FalloffStart` between 546 and 2340 units (13.9-59.4 m)
/// and `ShootInfo.DamageFor` gates the lerp behind `distance > FalloffStart`, so below
/// 13.9 m every weapon's curve is flat at 1.0 and swapping one start for another changes
/// nothing a player can perceive at zombie-fighting range. Roughly 29 of 31 draws on that
/// axis are unobservable. It is kept because the node's eight axes are the spec — but
/// `nz_chimera` printing the roll is the only way to see that axis work, and nobody
/// should spend a day trying to feel it.
///
/// ⚠️ THE PELLET AXIS IS LOPSIDED FOR THE SAME KIND OF REASON, counted rather than
/// guessed: exactly four of the 31 weapons author more than one bullet (KS23 16, HS10 /
/// Olympia / SPAS-12 8 each), so 27 of 31 draws donate a 1. On a rifle that draw is a
/// no-op; on a shotgun it is a very loud downgrade to a single slug. Not a bug — but do
/// not read "pellets 1" in the log as the axis failing to roll.
/// </summary>
public static class ChimeraPool
{
/// <summary>
/// One weapon's contribution. Everything is a primitive: <see cref="ReadPrefab"/>
/// copies out of the `JsonDocument` before disposing it.
/// </summary>
// ⛔ THE MULTIPLIERS ARE PART OF THE RECOIL AXIS, AND WITHOUT THEM IT IS A DEAD NODE.
// Chimera donated `RecoilUp`, the AUTHORED per-weapon recoil — and `GetRecoilAngles` only reads
// that field when `UseRecoilBase` is FALSE. Base mode has been the default since the recoil base
// was baked on 2026-09-14, so from that day the rolled recoil reached nothing: the gun kept its
// own `VerticalBase × RecoilVerticalMult` and the donor's kick was discarded. Eight axes were
// advertised and seven arrived.
record Donor( string Name, int ClipSize, float Damage, int Rpm, string FiringMode,
float FalloffStart, float FalloffEnd, float FalloffMultiplier,
float RecoilUp, float SpreadAddHipFire, float ReloadTime, int Bullets,
float RecoilVerticalMult, float RecoilHorizontalMult, float RecoilKick, float RecoilAutoControl );
// ⛔ RENAMED FROM `_pool` WHEN `Donor` GAINED TWO RECOIL FIELDS (2026-10-03). Hotload carries a static
// forward BY NAME (INSTRUCTIONS.md), so a pool built before the change would have kept donors whose new
// fields read 0 (no first-shot kick, no pull-down) until the editor restarted. A new name starts empty.
static List<Donor> _donors;
/// <summary>
/// Every readable weapon on the roster, built on first use.
///
/// ⚠️ A PREFAB THAT WILL NOT PARSE IS SKIPPED, NOT FATAL — the pool degrades to the
/// weapons it could read and says how many it lost, because a broken prefab must not
/// take a tech purchase down mid-round.
/// </summary>
static List<Donor> Pool
{
get
{
if ( _donors is not null ) return _donors;
_donors = new();
var roster = WeaponLibrary.All;
var lost = 0;
foreach ( var e in roster )
{
var d = ReadPrefab( e.Prefab, e.Name );
if ( d is null ) { lost++; continue; }
_donors.Add( d );
}
Log.Info( $"[chimera] pool built: {_donors.Count} donors from {roster.Count} on the roster"
+ (lost > 0 ? $", {lost} unreadable" : "") );
return _donors;
}
}
/// <summary>
/// Drop the cached pool. Call after porting a weapon.
///
/// ⚠️ ALSO DROPS THE ROSTER. The pool is built from `WeaponLibrary.All`, which caches
/// the manifest plus its prefab sweep — forgetting only our own list would rebuild
/// from the same stale roster and the newly ported weapon still would not appear,
/// which is the silent narrowing this node exists to avoid.
/// </summary>
public static void Invalidate()
{
_donors = null;
WeaponLibrary.Reload();
}
/// <summary>
/// One roll. Eight independent draws, WITH replacement — a weapon can donate to more
/// than one axis, which is what makes the outcome space 31^8 rather than a permutation.
///
/// ⚠️ RETURNS NULL WHEN NO ROLL COULD BE MADE — an empty or unreadable roster, or an
/// axis no weapon authors. A caller must treat null as "nothing was rolled" and must
/// NOT record the node as rolled, because a half roll with zeroes in it would read
/// downstream as a real substitution and hand the player a gun that deals no damage.
/// </summary>
public static ChimeraRoll Roll() => RollWith( null );
/// <summary>
/// The roll, optionally recording which weapon won each axis.
///
/// ⚠️ ONE IMPLEMENTATION WITH AN OPTIONAL LEDGER rather than two, so the console
/// command demonstrates the same draw the game makes. A second sampling path would
/// be free to drift from this one and the diagnostic would stop being evidence.
/// </summary>
static ChimeraRoll RollWith( Dictionary<string, string> sources )
{
var pool = Pool;
if ( pool.Count == 0 )
{
Log.Warning( "[chimera] no donors — roster empty or every prefab unreadable" );
return null;
}
// ⚠️ EACH PREDICATE ASKS "DID THIS WEAPON AUTHOR THIS AXIS", never "is this value
// good". A weapon that leaves an axis at its class default is donating an absence,
// not a value, and an absence substituted onto a real gun is a silent buff: a 0
// reload is instant, a 0/0 falloff pair is infinite range.
var clip = Draw( pool, sources, "clip", d => d.ClipSize > 0 );
var dmg = Draw( pool, sources, "damage", d => d.Damage > 0f );
var rpm = Draw( pool, sources, "rpm", d => d.Rpm > 0 );
var mode = Draw( pool, sources, "mode", d => d.FiringMode is not null );
// ⚠️ `FalloffEnd > FalloffStart` IS `DamageFor`'s OWN CONDITION for the curve to
// exist, so this is not a judgement about the value — a donor failing it has no
// curve to donate.
var range = Draw( pool, sources, "range", d => d.FalloffEnd > d.FalloffStart );
// ⚠️ ONE DONOR STILL SUPPLIES THE WHOLE RECOIL AXIS, mults included. Drawing the
// multipliers separately would hand the player a gun with one weapon's kick size and
// another's kick shape — which is not a chimera of two guns, it is a third gun nobody
// authored. The filter is unchanged: a donor with neither an authored kick nor hip spread
// has no recoil character to give.
var kick = Draw( pool, sources, "recoil", d => d.RecoilUp > 0f || d.SpreadAddHipFire > 0f );
// ⚠️ MATCHES `ReloadTech`'s `authored > 0f` GUARD, for the same reason it has one.
var reload = Draw( pool, sources, "reload", d => d.ReloadTime > 0f );
var pellets = Draw( pool, sources, "pellets", d => d.Bullets > 0 );
// ⛔ ALL EIGHT OR NONE. A partial roll cannot be represented — every field of
// ChimeraRoll is a plain number, so "no donor for this axis" and "a donor whose
// value happens to be 0" are the same bits downstream. Failing the whole roll is
// the only honest answer, and it is loud.
if ( clip is null || dmg is null || rpm is null || mode is null || range is null
|| kick is null || reload is null || pellets is null )
{
var dead = new[]
{
clip is null ? "clip" : null, dmg is null ? "damage" : null,
rpm is null ? "rpm" : null, mode is null ? "mode" : null,
range is null ? "range" : null, kick is null ? "recoil" : null,
reload is null ? "reload" : null, pellets is null ? "pellets" : null,
}.Where( a => a is not null );
Log.Warning( $"[chimera] no roll — no weapon on the roster authors: {string.Join( ", ", dead )}" );
return null;
}
return new ChimeraRoll( clip.ClipSize, dmg.Damage, rpm.Rpm, mode.FiringMode,
range.FalloffStart, range.FalloffEnd, range.FalloffMultiplier,
kick.RecoilUp, kick.SpreadAddHipFire, reload.ReloadTime, pellets.Bullets,
kick.RecoilVerticalMult, kick.RecoilHorizontalMult, kick.RecoilKick, kick.RecoilAutoControl );
}
/// <summary>One axis. Null when no weapon on the roster authors it.</summary>
static Donor Draw( List<Donor> pool, Dictionary<string, string> sources, string axis,
Func<Donor, bool> authored )
{
var candidates = pool.Where( authored ).ToList();
if ( candidates.Count == 0 ) return null;
var d = Game.Random.FromList( candidates );
if ( sources is not null ) sources[axis] = d.Name;
return d;
}
// ── reading a prefab without instantiating it ────────────────────────────
/// <summary>
/// Pull one weapon's axes out of its prefab file.
///
/// ⛔ THE FIRST `ShootInfo` IN THE FILE. A prefab may carry a second one for an
/// underbarrel or an akimbo; the primary is written first, and taking the first match
/// is what keeps a grenade launcher's damage out of the rifle's damage axis. (Checked
/// today: all 31 weapon prefabs carry exactly one `SWB.Base.ShootInfo` and one
/// `SWB.Base.Weapon`, both on the root object — the recursion and the first-match rule
/// are for the prefab someone authors next.)
///
/// ⚠️ `ReloadTime` IS ON THE WEAPON COMPONENT, NOT THE ShootInfo — see
/// `Weapon.Var.cs`. It is the only axis that is not a ShootInfo field, which is why
/// two components get looked up here.
///
/// ⚠️ A MISSING FIELD FALLS BACK TO A VALUE ITS AXIS FILTER REJECTS (0), not to the
/// class default. Donating a default is donating a number the author never wrote;
/// dropping the weapon from that one axis keeps it in the other seven. The lone
/// exception is `FalloffMultiplier`, which falls back to 1 — the field's own
/// documented "nothing lost at range" — because a 0 there would mean a donor with a
/// half-authored curve deals no damage past `FalloffEnd`.
/// </summary>
static Donor ReadPrefab( string path, string name )
{
// ⛔ THE COMPILED PREFAB'S JSON, NOT THE `.prefab` TEXT, which no longer ships (PrefabText).
var text = PrefabText.Read( path );
if ( string.IsNullOrWhiteSpace( text ) ) return null;
try
{
using var doc = JsonDocument.Parse( text );
if ( !doc.RootElement.TryGetProperty( "RootObject", out var root ) ) return null;
var shoot = FindComponent( root, "SWB.Base.ShootInfo" );
if ( shoot is null ) return null;
var si = shoot.Value;
var wep = FindComponent( root, "SWB.Base.Weapon" );
return new Donor( name,
Int( si, "ClipSize", 0 ),
Num( si, "Damage", 0f ),
Int( si, "RPM", 0 ),
Mode( si ),
Num( si, "FalloffStart", 0f ),
Num( si, "FalloffEnd", 0f ),
Num( si, "FalloffMultiplier", 1f ),
Num( si, "RecoilUp", 0f ),
Num( si, "SpreadAddHipFire", 0f ),
wep is null ? 0f : Num( wep.Value, "ReloadTime", 0f ),
Int( si, "Bullets", 0 ),
// ⚠️ DEFAULTED TO 1, NOT 0. A missing multiplier means "unscaled"; a zero would
// hand the player a donor whose recoil axis silently removes all recoil, which is
// the one roll that reads as a bug rather than as a bad draw.
Num( si, "RecoilVerticalMult", 1f ),
Num( si, "RecoilHorizontalMult", 1f ),
// ⛔ THE REST OF THE DONOR'S KICK (2026-10-03, the user: "for chimera, we should also add
// recoil"). In base mode the two multipliers set the size of every shot, but the FIRST
// shot's punch (`RecoilKick`: 0.5 on 509 prefabs, 1 on 512, up to 4) and the pull-down
// (`RecoilAutoControl`) stayed the old gun's, so a roll often felt like no roll. Defaults
// are the component's own (0), so an unauthored field donates what the game would use.
Num( si, "RecoilKick", 0f ),
Num( si, "RecoilAutoControl", 0f ) );
}
catch ( Exception e )
{
Log.Warning( $"[chimera] {path} unreadable: {e.Message}" );
return null;
}
}
/// <summary>
/// The first component of a type on this object or, failing that, anywhere below it.
///
/// ⚠️ ROOT COMPONENTS BEFORE CHILDREN, depth-first, so "first in the file" and "first
/// found" stay the same rule — which is what the primary-ShootInfo guarantee rests on.
/// </summary>
static JsonElement? FindComponent( JsonElement go, string type )
{
if ( go.TryGetProperty( "Components", out var comps ) && comps.ValueKind == JsonValueKind.Array )
{
foreach ( var c in comps.EnumerateArray() )
{
if ( c.ValueKind != JsonValueKind.Object ) continue;
if ( c.TryGetProperty( "__type", out var t ) && t.ValueKind == JsonValueKind.String
&& t.GetString() == type )
return c;
}
}
if ( go.TryGetProperty( "Children", out var kids ) && kids.ValueKind == JsonValueKind.Array )
{
foreach ( var k in kids.EnumerateArray() )
{
if ( k.ValueKind != JsonValueKind.Object ) continue;
var hit = FindComponent( k, type );
if ( hit is not null ) return hit;
}
}
return null;
}
/// <summary>
/// The fire mode, normalised through the enum.
///
/// ⚠️ IT IS A QUOTED STRING IN THE PREFAB (`"FiringType": "auto"`), which is why a
/// number scanner cannot see this axis at all. An ordinal is accepted too because
/// that is how an enum serialises if the writer ever changes, and everything goes
/// back out through `FiringType.ToString()` so a hand-edited prefab's typo becomes a
/// null the mode axis skips rather than a string that reaches a parse downstream.
/// </summary>
static string Mode( JsonElement si )
{
if ( !si.TryGetProperty( "FiringType", out var v ) ) return null;
if ( v.ValueKind == JsonValueKind.String )
return Enum.TryParse<FiringType>( v.GetString(), true, out var ft ) ? ft.ToString() : null;
if ( v.ValueKind == JsonValueKind.Number && v.TryGetInt32( out var i )
&& Enum.IsDefined( typeof( FiringType ), i ) )
return ((FiringType)i).ToString();
return null;
}
static float Num( JsonElement o, string key, float fallback )
=> o.TryGetProperty( key, out var v ) && v.ValueKind == JsonValueKind.Number
&& v.TryGetDouble( out var d ) ? (float)d : fallback;
static int Int( JsonElement o, string key, int fallback )
=> o.TryGetProperty( key, out var v ) && v.ValueKind == JsonValueKind.Number
&& v.TryGetInt32( out var i ) ? i : fallback;
// ── diagnostic ───────────────────────────────────────────────────────────
/// <summary>
/// Sample the pool: `nz_chimera [rolls]`.
///
/// ⛔ THIS IS NOT POLISH, IT IS THE ONLY FALSIFIER. The median Chimera roll is by
/// design WORSE than the gun it replaces, so "did the node work" cannot be answered by
/// feel — a bad roll and an unwired node are the same experience. The per-axis spread
/// and the donor names are how a human checks the eight axes are eight axes.
/// </summary>
[ConCmd( "nz_chimera" )]
public static void Sample( int rolls = 1 )
{
Invalidate();
var pool = Pool;
if ( pool.Count == 0 ) { Log.Info( "[chimera] no donors" ); return; }
Log.Info( $"[chimera] {pool.Count} donors — per-axis spread across the pool:" );
Span( pool, "clip", d => d.ClipSize > 0, d => d.ClipSize, "0" );
Span( pool, "damage", d => d.Damage > 0f, d => d.Damage, "0.#" );
Span( pool, "rpm", d => d.Rpm > 0, d => d.Rpm, "0" );
Span( pool, "falloff start", d => d.FalloffEnd > d.FalloffStart, d => d.FalloffStart, "0" );
Span( pool, "falloff end", d => d.FalloffEnd > d.FalloffStart, d => d.FalloffEnd, "0" );
Span( pool, "falloff mult", d => d.FalloffEnd > d.FalloffStart, d => d.FalloffMultiplier, "0.###" );
Span( pool, "recoil up", d => d.RecoilUp > 0f || d.SpreadAddHipFire > 0f, d => d.RecoilUp, "0.##" );
Span( pool, "hip spread", d => d.RecoilUp > 0f || d.SpreadAddHipFire > 0f, d => d.SpreadAddHipFire, "0.###" );
Span( pool, "reload", d => d.ReloadTime > 0f, d => d.ReloadTime, "0.###" );
Span( pool, "pellets", d => d.Bullets > 0, d => d.Bullets, "0" );
// ⚠️ THE MODE CENSUS, not a range — five tier-5 nodes write FiringType and this
// one draws it, and no other diagnostic in the project prints it at all.
Log.Info( " mode " + string.Join( ", ", pool
.Where( d => d.FiringMode is not null )
.GroupBy( d => d.FiringMode )
.OrderBy( g => g.Key )
.Select( g => $"{g.Key} x{g.Count()}" ) ) );
for ( var n = 0; n < Math.Clamp( rolls, 1, 20 ); n++ )
{
var sources = new Dictionary<string, string>();
var r = RollWith( sources );
if ( r is null ) return; // RollWith already said why
string From( string axis ) => sources.TryGetValue( axis, out var s ) ? s : "?";
Log.Info( $"[chimera] roll {n + 1}:"
+ $" clip {r.ClipSize} ({From( "clip" )})"
+ $" | dmg {r.Damage:0.#} ({From( "damage" )})"
+ $" | rpm {r.Rpm} ({From( "rpm" )})"
+ $" | {r.FiringMode} ({From( "mode" )})"
+ $" | pellets {r.Bullets} ({From( "pellets" )})" );
Log.Info( $" reload {r.ReloadTime:0.###}s ({From( "reload" )})"
+ $" | recoil x{r.RecoilVerticalMult:0.##}/x{r.RecoilHorizontalMult:0.##} kick {r.RecoilKick:0.##}"
+ $" auto {r.RecoilAutoControl:0.##} / hip {r.SpreadAddHipFire:0.###} ({From( "recoil" )})"
+ $" | range {r.FalloffStart:0}-{r.FalloffEnd:0}u x{r.FalloffMultiplier:0.###} ({From( "range" )})" );
// ⚠️ DPS AT POINT BLANK, which is where the player will judge it: damage x
// pellets x rounds per second, ignoring the clip and the reload. It is the one
// number that says whether this roll beat the gun it replaced.
Log.Info( $" point-blank dps {r.Damage * Math.Max( 1, r.Bullets ) * r.Rpm / 60f:0}" );
}
}
/// <summary>One axis' min and max across the donors that authored it.</summary>
static void Span( List<Donor> pool, string label, Func<Donor, bool> authored,
Func<Donor, float> value, string format )
{
var vals = pool.Where( authored ).Select( value ).ToList();
if ( vals.Count == 0 ) { Log.Info( $" {label,-13} nothing authored" ); return; }
Log.Info( $" {label,-13} {vals.Min().ToString( format )} to {vals.Max().ToString( format )}"
+ $" across {vals.Count} donor(s)" );
}
}