MapConfig and related small data classes for a zombie gamemode. Defines the saved per-map configuration: metadata (name, title, gamemode), lighting, spawn points, placeable objects (debris, barricades, lights, sounds, specials, bosses, etc.), room naming/zones, egg interactables and many tuneable settings; includes persistence (save/load/list/delete) using the engine FileSystem and simple caches for headers and listings, plus utility polygon tests.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
namespace NZombies;
/// <summary>
/// A named, saved setup for one map — spawns plus the two stat blocks.
///
/// This is the thing Creative builds and Survival plays, and it is the reason
/// the gamemode works on any map: the map ships geometry, the config ships
/// everything that makes it nZombies.
///
/// ⚠️ SAVED ONLY ON EXPLICIT SAVE. Nothing here autosaves. Editing in Creative
/// changes the in-memory copy; closing without pressing Save loses it. That is
/// deliberate — an autosaving map editor makes experimenting frightening.
///
/// Multiple configs per map, by name: configs/<map>/<name>.json
/// </summary>
public class MapConfig
{
/// <summary>Which map this was built for. Set on save, never edited by hand.</summary>
public string Map { get; set; } = "";
/// <summary>The config's own name — one map can have many.</summary>
public string Name { get; set; } = "default";
/// <summary>
/// The HUD's look for this config (`HudTheme`): blank is the ordinary HUD, "basalt" the carved one. A look only — every
/// element stays where the ordinary HUD puts it.
/// </summary>
public string HudTheme { get; set; } = "";
/// <summary>
/// The map's name as a player reads it, for this config: on its loading screen (`MapLoading`) and in the config list
/// (`ConfigBrowser`) — basalt's "Ignis Aeternus", Latin for "eternal fire" (2026-09-28). Blank: the map's own name, from
/// its manifest. ⚠️ NOT <see cref="Name"/>, which is the FILE's name — what `nz_load` asks for, and what `Save` sets.
/// </summary>
public string Title { get; set; } = "";
/// <summary>
/// A line of place under the map's name on the opening card (`IntroCard`) — basalt's "Beneath the Basalt" (2026-09-28).
/// Blank: the card has the name and the round alone.
/// </summary>
public string Location { get; set; } = "";
/// <summary>
/// What the published lobby's Gamemode list calls this config (`Gamemodes`, 2026-10-05). Each config a map ships is one
/// way to play it. Blank: its file's name, made readable ("default" reads "Default"). ⚠️ NOT <see cref="Title"/>, which
/// is the MAP's name in this config.
/// </summary>
public string Gamemode { get; set; } = "";
/// <summary>One line under <see cref="Gamemode"/> in that list: what this way of playing is. Blank: none.</summary>
public string GamemodeDescription { get; set; } = "";
/// <summary>How dark this map is, and whether its bounce is baked. See LightingSettings.</summary>
public LightingSettings Lighting { get; set; } = new();
public PlayerSettings Player { get; set; } = new();
public ZombieSettings Zombies { get; set; } = new();
/// <summary>How the nav mesh is generated for THIS map. See <see cref="NavSettings"/>.</summary>
public NavSettings Nav { get; set; } = new();
public GameplaySettings Gameplay { get; set; } = new();
public ArmorSettings Armor { get; set; } = new();
public SalvageSettings Salvage { get; set; } = new();
public PapSettings Pap { get; set; } = new();
public AmmoBoxSettings AmmoBox { get; set; } = new();
public List<SpawnPoint> PlayerSpawns { get; set; } = new();
public List<SpawnPoint> ZombieSpawns { get; set; } = new();
/// <summary>Where specials come from on their own rounds.
///
/// ⚠️ A SEPARATE LIST, not a flag on ZombieSpawns. A dog round replaces the
/// wave rather than joining it, and the original spawns dogs from their own
/// points inside the play space — reusing the walker's window spawns would
/// put hounds at the barricades they are specifically built to ignore.</summary>
public List<SpawnPoint> SpecialSpawns { get; set; } = new();
/// <summary>
/// Where bosses come from.
///
/// ⛔ A SEPARATE LIST AGAIN, AND THE ARGUMENT COMPOUNDS. `SpecialSpawns` is separate from
/// `ZombieSpawns` because a hound cannot use a window it is built to ignore; a boss cannot use
/// one at all - Brutus is 80 units tall with a 22-unit radius, so his spawn needs floor space a
/// walker's window does not have. Placing bosses on special points would put a boss in a
/// doorway and wedge him.
///
/// ⚠️ AND IT IS WHAT ENABLES BOSS ROUNDS AT ALL. With none placed there is nowhere to put a
/// boss, so the round type simply never fires - the same rule special rounds follow, and the
/// same reason the editor announces the first one placed.
/// </summary>
public List<SpawnPoint> BossSpawns { get; set; } = new();
/// <summary>Buyable barriers. What gates the map open.</summary>
public List<Debris> Debris { get; set; } = new();
/// <summary>
/// The rooms' names, one per flag: what the top left of the HUD says once a player walks through where one of that flag's
/// barriers stood (`RoomNames`, `RoomNameHud`). Flag 0 names where a game starts. Settings → Map → Room names, the debris
/// tool's "Room name" row, or `nz_room_name`.
///
/// ⚠️ PER FLAG, NOT ON EACH BARRIER: every barrier on a flag opens with it and leads into the same part of the map. Empty on
/// every config saved before it existed, which then simply shows no names.
/// </summary>
public List<RoomName> Rooms { get; set; } = new();
/// <summary>
/// Room zones — drawn volumes that name the part of the map they cover: walk into one and its name goes up at the top left
/// (`RoomZone`, the Room zone tool, `RoomNames`). ⚠️ THEY COME BEFORE THE DOORS: inside a zone it decides the name, and the
/// doors and the pads name a room only where no zone is drawn. Empty on every config saved before they existed.
/// </summary>
public List<RoomZone> RoomZones { get; set; } = new();
/// <summary>
/// Specials that trickle into ORDINARY rounds on their own schedule.
///
/// ⛔ A THIRD SPAWN SYSTEM, AND THE TWO THAT EXIST BOTH REFUSED THE JOB. Special rounds REPLACE
/// a round's contents and land on a fixed cadence; boss rounds are excluded from special rounds
/// entirely, so a napalm zombie on a 3-round cadence would silently lose every collision with
/// the 5-round pest horde — roughly one in five, with nothing in the log to say so.
///
/// ⚠️ IT ALSO KEEPS BOSS ROUNDS FREE. The Basalt easter egg ends in an actual boss, and having
/// the napalm zombie occupy the boss schedule would mean untangling them later.
/// </summary>
public List<AmbientSpecial> AmbientSpecials { get; set; } = new();
/// <summary>Hand-placed lights. See MapLight — the one placeable with a per-frame cost.</summary>
public List<MapLight> Lights { get; set; } = new();
/// <summary>Hand-placed looping sounds. See SoundSpot.</summary>
public List<SoundSpot> Sounds { get; set; } = new();
/// <summary>Drawn volumes of haze — see <see cref="FogArea"/>.</summary>
public List<FogArea> Fog { get; set; } = new();
/// <summary>Tuning for special rounds. See SpecialSettings.</summary>
public SpecialSettings Specials { get; set; } = new();
/// <summary>When bosses arrive. See BossSettings.</summary>
public BossSettings Bosses { get; set; } = new();
// ══ round-type questions — one author each ═════════════════════════
/// <summary>
/// Whether a given round is a special round.
///
/// ⛔ MOVED HERE FROM `RoundManager` SO THE BOSS SCHEDULE CAN ASK IT. It needs both the settings
/// AND the placed spawner count, which is exactly what this class holds and `BossSettings` does
/// not. `RoundManager.IsSpecialRound` now delegates, so there is still one author (§3).
///
/// ⚠️ IT REQUIRES A PLACED SPAWNER, and that is load-bearing rather than defensive. Without one
/// there is nowhere to put a hound, and a special round that spawns nothing never empties, so it
/// never ends. Falling back to a normal wave is the only safe answer.
/// </summary>
public bool IsSpecialRound( int round )
{
if ( !Specials.Enabled ) return false;
// ⚠️ THE MATCH'S "SPECIAL ROUNDS EVERY" (the lobby's Difficulty, 2026-10-05) when it has one, else the config's — from
// the config's first special round either way
var every = Difficulty.SpecialEvery( Specials.RoundInterval );
if ( every < 1 ) return false;
if ( round < Specials.FirstRound ) return false;
// ⛔ IT ASKS ABOUT THE SET THE ROUND WILL ACTUALLY DRAW FROM. This used to test
// `SpecialSpawns` unconditionally, which was right while every special round came from the
// special spawners — and became a silent trap the moment `UseZombieSpawns` existed: a map
// that fields fifty pests through its ORDINARY windows has no reason to place a single
// special spawner, and would have had its special round quietly never happen. Fully
// configured, no warning, no round.
if ( Specials.UseZombieSpawns
? ZombieSpawns.Count == 0
: SpecialSpawns.Count == 0 ) return false;
return (round - Specials.FirstRound) % every == 0;
}
/// <summary>
/// Does a boss arrive during this round.
///
/// ⛔ THERE IS NO BOSS ROUND — THIS IS A BOSS ARRIVING INSIDE AN ORDINARY ONE. The wave is not
/// replaced and nothing is announced as a boss round; he walks in at a spawner while the normal
/// round continues around him.
///
/// ⛔ AND NEVER DURING A SPECIAL ROUND. A dog round is already the round's event, and its wave
/// draws from a spawn list a boss has no place in. A due round that collides is SKIPPED rather
/// than deferred, so the interval keeps meaning what it says — `BossPreview` shows the result and
/// `BossNeverArrives` catches the case where every due round collides.
///
/// ⚠️ A PLACED BOSS SPAWNER IS REQUIRED, for the same reason a special round needs one: the
/// schedule can say yes and there is still nowhere to put him.
/// </summary>
public bool BossArrivesOn( int round )
=> Bosses.DueOn( round )
&& !IsSpecialRound( round )
&& BossSpawns.Count > 0;
/// <summary>
/// The next few rounds a boss actually arrives in, and how many.
///
/// ⚠️ IT RUNS THE REAL `BossArrivesOn`, so a settings panel cannot drift from the game —
/// including the special-round skips, which are invisible in the fields.
/// </summary>
public string BossPreview( int take = 6 )
{
if ( !Bosses.Enabled ) return "off";
if ( BossSpawns.Count == 0 ) return "no boss spawners placed";
var outp = new List<string>();
for ( var r = 1; r <= 200 && outp.Count < take; r++ )
{
if ( !BossArrivesOn( r ) ) continue;
var n = Bosses.CountForRound( r );
outp.Add( n > 1 ? $"{r} (x{n})" : r.ToString() );
}
return outp.Count == 0
? "NEVER — every due round is a special round"
: string.Join( ", ", outp ) + "...";
}
/// <summary>
/// True when the two schedules collide so badly that a boss can never arrive.
///
/// ⛔ AN EASY TRAP TO SET BY ACCIDENT. Bosses every 5 from round 10 against specials every 5
/// from round 5: every due round is a dog round, so the boss settings look perfectly reasonable
/// and no boss ever spawns. Both intervals are values a mapper would plausibly choose.
/// </summary>
public bool BossNeverArrives()
{
if ( !Bosses.Enabled || BossSpawns.Count == 0 ) return false;
for ( var r = 1; r <= 200; r++ )
if ( BossArrivesOn( r ) ) return false;
return true;
}
/// <summary>Boarded barricades — a thin wall zombies vault. See Barricade.</summary>
public List<BarricadeSpot> Barricades { get; set; } = new();
/// <summary>Hand-authored shortcuts through the navmesh — drops, jumps,
/// one-way routes. See NavLinkSpot.</summary>
public List<NavLinkSpot> NavLinks { get; set; } = new();
/// <summary>Mystery box locations. See MysteryBoxSpot.</summary>
public List<MysteryBoxSpot> Boxes { get; set; } = new();
/// <summary>
/// Which weapon packs the box can roll from. EMPTY MEANS ALL.
///
/// ⚠️ Empty-means-all rather than defaulting to a list of pack names: a map
/// authored before a new pack was imported would otherwise silently exclude
/// it, and the failure would look like bad luck rather than a stale config.
/// </summary>
public List<string> BoxPacks { get; set; } = new();
/// <summary>
/// Pack-a-Punch machines.
///
/// ⚠️ A LIST even though a map almost always has one. Some do have two (and
/// the original places them as ordinary machine entities, with no singleton
/// anywhere), so the plural costs nothing now and avoids a schema change on
/// the first map that wants a second.
/// </summary>
public List<PackAPunchSpot> PackAPunches { get; set; } = new();
/// <summary>Der Wunderfizz machines — random perk for a fixed price.</summary>
public List<WunderfizzSpot> Wunderfizzes { get; set; } = new();
/// <summary>Perk machines — ONE named perk each. See <see cref="PerkMachineSpot"/>.</summary>
public List<PerkMachineSpot> PerkMachines { get; set; } = new();
/// <summary>Teleporter pads — a source and where it sends you. See <see cref="TeleporterSpot"/>.</summary>
public List<TeleporterSpot> Teleporters { get; set; } = new();
/// <summary>Springboards — invisible pads that fling a player straight up. See <see cref="SpringboardSpot"/>.</summary>
public List<SpringboardSpot> Springboards { get; set; } = new();
/// <summary>Soul boxes — kill zombies nearby to fill them. See <see cref="SoulBoxSpot"/>.</summary>
public List<SoulBoxSpot> SoulBoxes { get; set; } = new();
/// <summary>Where the ammo boxes stand. See <see cref="AmmoBoxSpot"/>.</summary>
public List<AmmoBoxSpot> AmmoBoxes { get; set; } = new();
/// <summary>Buyable endings — walk up, pay, the run is over.</summary>
public List<EndingSpot> Endings { get; set; } = new();
/// <summary>Where the trading tables stand. See <see cref="TradeTableSpot"/>.</summary>
public List<TradeTableSpot> TradeTables { get; set; } = new();
/// <summary>Where the building tables stand. See `BuildTableManager`.</summary>
public List<BuildTableSpot> BuildTables { get; set; } = new();
/// <summary>Where the buildable's pieces lie. See `BuildPartManager`.</summary>
public List<BuildPartSpot> BuildParts { get; set; } = new();
/// <summary>Arsenal machines — armor tiers now, weapon tech and ammo mods
/// later. See ARSENAL_REMAKE.md.</summary>
public List<ArsenalSpot> Arsenals { get; set; } = new();
/// <summary>Wall-mounted weapon buys.
///
/// ⛔ THESE HAD NO CONFIG LIST AT ALL. `WallBuyManager.All` read live
/// components straight out of the SCENE, so a wallbuy existed only for as
/// long as the session did — placed, played, and gone on the next load, while
/// every other placeable round it saved. Reported as "wall buys are not being
/// saved in a config", which is exactly what it was.</summary>
public List<WallBuySpot> WallBuys { get; set; } = new();
/// <summary>
/// Invisible walls. Solid forever, bought by nobody, ignored by the navmesh.
///
/// ⚠️ A SEPARATE LIST FROM Debris, not a flag on it. They are drawn the same
/// way and share the same footprint geometry, but every OTHER thing about a
/// barrier — price, flag, power gate, the buy prompt, the nav blocker, being
/// destroyed when bought — is exactly what an invisible wall must not have.
/// Expressing that as `Price = -1 && Link = "" && !BlocksNav` would put five
/// special cases through the buying and gating code rather than none.
/// </summary>
public List<InvisibleWall> InvisibleWalls { get; set; } = new();
/// <summary>Damage walls — same footprint tool, no collision, hurts anyone inside.</summary>
public List<DamageWall> DamageWalls { get; set; } = new();
/// <summary>Misery acceleration devices — toggle the run into overdrive.</summary>
public List<MiserySpot> Miseries { get; set; } = new();
/// <summary>Easter-egg clues — text or an image on a wall. No gameplay logic.</summary>
public List<ClueSpot> Clues { get; set; } = new();
/// <summary>
/// Basalt seal 1's hex slots — hexagons of light on the walls, each showing a number and a colour that are rolled again
/// every round, not stored. See `HexSlotManager`.
/// </summary>
public List<HexSlotSpot> HexSlots { get; set; } = new();
/// <summary>Easter-egg pressables — buttons and levers in the flag system.</summary>
public List<PressableSpot> Pressables { get; set; } = new();
/// <summary>Easter-egg shootables — symbols and weak points triggered by a bullet.</summary>
public List<ShootableSpot> Shootables { get; set; } = new();
/// <summary>
/// Power switches. Turning one on powers the whole map.
///
/// ⚠️ AN EMPTY LIST MEANS POWER IS ALREADY ON. A map with no switch placed is
/// a map where nothing should be gated behind one — see Power.IsOn. This is
/// why the state is DERIVED from this list rather than stored: adding the
/// first switch to a map turns the power off by itself, and removing the last
/// one turns it back on, with no separate flag to keep in sync.
/// </summary>
public List<PowerSwitch> PowerSwitches { get; set; } = new();
// ── storage ──────────────────────────────────────────────────────────────
const string ROOT = "configs";
/// <summary>configs/<map>/<name>.json — sanitised, because a map
/// ident contains dots and a config name is typed by a person.</summary>
static string PathFor( string map, string name )
=> $"{ROOT}/{Sanitise( map )}/{Sanitise( name )}.json";
/// <summary>
/// Strip anything that is not safe in a filename.
///
/// ⚠️ Not cosmetic. A map ident looks like "facepunch.flatgrass" and a
/// config name is free text, so without this a name containing / or .. can
/// write outside the config folder.
/// </summary>
static string Sanitise( string s )
{
if ( string.IsNullOrWhiteSpace( s ) ) return "unnamed";
var clean = new string( s.Trim()
.Select( c => char.IsLetterOrDigit( c ) || c == '_' || c == '-' ? c : '_' )
.ToArray() );
return string.IsNullOrWhiteSpace( clean ) ? "unnamed" : clean.ToLowerInvariant();
}
public void Save( string map, string name )
{
Map = map;
Name = name;
var dir = $"{ROOT}/{Sanitise( map )}";
if ( !FileSystem.Data.DirectoryExists( dir ) )
FileSystem.Data.CreateDirectory( dir );
FileSystem.Data.WriteJson( PathFor( map, name ), this );
_titles.Remove( PathFor( map, name ) ); // the config list reads its title afresh (`TitleOf`)
_lists.Clear(); // and its folder (`ListFor`)
Log.Info( $"[nz] saved config '{name}' for {map} "
+ $"({PlayerSpawns.Count} player / {ZombieSpawns.Count} zombie spawns)" );
}
/// <summary>
/// Where configs are read from, in order, the first that has the file winning: the player's own (`FileSystem.Data`),
/// then what ships.
///
/// ⛔ IN A PUBLISHED COPY, WHAT SHIPS ALONE (`Edition`, 2026-10-05). It cannot save a config any more, so a file in its
/// Data is a leftover from a build that could, and Data winning by name would put that stale copy in place of the
/// gamemode this build ships: the trap INSTRUCTIONS calls "CHANGING A DEFAULT DOES NOTHING WHEN A SAVED CONFIG ALREADY
/// HOLDS THE OLD VALUE". `shippedOnly` asks the same question in the editor, for the Gamemode list.
/// </summary>
static BaseFileSystem[] Readers( bool shippedOnly = false )
=> shippedOnly || Edition.IsPublished
? new[] { FileSystem.Mounted }
: new[] { FileSystem.Data, FileSystem.Mounted };
/// <summary>The reader that has this file, by <see cref="Readers"/>' order, or null.</summary>
static BaseFileSystem ReaderOf( string path, bool shippedOnly = false )
=> Readers( shippedOnly ).FirstOrDefault( fs => fs is not null && fs.FileExists( path ) );
/// <summary>Load a config, or null if it isn't there. Null rather than a
/// blank default so the caller can tell "missing" from "empty".</summary>
public static MapConfig Load( string map, string name, bool shippedOnly = false )
{
var path = PathFor( map, name );
// ⚠️ Data (per-user, writable) WINS over Mounted (the shipped assets): a mapper's
// own saved edits must override the config we ship. Fall back to Mounted so a fresh
// install — and every PUBLISHED copy, which carries no user data at all — still finds
// the original maps. This is why published maps came up empty: configs were read only
// from Data. Mounted is the same read-only reader WeaponLibrary/MapLibrary use for
// shipped data, and `configs/` under Assets/ is its mount root.
// ⛔ EXCEPT IN A PUBLISHED COPY, which reads Mounted alone now — see `Readers`.
var fs = ReaderOf( path, shippedOnly );
if ( fs is null )
{
Log.Warning( $"[nz] no config '{name}' for {map}" );
return null;
}
var cfg = fs.ReadJson<MapConfig>( path );
Log.Info( $"[nz] loaded config '{name}' for {map} "
+ $"({cfg?.PlayerSpawns.Count ?? 0} player / "
+ $"{cfg?.ZombieSpawns.Count ?? 0} zombie spawns) "
+ $"[{(fs == FileSystem.Data ? "user" : "shipped")}]" );
return cfg;
}
/// <summary>
/// A saved config's <see cref="Title"/>, blank if it has none — for the config list, which shows file names (2026-09-28).
/// ⚠️ READ ONCE PER FILE AND KEPT — `Save` forgets its own — so the list can ask on every frame it draws.
/// </summary>
public static string TitleOf( string map, string name )
{
var path = PathFor( map, name );
if ( _titles.TryGetValue( path, out var title ) ) return title;
var fs = ReaderOf( path );
try { title = fs?.ReadJson<TitleOnly>( path )?.Title?.Trim() ?? ""; }
catch ( Exception ) { title = ""; }
_titles[path] = title;
return title;
}
/// <summary>⚠️ A STATIC CACHE, WHICH A HOTLOAD KEEPS (INSTRUCTIONS §1) — harmless here: it holds saved files' titles, and a
/// save drops its own.</summary>
static readonly Dictionary<string, string> _titles = new();
/// <summary>A config file read for its title alone.</summary>
public class TitleOnly
{
public string Title { get; set; }
}
/// <summary>
/// A SHIPPED config's <see cref="Gamemode"/> and <see cref="GamemodeDescription"/>, blank where it has none: for the
/// Gamemode list (`Gamemodes.For`), which lists only what ships. ⚠️ READ ONCE PER FILE AND KEPT, as <see cref="TitleOf"/>
/// is, because the lobby asks on every redraw.
/// </summary>
public static (string Name, string Description) GamemodeOf( string map, string name )
{
var path = PathFor( map, name );
if ( _gamemodes.TryGetValue( path, out var known ) ) return known;
var found = (Name: "", Description: "");
try
{
var h = ReaderOf( path, shippedOnly: true )?.ReadJson<GamemodeOnly>( path );
found = (h?.Gamemode?.Trim() ?? "", h?.GamemodeDescription?.Trim() ?? "");
}
catch ( Exception ) { }
_gamemodes[path] = found;
return found;
}
/// <summary>⚠️ A STATIC CACHE, like <see cref="_titles"/>: a file's gamemode name stays as first read until
/// <see cref="ForgetHeaders"/> or a restart.</summary>
static readonly Dictionary<string, (string Name, string Description)> _gamemodes = new();
/// <summary>A config file read for its gamemode name and line alone.</summary>
public class GamemodeOnly
{
public string Gamemode { get; set; }
public string GamemodeDescription { get; set; }
}
/// <summary>Forget every title and gamemode name read so far: `nz_side` changes which files are read.</summary>
public static void ForgetHeaders()
{
_titles.Clear();
_gamemodes.Clear();
_lists.Clear();
}
/// <summary>
/// Every config saved for a map, by name. Drives the load list.
///
/// ⚠️ `shippedOnly` lists what a published copy offers: the Gamemode list asks that even in the editor.
/// </summary>
public static List<string> ListFor( string map, bool shippedOnly = false )
{
var dir = $"{ROOT}/{Sanitise( map )}";
// ⛔ READ ONCE AND KEPT IN A PUBLISHED COPY (2026-10-05). There every `DirectoryExists` and `FindFile` goes through the
// downloaded package, which is slow: Map select asked this for each of the 20 maps, three times per redraw (the preview,
// the empty check, the rows), and redrew every second and on every hover. The published page fell to 8 fps, `ui` 101 ms
// a frame in the client's own probe, while the editor, reading its own disk, never showed it. A published copy's files
// never change, so the first answer stands. The editor reads afresh every time: its configs are being saved. A copy goes
// out, so no caller can change the kept list.
var cacheKey = (shippedOnly ? "shipped|" : "all|") + dir;
if ( Edition.IsPublished && _lists.TryGetValue( cacheKey, out var known ) ) return new List<string>( known );
// ⚠️ UNION of shipped (Mounted) and the player's own (Data), each name once, so the
// load menu shows the original maps on a published copy AND anything saved locally.
// Data-only was why a fresh/published game's menu was empty. Load() still lets Data
// win by name; this only decides what is OFFERED.
// ⛔ A PUBLISHED COPY LISTS MOUNTED ALONE NOW (`Readers`), so it offers exactly the gamemodes it ships.
var names = new HashSet<string>( System.StringComparer.OrdinalIgnoreCase );
foreach ( var fs in Readers( shippedOnly ) )
{
if ( fs is null || !fs.DirectoryExists( dir ) ) continue;
foreach ( var f in fs.FindFile( dir, "*.json" ) )
names.Add( f.Replace( ".json", "" ) );
}
var list = names.OrderBy( f => f ).ToList();
if ( Edition.IsPublished ) _lists[cacheKey] = list;
return new List<string>( list );
}
/// <summary>⚠️ A STATIC CACHE (<see cref="ListFor"/>): the config folders a published copy has listed, by reader and folder.</summary>
static readonly Dictionary<string, List<string>> _lists = new();
public static bool Delete( string map, string name )
{
var path = PathFor( map, name );
if ( !FileSystem.Data.FileExists( path ) ) return false;
FileSystem.Data.DeleteFile( path );
Log.Info( $"[nz] deleted config '{name}' for {map}" );
return true;
}
}
/// <summary>A placed spawn point. Angle matters — you should face into the
/// room you spawn in, not at the wall behind you.</summary>
public class SpawnPoint
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
// ── eligibility (zombie spawns only) ─────────────────────────────────────
//
// Player spawns carry these too and ignore them. One class rather than two
// keeps the save format flat and means a player spawn could be gated later
// without a migration — the original does gate them by round.
/// <summary>
/// Flag that unlocks this spawn. Blank = unlinked, always eligible.
///
/// ⚠️ This is the point of the whole doors system: a spawn behind a locked
/// door must not feed the horde until that door is bought.
///
/// ⚠️ A STRING, and it used to be an int — see LinkConverter, which keeps
/// configs saved before the change loading.
/// </summary>
[JsonConverter( typeof( LinkConverter ) )]
public string Link { get; set; } = DoorLinks.Unlinked;
/// <summary>Alternate flags — a spawn between two areas wakes on EITHER.
/// (nz_spawn_zombie.lua checks link/link2/link3 with `or`.)</summary>
[JsonConverter( typeof( LinkConverter ) )]
public string Link2 { get; set; } = DoorLinks.Unlinked;
[JsonConverter( typeof( LinkConverter ) )]
public string Link3 { get; set; } = DoorLinks.Unlinked;
/// <summary>Only eligible once the power is on. Not implemented yet — there
/// is no power system — so this currently reads as "never eligible" and is
/// left false by the tool.</summary>
public bool RequiresPower { get; set; }
/// <summary>Earliest round this spawn may be used. 0/1 = from the start.
/// (spawn:GetActiveRound())</summary>
public int ActiveRound { get; set; }
/// <summary>Which special this point spawns. SPECIAL SPAWNS ONLY — player and
/// zombie spawns carry it and ignore it, the same way they already carry Link
/// and ActiveRound, which keeps the save format flat.
///
/// ⚠️ Blank means "the first special in the roster" rather than "nothing", so
/// a point placed before any choice was made still works. An id that no longer
/// exists falls back the same way — see SpecialEnemies.Fallback. A special
/// spawner that silently contributes nothing to its round is far harder to
/// notice than one spawning the wrong thing.</summary>
public string Special { get; set; } = "";
public SpawnPoint() { }
public SpawnPoint( Vector3 position, float yaw )
{
Position = position;
Yaw = yaw;
}
public Rotation Rotation => Rotation.FromYaw( Yaw );
/// <summary>
/// Can this spawn be used right now?
///
/// Mirrors the gate in nz_spawn_zombie.lua:256-262 — link check, power
/// check, active-round check.
/// </summary>
public bool IsEligible( int round, bool powerOn )
{
if ( !DoorLinks.AnyOpen( Link, Link2, Link3 ) ) return false;
if ( RequiresPower && !powerOn ) return false;
if ( ActiveRound > 0 && round < ActiveRound ) return false;
return true;
}
/// <summary>Why it is not eligible, for the spawn diagnostics. Empty when
/// it is.</summary>
public string Blocker( int round, bool powerOn )
{
if ( !DoorLinks.AnyOpen( Link, Link2, Link3 ) )
return $"link {Link} closed";
if ( RequiresPower && !powerOn ) return "needs power";
if ( ActiveRound > 0 && round < ActiveRound ) return $"round < {ActiveRound}";
return "";
}
}
/// <summary>
/// DEBRIS — a buyable barrier. Pay points, it disappears, its link opens.
///
/// ⚠️ Debris and doors are the SAME THING in the original: a buyable entity
/// carrying price / elec / link (sh_tools_door.lua:197-199). Doors are map
/// entities (func_door); debris is a placed prop (prop_buys). We run on
/// arbitrary maps with no wired map doors, so the placed prop is the primitive
/// that actually works everywhere — a map's own doors can be adopted later
/// using the same three fields.
/// </summary>
public class Debris
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>What it looks like. Any prop model. Ignored when Size is set.</summary>
public string Model { get; set; } = "models/dev/box.vmdl";
/// <summary>
/// Box dimensions, when this barrier is a BLOCK rather than a prop.
/// Zero = use Model instead.
///
/// A block is drawn as a scaled cube, so a barrier can be built to fill an
/// arbitrary doorway — which is what the map actually needs. A prop only
/// happens to fit if the map has a prop the right shape.
/// </summary>
public Vector3 Size { get; set; }
/// <summary>True when this is a built block rather than a placed prop.</summary>
public bool IsBlock => !Size.IsNearZeroLength && Size.x > 0.1f;
/// <summary>
/// The drawn footprint, in the barrier's own space (XY, centred on Position).
///
/// ⛔ THIS IS THE SHAPE. `Size` remains the bounding box — nav rebuilding and
/// the remove radius still want one — but when this has three or more points
/// the barrier is built as a prism following them exactly, dents and all.
///
/// ⚠️ Empty on every barrier authored before footprints existed, and those
/// still build from `Size` as a plain box. Old configs keep working and old
/// barriers keep their shape; there is nothing to migrate.
/// </summary>
public List<Vector2> Footprint { get; set; } = new();
/// <summary>True when there is a real polygon to extrude.</summary>
public bool HasFootprint => Footprint is { Count: >= 3 };
/// <summary>
/// Is a world point inside this barrier?
///
/// ⛔ ASKED OF THE CONFIG, NOT OF THE SCENE, AND THAT IS THE POINT. The obvious implementation
/// is to trace and see what you hit — but a barrier that has already been BOUGHT has no
/// GameObject left, so a trace answers "no barrier here" for every door the player has opened.
/// Anything generated from that answer is correct only for the state the map happened to be in,
/// which is the worst kind of wrong: it works when you test it and not when someone plays.
///
/// ⚠️ THE PRISM RUNS FROM THE CENTRE, matching `DebrisManager`'s own half-height drop — the
/// footprint mesh is built from z=0 up and then lowered, so the authored `Position` is the
/// middle of the barrier and not its floor.
///
/// ⚠️ THIS IS THE THIRD POINT-IN-POLYGON IN THE PROJECT — `DamageWallVolume` and
/// `FogAreaManager` each carry their own. It lives here because containment is a fact about a
/// barrier rather than about whoever is asking; whoever next touches one of the other two should
/// pull all three out.
/// </summary>
public bool Contains( Vector3 world, float pad = 0f )
{
var half = ( IsBlock ? Size : new Vector3( 64f ) ) * 0.5f + new Vector3( pad );
if ( world.z < Position.z - half.z || world.z > Position.z + half.z ) return false;
// Into the barrier's own space, so Yaw is handled once and everything below is axis-aligned.
var local = Rotation.FromYaw( -Yaw ) * ( world - Position );
if ( !HasFootprint )
return MathF.Abs( local.x ) <= half.x && MathF.Abs( local.y ) <= half.y;
// ⚠️ THE FOOTPRINT IS ALREADY IN THE BARRIER'S SPACE, centred on Position — see its own
// remarks — so it is tested against the LOCAL point, not the world one.
var pt = new Vector2( local.x, local.y );
bool inside = false;
for ( int i = 0, j = Footprint.Count - 1; i < Footprint.Count; j = i++ )
{
var a = Footprint[i];
var b = Footprint[j];
if ( a.y > pt.y != b.y > pt.y
&& pt.x < ( b.x - a.x ) * ( pt.y - a.y ) / ( b.y - a.y ) + a.x )
inside = !inside;
}
return inside;
}
/// <summary>
/// Surface material. Empty = untextured, tinted by Tint.
///
/// ⚠️ A flat-tinted block reads as a dev placeholder no matter how well the
/// colour is chosen — it is the ABSENCE of texture the eye picks up, not the
/// hue. A real surface is what makes a barrier look like part of the map.
/// </summary>
public string Material { get; set; } = "materials/concrete/concretewall052a.vmat";
/// <summary>
/// Tint, applied on top of Material. White leaves the material alone.
///
/// ⚠️ Defaults to WHITE now there is a material. It used to be a brown, which
/// would multiply into the texture and muddy it.
/// </summary>
public Color Tint { get; set; } = Color.White;
/// <summary>
/// Draw it, or leave it solid-but-unseen.
/// ⚠️ DEFAULTS TO TRUE — the opposite of InvisibleWall, and deliberately. A
/// barrier is a thing the player is meant to SEE, walk up to and buy; one that is
/// invisible by default would be a debris block nobody can find. Every barrier placed
/// before this field existed deserializes to true and is unchanged.
///
/// ⚠️ RENDERING ONLY. The collider is built either way, exactly as
/// InvisibleWall.Visible works — an invisible barrier still blocks, still costs
/// points and still opens. Turning this off is for a barrier the MAP already draws:
/// a real door prop, a pile of crates that is part of the geometry, a gap the player
/// should read as impassable without a grey slab explaining it.
/// </summary>
public bool Visible { get; set; } = true;
/// <summary>Cost in points. (Settings default: price=1000)</summary>
public int Price { get; set; } = 1000;
/// <summary>The flag this opens when bought. ⚠️ Must be non-blank to do
/// anything — an unlinked barrier is "already open" and would gate
/// nothing.</summary>
[JsonConverter( typeof( LinkConverter ) )]
public string Link { get; set; } = "1";
/// <summary>
/// Needs the power on before it can be bought. (flags: elec)
///
/// ⚠️ COMBINED WITH Price = 0 THIS MEANS "OPENS ITSELF WHEN THE POWER COMES
/// ON" — not "free to buy". That is how a map author makes the power switch
/// physically open a route rather than merely permit a purchase. See
/// PowerManager.OpenFreeDoors.
/// </summary>
public bool RequiresPower { get; set; }
/// <summary>
/// Can a player buy this barrier at all? (Settings default: on)
///
/// ⛔ OFF IS NOT "FREE" AND NOT "ALREADY OPEN". The barrier is still solid
/// and still gates its flag — it simply has no offer. No HUD prompt, and the
/// use key passes over it as though it were scenery. The only thing that
/// opens it is buying a DIFFERENT barrier carrying the same <see cref="Link"/>,
/// which takes the whole group with it through DebrisManager.OpenAllOnLink.
///
/// ⚠️ THAT IS THE POINT. A route walled off at both ends should be bought
/// from ONE side, not quote a price at whichever end you walk up to — and a
/// player who pays at the far end has bought a wall with nothing behind it.
///
/// ⚠️ Price and RequiresPower are still stored and still meaningless here.
/// Turning this back on restores whatever they were, rather than making the
/// author re-enter them.
///
/// ⚠️ Defaults TRUE so every barrier authored before this existed keeps its
/// offer. There is nothing to migrate.
/// </summary>
public bool Buyable { get; set; } = true;
public Rotation Rotation => Rotation.FromYaw( Yaw );
}
/// <summary>
/// A ROOM'S NAME, for a flag (`MapConfig.Rooms`): what the top left of the HUD says once a player walks through where a barrier
/// carrying the flag stood (`RoomNames`).
/// </summary>
public class RoomName
{
/// <summary>The flag. Blank — or "0" — is where a game starts.</summary>
[JsonConverter( typeof( LinkConverter ) )]
public string Link { get; set; } = "";
/// <summary>What the HUD says, as typed.</summary>
public string Name { get; set; } = "";
/// <summary>
/// A line under the name, shown the first time each player walks into the room this game (`RoomNameHud`) — *"only the
/// first time each player enters a room"* (2026-09-27). Blank: the name alone. A zone called what this room is called
/// shows it too (`RoomNames.SubtitleOf`).
/// </summary>
public string Subtitle { get; set; } = "";
/// <summary>
/// What waits inside: an enemy (`SpecialEnemies` — "pest", "hellhound"…) at each of the flag's special spawns, the first time
/// the flag opens in a game (`FlagAmbush`). Blank: nothing. Basalt's Reliquary holds pests — *"the player opens up the
/// reliquary and there are pests inside"* (2026-09-28).
/// </summary>
public string Ambush { get; set; } = "";
}
/// <summary>
/// A ROOM ZONE — a drawn volume with a name: walk into it and the top left of the HUD says that name (`RoomNames`). Drawn as a
/// fog area or a wall is — corners on the floor, then a height click (`MapEditor.AddRoomZone`) — and, like the damage wall,
/// with no body: nothing collides with it, nothing paths around it, and nothing draws it in a round.
///
/// ⚠️ NOT THE EASTER EGG'S "ZONE". `Docs/EASTER_EGG_TOOLSET.md` has a Zone of its own — an area a step asks players to hold or
/// kill in — which is still a placeholder in the dev menu. This one only names a space.
/// </summary>
public class RoomZone
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>Bounding box of the drawn shape. Its height is the extrusion, around a middle at <see cref="Position"/>.</summary>
public Vector3 Size { get; set; }
/// <summary>The drawn outline, in the zone's own space — the walls' format and builder.</summary>
public List<Vector2> Footprint { get; set; } = new();
public bool HasFootprint => Footprint is { Count: >= 3 };
/// <summary>What the top left says inside it. Blank, and it names nothing: walking into it changes nothing.</summary>
public string Name { get; set; } = "";
/// <summary>
/// A line under the name on each player's first visit this game, as a flag's room has (`RoomName.Subtitle`). A flag's
/// room of the same name speaks first; this is for a zone whose name no flag has (basalt's "Lava Bridge").
/// </summary>
public string Subtitle { get; set; } = "";
public Rotation Rotation => Rotation.FromYaw( Yaw );
/// <summary>
/// Is a world point inside the zone? The damage wall's test (`DamageWallVolume.Contains`): the point turned into the zone's
/// own space, the height centred on <see cref="Position"/>, the outline tested by <see cref="Footprints.Contains"/>.
/// </summary>
public bool Contains( Vector3 world )
{
var local = Rotation.Inverse * (world - Position);
var half = Size.z * 0.5f;
if ( local.z < -half || local.z > half ) return false;
if ( HasFootprint ) return Footprints.Contains( new Vector2( local.x, local.y ), Footprint );
return MathF.Abs( local.x ) <= Size.x * 0.5f && MathF.Abs( local.y ) <= Size.y * 0.5f;
}
}
/// <summary>
/// Is a point inside a drawn outline? Crossing-number point-in-polygon, right for the concave shapes the corner tools allow.
///
/// ⚠️ THE FIRST COPY ANYTHING ELSE CAN CALL. `Debris.Contains`, `DamageWallVolume.InPolygon` and `FogAreaManager` each keep a
/// private one; the room zone uses this, and they should too the next time one of them is touched.
/// </summary>
public static class Footprints
{
public static bool Contains( Vector2 pt, IReadOnlyList<Vector2> poly )
{
if ( poly is null || poly.Count < 3 ) return false;
var inside = false;
for ( int i = 0, j = poly.Count - 1; i < poly.Count; j = i++ )
{
Vector2 a = poly[i], b = poly[j];
if ( (a.y > pt.y) != (b.y > pt.y) && pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x )
inside = !inside;
}
return inside;
}
}
/// <summary>
/// AN INVISIBLE WALL — geometry the player cannot cross and cannot see.
///
/// Built exactly like a debris block (corners on the floor, then a height
/// click, extruded through <see cref="DebrisMesh"/>), and deliberately nothing
/// like one after that:
///
/// | | Debris | Invisible wall |
/// |---|---|---|
/// | buyable | yes, price + flag | **never** |
/// | collision | until bought | **always** |
/// | navmesh | blocked | **untouched** |
/// | visible | yes | **your choice** |
///
/// ⚠️ NOT BLOCKING THE NAVMESH IS THE POINT, not an omission. This is for
/// fencing a PLAYER out of somewhere the map lets them reach — a rooftop, a
/// skybox gap, the far side of a fence — while zombies keep pathing across the
/// same ground as if it were open. A nav blocker here would carve holes in the
/// mesh at exactly the map edges where the horde needs to walk.
/// </summary>
/// <summary>
/// A DAMAGE WALL — the invisible wall's twin. Drawn the same way, from the same
/// corners, with the same footprint; the difference is that it has NO COLLISION and
/// hurts any player standing in it.
///
/// ⚠️ IT IS NOT AN `InvisibleWall` WITH A FLAG. A shared class would mean every
/// invisible wall in every saved config gained damage fields it never uses, and one
/// wrong default would turn a map boundary into a killbox. Two lists, two managers,
/// one shape builder.
/// </summary>
/// <summary>
/// A MISERY ACCELERATION DEVICE. Interact to toggle; the state itself is a static on
/// `MiseryDevice`, not a field here — it is a property of the RUN, and saving it would
/// let a map ship already miserable.
/// </summary>
/// <summary>
/// THE SHARED CONFIG OF AN EASTER-EGG INTERACTABLE — where it is, what step it belongs to,
/// and every condition the spec gives to more than one kind of them.
///
/// ⛔ A BASE CLASS BECAUSE THE SPEC KEEPS SAYING "same conditions as Pressable". Shootable,
/// Building Table, Lockpad and Zone are all defined by reference to it. A second copy of the
/// repeat count, the retry rule, the time window and the round reset would be four more places
/// for those to drift apart.
///
/// ⚠️ EACH SUBCLASS ADDS ONLY WHAT IS TRULY ITS OWN — the pressable's hold, the shootable's
/// Pack-a-Punch requirement.
/// </summary>
public abstract class EggSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>The surface normal here, so it mounts flat to a wall.</summary>
public Vector3 Normal { get; set; } = Vector3.Up;
/// <summary>Size of the placeholder box, in units. A real prop comes later.</summary>
public Vector3 Size { get; set; } = new( 12f, 20f, 20f );
/// <summary>
/// GENERAL-flag gate — the door link every other placeable in this project carries under
/// the same "Flag" row. Blank or "0" = always there.
///
/// ⛔ THE DOOR POOL, NOT THE EGG POOL, AND AN INTERACTABLE NEEDS BOTH. `Step.Required`
/// asks "has the easter egg reached this step"; this asks "does this thing exist yet at
/// all". Something in a room behind a 1000-point door must not be usable through the wall
/// before the door is bought, and that has nothing to do with the egg's progression.
///
/// ⚠️ DELIBERATELY OUTSIDE `EggStep.IsAvailable`. `Docs/EASTER_EGG_TOOLSET.md` is explicit
/// that General flags are not a valid Required/Excluded input — "no shared IDs, no implicit
/// crossover" — so this is read at the INTERACTION layer beside the perk and weapon checks,
/// exactly where `Arsenal` and `Wunderfizz` read theirs.
/// </summary>
public string Link { get; set; } = "";
/// <summary>The flag baseline — Required / Reward / Excluded / StepNumber / OnFail.</summary>
public EggStep Step { get; set; } = new();
// ── conditions ──────────────────────────────────────────────────────────
/// <summary>Must be triggered EXACTLY this many times. 0 = a single trigger completes it.
///
/// ⚠️ OVERSHOOTING INVALIDATES THE ATTEMPT — the spec is explicit that it "does not cap
/// at max". Triggering a 3-count target four times fails it rather than sitting at 3.</summary>
public int RepeatCount { get; set; }
/// <summary>Whether a failed attempt is locked out by a clock or by the round turn.
/// <see cref="CooldownSeconds"/> only means anything under <see cref="EggRetry.Seconds"/>.</summary>
public EggRetry Retry { get; set; } = EggRetry.Seconds;
/// <summary>Retry delay after a FAILED attempt, in seconds. 0 = retry at once. Ignored
/// unless <see cref="Retry"/> is <see cref="EggRetry.Seconds"/>.
///
/// ⚠️ AFTER A FAILURE, NOT AFTER EVERY TRIGGER. The spec files Cooldown as its own
/// condition precisely so it is not confused with the Time window.</summary>
public float CooldownSeconds { get; set; }
/// <summary>
/// Seconds to finish the WHOLE STEP once anyone starts it. 0 = no limit.
///
/// ⛔ THE SPEC'S TIME WINDOW, AND IT IS ONE PARAMETER THAT READS TWO WAYS. A long window
/// (60s) lets one player wander between four statues at their own pace; a short one (1s)
/// forces several players to trigger together. That is why there is no separate
/// "simultaneous" tickbox — the duration IS the difference.
///
/// ⚠️ NOT `TimedDelay`. That one delays when a step becomes AVAILABLE; this one limits
/// how long you have once it is under way.
/// </summary>
public float TimeWindow { get; set; }
/// <summary>Becomes available this many seconds AFTER its Required flags are met.
/// 0 = immediately. Independent of Round.</summary>
public float TimedDelay { get; set; }
/// <summary>Progress resets at the start of the next round if not completed.</summary>
public bool ResetOnRound { get; set; }
/// <summary>Must be using this weapon (a substring of its class name) to trigger. Blank =
/// any. The spec's State toggle.</summary>
public string RequiredWeapon { get; set; } = "";
/// <summary>Must own this perk to trigger. Blank = none needed.</summary>
public string RequiredPerk { get; set; } = "";
public Rotation Rotation => Rotation.FromYaw( Yaw );
}
/// <summary>
/// AN EASTER-EGG PRESSABLE — a button or lever. One interact key triggers it.
///
/// Everything but the hold lives on <see cref="EggSpot"/>.
///
/// ⛔ ORDER AND KILLS ARE DELIBERATELY ABSENT RATHER THAN PRESENT AND INERT. Order needs an
/// OrderIndex per member on top of the step group; Kills needs a death-proximity hook that is
/// not written. Shipping the fields with nothing behind them would put settings in the tool
/// panel that silently do nothing, which is the false-display problem this project keeps
/// writing rules about.
/// </summary>
public class PressableSpot : EggSpot
{
/// <summary>Press-and-HOLD for this long instead of tapping. 0 = a tap.</summary>
public float HoldSeconds { get; set; }
/// <summary>Taking damage cancels a hold in progress.</summary>
public bool CancelOnDamage { get; set; } = true;
/// <summary>Moving cancels a hold in progress.</summary>
public bool CancelOnMove { get; set; } = true;
}
/// <summary>
/// AN EASTER-EGG SHOOTABLE — a symbol, weak point or switch triggered by being SHOT.
///
/// The spec: "same conditions as Pressable", minus the hold — there is no hold-the-shot-key
/// equivalent for a ranged trigger — plus an expanded weapon requirement.
///
/// ⚠️ `RequiredElement` FROM THE SPEC IS NOT HERE. This project has no weapon-element
/// system to read (Elemental Pop is a perk, not an upgrade a gun carries), so the field would
/// be a setting that can never be satisfied. It arrives with elements, if they ever do.
/// </summary>
public class ShootableSpot : EggSpot
{
/// <summary>Must be shot with a Pack-a-Punched weapon of any tier.
///
/// ⚠️ ANY TIER, NOT A SPECIFIC ONE. The spec says "a Pack-a-Punched weapon (any, or a
/// specific one)" — and "a specific one" is already `RequiredWeapon`, so a tier number here
/// would be a third way to say the same thing.</summary>
public bool RequiresPaP { get; set; }
}
/// <summary>
/// AN EASTER-EGG CLUE — text, or an image, on a surface.
///
/// ⛔ IT IS NOT PART OF THE FLAG SYSTEM AT ALL. `Docs/EASTER_EGG_TOOLSET.md` is explicit:
/// no Required, no Reward, no Excluded, no StepNumber, no conditions. Always visible,
/// static, carries no gameplay logic of its own — it exists to hint at things like a
/// Lockpad's code. Giving it an `EggStep` "for consistency" would be inventing a
/// mechanism the design deliberately withholds.
/// </summary>
public class ClueSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>The surface normal here, so it mounts flat to a wall.</summary>
public Vector3 Normal { get; set; } = Vector3.Up;
/// <summary>What it says. Blank with an image set is a picture-only clue.</summary>
public string Text { get; set; } = "";
/// <summary>Optional image, shown instead of or alongside the text.
///
/// ⚠️ A PATH THE MAPPER TYPES, so a wrong one is expected rather than exceptional —
/// `ClueManager` says so rather than leaving a blank panel that looks like a clue
/// with nothing written on it.</summary>
public string Image { get; set; } = "";
/// <summary>Panel size in world units.
///
/// ⚠️ 1000x1000 BY DEFAULT — deliberately far larger than any clue needs. The board has
/// no background, so unused room is invisible, and a board that is always big enough
/// takes the size out of the correctness path: text can never be cropped by it. The
/// earlier auto-fit sized the board from glyph metrics and was removed for being a
/// guess that could be wrong in the one direction that shows.
///
/// ⚠️ IN PANEL UNITS, NOT WORLD UNITS: a world panel draws 0.05 world units to each, so
/// 1000 is 50 world units across (`RingsClue.PanelScale` gives the measurement).</summary>
public Vector2 Size { get; set; } = new( 1000f, 1000f );
/// <summary>Text size, in panel units.</summary>
public float FontSize { get; set; } = 16f;
/// <summary>Text colour. Its ALPHA is the transparency — see `ClueSpot.Opacity` on the
/// tool side, which writes it.</summary>
public Color Tint { get; set; } = new Color( 1f, 0.94f, 0.72f, 1f );
public Rotation Rotation => Rotation.FromYaw( Yaw );
}
/// <summary>
/// Where one of basalt seal 1's hex slots is (`HexSlotManager`): a point on a wall, and the way it faces. What it shows is
/// rolled again every round, so it is not here.
///
/// ⚠️ A WALL BUY'S SHAPE — a position and a ROTATION facing out of the wall — because the first four were placed as wall
/// buys and turned into slots where they stood (`nz_hex_slots_from_wallbuys`), and a wall is where one goes.
/// </summary>
public class HexSlotSpot
{
public Vector3 Position { get; set; }
/// <summary>Facing, out of the wall, as pitch/yaw/roll so it survives JSON.</summary>
public Angles Angles { get; set; }
}
public class MiserySpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>The floor's normal here, so it sits flat on a slope.</summary>
public Vector3 Normal { get; set; } = Vector3.Up;
/// <summary>Size of the placeholder box, in units.
///
/// ⚠️ A BOX FOR NOW, BY REQUEST — the real model comes later. Kept as a config field
/// rather than a constant so swapping in a prop is a change to the manager and this
/// row, not a hunt through hardcoded numbers.</summary>
public Vector3 Size { get; set; } = new( 32f, 32f, 48f );
public Rotation Rotation => Rotation.FromYaw( Yaw );
}
/// <summary>
/// How dark a map is, per map, and whether its bounce light is baked.
///
/// ⛔ PER MAP, NOT GLOBAL, AND THE DEFAULTS ARE "EXACTLY AS IMPORTED" ON PURPOSE. Every ported map
/// lost its Source texture lights, and only the ones whose fixture materials have been made
/// emissive have anything to light them once the ambient comes down. Darkening globally would take
/// `gm_island_d` and `countdown` from flat to UNPLAYABLE — there is nothing left in them to see by.
/// 1.0 everywhere means adding this field changed no map at all.
///
/// ⚠️ SCALES OF WHAT THE MAP ARRIVED WITH, not absolute colours — see `NZAtmosphere`, which keeps
/// each map's own authored hue and only takes the level.
/// </summary>
public class LightingSettings
{
/// <summary>The flat fill. On an interior map this is nearly all of the light there is.
///
/// ⚠️ WITH A BAKE, THIS IS ONLY WHAT LIES **OUTSIDE** THE PROBE VOLUME. The engine documents
/// `AmbientLight.Color` as "ambient light color outside of all light probes" — inside, the
/// probes decide. See <see cref="BakeAmbient"/>.</summary>
public float Ambient { get; set; } = 1f;
/// <summary>
/// The ambient level used DURING the bake, if different from <see cref="Ambient"/>.
///
/// ⛔ TWO NUMBERS BECAUSE ONE FIELD WAS DOING TWO INCOMPATIBLE JOBS. The bake needs a LOT of
/// ambient to have anything to integrate — below about 2 on Basalt the probes come back nearly
/// black. But that same value is also the fallback for every surface OUTSIDE the volume, and
/// Basalt's volume spans z 1000..1900 while its geometry runs down to z −842. So baking at
/// ambient 2 lit every wall below the playable band at TWICE the map's original brightness —
/// reported as *"the map is not fully dark, the walls on the bottom have light"*, and correct.
///
/// ⚠️ SO: THIS FEEDS THE PROBES, `Ambient` LIGHTS WHAT THE PROBES DO NOT REACH. The bake raises
/// the ambient to this, bakes, and puts it back — probe data is baked, so it keeps the bright
/// result while the world outside returns to dark.
///
/// ⚠️ ZERO MEANS "USE `Ambient`", so a map that never bakes is unaffected.
/// </summary>
public float BakeAmbient { get; set; }
/// <summary>
/// `DirectionalLight.SkyColor` — a SECOND fill, independent of the ambient above.
///
/// ⚠️ ITS OWN FIELD BECAUSE TURNING ONE DOWN ALONE LOOKS LIKE NOTHING HAPPENED. Measured on
/// Basalt: zeroing the AmbientLight barely changed the interior, because the sky term was still
/// lighting it.
/// </summary>
public float Sky { get; set; } = 1f;
/// <summary>The directional's own light. Outdoors this is the sun; inside Basalt it contributes
/// almost nothing.</summary>
public float Sun { get; set; } = 1f;
/// <summary>Let the one directional keep casting shadows. It is usually a map's only
/// shadow-caster, and shadows are the single biggest cost in this engine.</summary>
public bool SunShadows { get; set; } = true;
// ── the colours (2026-09-29) — `NZAtmosphere` takes one of these, where it is set, in place of the colour the map was imported
// with, before the multipliers above scale it. BLANK IS AS IMPORTED, so a map that names none is lit exactly as before.
/// <summary>
/// The sun's own colour, #RRGGBB, before <see cref="Sun"/> scales it. Blank: the map's `light_environment` as imported.
///
/// ⛔ A COLOUR, WHERE THE MULTIPLIERS ARE NOT, BECAUSE NO MULTIPLIER CHANGES A HUE (the user, 2026-09-29, on City Uprising:
/// *"give it more natural light baked in"*). That map came in with a grey sun over a grey sky (0.85, 0.2), and no level of grey
/// reads as daylight; a warm sun over a blue sky does. `NZAtmosphere`'s rule stands — a map's own colour is kept unless its
/// config names another.
/// </summary>
public string SunColour { get; set; } = "";
/// <summary>The sky's fill colour, #RRGGBB (`DirectionalLight.SkyColor`), before <see cref="Sky"/> scales it. Blank: as imported.</summary>
public string SkyColour { get; set; } = "";
/// <summary>The flat fill's colour, #RRGGBB (`AmbientLight.Color`), before <see cref="Ambient"/> scales it. Blank: as imported.</summary>
public string AmbientColour { get; set; } = "";
/// <summary>A colour field's colour, or <paramref name="imported"/> when the field is blank or no colour. The imported alpha is kept.</summary>
public static Color ColourOr( string field, Color imported )
=> !string.IsNullOrWhiteSpace( field ) && Color.Parse( field.Trim() ) is Color c ? c.WithAlpha( imported.a ) : imported;
/// <summary>
/// Bake the indirect light volume when this map loads.
///
/// ⛔ A DARK MAP IS BLACK WITHOUT IT, SO THIS IS NOT DECORATION. With the ambient down there is
/// nothing lighting anything a fixture does not directly face; the bake is what carries light
/// off the emissive panels onto the concrete. `nz_dark` therefore turns this ON — the two go
/// together and setting only the first produces a map nobody can see in.
///
/// ⚠️ OFF BY DEFAULT because on an as-imported map the bake is a few seconds of load time for a
/// subtle gain, and a map that pauses on load with no explanation is worse than a flat one.
/// </summary>
public bool Bake { get; set; }
/// <summary>
/// Probes per 1024 units for that bake.
///
/// ⛔ 4 IS A FLOOR, NOT A DEFAULT TO ECONOMISE ON, AND GOING UNDER IT MAKES THE MAP BLACK.
/// Once an IndirectLightVolume exists it REPLACES the ambient term, so probes too sparse to
/// cover a room do not merely fail to help — they remove the fill that was working. Measured on
/// Basalt: density 2 over 9320x5314x3106 units is a 20x12x8 grid, one probe per ~450 units, and
/// the map rendered completely black. Density 4 is 40x24x16 and reads correctly.
///
/// ⚠️ PROBE COUNT SCALES WITH THE CUBE OF THIS. 4 -> 5 is not a nudge, it is roughly double the
/// bake.
/// </summary>
public int BakeDensity { get; set; } = 4;
/// <summary>
/// Confine the bake to the playable area. Zero size means "size to the whole scene".
///
/// ⛔ BECAUSE A 3D SKYBOX IS SCENE GEOMETRY AND THE AUTOMATIC SIZING SWALLOWS IT. Basalt's
/// skybox is a forest thousands of units above the map; with it included the volume went from
/// 9320x5314x**3106** to 9320x5314x**20927** and the same probe budget was stretched over seven
/// times the height. The playable rooms ended up with one probe per ~520 units — taller than any
/// room in the map — and it rendered BLACK.
///
/// ⚠️ SMALLER BOUNDS ARE WORTH MORE THAN HIGHER DENSITY. Probe count scales with the cube of
/// density but only linearly with each axis of the volume, so cutting the empty sky out is the
/// cheap fix and raising density is the expensive one.
/// </summary>
public Vector3 BakeCenter { get; set; }
/// <inheritdoc cref="BakeCenter"/>
public Vector3 BakeSize { get; set; }
/// <summary>
/// Textures from an EDITOR bake, loaded instead of baking at runtime.
///
/// ⛔ THIS IS THE ONLY WAY THE BAKE SURVIVES, AND THE RUNTIME PATH CANNOT BE FIXED. Baking
/// indirect volumes is an EDITOR action in s&box: a play-mode scene is a clone with no folder
/// to write baked data into, so the engine's `SaveTexture` throws and then replaces the volume's
/// distance texture with an ERROR TEXTURE — `640x416` becomes `32x32` about a minute later and
/// the map goes black. Measured repeatedly. An editor bake writes real `.vtex` assets and has
/// none of that.
///
/// ⚠️ SO THE WORKFLOW IS: bake once in the editor, paste the three paths here, ship them. The
/// game then never bakes at all — it loads three textures, which is free.
///
/// ⛔ AND THE VOLUME CANNOT SIMPLY LIVE IN THE MAP SCENE INSTEAD. `Assets/maps/manifest.json`
/// requires a shipped map scene to hold GEOMETRY ONLY, because MapInstance parents everything in
/// it under the map object — a camera or light rig in there fights the gamemode's own. Paths in
/// the config keep the map scene untouched and the manifest unchanged.
/// </summary>
public string BakedIrradiance { get; set; } = "";
/// <inheritdoc cref="BakedIrradiance"/>
public string BakedDistance { get; set; } = "";
/// <inheritdoc cref="BakedIrradiance"/>
public string BakedRelocation { get; set; } = "";
/// <summary>True when this map ships a finished editor bake.</summary>
public bool HasBakedTextures
=> !string.IsNullOrWhiteSpace( BakedIrradiance )
&& !string.IsNullOrWhiteSpace( BakedDistance );
/// <summary>
/// Let the view adapt to how bright the room is.
///
/// ⚠️ THIS IS WHAT MAKES A DARK MAP PLAYABLE RATHER THAN JUST DARK — walking from a lit hall
/// into an unlit tunnel, a fixed exposure gives a black rectangle.
/// </summary>
public bool AutoExposure { get; set; } = true;
/// <summary>Bounds on that adaptation. Unbounded, a pitch-black corner brightens until it is
/// grey noise, which reads as a rendering fault rather than as darkness.</summary>
public float ExposureMin { get; set; } = 0.6f;
/// <inheritdoc cref="ExposureMin"/>
public float ExposureMax { get; set; } = 2.0f;
/// <summary>
/// Push the whole image darker (negative) or brighter.
///
/// ⛔ ON A BAKED MAP THIS IS THE DARKNESS CONTROL, NOT `Ambient`, AND THAT IS THE OPPOSITE OF
/// WHAT IT LOOKS LIKE. Measured on Basalt: with a volume present, ambient 2 and ambient 5 render
/// almost identically because auto-exposure normalises them, and below about 2 the map falls off
/// a cliff into black — because the probes need real energy to integrate and lose most of it.
/// So the ambient sets how much SHAPE the bake can produce, and this sets how dark the result
/// reads. Basalt's look is ambient 2 baked, then −2 here.
///
/// ⚠️ WHICH IS WHY IT LIVES IN THE MAP'S CONFIG. It is doing the job "how dark is this map",
/// and that is a per-map decision.
/// </summary>
public float ExposureBias { get; set; }
// ── the grade (2026-09-28) — the look over the whole frame, after the exposure (`NZPostProcess`). NEUTRAL BY DEFAULT: a map that
// names none is drawn as it always was. `nz_grade` tunes a part at a time; `nz_save` keeps it.
/// <summary>Colour, 1 as it is: under 1 greyer, over 1 richer (`ColorAdjustments.Saturation`).</summary>
public float GradeSaturation { get; set; } = 1f;
/// <summary>
/// Contrast, 1 as it is: an S about mid-grey in the grade's curves. ⚠️ NOT THE ENGINE'S CONTRAST, which pivots at 0.5 in
/// linear light and on a dark map pushes the shadows to black.
/// </summary>
public float GradeContrast { get; set; } = 1f;
/// <summary>Brightness after the grade, 1 as it is (`ColorAdjustments.Brightness`).</summary>
public float GradeBrightness { get; set; } = 1f;
/// <summary>
/// A split tone, 0 none: the highlights warmed towards the lava's orange and the deepest shadows cooled a hair, the mids left
/// be — through the grade's curves. Small by design: 0.5 takes 4% of the blue out of a white.
/// </summary>
public float GradeWarmth { get; set; }
/// <summary>Darkening at the frame's edges, 0 none (`Vignette.Intensity`).</summary>
public float GradeVignette { get; set; }
/// <summary>The vignette's colour, #RRGGBB. Blank: black.</summary>
public string GradeVignetteColour { get; set; } = "";
/// <summary>Film grain, 0 none (`FilmGrain.Intensity`).</summary>
public float GradeGrain { get; set; }
/// <summary>The tonemapper: blank for the engine's own (Hable filmic), or "aces", "agx", "reinhard", "linear".</summary>
public string GradeTonemap { get; set; } = "";
// ── the map's own lamps, for the bake only (2026-09-29) ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Lights that exist ONLY WHILE THE MAP BAKES (`MapBake`): the original map's own lamps, their glow baked into its bounce light
/// and then gone, so they cost nothing in play. Written from the BSP's `light` and `light_spot` entities, which the import leaves
/// behind, by `Tools/map_bake_lights.py`. Empty: none. Asked for on the office (the user, 2026-09-29: *"Daylight + its own
/// lights"*) — an interior lit by its windows alone is dim in the middle.
///
/// ⛔ BAKE-ONLY, NOT `Lights`. Those are placed lights, live every frame, each costing framerate (see the class below); these
/// were the Source map's lightmap lights, free baked data there, and free baked data here.
/// </summary>
public List<BakeLight> BakeLights { get; set; } = new();
/// <summary>Every bake light's brightness at once, 1 as written: the knob for a baked room that reads too dark or too bright.</summary>
public float BakeLightScale { get; set; } = 1f;
}
/// <summary>
/// A light that exists only while its map bakes (`LightingSettings.BakeLights`), as `Tools/map_bake_lights.py` wrote it from the
/// Source map's own entity.
/// </summary>
public class BakeLight
{
/// <summary>"point" or "spot".</summary>
public string Kind { get; set; } = "point";
/// <summary>Where it stands, in world units — the Source entity's origin, which the import keeps.</summary>
public Vector3 Position { get; set; }
/// <summary>A spot's aim, s&box angles in degrees: pitch down from level, and yaw.</summary>
public float Pitch { get; set; }
/// <inheritdoc cref="Pitch"/>
public float Yaw { get; set; }
/// <summary>Its colour, #RRGGBB.</summary>
public string Colour { get; set; } = "#FFFFFF";
/// <summary>Its colour's multiplier (the Source brightness over 200, `map_bake_lights.py`).</summary>
public float Brightness { get; set; } = 1f;
/// <summary>How far it reaches, in units.</summary>
public float Radius { get; set; } = 400f;
/// <summary>A spot's outer and inner half-angles in degrees, as Source's `_cone` and `_inner_cone`.</summary>
public float ConeOuter { get; set; } = 45f;
/// <inheritdoc cref="ConeOuter"/>
public float ConeInner { get; set; } = 30f;
/// <summary>The entity it came from, e.g. "light_spot 19040" (its Hammer id).</summary>
public string From { get; set; } = "";
}
/// <summary>
/// A light the mapper placed.
///
/// ⛔ THE ONLY PLACEABLE IN THIS PROJECT THAT COSTS FRAMERATE, and the cost is real. s&box has
/// no lightmaps and no shadow baking — `Light` exposes Contribution, LegacyData, LightColor and
/// Shadows and nothing else — so every one of these is computed every frame. Porting
/// `ttt_canyon_labs`'s 71 Source lights, which were FREE baked data in Source 1, produced a **3.5x
/// fps swing by view direction**. That history is written up in `Code/Diagnostics/LightBake.cs`.
///
/// ⚠️ SO PREFER THE FREE OPTIONS FIRST. An emissive material costs nothing; the indirect bake
/// costs nothing at runtime. Basalt's whole look is carried by those two, with the lava as its light
/// source. Place these where a specific spot needs a pool of light those cannot give.
/// </summary>
/// <summary>
/// A patch of haze the player walks into — volcanic soot over the lava, dust in a collapsed wing.
///
/// ⛔ A SPHERE OF INFLUENCE, NOT A BOX OF FOG, AND THE ENGINE DECIDED THAT. `VolumetricFogVolume`
/// is the component that would have hung actual fog inside a box, and it DOES NOTHING on the scene
/// path — added with Bounds set and Strength at 25, with and without a `VolumetricFogController`
/// beside it, and the frame is pixel-identical. Its own controller says why: it exists "to fetch the
/// baked fog texture FROM THE MAP FILE". Volumetric fog is a map-compile feature, like lightmaps and
/// like shadow baking, and this project is on the scene path.
///
/// What does work is `GradientFog`, which is **global to the scene at any instant**. So an area is
/// not a container of fog — it is a region that decides what the global fog looks like while you are
/// standing in it, cross-faded as you move.
///
/// ⚠️ WHICH MEANS YOU CANNOT SEE A SOOT CLOUD FROM OUTSIDE IT. Standing in a clear room looking into
/// a hazy one, the doorway is clear; walk through and the haze comes up around you. For atmosphere
/// per division of a map that reads correctly. For a visible bank of smoke hanging in a room it does
/// not, and that would need particles or haze geometry instead.
/// </summary>
public class FogArea
{
/// <summary>Centre of the drawn prism — the middle of its extrusion, the same convention the
/// wall tools use.</summary>
public Vector3 Position { get; set; }
public float Yaw { get; set; }
public Rotation Rotation => Rotation.FromYaw( Yaw );
/// <summary>Bounding box of the drawn shape. `Size.z` is the extrusion, so the haze runs from
/// `Position.z - Size.z/2` to `Position.z + Size.z/2`.</summary>
public Vector3 Size { get; set; }
/// <summary>The drawn outline, in the area's own space. Same format and the same builder as
/// `InvisibleWall.Footprint`, so a fog area can be any shape a barrier can.</summary>
public List<Vector2> Footprint { get; set; } = new();
public bool HasFootprint => Footprint is { Count: >= 3 };
/// <summary>
/// How far INSIDE the boundary the haze reaches full strength, in units.
///
/// ⛔ A DISTANCE NOW, NOT A FRACTION, BECAUSE THE SHAPE IS NO LONGER A SPHERE. A fraction of a
/// radius has no meaning on a drawn polygon — the same 0.5 would be a 40-unit fade in a doorway
/// and a 900-unit one in a hall. Measured inward from every face, including the top and bottom
/// caps, so walking in, ducking under or dropping into an area all fade rather than pop.
/// </summary>
public float Feather { get; set; } = 200f;
/// <summary>Hex. Volcanic soot is a warm near-black, not a grey.</summary>
public string Color { get; set; } = "#2a2320";
/// <summary>
/// How thick it gets, 0 to 1.
///
/// ⚠️ THIS IS THE ONE TO KEEP LOW, and lower than it reads. Measured against figures at 200,
/// 500, 900 and 1600 units: fog only starts to show at all around 0.15, and 0.5 is already
/// heavy. It was asked for "light", so the default is under what feels right when typing it.
/// </summary>
public float Density { get; set; } = 0.18f;
/// <summary>Seconds to cross-fade in and out. Short enough to follow you, long enough not to
/// flicker when you stand on a boundary.</summary>
public float Blend { get; set; } = 0.75f;
}
public class MapLight
{
public Vector3 Position { get; set; }
/// <summary>Hex, like the clue colour — the tool panel's swatch row writes this.</summary>
public string Color { get; set; } = "#ffeeb8";
/// <summary>
/// Multiplier on the colour.
///
/// ⚠️ SEPARATE FROM THE COLOUR ON PURPOSE. A swatch row can only pick a hue, and a light that
/// can only be as bright as #ffffff cannot compete with an emissive panel at 2x — which is what
/// the map is already lit by.
/// </summary>
public float Brightness { get; set; } = 1f;
/// <summary>How far it reaches, in world units.</summary>
public float Radius { get; set; } = 400f;
/// <summary>
/// Cast shadows.
///
/// ⛔ OFF BY DEFAULT AND THAT IS NOT TIMIDITY. Shadows are the single biggest cost in this
/// engine and the specific thing that cost canyon_labs its framerate. A shadowless light is
/// cheap; a shadowed one is the expensive kind, and on a map lit mostly by baked indirect the
/// difference is rarely visible.
/// </summary>
public bool Shadows { get; set; }
/// <summary>
/// Only lit once the power is on.
///
/// ⚠️ OFF BY DEFAULT because a map with no power switch would otherwise be permanently dark
/// — `Power` reports "power ON (no switch on this map)" for exactly that reason, but a light
/// that defaulted to requiring it would still read as broken on every map without one.
/// </summary>
public bool RequiresPower { get; set; }
}
/// <summary>
/// A looping sound the mapper placed.
///
/// ⚠️ `Sound` IS A `.sound` EVENT PATH, NOT AN AUDIO FILE. s&box plays sound EVENTS — the
/// event carries the volume, falloff, occlusion and mixer, and points at the actual audio. Dropping
/// an mp3 into the project is half the job; `Tools/import_sound.py` does the other half.
/// </summary>
public class SoundSpot
{
public Vector3 Position { get; set; }
/// <summary>The `.sound` event to play.</summary>
public string Sound { get; set; } = "";
public float Volume { get; set; } = 1f;
/// <summary>How far it carries, in world units.</summary>
public float Distance { get; set; } = 1000f;
/// <summary>
/// Re-trigger the sound. ON by default.
///
/// ⛔ OFF MEANS "PLAY ONCE AND STOP FOREVER", AND I DEFAULTED IT WRONG. An mp3 has no loop
/// points, so `PlayOnStart` alone fires the clip once when the spot is built and the map is
/// silent from then on. A placed AMBIENCE is a bed by definition — the whole folder is called
/// ambience — so playing once is essentially never what was wanted.
///
/// ⚠️ WORSE, IT LOOKED CORRECT: `nz_sound_list` printed "looping" for exactly this state, so
/// a spot that had already finished reported itself as a healthy loop. Reported as *"i do not
/// hear it"*, with the list insisting it was fine.
/// </summary>
public bool Repeat { get; set; } = true;
/// <summary>
/// Seconds between re-triggers. ⚠️ **SET THIS TO THE CLIP'S LENGTH**, a shade under.
///
/// ⛔ IT CANNOT BE DERIVED — `SoundEvent` EXPOSES NO DURATION. Checked: there is no `Duration`
/// on it, so nothing in code can know how long the audio runs. Too long leaves an audible gap of
/// silence; too short starts a second copy over the first, which doubles the volume and
/// comb-filters. The editor logs the real figure on import — *"Parsed MP3 ... estimated duration
/// 20.06s"* — and that is the number to put here.
/// </summary>
public float RepeatMin { get; set; } = 20f;
/// <inheritdoc cref="RepeatMin"/>
public float RepeatMax { get; set; } = 20f;
}
public class DamageWall
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>Bounding box of the drawn shape. Used for the remove radius, the marker
/// and — unlike the invisible wall — the containment test itself.</summary>
public Vector3 Size { get; set; }
/// <summary>The drawn outline, in the wall's own space. Same format and builder as
/// `InvisibleWall.Footprint`, so a damage wall can be any shape a barrier can.</summary>
public List<Vector2> Footprint { get; set; } = new();
public bool HasFootprint => Footprint is { Count: >= 3 };
/// <summary>Damage dealt per tick to each player inside.</summary>
public float Damage { get; set; } = 20f;
/// <summary>Seconds between ticks.
///
/// ⚠️ CLAMPED WHERE IT IS USED, not here. A config can carry 0 — hand-edited, or a
/// tool that let it through — and 0 would deal damage every frame, which at 60fps is
/// the difference between "hurts" and "kills instantly on contact".</summary>
public float Interval { get; set; } = 1f;
/// <summary>Draw it while AUTHORING. Never drawn in a round either way — see
/// `DamageWallVolume.TickVisibility`.
///
/// ⚠️ DEFAULTS TO ON, unlike the invisible wall, because there is no collision to
/// bump into and nothing else says where the volume is while building.</summary>
public bool Visible { get; set; } = true;
/// <summary>
/// Show it to PLAYERS, in a round, not only while authoring.
///
/// ⛔ A SEPARATE FLAG, NOT A CHANGE TO `Visible`. Every saved config on disk carries
/// `Visible: true` — it defaults to on — so widening that field's meaning would turn every
/// killbox on every existing map into a solid red dev-textured block the moment this shipped.
/// `Visible` still means "draw it while I am building"; this means "and leave it drawn".
///
/// ⚠️ OFF BY DEFAULT, because a damage volume that a player can see is a DESIGN choice: a
/// lava pool you are meant to avoid wants to be seen, and a scripted hurt-box around a boss
/// arena wants not to be.
/// </summary>
public bool VisibleInGame { get; set; }
/// <summary>Surface, when drawn. Ignored entirely when it is not.
///
/// ⚠️ `materials/nz/lava.vmat` IS THE ONE WORTH KNOWING ABOUT — procedural animated lava,
/// world-space, no UVs needed, which is what a wall extruded from an arbitrary footprint can
/// actually use. See `Assets/shaders/lava.shader`.</summary>
public string Material { get; set; } = "materials/dev/reflectivity_50.vmat";
/// <summary>Tint over the material, when a PLAYER sees it.
///
/// ⛔ WHITE, NOT THE KILLBOX RED IT USED TO BE — and that red has not gone away, it moved
/// to `DamageWallManager.KillboxTint`. A wall nobody but the mapper sees is still drawn red so
/// it reads as danger at a glance; this field is what the wall wears once `VisibleInGame` puts
/// it in front of a player, and red multiplied over lava is brown.
///
/// ⚠️ SAFE TO CHANGE THE DEFAULT because no config on disk has a damage wall in it — all
/// four shipped maps carry `DamageWalls: []`, so there is nothing to migrate.</summary>
public Color Tint { get; set; } = Color.White;
/// <summary>
/// A looping sound the volume makes. Empty for silent.
///
/// ⚠️ `sounds/effects/fire/fire_burn_loop01.sound` IS THE ONE WORTH KNOWING ABOUT for lava. The
/// project ships 2,189 sound events and not one is a fire or lava ambience — every "fire" in the
/// list is a weapon discharge or the Fire Sale powerup. This one comes from the engine's mounted
/// content.
/// </summary>
public string Sound { get; set; } = "";
/// <summary>How loud, before distance falloff.</summary>
public float SoundVolume { get; set; } = 1f;
/// <summary>
/// How far the sound carries.
///
/// ⚠️ MEASURED FROM THE NEAREST POINT OF THE VOLUME, NOT ITS CENTRE — see
/// `DamageWallVolume.ClosestPoint`. On Basalt the lava is a single 7515x5152 wall, so a
/// centre-anchored emitter would be a thousand units away from lava you are standing on.
/// </summary>
public float SoundDistance { get; set; } = 1200f;
/// <summary>
/// Re-trigger the sound every <see cref="SoundRepeatTime"/> seconds.
///
/// ⛔ AN MP3 HAS NO LOOP POINTS, SO `PlayOnStart` ALONE PLAYS IT ONCE AND STOPS. A 20-second
/// ambience bed then goes silent forever, which is indistinguishable from a sound that never
/// started — and was reported as exactly that.
///
/// ⚠️ SET THE TIME TO THE CLIP'S LENGTH, a shade under. Too long leaves an audible gap; too
/// short stacks a second copy over the first, which doubles the volume and comb-filters.
/// </summary>
public bool SoundRepeat { get; set; }
/// <inheritdoc cref="SoundRepeat"/>
public float SoundRepeatTime { get; set; } = 20f;
public Rotation Rotation => Rotation.FromYaw( Yaw );
}
public class InvisibleWall
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>
/// Tilt, so a wall lying flat can be a RAMP rather than a step.
/// </summary>
///
/// ⛔ ADDED FOR BRIDGES, AND IT COSTS THE EXISTING WALLS NOTHING. Both default to zero and
/// `Rotation` rebuilds from all three, so `Rotation.From( 0, Yaw, 0 )` is exactly the
/// `Rotation.FromYaw( Yaw )` it replaces — every wall authored before this deserialises
/// unchanged and stands where it always did.
///
/// ⚠️ A RAMP IS ONLY WALKABLE UP TO `NavSettings.MaxSlope` (45° on Basalt). Past that the
/// navmesh will not put a walkable polygon on the surface however solid it is, so a steeper
/// slab is a wall the horde bumps into rather than a route — which is exactly what it looks
/// like, and exactly the wrong thing to debug at three in the morning.
public float Pitch { get; set; }
/// <summary>Bank, for a ramp that also leans. Rarely wanted; zero for everything generated.</summary>
public float Roll { get; set; }
/// <summary>Bounding box of the drawn shape. Used for the remove radius and
/// the marker, the same as Debris.Size.</summary>
public Vector3 Size { get; set; }
/// <summary>The drawn outline, in the wall's own space. See Debris.Footprint
/// — same format, same builder, so a wall can be any shape a barrier can.
/// </summary>
public List<Vector2> Footprint { get; set; } = new();
public bool HasFootprint => Footprint is { Count: >= 3 };
/// <summary>
/// Draw it, or leave it invisible.
///
/// ⚠️ DEFAULTS TO INVISIBLE — that is what the tool is for, and a wall that
/// appears solid by default would be indistinguishable from a debris block
/// you cannot buy. Turn it on to fence somewhere off with a wall the player
/// can actually see, which is the honest option when the boundary is not
/// obvious from the map.
///
/// ⚠️ Only affects RENDERING. Collision is on either way — an invisible wall
/// you can walk through is not a wall.
/// </summary>
public bool Visible { get; set; }
/// <summary>Surface, when Visible. Ignored entirely when it is not.</summary>
public string Material { get; set; } = "materials/concrete/concretewall052a.vmat";
public Color Tint { get; set; } = Color.White;
/// <summary>
/// Whether the horde is stopped by this wall too, or walks through it.
///
/// ⛔ FALSE IS THE DEFAULT AND THE ORIGINAL BEHAVIOUR. An invisible wall is
/// normally a MAP BOUNDARY — it keeps the player inside the playable area, and
/// zombies are spawned inside it anyway, so blocking them buys nothing and costs
/// a hole in the navmesh that the horde has to path around. Every wall placed
/// before this field existed means to be player-only, and deserializes to false.
///
/// ⚠️ TRUE IS A NAVMESH EDIT, NOT A COLLISION ONE. The collider is solid
/// either way; what changes is whether the body is excluded from generation. A
/// wall with this on is BAKED IN, so zombies route around it — which is the point,
/// and also why it is worth being deliberate about: it can close a route the horde
/// depends on, and the symptom (a lane that stops being used) shows up rounds later
/// and nowhere near the wall.
/// </summary>
public bool BlocksZombies { get; set; }
public Rotation Rotation => Rotation.From( Pitch, Yaw, Roll );
}
/// <summary>
/// A placed power switch. Interact with it to turn the map's power on.
///
/// Deliberately has no "on" field: the switched state is runtime, not authored,
/// and lives in <see cref="Power"/>. Saving it would mean a config could ship
/// with the power already on, which is exactly the thing the switch exists to
/// prevent.
/// </summary>
public class PowerSwitch
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>Collision box, and the fallback block's dimensions. The prop's
/// own shape does not affect either — see PowerManager.Spawn.</summary>
public Vector3 Size { get; set; } = new( 24f, 24f, 48f );
/// <summary>
/// The surface it is mounted on, as that surface's normal.
///
/// ⛔ THIS IS WHAT MAKES IT SIT FLAT ON A WALL. A yaw alone can only spin a
/// prop about the vertical, so a switch placed on anything but a floor stood
/// bolt upright and clipped through it. The normal carries the full
/// orientation, and a box mounted on a slope or a ceiling comes out flush.
///
/// ⚠️ ZERO ON EVERY SWITCH SAVED BEFORE THIS EXISTED, which is why Yaw is
/// still here and still used when there is no normal. Old configs keep the
/// placement they were authored with rather than silently rotating.
/// </summary>
public Vector3 Normal { get; set; }
public bool HasNormal => !Normal.IsNearZeroLength;
/// <summary>
/// ⚠️ The up vector is NOT always Vector3.Up. `LookAt` needs an up that is
/// not parallel to its forward, and on a floor or ceiling the normal IS
/// vertical — passing Up there is degenerate and yields a NaN rotation, so
/// a flat placement would make the switch vanish rather than lie down.
/// </summary>
public Rotation Rotation => HasNormal
? Rotation.LookAt( Normal, MathF.Abs( Normal.Normal.z ) > 0.99f
? Vector3.Forward
: Vector3.Up )
: Rotation.FromYaw( Yaw );
}
/// <summary>
/// Player tuning. Defaults are s&box's own where one exists, so an
/// untouched config plays like the stock controller.
/// </summary>
public class PlayerSettings
{
/// <summary>⚠️ 150, not 100. The original's default
/// (sv_mapsettings.lua: `Settings.hp ... or 150`).</summary>
public float MaxHealth { get; set; } = 150f;
/// <summary>
/// How many perks survive going down. Everything above this is lost.
///
/// ⚠️ 0 = the classic behaviour, lose the lot. Setting it to 1 is the common
/// house rule: you keep Juggernog and buy the rest back.
///
/// ⚠️ The perks KEPT are the ones bought FIRST. `Perks` is ordered by
/// purchase, so this is deterministic and explainable — "you keep what you
/// have had longest". Random would be crueller and impossible to plan around;
/// keeping the newest would punish buying early, which is the opposite of what
/// a perk economy should reward.
/// </summary>
public int PerksKeptOnDown { get; set; }
/// <summary>
/// How many perks a player may hold at once.
///
/// ⚠️ A CAP, NOT A COUNT OF MACHINES. The Wunderfizz sells every perk from one
/// spot, so without a cap a player with points simply owns all 18 — the choice
/// of WHICH perks to run is the entire perk economy, and it only exists
/// because this number is smaller than the roster.
///
/// ⚠️ Raised at RUNTIME by buying slots at the Wunderfizz — see
/// `NZPlayer.BonusPerkSlots`. This field is the STARTING allowance and is
/// never written to by the purchase, so a config reload cannot inherit slots
/// somebody bought in a previous game.
/// </summary>
public int PerkSlots { get; set; } = 4;
// ── slide ────────────────────────────────────────────────────────────────
// ⚠️ OURS, NOT THE ORIGINAL'S. GMod nZombies has no slide — its one movement
// extra is the BO1 dive (Docs/DIVE_TO_PRONE.md). There are no original values
// to match, so these are tuned by feel and exposed so a mapper can retune them.
/// <summary>Slide launch speed, as a multiple of run speed.</summary>
public float SlideSpeed { get; set; } = 1.45f;
/// <summary>How long a slide lasts, seconds.</summary>
public float SlideDuration { get; set; } = 0.85f;
/// <summary>Wait before another slide can start, seconds.</summary>
public float SlideCooldown { get; set; } = 0.55f;
// ── movement ─────────────────────────────────────────────────────────────
// ⚠️ NOT config settings in the original — nZombies never sets walk or run
// speed from map settings, it uses GMod's defaults and lets perks override
// (Stamin-Up sets 210 / 341). Exposed here because they were asked for, but
// the values below are s&box's own defaults, not ported numbers.
/// ⚠️ 200, up from 110. The original nZombies player moves considerably faster
/// than a stock s&box one, and 110 made every map feel twice its size.
public float WalkSpeed { get; set; } = 200f;
/// ⚠️ 340, up from 250 — and note it is only 1.7x walk rather than 2.3x, so
/// sprinting is now a smaller relative gain. That is deliberate: the base pace
/// carries the movement and sprint is the burst, which is how the original
/// reads (its Stamin-Up numbers are 210 walk / 341 run).
public float SprintSpeed { get; set; } = 340f;
public float JumpPower { get; set; } = 200f; // Settings.jumppower
// ── stamina ──────────────────────────────────────────────────────────────
// ⚠️ s&box has NO stamina — sprint is unlimited. This whole system is new.
//
// The original ticks on a FIXED 0.05s interval and applies an AMOUNT per
// tick, rather than a rate per second. Kept identical so the numbers below
// are the real ones and the feel matches:
// drain 0.9/tick -> "sprint now lasts around 8 seconds" (sh_sprint.lua:79)
// regen 4.5/tick -> refills in well under a second once it starts
public float StaminaMax { get; set; } = 100f;
/// <summary>Stamina removed per tick while sprinting. (sh_sprint.lua: 0.9)</summary>
public float StaminaDrainPerTick { get; set; } = 0.9f;
/// <summary>Stamina restored per tick while recovering.
/// (Settings.staminaregenamount = 4.5)</summary>
public float StaminaRegenPerTick { get; set; } = 4.5f;
/// <summary>Seconds after sprinting stops before stamina recovers.
/// (Settings.staminaregendelay = 0.5)</summary>
public float StaminaRegenDelay { get; set; } = 0.5f;
// ── health regen ─────────────────────────────────────────────────────────
// ⚠️ A RATIO of max health per tick, not HP per second. Taken from BO2's
// _zm_playerhealth.gsc via sv_healthregen.lua: once the delay has passed,
// every `rate` seconds it adds `ratio` of MAX health. At 0.1 per 0.05s that
// is a full heal in about half a second — the regen is not slow, the WAIT
// is. Modelling it as HP/second would get the feel wrong.
/// <summary>Seconds without taking damage before healing starts.
/// (Settings.healthregendelay = 5)</summary>
public float HealthRegenDelay { get; set; } = 5f;
/// <summary>
/// PERCENT of MAX health restored per tick — 10 means 10%, so ten ticks is a
/// full heal from empty.
///
/// ⚠️ Stored as a percent (10), not the original's fraction (0.1). Its own
/// settings tool labels the field "Health Regen Amount (%)" while storing
/// 0.1, so the number in the box never matched the label. Divided by 100 at
/// the point of use in HealthRegen. (Settings.healthregenratio = 0.1)
/// </summary>
public float HealthRegenPercent { get; set; } = 10f;
/// <summary>Seconds between regen ticks. (Settings.healthregenrate = 0.05)</summary>
public float HealthRegenRate { get; set; } = 0.05f;
// ⚠️ StartingPoints MOVED to GameplaySettings. It sat here unwired while
// points were half-built; now that awarding and spending both exist, every
// points value belongs in one block rather than split across two.
// Armor MOVED to its own ArmorSettings block at the end of this file. It was
// "deliberately omitted, deferred to a later phase" until 2026-08-19; it now
// has enough values (tiers, plates, bleed-through, drop rates) that folding
// them in here would have made PlayerSettings the place two systems live.
}
/// <summary>
/// GAMEPLAY — the economy. What a player starts with and what killing pays.
///
/// Its own block because points span BOTH sides: the player earns and spends
/// them, the zombie awards them. Putting them under either one would mean
/// looking in the wrong place half the time.
///
/// ⚠️ Every default is the original's, verified against sv_hooks.lua rather
/// than guessed — 10 a hit (:644), 50 a kill (:260), 100 a headshot kill
/// (:257), 130 a melee kill (:250).
/// </summary>
public class GameplaySettings
{
/// <summary>Points at the start of a game. (Settings.startpoints)</summary>
public int StartingPoints { get; set; } = 500;
/// <summary>
/// Seconds from round 1's start to its first zombie — a breath after the spawn-in's fade: *"when the players start the game,
/// for round 1 only, add a 3 second delay before zombies start spawning"* (2026-09-27). 0 = at once, as it was.
///
/// ⚠️ A NULLABLE BEHIND IT, so a config already in memory when this arrived by hotload reads 3 too, not 0.
/// </summary>
public float FirstRoundDelay
{
get => _firstRoundDelay ?? 3f;
set => _firstRoundDelay = Math.Max( 0f, value );
}
float? _firstRoundDelay;
/// <summary>
/// The map's own tremors: every <see cref="TremorMinMinutes"/> to <see cref="TremorMaxMinutes"/> minutes of a game, rolled
/// again each time, the ground shakes softly and the ceiling sheds its dust (`AmbientTremor`) — basalt's.
///
/// ⛔ OFF BY DEFAULT SINCE 2026-10-01. It was ON for every map (2026-09-27), so basalt's volcano shook every map: *"you made
/// it so it affects all configs, not just basalt"*. Every config the game had saved since carried `"Tremors": true` written
/// out, so those were set false by hand; basalt's alone keeps it.
/// ⚠️ Nullable-backed, as the round 1 delay is.
/// </summary>
public bool Tremors
{
get => _tremors ?? false;
set => _tremors = value;
}
bool? _tremors;
/// <summary>
/// The spawn-in's tremor: as the game fades up out of the black the view shakes, the rock rumbles and the ceiling sheds its
/// dust (`SpawnTremor`) — basalt's. Off, the default (2026-10-01): it had played at every map's start.
/// </summary>
public bool SpawnInTremor { get; set; }
/// <summary>
/// The power's tremor: as the power comes on the view rumbles, the rock sounds and the dust falls (`PowerTremor`) — basalt's.
/// Off, the default (2026-10-01): it had played on every map with a power switch.
/// </summary>
public bool PowerOnTremor { get; set; }
/// <summary>The shortest wait between two of the map's tremors, in minutes — *"from 5-15 minutes"*.</summary>
public float TremorMinMinutes
{
get => _tremorMin ?? 5f;
set => _tremorMin = Math.Max( 0.1f, value );
}
float? _tremorMin;
/// <summary>The longest wait between two of the map's tremors, in minutes.</summary>
public float TremorMaxMinutes
{
get => _tremorMax ?? 15f;
set => _tremorMax = Math.Max( 0.1f, value );
}
float? _tremorMax;
// ── the map's own sounds (2026-09-27) — a cue under sounds/nz/ for each moment, blank for the game's own. Basalt's are
// its own (`NZSound.BasaltRoundStart` and the rest); every other map plays the game's.
/// <summary>A round beginning (`RoundManager.BeginRound`). Blank: `nz.round.start`.</summary>
public string RoundStartSound { get; set; } = "";
/// <summary>A round cleared. Blank: `nz.round.end`. ⚠️ KEEP IT UNDER THE GAP BETWEEN ROUNDS (10 s), or it runs into the
/// next round's start.</summary>
public string RoundEndSound { get; set; } = "";
/// <summary>The run over (`RoundManager.EndGame`). Blank: `nz.round.gameover`.</summary>
public string GameOverSound { get; set; } = "";
/// <summary>A barrier coming down (`DebrisManager`). Blank: `nz.debris.clear`.</summary>
public string DebrisSound { get; set; } = "";
/// <summary>
/// The map's motif as the power comes on (`PowerMotif`), over the game's own `nz.power.on` — basalt's, on bronze war horns, war
/// drums and a lyre. Blank: none, the power-on sound alone.
/// </summary>
public string PowerSound { get; set; } = "";
/// <summary>
/// A wall weapon or its ammo bought (`WallBuy.TryBuy`) — once, at the wall, in place of the spend's cha-ching: basalt's flame.
/// Blank: the game's own, as it always was.
/// </summary>
public string WallBuySound { get; set; } = "";
/// <summary>
/// The Easter egg's challenge music: the altar defense's, while the wave comes (`EggMusic`) — basalt's, Gorod Krovi's Pavlov's
/// defend. Blank: none.
/// </summary>
public string DefenseMusic { get; set; } = "";
/// <summary>
/// The boss fight's music, from his coming up to his death, low through each phase change (`EggMusic`) — basalt's, Gorod
/// Krovi's dragon fight. Blank: none.
/// </summary>
public string BossMusic { get; set; } = "";
/// <summary>
/// A sting as the boss dies, over his music going (`EggMusic`). Blank: none — basalt's since 2026-09-28 17:25, where the Easter
/// egg's fanfare plays its victory motif instead (`EggFanfare`, step 15).
/// </summary>
public string BossWinSound { get; set; } = "";
/// <summary>
/// A player's first visit to a named room, this game (`RoomNames.NoteShown`) — heard by that player alone. Blank: none;
/// the game has no sound of its own for it.
/// </summary>
public string RoomSound { get; set; } = "";
/// <summary>The mystery box spinning, from the buy, played to its end (`MysteryBox`). Blank: `nz.box.jingle`.</summary>
public string BoxSpinSound { get; set; } = "";
/// <summary>The mystery box's look (`MysteryBoxSkins`): blank, the original crate; "origins", the Origins stone chest on its base.</summary>
public string BoxSkin { get; set; } = "";
/// <summary>
/// What the lobby plays while this map's config is loaded (`LobbyMenu`). Blank: `nz.music.lobby`, the thirteen loading-screen
/// tracks. ⚠️ THE LOBBY RESTARTS A TRACK THAT ENDS (`NZMusic.Tick`), so one made for it should loop without a seam.
/// </summary>
public string LobbyMusic { get; set; } = "";
/// <summary>
/// The loading screen between everyone ready and the spawn-in (`MapLoading`), in seconds: the map's lobby image, its
/// name and a bar, the lobby's music playing on (2026-09-28). 0: none — the game starts at once, as it used to.
/// </summary>
public float LoadingSeconds
{
get => _loadingSeconds ?? 10f;
set => _loadingSeconds = Math.Clamp( value, 0f, 60f );
}
float? _loadingSeconds;
/// <summary>
/// One quiet bed under the whole game, always there (`MapAmbience`) — *"some ambience sounds very lightly but always there so
/// it does not feel so empty"* (2026-09-28). Blank: none. ⚠️ IT SHOULD LOOP WITHOUT A SEAM, as the lobby's music: it is
/// restarted when it ends.
/// </summary>
public string AmbientBed { get; set; } = "";
/// <summary>The common tier's wall-buy chalk, as #RRGGBB (`WallBuyManager.ChalkBase`) — basalt's ember. Blank: the original's white.</summary>
public string ChalkColour { get; set; } = "";
/// <summary>How much that chalk breathes, like embers, 0 to 1 (`WallBuyManager.TickChalk`). 0: steady.</summary>
public float ChalkGlow { get; set; }
/// <summary>
/// Seconds a wall weapon takes to burn into its chalk, one side to the other, as it is looked at (`WallBuyBurnIn`): a front of
/// embers in the chalk's colour. Basalt's is 0.9. 0 — the default — it appears at once, as it always did.
/// </summary>
public float ChalkBurnIn { get; set; }
/// <summary>
/// Embers rising in the fog areas beside the ash (`FogEmbers`), as a scale on their rate: 1 is basalt's, 0 — the default — none,
/// so a cold map's fog stays ash alone.
/// </summary>
public float FogEmbers { get; set; }
/// <summary>
/// The map's light strips dark until the power is on, then lit in a wave from the switch (`StripLights`) — basalt's. Off, the
/// default: a map's strips are lit from the start, as they always were.
/// </summary>
public bool StripsNeedPower { get; set; }
/// <summary>The strips' material, as a part of its path (`StripLights`). Blank: `lights/white001`, basalt's.</summary>
public string StripMaterial { get; set; } = "";
/// <summary>
/// How much the lights flicker when the ground shakes — the strips, the placed lights and the Easter egg's light panels, each on
/// its own clock (`MapTremor`): 1 is basalt's, 0 — the default — none.
/// </summary>
public float LightFlicker { get; set; }
/// <summary>
/// Embers rising off the lava where the player is near it (`LavaEmbers`), as a scale on their rate: 1 is basalt's, 0 — the
/// default — none.
/// </summary>
public float LavaEmbers { get; set; }
/// <summary>
/// Heat haze over the lava — the air above every pool drawn in game wavering (`LavaHaze`): 1 is basalt's, 0 — the default — none.
/// </summary>
public float LavaHaze { get; set; }
/// <summary>
/// Basalt's engravings: carvings cut into its pillars, placed from its own BSP (`BasaltEngravings`) — basalt's. Off, the default.
/// </summary>
public bool Engravings { get; set; }
/// <summary>
/// Damaging a zombie without killing it.
///
/// ⚠️ Paid PER HIT, not per bullet of damage — a shotgun pellet and a rifle
/// round are worth the same. That is what makes early rounds survivable:
/// shooting is income, so a weak gun still earns.
/// </summary>
public int PointsHit { get; set; } = ZombieStats.PointsHit; // 5
public int PointsKill { get; set; } = ZombieStats.PointsKillBody; // 50
/// <summary>Kill with the last hit to the head — the reason to aim.</summary>
public int PointsKillHeadshot { get; set; } = ZombieStats.PointsKillHeadshot; // 100
/// <summary>
/// Melee kill. The biggest single award in the game, deliberately: it pays
/// for the risk of being in arm's reach, and it is how you survive round 1
/// with no money and a starting pistol.
///
/// ⚠️ Nothing awards this yet — there is no melee weapon. The setting and
/// the award path exist so a knife only has to tag its damage.
/// </summary>
public int PointsKillKnife { get; set; } = ZombieStats.PointsKillMelee; // 130
}
/// <summary>
/// Zombie tuning.
///
/// ⚠️ Every default here is lifted from ZombieStats, which was ported from the
/// original — so an untouched config already behaves like nZombies. These are
/// not invented starting points. Changing a value here overrides the constant;
/// leaving it alone keeps the original's balance.
/// </summary>
/// <summary>
/// MAP/NAV — the nav mesh agent this map generates for.
///
/// ⛔ THESE HAVE TO LIVE IN THE MAP CONFIG BECAUSE THE SCENE CANNOT HOLD THEM. `MapInstance` loads a
/// map as CHILDREN of the active scene, so the ACTIVE scene's `NavMesh` is the only one that exists
/// and a map scene's own nav settings are inert — `NavBake`'s header says so, and it is why
/// `Basalt.scene` sits at the engine defaults (step 18, radius 16, Enabled false) while the mesh the
/// map actually pathed on came from `nzombies.scene` (step 24, radius 9). Anyone tuning the map
/// scene's numbers would have been editing a field nothing reads.
///
/// ⚠️ SO THE ONE LIVE MESH IS RECONFIGURED PER MAP, in `NavBake.Apply`, immediately before the
/// regenerate it already does. Nothing new is generated for this; the settings just land first.
///
/// ⚠️ ZERO MEANS "LEAVE THE SCENE'S VALUE ALONE", it does not mean zero. A config that stated real
/// defaults would silently override the scene on every map, so the day someone retuned
/// `nzombies.scene` their change would do nothing and the reason would be in a JSON file they were
/// not looking at.
/// </summary>
public class NavSettings
{
/// <summary>
/// How high a ledge a zombie can walk straight up, in units. 0 = the scene's value (24).
///
/// ⛔ THIS IS THE ONE THAT DECIDES WHETHER A PLATFORM IS REACHABLE AT ALL. Recast links two
/// surfaces into one walkable mesh only when the climb between them is within this; above it the
/// platform becomes a separate island that pathing cannot enter, and a zombie stands at the edge
/// looking at it. It is not a movement speed or an animation — the geometry is simply not
/// connected.
///
/// ⚠️ RAISING IT LETS THEM UP THINGS YOU DID NOT MEAN, and crates, railings and rubble are the
/// usual casualties. The player's own `StepHeight` is the engine default 18, so anything much
/// past the low 30s means zombies climb what the player cannot.
/// </summary>
public int StepSize { get; set; } = 0;
/// <summary>
/// Steepest ground a zombie will path across, in degrees. 0 = the scene's value (40).
///
/// ⚠️ THE PLAYER'S `GroundAngle` IS 45 AND THE MESH WAS BUILT FOR 40, which is a real gap rather
/// than a rounding difference: there are ramps a player walks up and the horde will not follow.
/// Matching them is usually what "the zombies cannot get to me" means on a sloped map.
/// </summary>
public float MaxSlope { get; set; } = 0f;
/// <summary>How wide a gap a zombie needs, in units. 0 = the scene's value (9).
///
/// ⚠️ THE MESH IS INSET FROM EVERY WALL BY THIS, so raising it closes doorways and narrow
/// walkways map-wide — a far blunter instrument than it looks.</summary>
public int AgentRadius { get; set; } = 0;
/// <summary>Headroom a zombie needs, in units. 0 = the scene's value (72).</summary>
public int AgentHeight { get; set; } = 0;
/// <summary>True when this map asks for anything at all.
///
/// ⚠️ `[JsonIgnore]` BECAUSE IT IS DERIVED. Without it the saved config carries `"IsSet": true`
/// beside the four fields it is computed FROM — a value that can contradict them after a hand
/// edit and that nothing will ever read back, since a getter-only property is skipped on
/// deserialise. Two sources of truth in a file people edit by hand is how a setting comes to
/// "not work" for reasons invisible in the code.</summary>
[JsonIgnore]
public bool IsSet => StepSize > 0 || MaxSlope > 0f || AgentRadius > 0 || AgentHeight > 0;
public string Describe() => IsSet
? $"step {( StepSize > 0 ? StepSize.ToString() : "scene" )}, "
+ $"slope {( MaxSlope > 0f ? MaxSlope.ToString( "0.#" ) : "scene" )}, "
+ $"radius {( AgentRadius > 0 ? AgentRadius.ToString() : "scene" )}, "
+ $"height {( AgentHeight > 0 ? AgentHeight.ToString() : "scene" )}"
: "(none — the scene's own agent)";
}
public class ZombieSettings
{
// ── appearance ───────────────────────────────────────────────────────────
/// <summary>
/// Which body the ORDINARY horde wears here — a `.zvar` name under `zombies/`, or empty for
/// the stock walker. See <see cref="WalkerSkins"/>.
///
/// ⚠️ PER MAP, NOT PER ROUND OR PER ZOMBIE. Upstream a walker skin IS the map's identity —
/// Origins fields knights, Buried fields townsfolk — and mixing them is a thing no stock map
/// does. A variant's own `Models` list is where per-zombie variety lives.
///
/// ⚠️ PERSISTED IN THE MAP CONFIG, so it is a string and not a resource reference. The config
/// is JSON and travels between machines; a missing asset must degrade to the stock walker with
/// a warning rather than fail to deserialise the whole map.
/// </summary>
public string WalkerSkin { get; set; } = WalkerSkins.Stock;
// ── health ───────────────────────────────────────────────────────────────
public int BaseHealth { get; set; } = ZombieStats.BaseHealth; // 75
/// <summary>Flat HP added per round below round 10.</summary>
public int HealthIncrement { get; set; } = ZombieStats.HealthIncrement; // 50
/// <summary>From round 10 the increase goes multiplicative instead.</summary>
public float HealthMultiplier { get; set; } = ZombieStats.HealthMultiplier; // 0.1
public int HealthCap { get; set; } = ZombieStats.HealthCap; // 6,000,000 at HealthCapRound; NOT a ceiling since 2026-10-06 — see ZombieStats.HealthCap
/// <summary>
/// The last round on the compounding curve, 59. From the next, health climbs in a straight line through
/// <see cref="HealthCap"/> at <see cref="HealthCapRound"/> (2026-10-04), and on without end (2026-10-06).
///
/// ⚠️ A CAP ROUND AT OR BEFORE THIS SWITCHES THE LINE OFF, and the curve compounds until it meets the cap, as before.
/// </summary>
public int HealthLinearFrom { get; set; } = ZombieStats.HealthLinearFrom;
/// <summary>The round health reaches <see cref="HealthCap"/>, 100 (2026-10-04). A point on the line, not where it stops:
/// health keeps climbing past it (2026-10-06).</summary>
public int HealthCapRound { get; set; } = ZombieStats.HealthCapRound;
// ── speed ────────────────────────────────────────────────────────────────
/// <summary>
/// The round from which EVERY zombie is in the top speed tier (super-sprint), 60 (2026-10-05). The speed rating climbs in a
/// straight line from 0 on round 1 to the super-sprint threshold (155) here, and on at that rate to `SpeedCap`. See
/// `ZombieStats.SpeedForRound` for the round each tier arrives.
///
/// ⚠️ 0 OR 1 SWITCHES IT OFF, and `SpeedPerRound` drives the curve as before (+4 a round put the cap on round 40).
/// </summary>
public int SpeedCapRound { get; set; } = ZombieStats.SpeedCapRound; // 60
/// <summary>Added per round, ONLY when `SpeedCapRound` is 0 or 1. The original's curve is round*4 - 4, so round 1
/// adds nothing and base speed carries it.</summary>
public int SpeedPerRound { get; set; } = ZombieStats.SpeedMultiplier; // 4
public int SpeedCap { get; set; } = ZombieStats.SpeedCap; // 300
// ── wave size ────────────────────────────────────────────────────────────
/// <summary>Zombies in round 1.</summary>
public int WaveBase { get; set; } = ZombieStats.WaveBase; // 24
/// <summary>Most a single wave can ever contain.</summary>
public int WaveCap { get; set; } = ZombieStats.WaveCap; // 240
/// <summary>How many may exist AT ONCE — unrelated to wave size. A 240
/// zombie wave still trickles in 35 at a time.</summary>
public int MaxAlive { get; set; } = ZombieStats.MaxAlive; // 35
// ── pacing ───────────────────────────────────────────────────────────────
public float SpawnDelayBase { get; set; } = ZombieStats.SpawnDelayBase; // 2.0
public float SpawnDelayMin { get; set; } = ZombieStats.SpawnDelayMin; // 0.08
// ── attack pressure over rounds ──────────────────────────────────────────
// All four ramp LINEARLY from "no change at all" on round 1 to the scale named here on
// AttackScaleEndRound, then hold. See ZombieStats' CURVES/ATTACK PRESSURE block for why the
// immunity window is the one that actually raises the damage ceiling, and
// `nz_zombie_attack_curve` to print the result.
/// <summary>Round the attack ramp finishes. Below 2 the ramp is skipped entirely.</summary>
public int AttackScaleEndRound { get; set; } = ZombieStats.AttackScaleEndRound; // 60 (55 until 2026-10-03)
/// <summary>Swing playback multiplier at the end round. 1.0 = off.</summary>
public float AttackSpeedEndScale { get; set; } = ZombieStats.AttackSpeedEndScale; // 1.9 (1.25 until 2026-10-03)
/// <summary>Swing REACH multiplier at the end round. The trigger range never moves.</summary>
public float AttackReachEndScale { get; set; } = ZombieStats.AttackReachEndScale; // 1.15
/// <summary>How far into the swing the hit lands, at the end round. Below 1 = earlier.</summary>
public float AttackDamagePointEndScale { get; set; } = ZombieStats.AttackDamagePointEndScale; // 0.60
/// <summary>The PLAYER's post-hit immunity window at the end round. Below 1 = shorter, which
/// is the only knob that raises the horde's combined DPS ceiling.</summary>
public float VictimImmunityEndScale { get; set; } = ZombieStats.VictimImmunityEndScale; // 0.40 (0.70 until 2026-10-03)
/// <summary>
/// Zombie damage rises again from round 31's 90 to `DamageRampEndScale` times that by this round, then holds
/// (`ZombieStats.AttackDamageForRound`). Added 2026-10-03: 180 by round 60.
/// </summary>
public int DamageRampEndRound { get; set; } = ZombieStats.DamageRampEndRound; // 60
/// <summary>How far zombie damage rises past round 31: 2 = double (180). 1 switches the rise off.</summary>
public float DamageRampEndScale { get; set; } = ZombieStats.DamageRampEndScale; // 2.0
// ── how fast each speed tier travels ─────────────────────────────────────
// The round curve picks a TIER; these decide what the tier is worth. Multiplied into the
// MOVE speed only, never the clip speed — see ZombieStats' CURVES/TIER SPEED block for why
// that distinction is what stops the zombie skating. `nz_zspeed_scale` tunes them live and
// `nz_zspeed_curve` prints the result per round.
/// <summary>Rounds 1-14 (1-9 before 2026-10-05). 1 = the authored shamble.</summary>
public float WalkSpeedScale { get; set; } = ZombieStats.WalkSpeedScale; // 1.0
/// <summary>Rounds ~15-28 (~10-18 before 2026-10-05). 1.8 -> 104-218 u/s.</summary>
public float RunSpeedScale { get; set; } = ZombieStats.RunSpeedScale; // 1.2
/// <summary>Rounds ~29-59 (~19-39 before 2026-10-05). 1.8 -> 260-310 u/s.</summary>
public float SprintSpeedScale { get; set; } = ZombieStats.SprintSpeedScale; // 1.4
/// <summary>Round 60+ (40+ before 2026-10-05). 2.5 -> 493-586 u/s, against a maxed player sprint of 538.</summary>
public float SuperSprintSpeedScale { get; set; } = ZombieStats.SuperSprintSpeedScale; // 1.6
/// <summary>Fastest a locomotion clip may play. Above this the legs stop keeping up with the
/// body and the zombie skates, so it is the real ceiling on every scale above.</summary>
public float MaxAnimRate { get; set; } = ZombieStats.MaxAnimRate; // 2.5
}
/// <summary>
/// A NAV LINK — a hand-authored connection between two points on the navmesh.
///
/// ⚠️ THIS IS THE ONE PIECE OF GMOD'S NAV EDITING s&box DOES NOT GIVE YOU FOR
/// FREE. Its mesh is Recast: adjacency is DERIVED from which polygons physically
/// touch, regenerated with the geometry, and there is no per-area connection API
/// to sever or add — see DebrisManager.AddNavBlocker. So anything the geometry
/// does not already imply has to be stated as a link:
///
/// • a DROP off a ledge — walkable above, walkable below, nothing joining
/// them because a 200u fall is not a step
/// • a ONE-WAY route — down into a pit but never back out
/// • a shortcut the mesh — over a rail, across a gap
/// would not generate
///
/// The generated mesh already handles everything you can WALK. Links are for
/// everything you cannot.
/// </summary>
public class NavLinkSpot
{
/// <summary>Where the traversal starts. For a drop, the LEDGE.</summary>
public Vector3 A { get; set; }
/// <summary>Where it ends. For a drop, the FLOOR BELOW.</summary>
public Vector3 B { get; set; }
/// <summary>Usable in both directions?
///
/// ⚠️ DEFAULTS FALSE, i.e. A → B ONLY — the opposite of the engine's own
/// default. The common case by far is a drop, and a two-way drop means
/// zombies walking UP a sheer face, which reads as a bug in the map rather
/// than a setting. Say so explicitly when you want a rope or a ladder.</summary>
public bool BiDirectional { get; set; } = false;
/// <summary>How wide the link's mouth is. A wider radius lets more of the
/// approaching crowd funnel in rather than queueing at one exact point.</summary>
public float Radius { get; set; } = 32f;
/// <summary>
/// Cross this one by WALKING — no jump or drop clip, the zombie's own speed, nothing to wait for
/// at either end.
///
/// ⛔ FOR LINKS THAT ONLY EXIST BECAUSE THE MESH WOULD NOT JOIN, which is most of them on a map
/// built out of platforms. A step the navmesh refuses to link is still a step a person walks
/// over, and staging a jump animation for it reads as a zombie leaping onto a kerb. The link is
/// doing pathfinding work, not animation work, and this says so.
///
/// ⚠️ IT CHANGES THREE THINGS AND THEY ONLY MAKE SENSE TOGETHER: no action clip (the locomotion
/// keeps running), the crossing timed from the zombie's OWN ground speed instead of
/// `CrossUnitsUp`/`Down`, and a straight lerp instead of `CrossBias`'s lead-and-trail curve —
/// that curve exists to make a jump arc, and on a walk it just makes the body surge.
///
/// ⛔ AND IT SKIPS `TraverseCooldown`. That is a one-second guard against re-entering the link
/// you just left; on a walk link it is the delay this flag exists to remove, and two low steps a
/// second apart would stall the second one. The pathfinder still has to have ROUTED through the
/// link (`IsTraversingLink`), which is what stops a zombie being grabbed by one it merely walked
/// past. If a link does start ping-ponging, turn this off for it rather than raising the
/// cooldown — the cooldown is not consulted here at all.
/// </summary>
public bool Walk { get; set; } = false;
/// <summary>Flag that must be open before this link works — same door-link
/// system as spawns and barriers. Blank = always on.</summary>
[JsonConverter( typeof( LinkConverter ) )]
public string Link { get; set; } = DoorLinks.Unlinked;
public float Length => A.Distance( B );
/// <summary>Vertical drop, negative if it goes up.</summary>
public float Fall => A.z - B.z;
}
/// <summary>
/// SPECIAL ROUNDS — when they happen, how big they are, and how hard.
///
/// The multipliers stack ON TOP of the variant's own. A .zvar states what the
/// enemy IS (a hellhound is fast because hellhounds are fast); this states how
/// a given MAP wants to use it. Keeping them separate means retuning one map
/// cannot make every hound in the game faster.
/// </summary>
/// <summary>
/// When bosses appear, how often, and how many.
///
/// ⛔ THERE IS NO SUCH THING AS A BOSS ROUND, AND THAT IS THE WHOLE DIFFERENCE FROM
/// `SpecialSettings`. A special round REPLACES the wave — eight hounds ARE the round. A boss does
/// not: he walks in during an ordinary round, at a boss spawner, while the normal wave continues
/// around him. So there is no "count per round" in the sense a dog round has one, no wave to
/// replace, and no round type to announce.
///
/// ⛔ AND HE NEVER ARRIVES DURING A SPECIAL ROUND. A dog round is already the round's event; a
/// boss on top of it is two events competing, and the special wave draws from its own spawn list
/// which a boss has no place in. `SpawnsOnRound` takes the special settings so it can refuse.
///
/// ⚠️ IT IS INERT WITHOUT SPAWN POINTS, exactly as special rounds are. `MapConfig.BossSpawns` is
/// what actually enables bosses — these numbers only decide the cadence once there is somewhere to
/// put one.
/// </summary>
public class BossSettings
{
/// <summary>Boss rounds off entirely. A map with no boss spawners behaves this way anyway.</summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// First round a boss appears.
///
/// ⚠️ OURS RATHER THAN UPSTREAM'S. nZombies has no boss schedule at all — bosses come from map
/// scripts and Easter eggs — so there is no original number to be faithful to. This puts the
/// first one two special rounds in, by which point the player has a wall weapon, a perk and
/// enough points to run.
///
/// ⛔ 11 AND NOT 10, AND THE ODD NUMBER IS THE WHOLE POINT. Bosses never arrive on a special
/// round, and specials default to every 5 from round 5 — so a boss schedule of "every 10 from
/// round 10" has EVERY due round land on a dog round and a boss never appears at all. 10/10 was
/// the first default written here and `BossNeverArrives` caught it. 11 with an interval of 10
/// gives 11, 21, 31, 41 — none of them a multiple of 5.
///
/// ⚠️ SO IF EITHER INTERVAL IS RETUNED, CHECK THE "Arrives on" ROW. Any boss cadence that is a
/// multiple of the special cadence collides on every cycle, and the fields all look reasonable
/// while nothing spawns.
/// </summary>
public int FirstRound { get; set; } = 11;
/// <summary>
/// Rounds between boss rounds. 10 = rounds 10, 20, 30...
///
/// ⚠️ DELIBERATELY LONGER THAN THE SPECIAL INTERVAL. A boss that arrives as often as a dog
/// round stops being an event; the gap is most of what makes one feel like one.
/// </summary>
public int RoundInterval { get; set; } = 10;
/// <summary>
/// How many bosses arrive on a boss round, before the ramp below.
/// </summary>
public int CountPerRound { get; set; } = 1;
/// <summary>
/// From this round onwards, <see cref="CountPerRound"/> is increased by
/// <see cref="CountStep"/>. 0 disables the ramp.
///
/// ⛔ A SECOND THRESHOLD RATHER THAN A CURVE, AND THAT IS THE POINT. "From round 30, two instead
/// of one" is a thing a mapper can state and the panel can display; `round / 15` rounded is not —
/// it is the kind of formula whose behaviour has to be worked out by hand before anyone can tell
/// whether it is what they wanted. The same argument `SpecialSettings.RoundInterval` makes for a
/// fixed interval over the original's `random(5,7)`.
///
/// ⚠️ IT COMPOUNDS EVERY `RampInterval` ROUNDS, so a long game keeps escalating rather than
/// stepping once and flattening. Set `RampInterval` to 0 for a single step.
/// </summary>
public int RampFromRound { get; set; } = 30;
/// <summary>How many extra bosses each ramp step adds. 1.</summary>
public int CountStep { get; set; } = 1;
/// <summary>
/// Rounds between ramp steps once <see cref="RampFromRound"/> is reached. 0 = step once and stop.
/// </summary>
public int RampInterval { get; set; } = 20;
/// <summary>Most bosses that may exist at once, whatever the ramp says.</summary>
public int MaxAlive { get; set; } = 4;
/// <summary>
/// THE BOSSES THIS MAP'S BOSS ROUNDS PICK FROM, AT RANDOM (2026-10-07, City Uprising's Margwas — the user: *"instead of
/// brutus, it spawns a random margwa, and when it spawns more than one it always spawns diferent ones"*). Boss ids
/// (`SpecialEnemies`), e.g. `["margwa", "margwa_zod", "margwa_genesis", "margwa_fire", "margwa_shadow"]`.
///
/// ⛔ EMPTY BY DEFAULT, AND EMPTY MEANS THE OLD WAY: each boss is its spawn point's own (`SpawnPoint.Special`). A map's
/// request is that map's switch, not every map's (the basalt tremors' lesson).
/// ⚠️ ALWAYS A DIFFERENT ONE: a boss round's picks are all different, and none is one still alive, while the pool has another
/// (`RoundManager.PickPooledBoss`). Names that are not bosses are skipped. `nz_boss_spawn` with no name picks from it too;
/// `nz_boss_pool` shows it.
/// </summary>
public List<string> Pool { get; set; } = new();
/// <summary>
/// Added to a boss's own `HealthMultiplier` for every round past <see cref="FirstRound"/>.
/// **0 — off.** A boss is a flat multiple of the walker curve.
///
/// ⛔ TRIED AT 1 AND TURNED OFF THE SAME MORNING, AND THE REASON IS WORTH KEEPING. It was
/// added to fix a real plateau: walker health capped at 60,000 from round 41, so every boss
/// from then on was byte-identical. Raising `ZombieStats.HealthCap` to 1,000,000 fixed that
/// plateau properly — at its source, for walkers and bosses alike — and left this term
/// stacking a second growth curve on top of one that already grows.
///
/// The two compounded badly. At ×65 on a 1,000,000 walker, a round-61 Brutus was 65,000,000
/// health — and `BrutusHelmet` scales body damage to 0.15, so the pool a player actually had
/// to deal was **433 million**. Not a fight; a wall.
///
/// ⚠️ THE KNOB IS KEPT, NOT DELETED, because "should boss health grow per round?" is a
/// question somebody will ask again, and the answer is here with its arithmetic. Setting it
/// above 0 re-enables the growth — and re-creates the wall unless `BrutusHelmet.BodyScale`
/// or the cap moves with it.
/// </summary>
public float HealthPerRound { get; set; } = 0f;
/// <summary>Seconds between boss arrivals when more than one is due.</summary>
public float SpawnDelay { get; set; } = 3f;
/// <summary>
/// Is this round due a boss by the interval alone — ignoring special rounds and spawners.
///
/// ⚠️ THE PURE ARITHMETIC, NOTHING ELSE. Whether a boss actually arrives also depends on the
/// special schedule and on a spawner existing, and both of those are config-level facts this
/// class cannot see. `MapConfig.BossArrivesOn` is the question callers want.
/// </summary>
public bool DueOn( int round )
{
if ( !Enabled ) return false;
// ⚠️ THE MATCH'S FIRST BOSS AND BOSS ROUNDS (the lobby's Difficulty, 2026-10-05) when it has them, else these
var first = Difficulty.BossFirst( FirstRound );
var every = Difficulty.BossEvery( RoundInterval );
if ( round < first ) return false;
return every <= 0
? round == first
: (round - first) % every == 0;
}
/// <summary>
/// How many bosses round N is worth, after the ramp and the cap.
///
/// ⚠️ CAPPED BY `MaxAlive`, because a count above it could never all exist and the number would
/// be a lie the panel repeated.
/// </summary>
public int CountForRound( int round )
{
var n = Math.Max( 1, CountPerRound );
if ( RampFromRound > 0 && round >= RampFromRound && CountStep > 0 )
{
var steps = RampInterval > 0
? 1 + (round - RampFromRound) / RampInterval
: 1;
n += CountStep * steps;
}
return Math.Clamp( n, 1, Math.Max( 1, MaxAlive ) );
}
}
/// <summary>
/// One enemy's rule for arriving during ordinary rounds.
///
/// ⛔ TIERS RATHER THAN A PILE OF RAMP FIELDS. The two rules this was built for are
/// "every 3 rounds from 8, then every round from 30" and "1 per round from 14, 2 from 20, 4 from
/// 30 with two alive at once" — expressing that as FirstRound/Interval/IntervalFrom/IntervalAfter/
/// PerRound/PerRoundStep/PerRoundFrom/MaxAlive/MaxAliveFrom is nine fields that only make sense
/// read together. A list of "from round N, these numbers apply" says the same thing in the shape
/// the designer actually thinks in, and adding a fourth tier costs a row instead of three fields.
/// </summary>
public class AmbientSpecial
{
/// <summary>Roster id — see <see cref="NZombies.SpecialEnemies"/>.</summary>
public string Enemy { get; set; } = "";
/// <summary>Off without deleting the rule.</summary>
public bool Enabled { get; set; } = true;
/// <summary>First round it can appear at all. Below this, nothing.</summary>
public int FirstRound { get; set; } = 1;
/// <summary>
/// Draw from the BOSS spawn points instead of the ordinary zombie ones.
///
/// ⚠️ THIS IS A PLACEMENT CHOICE, NOT A STATUS ONE. Whether the round can end with one alive is
/// decided by the variant's `IsBoss` — `RoundManager.AliveBlocking` counts everything that is
/// not a boss — so the two happen to agree for the napalm zombie and deliberately disagree for
/// nothing yet. Do not infer one from the other.
/// </summary>
public bool UseBossSpawns { get; set; } = false;
/// <summary>
/// Seconds between arrivals of this enemy inside one round.
///
/// ⚠️ THEY ARRIVE ONE AT A TIME AS THE ROUND RUNS, not in a clump at the start. A round's whole
/// quota landing together is a special round, which is a different feature.
/// </summary>
public float SpawnDelay { get; set; } = 20f;
/// <summary>How long into a round before the first one can arrive.</summary>
public float FirstDelay { get; set; } = 15f;
/// <summary>
/// Stay out of special rounds.
///
/// ⛔ IT DEFERS THE ARRIVAL TO THE NEXT ORDINARY ROUND — IT DOES NOT DELETE IT. Basalt's specials
/// land every 5 from round 5 and its napalm zombie is due every 3 from round 8, so rounds 20, 35
/// and 50 are both. The first version dropped those, which quietly cost the cadence one arrival
/// in five; now round 20's napalm zombie turns up on round 21. `nz_ambient_report` prints the
/// moves as `20->21` and how many are owed right now.
///
/// ⚠️ THE DEFAULT IS A JUDGEMENT ABOUT BASALT SPECIFICALLY: its special round is a fifty-strong
/// pest horde, and a 15x-health napalm zombie wandering through that is not the fight either of
/// them was designed for. A map whose special round is four hellhounds would reasonably set this
/// false.
/// </summary>
public bool SkipSpecialRounds { get; set; } = true;
/// <summary>
/// When a special round is skipped, hand the arrivals to the next ordinary round.
///
/// ⛔ IT IS ONLY MEANINGFUL FOR A RULE WITH AN INTERVAL ABOVE 1, AND IT IS A TRAP BELOW IT. An
/// enemy due every THIRD round loses a real arrival when one is skipped, so moving it matters.
/// An enemy due EVERY round loses nothing — there is another next round by definition — and
/// deferring instead stacks a whole round's worth on top of the next one's.
///
/// ⚠️ ON BASALT THAT IS THE DIFFERENCE BETWEEN A NUDGE AND A SPIKE. The napalm zombie moves
/// 20 -> 21, one arrival, exactly as intended. The Shrieker is due every round from 14, so every
/// special round hands its whole quota forward: round 21 gets 4 and round 31 gets **8**, against
/// a normal 2 and 4. With `MaxAlive` at 2 and Shriekers blocking the round from ending, that is a
/// very long round.
///
/// ⚠️ LEFT TRUE FOR BOTH because "skipping moves it to the next round" is what was asked for.
/// Setting it false on the Shrieker is a one-word change if the spike is not wanted.
/// </summary>
public bool DeferOnSkip { get; set; } = true;
/// <summary>Rules by round, lowest first. The highest one at or below the round wins.</summary>
public List<AmbientTier> Tiers { get; set; } = new();
/// <summary>The tier in force for a round, or null if none applies.</summary>
public AmbientTier TierFor( int round )
{
AmbientTier best = null;
foreach ( var t in Tiers )
{
if ( round < t.FromRound ) continue;
if ( best is null || t.FromRound > best.FromRound ) best = t;
}
return best;
}
/// <summary>
/// How many should arrive during this round — 0 when it is not due.
///
/// ⚠️ THE INTERVAL IS COUNTED FROM `FirstRound`, NOT FROM THE TIER'S OWN START. A tier that
/// changed the phase as well as the period would make "every 3 rounds from 8" stop meaning
/// 8/11/14 the moment a later tier existed, and the schedule a designer wrote would quietly
/// shift under them.
/// </summary>
public int CountForRound( int round )
{
if ( !Enabled || round < FirstRound ) return 0;
var t = TierFor( round );
if ( t is null ) return 0;
var every = Math.Max( 1, t.RoundInterval );
if ( (round - FirstRound) % every != 0 ) return 0;
return Math.Max( 0, t.PerRound );
}
/// <summary>Most of this enemy that may exist at once during this round.</summary>
public int MaxAliveForRound( int round )
{
var t = TierFor( round );
return t is null ? 0 : Math.Max( 1, t.MaxAlive );
}
}
/// <summary>One band of an <see cref="AmbientSpecial"/>'s schedule.</summary>
public class AmbientTier
{
/// <summary>Round this band starts at.</summary>
public int FromRound { get; set; } = 1;
/// <summary>Rounds between appearances. 1 = every round, 3 = every third.</summary>
public int RoundInterval { get; set; } = 1;
/// <summary>How many arrive across a round it IS due on.</summary>
public int PerRound { get; set; } = 1;
/// <summary>Most alive at once. A quota above this arrives as earlier ones die.</summary>
public int MaxAlive { get; set; } = 1;
}
public class SpecialSettings
{
/// <summary>Special rounds off entirely. A map with no special spawners
/// placed behaves this way regardless.</summary>
public bool Enabled { get; set; } = true;
/// <summary>First round that is a special round.
///
/// ⚠️ 5 is the original's first dog round, not a round number chosen to look
/// tidy. Earlier than this and the player has neither the points nor the wall
/// weapons to deal with something that ignores barricades.</summary>
public int FirstRound { get; set; } = 5;
/// <summary>Rounds between special rounds. 5 = rounds 5, 10, 15...
///
/// ⚠️ The original re-rolls `random(5,7)` after each one, so its dog rounds
/// drift and cannot be counted on. A fixed interval is the deliberate choice
/// here: a mapper setting this number wants to know when they land, and a
/// random one is not something the settings panel can honestly display.</summary>
public int RoundInterval { get; set; } = 5;
/// <summary>How many spawn in a special round.
///
/// ⚠️ A FLAT COUNT, not the original's `clamp(round x players, 6, 24)`. That
/// curve is a solo-versus-four-player balance decision, and this game has no
/// player count to feed it yet; 8 sits where that formula lands around round
/// 8 solo. Revisit when multiplayer exists.</summary>
public int CountPerRound { get; set; } = 8;
/// <summary>Most that may exist AT ONCE — a 24 strong round still arrives in
/// waves. Unrelated to CountPerRound, exactly as MaxAlive is to WaveBase.</summary>
public int MaxAlive { get; set; } = 8;
/// <summary>Seconds between spawns within a special round.
///
/// ⚠️ 1.25 is the original's solo spacing — `clamp(2 - players*0.75, 0.25, 2)`
/// with one player. All at once is not a dog round, it is an ambush.
///
/// ⚠️ AND AN AMBUSH IS NOW A THING A MAP CAN ASK FOR — see `SpawnsPerTick`. Basalt fields
/// fifty sprinters at once on purpose; the sentence above still describes what a DOG round
/// should be, which is why the default has not moved.</summary>
public float SpawnDelay { get; set; } = 1.25f;
/// <summary>
/// Which special a special round fields, overriding what the spawn points name.
///
/// ⛔ BLANK MEANS "ASK THE SPAWN POINT", WHICH IS THE OLD BEHAVIOUR AND STILL THE DEFAULT. Every
/// `SpawnPoint` carries its own `Special`, which is right for a map that wants hounds at one
/// entrance and something else at another — but it is the wrong place to say "this map's
/// special round is pests", because that is one decision and it would otherwise have to be
/// repeated at every point and kept in step forever.
///
/// ⚠️ IT OVERRIDES RATHER THAN DEFAULTS. A per-point `Special` cannot win against this, because
/// a map that sets it has decided at the map level; if per-point variety is wanted, leave this
/// blank and let the points speak.
/// </summary>
public string Enemy { get; set; } = "";
/// <summary>
/// Does a special round bring its fog (`SpecialFog`)? ON by default — the hellhound round's own look. Off for a special
/// round that should look like any other: basalt's pests, *"i dont want the hellhounds fog in this round"* (2026-09-27).
/// </summary>
public bool Fog { get; set; } = true;
/// <summary>
/// Does the announcer call a special round, three seconds in (`RoundManager.SpecialAnnouncement`)? ON by default. Off on
/// basalt: *"i dont want the anoucer for the special round in this config either"* (2026-09-27).
/// </summary>
public bool Announce { get; set; } = true;
/// <summary>
/// A sound looped for as long as a special round is fought — its cue under `sounds/nz/`, as `nz.music.lobby` is — played on
/// every machine as its music (`NZMusic`) and stopped as the round ends. Blank = none. Basalt's pests: a swarm of flies.
/// </summary>
public string Loop { get; set; } = "";
/// <summary>
/// Does a special round play the round's own start and end sounds (`NZSound.RoundStart`, `RoundEnd`)? ON by default. Off on
/// basalt: *"i dont want the round start and end sound on pest rounds"* (2026-09-27) — its pests come in to the flies.
///
/// ⚠️ A NULLABLE BEHIND IT, so a config already in memory when this arrived by hotload reads ON, not off.
/// </summary>
public bool RoundSounds
{
get => _roundSounds ?? true;
set => _roundSounds = value;
}
bool? _roundSounds;
/// <summary>
/// Draw a special round from the ORDINARY zombie spawns instead of the special ones.
///
/// ⛔ BECAUSE NOT EVERY SPECIAL IS A HOUND. The special spawner set exists for enemies that
/// arrive from their own places — hellhounds come out of the ground, away from the barricades —
/// and it is deliberately small. A horde of fifty sprinters is a wave of ordinary zombies that
/// happen to be fast, so it should come through the windows and doors the player has been
/// watching all game, which is where the normal spawns are.
///
/// ⚠️ AND IT CHANGES HOW THE POINT IS PICKED, NOT JUST WHICH LIST. `SpawnOneSpecial` picks
/// uniformly on purpose — a hound round's arrival pattern is authored — but the ordinary
/// spawner weights toward the player, and that bias is what makes a horde feel like one.
/// Drawing from the normal set while keeping the uniform pick would spread fifty sprinters
/// evenly across a map the player is standing in one corner of.
/// </summary>
public bool UseZombieSpawns { get; set; } = false;
/// <summary>
/// How many arrive per spawn tick.
///
/// ⛔ `SpawnDelay` CANNOT EXPRESS "AT ONCE" ON ITS OWN, because the tick spawns one and then
/// waits, and that wait is floored at 0.05s — so fifty enemies take at least two and a half
/// seconds however low the delay is set. The floor is there to stop a misconfigured map pinning
/// the frame, and this is the honest way round it rather than removing a guard.
///
/// ⚠️ IT IS STILL BOUNDED BY `MaxAlive`, which is re-checked per spawn. A burst of 50 into a cap
/// of 8 arrives as 8 — the cap is the population target and this is only the rate.
/// </summary>
public int SpawnsPerTick { get; set; } = 1;
/// <summary>Scales the round's health curve for specials only.</summary>
public float HealthMultiplier { get; set; } = 1f;
/// <summary>Scales movement speed for specials only. Applies on top of the
/// variant's own multiplier and of its clips' authored ground speed, so the
/// stride follows the body and the feet still do not skate.</summary>
public float SpeedMultiplier { get; set; } = 1f;
}
/// <summary>
/// A barricade: a thin boarded wall between two clicked points.
///
/// ⚠️ TWO POINTS AND A DEFAULT HEIGHT, not the four-corners-plus-height flow that
/// Debris and InvisibleWall share. A barricade is a LINE, not a footprint — its
/// whole shape is "between here and there" — and its height is the one number
/// that must NOT be author-chosen, because it is what makes the thing vaultable.
/// A barricade someone built chest-high would silently stop being a barricade.
/// </summary>
public class BarricadeSpot
{
/// <summary>First clicked point, world space.</summary>
public Vector3 A { get; set; }
/// <summary>Second clicked point, world space.</summary>
public Vector3 B { get; set; }
/// <summary>
/// How tall, in units.
///
/// ⚠️ 44 IS A VAULT HEIGHT, not a wall height. It has to read as something a
/// zombie climbs over rather than something it walks around: low enough to be
/// obviously surmountable on sight, high enough that the player reads it as a
/// barrier rather than a step. Player step height in s&box is ~18, so this is
/// comfortably above "walk straight over".
/// </summary>
public float Height { get; set; } = 44f;
/// <summary>How thick the wall is. Boards, not masonry.</summary>
public float Thickness { get; set; } = 6f;
/// <summary>Boards on it. The original's cap, and Barricade.MaxPlanks.</summary>
public int Boards { get; set; } = 6;
/// <summary>
/// Wood. ⚠️ A named material rather than a tint — a flat-tinted block reads as
/// a dev placeholder however well the colour is picked, which is the same
/// lesson Debris.Material records.
/// </summary>
public string Material { get; set; } = "materials/models/cscgroupe/props/awp_ardennes/wood_trunk01.vmat";
/// <summary>Tint over the material. White leaves it alone.</summary>
public Color Tint { get; set; } = Color.White;
/// <summary>Midpoint of the run — where the component sits.</summary>
public Vector3 Centre => (A + B) * 0.5f;
/// <summary>How long the run is.</summary>
public float Length => A.Distance( B );
/// <summary>Facing, so the thin axis crosses the run.</summary>
public float Yaw => (B - A).WithZ( 0 ).EulerAngles.yaw;
}
/// <summary>
/// A mystery box location.
///
/// ⚠️ ONE POINT AND A YAW, unlike the barricade's two-point run — a box is an
/// object you stand at, not a span you build. The tool is correspondingly
/// simpler: click where it goes, it faces you.
///
/// ⚠️ In the original these are `random_box_spawns` and the box MOVES between
/// them after a teddy bear. That is phase three; today the list is just "where
/// the boxes are", and it already has the right shape for it.
/// </summary>
public class MysteryBoxSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>Price. The original's `rboxprice or 950`.</summary>
public int Cost { get; set; } = 950;
/// <summary>
/// The floor's surface normal here, so the box lies FLAT on a slope instead
/// of level with the world.
///
/// ⚠️ Stored rather than re-traced at build time. The trace that found this
/// point already returned it, and re-tracing later could hit a zombie, a
/// prop, or the box's own collider and tilt it to something that is not the
/// floor.
///
/// ⚠️ Defaults to straight up, so a config authored before this existed still
/// builds level rather than face-down at zero.
/// </summary>
public Vector3 Normal { get; set; } = Vector3.Up;
/// <summary>
/// May the box START here?
///
/// ⛔ THE SPOTS ARE CANDIDATES, NOT BOXES. Only ONE box exists at a time — the
/// rest of the list is where it can move to later. A spot with this off is
/// still a valid destination, it simply never holds the box at round one.
/// That is the original's `random_box_spawns` model, and it is why the
/// manager picks one spot rather than building them all.
/// </summary>
public bool CanStart { get; set; } = true;
}
/// <summary>
/// Where a Pack-a-Punch machine stands.
///
/// ⚠️ Deliberately the same shape as MysteryBoxSpot — position, yaw, floor
/// normal — because both are floor-standing props placed by the same kind of
/// trace, and a second placement convention is a second set of slope bugs.
/// </summary>
/// <summary>
/// A Der Wunderfizz machine. One click, faces you, like Pack-a-Punch.
///
/// ⚠️ Its own list rather than a flag on PackAPunchSpot even though the shape is
/// identical today. They diverge the moment price, perk pool or cooldown lands —
/// and a shared class with three fields one side ignores is how the debris/
/// invisible-wall split went wrong before it was separated.
/// </summary>
/// <summary>
/// A wall-mounted weapon buy.
///
/// ⚠️ Stores a ROTATION, not a yaw. A wallbuy faces out along the surface normal
/// and can sit on a sloped or overhead surface, so a single yaw cannot describe
/// it — unlike the floor-standing machines, which flatten onto their own normal.
/// </summary>
public class WallBuySpot
{
public Vector3 Position { get; set; }
/// <summary>Facing, stored as pitch/yaw/roll so it survives JSON.</summary>
public Angles Angles { get; set; }
public string WeaponPrefab { get; set; } = "";
public int Price { get; set; } = 500;
/// <summary>
/// Rarity tier this wall sells at, 0-4. 0 = Common, the default and the original behaviour.
///
/// ⚠️ DRIVES THE CHALK COLOUR AND THE GUN'S TIER TOGETHER. A player reads the wall before
/// they can afford it, so the colour has to be a promise the purchase keeps.
///
/// ⚠️ DEFAULT 0 MEANS EVERY EXISTING CONFIG STILL LOADS UNCHANGED — a saved wall buy with no
/// Rarity key deserialises to Common and draws in the same white chalk it always did.
/// </summary>
public int Rarity { get; set; }
/// <summary>Flag that must be open before it can be bought from.</summary>
[JsonConverter( typeof( LinkConverter ) )]
public string Link { get; set; } = DoorLinks.Unlinked;
}
/// <summary>
/// An Arsenal machine.
///
/// ⚠️ SAME PLACEMENT SHAPE as the Wunderfizz and Pack-a-Punch — position, yaw,
/// floor normal. All three are floor-standing props dropped by the same trace, and
/// a second placement convention is a second set of slope bugs.
///
/// ⚠️ ITS OWN LIST rather than a flag on WunderfizzSpot, for the reason that class
/// documents about not sharing with Pack-a-Punch: the Arsenal spends SALVAGE, sells
/// three different products and has no per-use price escalation, so the fields
/// diverge immediately.
/// </summary>
public class ArsenalSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>The floor's normal here, so it stands flat on a slope.</summary>
public Vector3 Normal { get; set; } = Vector3.Up;
// ── armor tiers ────────────────────────────────────────────────────
//
// ⚠️ 700 / 2000 / 5000 SALVAGE, sequential, from ARSENAL_REMAKE.md §4.3 where
// they are recorded as LOCKED. 7,700 for a full vest against roughly 10 salvage
// per zombie makes armor a mid-game goal competing with augments for one pool,
// which is the stated intent — they are deliberately not cheap.
public int ArmorTier1Price { get; set; } = 700;
public int ArmorTier2Price { get; set; } = 2000;
public int ArmorTier3Price { get; set; } = 5000;
// ── weapon rarity tiers ─────────────────────────────────
//
// ⚠️ 750 / 1250 / 2500 / 5000 SALVAGE, the original's exact defaults
// (`RARITY_COST_DEFAULT` in config/sh_sinner_settings.lua). Sequential, priced by
// TARGET tier, so Legendary costs 9,500 in total — more than a full vest.
//
// ⚠️ PER MACHINE, like the armor prices above, because the original makes them
// map-settable (`Sinner_RarityCost1..4`) and a mapper who can price armor per
// Arsenal would be surprised to find rarity global.
//
// ⛔ FIVE PRICES FOR FIVE TIERS, and tier 0 has none. Common is what every
// weapon already is; a price on it would be a card that sells you nothing.
//
// ⚠️ THE FIFTH IS GODLY'S, basalt's Easter egg's tier (`Rarity.GodlyTier`): 10,000, the ladder doubled
// once more, as it doubled from Rare on — sold by no Arsenal until the Easter egg is complete.
public int RarityTier1Price { get; set; } = 750;
public int RarityTier2Price { get; set; } = 1250;
public int RarityTier3Price { get; set; } = 2500;
public int RarityTier4Price { get; set; } = 5000;
public int RarityTier5Price { get; set; } = 10000;
// ══ ammo mods ════════════════════════════════════════
//
// ⛔ 500, WHICH IS UPSTREAM'S NUMBER IN A DIFFERENT CURRENCY. Its
// `ammo_mod` machine charges 500 POINTS (`Sinner_AmmoMod`); the Arsenal deals
// only in salvage, so hosting the page here re-denominates the price. Against
// roughly ten salvage a zombie that is about fifty kills — far steeper
// than 500 points. Kept because it is the only figure upstream states, and
// per-machine like every other Arsenal price so a mapper can retune it.
public int AmmoModPrice { get; set; } = 500;
/// <summary>
/// What a mod chosen BY NAME costs here, in salvage. 750.
///
/// ⚠️ THE RANDOM ROLL STAYS THE CHEAPER OPTION (2026-10-04, the user: *"make it 750 salvage per mod / but 500 for
/// random"*). `AmmoModPrice` above is the roll's price; picking the exact mod you want costs half as much again. A
/// config saved before this has no such field and gets 750.
/// </summary>
public int AmmoModChosenPrice { get; set; } = 750;
/// <summary>
/// What an ammo mod's upgrade levels cost here, in salvage: I 1,000, II 2,000, III 3,000, so 6,000 takes one mod to III
/// (2026-10-05, the user: *"make the costs 1000 / 2000 / 3000"*). The same for every mod; see `AmmoModUpgrades`.
///
/// ⚠️ AND IV 5,000, V 10,000 (2026-10-06, the user: *"i'd go as far as to make tier 4 5000 and tier 5 10000 like a really late
/// game thing"*): 21,000 takes one mod to V.
///
/// ⚠️ NULLABLE-BACKED, NOT `= 1000` LIKE THE PRICES ABOVE. A field added while an Arsenal is loaded arrives EMPTY on the
/// live object (INSTRUCTIONS §1), and an empty price reads 0: "Nothing left to buy" on every upgrade until the map
/// reloaded. The getter's default is read fresh, and a config saved before these has no such fields and gets it too —
/// which is how every config saved before IV and V existed gets 5,000 and 10,000.
/// </summary>
int? _ammoUpgrade1Price, _ammoUpgrade2Price, _ammoUpgrade3Price, _ammoUpgrade4Price, _ammoUpgrade5Price;
public int AmmoUpgrade1Price { get => _ammoUpgrade1Price ?? 1000; set => _ammoUpgrade1Price = value; }
public int AmmoUpgrade2Price { get => _ammoUpgrade2Price ?? 2000; set => _ammoUpgrade2Price = value; }
public int AmmoUpgrade3Price { get => _ammoUpgrade3Price ?? 3000; set => _ammoUpgrade3Price = value; }
public int AmmoUpgrade4Price { get => _ammoUpgrade4Price ?? 5000; set => _ammoUpgrade4Price = value; }
public int AmmoUpgrade5Price { get => _ammoUpgrade5Price ?? 10000; set => _ammoUpgrade5Price = value; }
/// <summary>Earliest round it will serve. 0/1 = from the start.</summary>
public int StartRound { get; set; }
/// <summary>Needs the power on. The original gates this on
/// ArsenalRequirePower.</summary>
public bool RequiresPower { get; set; } = true;
/// <summary>Door flag that must be open before it can be used.</summary>
[JsonConverter( typeof( LinkConverter ) )]
public string Link { get; set; } = DoorLinks.Unlinked;
}
public class WunderfizzSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>The floor's normal here, so it stands flat on a slope.</summary>
public Vector3 Normal { get; set; } = Vector3.Up;
/// <summary>Cost of the FIRST roll. 1500 is the nZombies default.</summary>
public int BasePrice { get; set; } = 2500;
/// <summary>Added to the price after every roll, so the machine gets dearer
/// the more you lean on it. 0 = flat price forever.
///
/// ⚠️ PER MACHINE, not per player — that is the decision to make when this is
/// wired. Per-player rewards splitting up; per-machine rewards the team
/// rationing one. Left unstated here on purpose.</summary>
public int PriceIncrement { get; set; } = 500;
/// <summary>Earliest round it can be used at all. 0/1 = from the start.
///
/// ⚠️ Separate from the door flag: a flag gates on GEOGRAPHY (have you bought
/// your way here) and this gates on TIME (is it too early to be worth it).
/// A machine can reasonably want both.</summary>
public int StartRound { get; set; }
/// <summary>Needs the power on before it will serve.</summary>
public bool RequiresPower { get; set; } = true;
/// <summary>Cost of buying an EXTRA perk slot from this machine, if the map
/// allows it. 0 = not offered here.</summary>
public int PerkSlotPrice { get; set; } = 10000;
/// <summary>Added to the slot price for every extra slot this player has already
/// bought. 0 = flat forever, which is what every existing map has.
///
/// ⚠️ COUNTED ON `BonusPerkSlots`, NOT ON TOTAL SLOTS, so `PerkSlotPrice` is
/// literally what the FIRST one costs. Counting the map's starting slots would
/// mean the configured base price is a number nobody is ever charged.
///
/// ⚠️ Defaults to 0 so a config that has never heard of this field deserialises to
/// exactly the flat pricing it had before.</summary>
public int PerkSlotIncrement { get; set; }
/// <summary>Flag that must be open before it can be used. Blank = always.</summary>
[JsonConverter( typeof( LinkConverter ) )]
public string Link { get; set; } = DoorLinks.Unlinked;
}
/// <summary>
/// A PERK MACHINE — one machine, one named perk, bought outright.
///
/// ⛔ NOT A SMALL WUNDERFIZZ. The Wunderfizz sells a RANDOM perk and opens a menu; this sells
/// exactly the perk it is standing there advertising, so E buys it with no menu in between. That
/// split is deliberate and is the user's rule: BASE PERKS come from these machines, AUGMENTS stay
/// exclusive to the Wunderfizz. A machine that also sold augments would make the Wunderfizz
/// pointless on any map that placed all seventeen.
///
/// ⚠️ The MODEL is not stored here. It is looked up from <see cref="PerkId"/> in
/// PerkMachineManager.ModelFor, so re-pointing a machine's mesh is a code change in one place
/// rather than a re-edit of every config that placed one.
/// </summary>
public class PerkMachineSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>The floor's normal here, so it stands flat on a slope.</summary>
public Vector3 Normal { get; set; } = Vector3.Up;
/// <summary>
/// Which perk this machine sells, as a <see cref="PerkRegistry"/> id ("jugg", "speed", ...).
///
/// ⚠️ THE ID, NOT THE DISPLAY NAME. Names are shown in menus and get retitled; an id is the
/// thing PerkRegistry.Find and NZPlayer.HasPerk both key on, so storing the name would break
/// every saved config the first time a perk was renamed.
/// </summary>
public string PerkId { get; set; } = "jugg";
/// <summary>
/// What it costs, flat. -1 = auto: the Wunderfizz's price, more for each perk owned
/// (<see cref="PerkMachine.PriceFor"/>). Until 2026-09-27 auto was the perk's own
/// <see cref="PerkRegistry"/> price.
///
/// ⚠️ -1 RATHER THAN 0, because 0 is a legitimate price — a free perk machine is how a map
/// author hands out Quick Revive in solo. There has to be a value that means "unset", and it
/// cannot be one a mapper might actually want.
/// </summary>
public int Price { get; set; } = -1;
/// <summary>Earliest round it can be used at all. 0/1 = from the start.</summary>
public int StartRound { get; set; }
/// <summary>
/// Needs the power on before it will serve. ON BY DEFAULT.
///
/// ⚠️ Matching WunderfizzSpot and the original both — an unpowered perk machine sitting dark
/// until someone finds the switch is the whole reason the power switch is worth walking to.
/// </summary>
public bool RequiresPower { get; set; } = true;
/// <summary>Flag that must be open before it can be used. Blank = always.</summary>
[JsonConverter( typeof( LinkConverter ) )]
public string Link { get; set; } = DoorLinks.Unlinked;
}
/// <summary>
/// A TELEPORTER — a pad you stand on and a place it sends you.
///
/// ⛔ ONE SPOT IS THE WHOLE PAIR, not two spots that reference each other. A and B are stored
/// together because "the destination" is not a thing that exists on its own: a B with no A is
/// invisible, unusable and impossible to notice is broken. Two linked records would also need an
/// id scheme, and every id scheme in a hand-edited config eventually has a dangling one in it.
///
/// ⚠️ ONE-WAY BY DESIGN, A to B. Standing at B does nothing. A return trip is a second teleporter
/// pointing the other way, which is one more click and keeps each direction independently priced,
/// powered and flagged — a two-way pad would have to share all three.
/// </summary>
public class TeleporterSpot
{
/// <summary>The pad you stand on. The box is drawn here.</summary>
public Vector3 A { get; set; }
/// <summary>Where it puts you. Nothing is drawn here in game.</summary>
public Vector3 B { get; set; }
/// <summary>
/// Which way you are facing when you arrive.
///
/// ⚠️ Aimed along A -> B at placement, so you step out looking the way you travelled. Arriving
/// facing backwards is disorienting in a way that reads as the teleporter being broken.
/// </summary>
public float Yaw { get; set; }
/// <summary>
/// How wide the pad is, and therefore who counts as standing on it.
///
/// ⚠️ MEASURED FROM THE PAD MODEL, not guessed: Der Riese's pad is 175.1 x 175.1 x 11.5, so
/// the ride area matches what the player can see themselves standing on. It stayed a separate
/// number from the model because the two are not the same question — a pad with a raised rim
/// wants a ride area slightly inside its own silhouette, and the placeholder box has no
/// silhouette at all.
/// </summary>
public float PadSize { get; set; } = 176f;
/// <summary>How thick the placeholder box is. Low enough to step onto without a jump.
/// ⚠️ 12 matches the real pad's 11.5, so blanking the model does not change the step.</summary>
public float PadHeight { get; set; } = 12f;
/// <summary>
/// The pad's model. Empty = the placeholder box built from <see cref="Footprint"/>.
///
/// ⚠️ THE BOX PATH STAYS. It is not dead code now a real pad exists: it is what a map author
/// gets when they blank this, what the pad falls back to if the vmdl fails to load, and the
/// only thing that can be resized freely — a model has the footprint it was authored with.
///
/// ⚠️ This is Der Riese's pad, which is what upstream's nz_teleporter defaults to
/// (`shared.lua:85`). Its materials are named for a lightning chamber and a mainframe, which
/// is why it has a glass shell rather than being a plain plate.
/// </summary>
public string Model { get; set; } = "models/moo/_codz_ports_props/t5/zm/zombie_teleporter_pad/moo_codz_zm_teleporter_pad.vmdl";
/// <summary>Surface material for the placeholder box. Empty = untextured, tinted by Tint.</summary>
public string Material { get; set; } = "materials/metal/metalwall048a.vmat";
/// <summary>Tint, applied on top of Material. White leaves the material alone.</summary>
public Color Tint { get; set; } = new Color( 0.35f, 0.83f, 0.83f, 1f );
/// <summary>Cost per trip. 0 = free.</summary>
public int Price { get; set; }
/// <summary>
/// Seconds between pressing E and actually leaving. The pad spins up during this.
///
/// ⚠️ 2.5 IS UPSTREAM'S OWN `SetTeleporterTime(2.5)`. It fires the warmup sound and the
/// departure portal at T-1, so a warmup under 1 second has no room for either — the tool
/// clamps rather than letting one be authored that skips its own effects.
///
/// ⛔ RIDERS ARE LATCHED AT THE END OF THIS, NOT THE START, matching upstream. You can step
/// onto the pad while it is spinning up and still travel, which is what makes the warmup a
/// window for the team to pile on rather than dead time.
/// </summary>
public float WarmupTime { get; set; } = 2.5f;
/// <summary>
/// Seconds spent in transit — frozen, invulnerable, staring at the overlay.
///
/// ⚠️ 4 IS UPSTREAM'S, and it is a HARDCODED timer there rather than a setting
/// (`nz_teleporter/shared.lua:735`), which is why it is separate from WarmupTime. Its damage
/// immunity window is 4 seconds too, and the two being equal is not a coincidence.
/// </summary>
public float TransitTime { get; set; } = 4f;
/// <summary>
/// Seconds before this pad can be used again. 0 = no cooldown.
///
/// ⛔ PER MACHINE, NOT PER PLAYER. A recharging teleporter is a thing the whole team has to
/// wait on, which is what makes a cooldown a decision — per-player it would just be a personal
/// timer that four people stagger around, and the pad would effectively always be available.
///
/// ⚠️ THE COUNTDOWN ITSELF IS NOT STORED HERE. This is the authored duration; how long is left
/// lives on the live Teleporter component, so a cooldown mid-tick never gets written into the
/// saved map.
///
/// ⚠️ 20 IS UPSTREAM'S OWN DEFAULT, not a guess — `nz_teleporter/shared.lua:75` calls
/// `SetCooldownTime(20)`. Its editable range there is 0..10000.
/// </summary>
public float Cooldown { get; set; } = 20f;
/// <summary>
/// Needs the power on before it will work.
///
/// ⚠️ DEFAULTS FALSE, unlike the perk machines. A teleporter is a route through the map rather
/// than a purchase, and one that silently does nothing until a power switch exists is the
/// hardest kind of placement to debug. Turn it on deliberately.
/// </summary>
public bool RequiresPower { get; set; }
/// <summary>Flag that must be open before it works. Blank = always.</summary>
[JsonConverter( typeof( LinkConverter ) )]
public string Link { get; set; } = DoorLinks.Unlinked;
/// <summary>
/// Does arriving by this pad change the room name at the top left (`RoomNames`)? Off, the name stays as it was — a teleport
/// crosses no doorway, so the pad has to say which room its far end is in. Basalt's, in flag 9, sends you home to flag 0.
///
/// ⚠️ A SWITCH APART FROM <see cref="ArrivalRoom"/>, because flag 0 — where a game starts — is stored blank, which is also
/// what "not set" would look like.
/// </summary>
public bool SetsRoom { get; set; }
/// <summary>The flag whose room this pad's riders arrive in, when <see cref="SetsRoom"/> is on. Blank = flag 0, the start.</summary>
[JsonConverter( typeof( LinkConverter ) )]
public string ArrivalRoom { get; set; } = DoorLinks.Unlinked;
/// <summary>The pad's footprint as a square, in its own space — what DebrisMesh extrudes.</summary>
public List<Vector2> Footprint()
{
var h = MathF.Max( 8f, PadSize ) * 0.5f;
return new List<Vector2> { new( -h, -h ), new( h, -h ), new( h, h ), new( -h, h ) };
}
}
/// <summary>
/// A SPRINGBOARD — an invisible pad on the floor that flings a player straight up (`SpringboardSystem`). Asked for as Banana
/// Colada's springboard, *"but invisible and i can set how much strength it has and the radius"* (2026-10-01).
///
/// ⚠️ NO USES AND NO CLOCK, unlike the perk's (`Placeable`): it is part of the map, not something a player spends charge on.
/// ⚠️ NOTHING IS DRAWN IN A ROUND. Creative draws its circle and how high it throws (`MapEditor.DrawSpringboards`).
/// </summary>
public class SpringboardSpot
{
/// <summary>The middle of the pad, on the floor.</summary>
public Vector3 Position { get; set; }
/// <summary>
/// Upward speed it gives, in units per second. 700, the perk's (`Placeable.LaunchSpeed`): about twice a jump's speed, and
/// roughly 300 units high. The height grows with the square of this, so 1000 is about 625 units.
/// </summary>
public float Strength { get; set; } = 700f;
/// <summary>How far from its middle a player counts as on it, flat, in units. 48, the perk's.</summary>
public float Radius { get; set; } = 48f;
/// <summary>
/// No fall damage on the landing after its launch. On by default. A launch lands at the speed it left with, and 700 u/s
/// costs about a third of a health bar (`ShoveGuard.Land`), which a way of getting about must not do. Off makes it a
/// trap.
/// </summary>
public bool SafeLanding { get; set; } = true;
}
/// <summary>
/// A SOUL BOX — kill zombies near it and it fills; fill every box on a flag and that flag opens.
///
/// ⛔ THE FLAG IS AN "ALL", NOT AN "ANY", AND THAT IS THE OPPOSITE OF EVERY OTHER LINK USER. A
/// debris barrier opens its flag the moment ONE of them is bought (DebrisManager.OpenAllOnLink);
/// five soul boxes sharing a flag open it only when ALL FIVE are full. Same field, same
/// DoorLinks.Open at the end, deliberately inverted rule in between — see
/// SoulBoxManager.CheckLink, which is the only place that knows the difference.
///
/// ⚠️ SO THE FLAG IS ALSO THE GROUPING. There is no separate "group id": boxes are a set because
/// they share a flag, which is the thing a map author is already thinking about. A second group is
/// a second flag.
///
/// ⚠️ Upstream has no equivalent of this at all. `nz_script_soulcatcher` is a SCRIPTABLE entity
/// with no settings and no gameplay of its own — every map script supplies its own CompleteFunc,
/// and the three that exist all end with `nzDoors:OpenLinkedDoors(...)` and then remove the box.
/// This is that pattern turned into a placeable, with the "all of them" rule made explicit rather
/// than counted by hand in Lua.
/// </summary>
public class SoulBoxSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>The floor's normal here, so the box sits flat on a slope.</summary>
public Vector3 Normal { get; set; } = Vector3.Up;
/// <summary>
/// Kills needed to fill this box. (Upstream's map scripts all use 20.)
///
/// ⚠️ KILLS, NOT DAMAGE. One dead zombie in range is one soul, and a zombie killed in range of
/// two boxes feeds only ONE of them — upstream `break`s out of its loop on the first match, and
/// splitting a soul between boxes would make a cluster fill in half the time.
/// </summary>
public int Target { get; set; } = 20;
/// <summary>
/// How far from the box a kill still counts. (Upstream's default, in its own units.)
///
/// ⚠️ 500 IS LARGE — a little over 40 feet, so a box covers a room rather than a spot. That is
/// upstream's own number and it is what makes a soul box a place you fight AT rather than a
/// button you stand on.
/// </summary>
public float Range { get; set; } = 500f;
/// <summary>
/// The flag opened once EVERY box carrying it is full. Blank/0 opens nothing.
///
/// ⚠️ A blank flag is not an error and is not useless — the box still fills and still says so,
/// which is how you test one before deciding what it should gate.
/// </summary>
[JsonConverter( typeof( LinkConverter ) )]
public string Link { get; set; } = DoorLinks.Unlinked;
/// <summary>Needs the power on before it will take souls. Upstream's example gates on it.</summary>
public bool RequiresPower { get; set; }
/// <summary>
/// Drop a random powerup when THIS box fills. On by default.
///
/// ⚠️ PER BOX, NOT PER FLAG. Five boxes on a flag pay five powerups, which is a deliberate
/// choice: each box is its own twenty kills and gets its own payoff, and the door the flag
/// opens is the reward for the set.
///
/// ⛔ NOT SUBJECT TO PowerupDrops.MaxPerRound, AND IT DOES NOT COUNT AGAINST IT. Filling a box
/// is a hundred kills in one place, not a lucky roll — so it is guaranteed the way the
/// special-round Max Ammo is. PowerupDrops.RollOnDeath already records why a guaranteed reward
/// must not eat a random slot: it would make powerups rarer in exactly the rounds that pay one.
/// </summary>
public bool Powerup { get; set; } = true;
/// <summary>
/// The Prisma build part dropped by whichever box FINISHES this flag's set, instead of that
/// box's powerup. 0 = none, which is every box that has not been told otherwise.
/// </summary>
///
/// ⛔ PER FLAG, NOT PER BOX — the opposite of <see cref="Powerup"/> directly above, and the
/// reason it reads oddly next to it. Five boxes on a flag pay five powerups because each is its
/// own twenty kills; the PART is the reward for the whole set, so exactly one drops however
/// many boxes carry the setting. Setting it on one box of the flag is enough and setting it on
/// all of them is harmless — whichever box fills last asks its siblings what the flag awards.
///
/// ⚠️ IT REPLACES THAT BOX'S POWERUP RATHER THAN ADDING TO IT. Two rewards landing on one spot
/// in the same instant means one gets picked up by accident, and the part is the one worth a
/// walk across the map.
public int FinalPart { get; set; }
/// <summary>
/// Drop the flag's part at <see cref="PartDrop"/> instead of above the box that finished the
/// set. Off = above that box, which is how it has always worked.
/// </summary>
///
/// ⛔ ASKED FOR AS *"i'd rather make it so it appears in the place i'm standing in right now in
/// the editor when the boxes are complete"*. Above-the-last-box puts the reward wherever the
/// players happened to fight — the one place on the map the author did not choose. A fixed spot
/// makes the payoff a destination, the same way the authored first part is.
///
/// ⚠️ A SWITCH BESIDE THE POSITION, NOT A NULLABLE POSITION. Nothing in this file is nullable,
/// and the config's serialiser has never been asked to round-trip a `Vector3?`; the first time
/// it is asked should not be on the reward for an Easter egg. `(0,0,0)` as "unset" was the other
/// candidate and is a real place on some maps.
///
/// ⚠️ PER FLAG, READ THE WAY <see cref="FinalPart"/> IS: the first box of the flag carrying a
/// drop spot wins, so it does not have to sit on the same box as the part itself.
/// `nz_soul_partdrop` writes it onto one box and clears the rest.
public bool HasPartDrop { get; set; }
/// <summary>Where the flag's part appears when <see cref="HasPartDrop"/> is on. On the floor.</summary>
public Vector3 PartDrop { get; set; }
/// <summary>Which way the dropped part faces.</summary>
public float PartDropYaw { get; set; }
/// <summary>
/// The box's model. Empty = the placeholder built from <see cref="Footprint"/>.
///
/// ⚠️ THIS IS ORIGINS' OWN SOUL BOX — `zm_tm_soul_box`, from the map whose internal name is
/// `tomb`, which is where the mechanic comes from. Carved stone, and it has a `j_lid` bone with
/// `open` and `close` sequences.
///
/// ⚠️ THE PLACEHOLDER PATH STAYS. It is not dead code: it is what a map author gets when they
/// blank this, what the box falls back to if the vmdl fails to load, and the only version whose
/// Size and Height can be changed at all — a model has the dimensions it was authored with.
/// </summary>
public string Model { get; set; } = "models/zmb/bo2/tomb/zm_tm_soul_box.vmdl";
/// <summary>How wide the placeholder box is. ⚠️ Ignored when Model is set.</summary>
public float Size { get; set; } = 48f;
/// <summary>How tall the placeholder box is.</summary>
public float Height { get; set; } = 48f;
/// <summary>Surface material for the placeholder. Empty = untextured, tinted by Tint.</summary>
public string Material { get; set; } = "materials/metal/metalwall048a.vmat";
/// <summary>Tint, applied on top of Material.</summary>
public Color Tint { get; set; } = new Color( 1f, 0.62f, 0.25f, 1f );
/// <summary>The placeholder's footprint as a square, in its own space.</summary>
public List<Vector2> Footprint()
{
var h = MathF.Max( 8f, Size ) * 0.5f;
return new List<Vector2> { new( -h, -h ), new( h, -h ), new( h, h ), new( -h, h ) };
}
}
public class PackAPunchSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>The floor's normal here, so it stands flat on a slope.</summary>
public Vector3 Normal { get; set; } = Vector3.Up;
}
/// <summary>
/// ARMOR — the BO6 plate system.
///
/// ⚠️ NOT A NORMAL ABSORB. Ported from the original's
/// extra/sv_nz_armor_system.lua:115-139, where armor loses the FULL pre-reduction
/// damage while the player STILL takes 30% through it. So armor is a 70% damage
/// reduction that depletes at the full rate — 300 armor does not soak 300 damage,
/// it halves-and-then-some of 300 damage worth of hits and then breaks.
///
/// ⚠️ 30% UNTIL ROUND 30, THEN MORE (2026-10-03): `BleedThroughLate` takes it to 60% by round 90.
///
/// ⚠️ Modelling it as a straight pool ("armor absorbs until empty") would be
/// simpler and is NOT what the original does: it would make a full vest an extra
/// health bar rather than a temporary damage cut, which changes how long a horde
/// takes to kill you at every round.
/// </summary>
public class ArmorSettings
{
/// <summary>Master switch. The original gates everything on BO6_Armor, which
/// defaults OFF — ours defaults ON because there is no menu to turn it on
/// with yet.</summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// How many zombie hits ONE BAR of each tier absorbs, at <see cref="BarHitsRound"/>.
/// Tier 1 = 10, tier 2 = 15, tier 3 = 20.
///
/// ⛔️ AUTHORED IN HITS, NOT ARMOR POINTS, and that is the whole change. The old field was
/// a flat 150 per bar, which at round 10 (zombies hit for 50) is exactly three hits — the
/// reported problem. Stating the durability as HITS makes the design goal the thing that is
/// written down, and the armor number a consequence of it.
///
/// ⚠️ PER TIER, NOT ONE NUMBER TIMES THE TIER. A tier also grants that MANY BARS, so the two
/// compound: tier 1 is 1 bar of 10, tier 2 is 2 bars of 15, tier 3 is 3 bars of 20 -- a total
/// of 10 / 30 / 60 hits. Buying up is worth more than the extra bar alone.
///
/// ⚠️ ARMOR DOES NOT SCALE WITH THE ROUND, so these hit counts hold only at BarHitsRound and
/// fall as zombies hit harder: at 30 damage (rounds 1-6) tier 1 takes 16 hits, at 90 (round
/// 31+) it takes 5. That decay is the point -- armor is meant to stop being a wall.
/// </summary>
public List<int> BarHits { get; set; } = new() { 10, 15, 20 };
/// <summary>
/// The round <see cref="BarHits"/> is quoted at. 10.
///
/// ⚠️ IT PICKS THE DAMAGE NUMBER, nothing else. ZombieStats.AttackDamageForRound is a step
/// curve (30 / 50 / 75 / 90), so any round from 7 to 15 gives the same 50 and the same armor
/// pool; this names which step the durability is quoted against rather than adding a curve.
/// </summary>
public int BarHitsRound { get; set; } = 10;
/// <summary>Highest buyable tier. (BO6_Armor tiers 1-3)</summary>
public int MaxTier { get; set; } = 3;
/// <summary>How many plates can be carried at once. (BO6_Armor_MaxPlates)</summary>
public int MaxPlates { get; set; } = 3;
// ⛔ PerPlate REMOVED. A plate fills one BAR, so the amount it restores is
// CapPerTier by definition — see Armor.NextBarTop. Keeping a second, independent
// number meant the two could disagree, and the only reason the old default looked
// correct is that both happened to be 150.
/// <summary>
/// Fraction of incoming damage that reaches the player THROUGH armor.
///
/// ⚠️ 0.3 = the original's BO6_Armor_Percent 30. This is what the player
/// takes; armor separately loses the full 100%. Setting it to 0 would make
/// armor total immunity until it breaks, not a reduction.
/// </summary>
public float BleedThrough { get; set; } = 0.30f;
/// <summary>
/// What armor lets through by round `BleedRampTo` and after: it rises in a straight line from `BleedThrough` at round
/// `BleedRampFrom`. Read through `Armor.BleedThroughNow`.
///
/// ⛔ ADDED 2026-10-03, AFTER A ROUND-88 GAME WHERE ZOMBIES DID "LITTLE TO NO DAMAGE" (the user) to a player with a tier-3
/// vest and Juggernog's two armor minors. Zombie damage stops growing at round 31 (90 a hit) and armor never weakened, so
/// a hit cost 13.5 health for the rest of the game. Now 30% at round 30, 45% at 60, 60% from 90: rounds 1-30 are untouched.
/// Set it equal to `BleedThrough` to switch the ramp off.
/// </summary>
public float BleedThroughLate { get; set; } = 0.60f;
/// <summary>The round armor starts letting more through (`BleedThroughLate`).</summary>
public int BleedRampFrom { get; set; } = 30;
/// <summary>The round armor reaches `BleedThroughLate`, and stays there.</summary>
public int BleedRampTo { get; set; } = 90;
// ── plate drops ────────────────────────────────────────────────────
/// <summary>Chance a normal zombie drops a plate. (BO6_Armor_Drop 5)</summary>
public float PlateDropChance { get; set; } = 0.05f;
/// <summary>Chance a special drops one. (BO6_Armor_DropSpecial 25)</summary>
public float PlateDropChanceSpecial { get; set; } = 0.25f;
/// <summary>Chance a boss drops one. (BO6_Armor_DropBoss 100)</summary>
public float PlateDropChanceBoss { get; set; } = 1.00f;
}
/// <summary>
/// SALVAGE — the currency the Arsenal, weapon tech tree, Gunsmith and perk
/// augments all price against (see ARSENAL_REMAKE.md).
///
/// ⚠️ EARN-ONLY FOR NOW. Nothing spends salvage yet, deliberately: the earn
/// rate is worth feeling before anything is priced against it, and every sink on
/// the roadmap needs a system that does not exist. Expect the numbers here to be
/// the ones a future Arsenal argues with.
/// </summary>
public class SalvageSettings
{
/// <summary>Master switch. (BO6_Salvage, off by default in the original)</summary>
public bool Enabled { get; set; } = true;
/// <summary>Salvage awarded per pickup.
///
/// ⚠️ 75, not 50. The original hardcodes 50 at sv_killstreaks.lua:133; this
/// copy's own [NZAUGMENT] edit raised it to 75, and 75 is the number the GMod
/// build has actually been played with.</summary>
public int PerPickup { get; set; } = 75;
/// <summary>Chance a normal zombie drops salvage. (BO6_Salvage_Drop 20)</summary>
public float DropChance { get; set; } = 0.20f;
/// <summary>Chance a special drops salvage. (BO6_Salvage_DropSpecial 50)</summary>
public float DropChanceSpecial { get; set; } = 0.50f;
/// <summary>Chance a boss drops salvage. (BO6_Salvage_DropBoss 100)</summary>
public float DropChanceBoss { get; set; } = 1.00f;
// ── the wave falloff ─────────────────────────────────────────────────────
//
// ⛔ SALVAGE INCOME WAS PURELY LINEAR IN WAVE SIZE, because nothing in the salvage
// path knows what round it is: the award is `zombies × chance × perPickup`, and only
// `zombies` moves. Measured on a single player with Vulture Aid + M1 Carrion + m1
// Scavenger, that is 720 salvage at round 5 and 7,200 at round 50 — a tenfold income
// rise for no decision the player makes — and it never stops, because `WaveTotal`
// pins at the 240 cap from round 49 and simply pays 7,200 every round forever.
//
// ⚠️ SCALED AGAINST WAVE SIZE, NOT AGAINST THE ROUND NUMBER. Wave size is the thing
// actually driving the income, and it already accounts for player count and for the
// early-round scaling table — a round-number curve would have to duplicate all of it
// and would then disagree on a 4-player game.
/// <summary>
/// Wave size at which <see cref="DropChance"/> applies unscaled. 24 — the wave base,
/// which is what rounds 5 through 9 actually spawn.
/// </summary>
public int DropFalloffReference { get; set; } = 24;
/// <summary>
/// How hard the drop chance falls as the wave grows past the reference.
///
/// chance ×= (DropFalloffReference / waveTotal) ^ DropFalloffExponent
///
/// 0 disables it. 1 is fully inverse — income per round becomes flat.
///
/// ⚠️ **0.36, SOLVED RATHER THAN CHOSEN**, from the user's two requirements: the floor is
/// 0.5 and the curve must not REACH it before round 40. A solo wave at round 40 is 168, so
/// the exponent is fixed by `(24/168)^e = 0.5` → e = 0.356, rounded to 0.36 (which lands on
/// ×0.4963 at round 40 and is floored to 0.50 there). Nothing else about the shape is free
/// once those two numbers are given.
///
/// ⚠️ It was **0.75** with a 0.25 floor, which reached bottom at round 40 solo and at round
/// ~16 in a four-player game, pinning a 4-player wave at 241 salvage per head from round 20
/// to 60 while a solo player climbed to 900.
/// </summary>
public float DropFalloffExponent { get; set; } = 0.36f;
/// <summary>
/// The floor, as a fraction of the unscaled chance. 0.5 — so salvage bottoms out at half
/// its base rate and never approaches zero. Raised from 0.25 by request.
///
/// ⛔ WITHOUT THIS THE CURVE HAS NO BOTTOM. `WaveTotal` pins at 240 so the ratio stops
/// falling in practice, but a map raising `WaveCap` would drive the chance toward
/// nothing and salvage would quietly stop existing on exactly the maps that spawn most.
///
/// ⛔ CHANGING THIS IN CODE IS NOT ENOUGH ON ITS OWN. Three of the four saved map configs
/// serialise `DropFalloffFloor` and `DropFalloffExponent` explicitly, so they keep loading
/// whatever is in their own JSON and the new default only reaches a map that never saved
/// the key. Edit the configs under `FileSystem.Data` too, then `Tools/ship_data.py --apply`.
/// </summary>
public float DropFalloffFloor { get; set; } = 0.5f;
}
/// <summary>
/// PACK-A-PUNCH — how many tiers, what each costs, and what each does to damage.
///
/// ⛔ THE TIER COUNT IS A SETTING, NOT A CONSTANT. `NZPlayer.PapMaxLevel` used to be
/// `const int = 3`; it now reads <see cref="Tiers"/>, so a map can ship one upgrade or five.
/// Everything that gates on the cap follows automatically — the machine's refusal message,
/// `UsePrompt`, Vulture Aid's Wildcard ceiling and the camo table's length.
///
/// ⛔ MULTIPLIERS ARE TOTALS, NOT PER-STEP FACTORS. `Multipliers[2]` is what an MK3 weapon deals
/// against its unpacked self — 15.625, not the 2.5 that got it there. The panel has to show the
/// number a player would actually feel, and the console already prints "expected damage x15.63";
/// a per-step column would mean the panel and the diagnostic disagreed about the same weapon.
///
/// ⚠️ THE DEFAULTS ARE THE ORIGINAL'S CURVE, `2.5 ^ tier` (sh_wep_modifiers_arc9.lua:177), carried
/// two steps past where the original stops. MK4 x39 and MK5 x97.7 are large on purpose and the
/// PRICE ladder is what gates them; retuning the curve is exactly what this block is for.
///
/// ⛔ MK6 IS BASALT'S EASTER EGG'S, NO MAP'S OWN (2026-09-27) — *"pack a punch up to mk 6 … these are just normal scaling
/// pack a punch and rarity, nothing new"*. The ladders run one step on, so it has a price and a punch like any tier's, but
/// <see cref="Tiers"/> stops at five, and the sixth has no round: only the Easter egg complete opens it (`NZPlayer.PapMaxLevel`,
/// <see cref="TierOpenAt"/>). A save holds
/// five of each: a tier past a saved array reads the shipped ladder (<see cref="DefaultCosts"/>), never nothing.
/// </summary>
public class PapSettings
{
/// <summary>The most tiers this block can describe: MK1 to MK6. The arrays below ship this long.</summary>
public const int MaxTiers = 6;
/// <summary>The most a map sells of its own: MK5. The sixth is basalt's Easter egg's (see the class note).</summary>
public const int MaxMapTiers = 5;
/// <summary>
/// How many upgrades a weapon can buy, 1-5 — and with basalt's Easter egg complete, six (see the class note).
///
/// ⚠️ LOWERING THIS DOES NOT DEMOTE A WEAPON that is already above it. `SetPapLevel` clamps
/// on write, so an MK5 gun in a save stays MK5 and simply cannot be packed again; the camo
/// table's own clamp keeps it wearing its top material rather than turning plain.
/// </summary>
public int Tiers { get; set; } = 5;
/// <summary>
/// The camo a packed weapon wears on this map: a `PapCamo` id (`nz_camo_set` lists them), or blank for the game's own,
/// Crazy Place. Basalt's is `basalt_hex`, its hexagons with the light in the seams (2026-09-28). `nz_camo_map` and the
/// panel's "Camo" row set it; `nz_camo_set` overrides it for a session.
/// </summary>
public string Camo { get; set; } = "";
/// <summary>
/// Points to buy each tier, indexed from ZERO for MK1.
///
/// ⛔ NOT THE ORIGINAL'S NUMBERS. It charges 5000 then a flat 2500 to re-pack, which gets
/// cheaper exactly as the damage compounds. This ladder makes each step cost roughly what it
/// is worth, so the top tier is a late-game goal rather than something you buy on round 12
/// because you happened to walk past.
/// </summary>
public int[] Costs { get; set; } = DefaultCosts;
/// <summary>
/// The shipped <see cref="Costs"/>, MK1 to MK6 — and what a tier past a saved array costs (<see cref="CostAt"/>). MK6's
/// 135,000 carries the ladder on: 45,000 over MK5, as MK5 was 35,000 over MK4 and MK4 25,000 over MK3.
///
/// ⚠️ A PROPERTY THAT BUILDS THE ARRAY, NOT A STATIC FIELD: a static survives a hotload holding its old values
/// (INSTRUCTIONS.md §1), and each config needs an array of its own for the panel to write into.
/// </summary>
public static int[] DefaultCosts => new[] { 5000, 15000, 30000, 55000, 90000, 135000 };
/// <summary>
/// Total damage multiplier at each tier, indexed from ZERO for MK1.
///
/// ⛔ A DIMINISHING LADDER, NOT A GEOMETRIC ONE. The original curve was `2.5 ^ tier`, which
/// reaches x97.7 at MK5 -- 83% of the entire damage stack before rarity, perks or headshots are
/// applied, and enough that a 30-damage rifle and a 60-damage battle rifle both one-shot
/// everything from round 30 on. Class identity cannot survive a term that large.
///
/// ⚠️ EACH STEP IS WORTH 0.3 LESS THAN THE LAST: x2.7, then x2.4, x2.1, x1.8, x1.5 — and x1.2 to MK6. The values
/// here are the CUMULATIVE product of those, because that is what this field means. It also
/// restores the original design intent, where Pack-a-Punch stopped at MK3 -- that is now the
/// point where the curve visibly flattens rather than an arbitrary cut.
/// </summary>
public float[] Multipliers { get; set; } = DefaultMultipliers;
/// <summary>
/// The shipped <see cref="Multipliers"/>, MK1 to MK6 — and what a tier past a saved array deals (<see cref="MultiplierAt"/>).
/// MK6's x44.09 is the ladder's next step: x1.2 on MK5, 0.3 less than MK5's x1.5 on MK4. A property, as
/// <see cref="DefaultCosts"/> is and for its reason.
/// </summary>
public static float[] DefaultMultipliers => new[] { 2.7f, 6.48f, 13.608f, 24.4944f, 36.7416f, 44.08992f };
/// <summary>
/// The round each tier becomes buyable, indexed from ZERO for MK1.
///
/// ⛔ A GATE ON BUYING, NOT A CAP ON OWNING. `SetPapLevel` still clamps to
/// <see cref="Tiers"/> and nothing here can demote a weapon — a gun that reached MK3
/// keeps MK3 forever. Making the storage clamp round-aware would let any later write
/// (the trade table, Vulture's Wildcard, a command) silently knock a weapon down a tier,
/// and the player would have no idea what took it.
///
/// ⚠️ ONE ENTRY PER TIER, SHORT ARRAYS TOLERATED. A config from an older save has none
/// of these; a missing entry means "no gate", so an old map plays exactly as it did.
///
/// ⚠️ 1 / 15 / 30 / 45 / 60 — the spacing is the point. At 15 rounds apart the ladder
/// outlasts a typical run, so MK5 is something a long game reaches rather than something
/// every game ends with.
///
/// ⛔ NONE FOR MK6, AND NONE IS READ: basalt's Easter egg is its only gate, at any round — *"mk6 and godly are not locked
/// behind a round number, instead it's only unlocked after beating the easter egg"* (<see cref="TierOpenAt"/>). A gun still
/// climbs to MK5 first, by MK5's round; one that came by an MK5 some other way — the Wildcard, the trade table — packs to
/// MK6 at once.
/// </summary>
public int[] UnlockRounds { get; set; } = { 1, 15, 30, 45, 60 };
/// <summary>
/// The highest tier buyable at <paramref name="round"/> on a ladder of <paramref name="tiers"/> — the cap as it stands,
/// `NZPlayer.PapMaxLevel`, six with basalt's Easter egg complete — or all of them when no gate is configured.
///
/// ⚠️ COUNTS FROM THE BOTTOM AND STOPS AT THE FIRST LOCKED TIER, rather than taking the
/// highest entry that passes. An array authored out of order — 1, 30, 15 — would
/// otherwise unlock MK3 before MK2, and nothing would ever say so.
/// </summary>
public int MaxTierForRound( int round, int tiers )
{
// ⚠️ COUNTED UP THE LADDER, STOPPING AT THE FIRST LOCKED TIER (`TierOpenAt`, whose gates are the highest from MK1 up —
// so a tier past a short array's end is ungated once every entry is, as the note above has it). MK6 is never locked
// by a round, but a ladder is climbed a tier at a time, so it counts here only once MK5 is open.
var allowed = 0;
while ( allowed < tiers && TierOpenAt( allowed + 1, round ) ) allowed++;
return allowed;
}
/// <summary>
/// The round that unlocks the tier above <paramref name="current"/>, or 0: the highest gate from MK1 up to it, since the
/// ladder counts from the bottom (<see cref="MaxTierForRound"/>) — its own, on a ladder in order. ⛔ 0 FOR MK6, which has no
/// round at all: basalt's Easter egg is its only gate (<see cref="TierOpenAt"/>).
/// </summary>
public int UnlockRoundFor( int current )
{
if ( UnlockRounds is null || current < 0 || current >= MaxMapTiers ) return 0;
var at = 0;
for ( int i = 0; i <= current && i < UnlockRounds.Length; i++ )
at = Math.Max( at, UnlockRounds[i] );
return at;
}
/// <summary>
/// Is tier <paramref name="tier"/> (1 = MK1) open at <paramref name="round"/> — its gate and every gate under it, as the
/// ladder counts from the bottom? ⛔ MK6 ALWAYS IS, HERE: it has no round — *"not locked behind a round number, instead
/// it's only unlocked after beating the easter egg"* — and whether the egg is complete is the cap's question
/// (`NZPlayer.PapMaxLevel`), not the round's.
/// </summary>
public bool TierOpenAt( int tier, int round )
=> tier > MaxMapTiers || round >= UnlockRoundFor( tier - 1 );
/// <summary>
/// Cost of packing from <paramref name="current"/> to the next tier on a ladder of <paramref name="tiers"/> — the cap as it
/// stands, `NZPlayer.PapMaxLevel` — or 0 when there is none.
///
/// ⛔ BOUNDS-CHECKED RATHER THAN INDEXED. A config deserialized from an older save can carry a
/// three-entry array, and `Costs[3]` on it throws inside whatever happens to be reading — for
/// this field that is the use prompt, i.e. an exception every frame you stand near a machine.
/// Returning 0 is also why the caller must check the tier cap FIRST: 0 reads as free, not as
/// refused.
/// </summary>
public int CostToReach( int current, int tiers )
{
if ( current < 0 || current >= tiers ) return 0;
return CostAt( current );
}
/// <summary>
/// Tier <paramref name="i"/>'s price, from 0 for MK1: the config's — and past a saved array's end, the shipped one's
/// (<see cref="DefaultCosts"/>). ⛔ Basalt's own save holds five prices, and the Easter egg's MK6 must cost 135,000 rather
/// than read as nothing to upgrade.
/// </summary>
public int CostAt( int i )
{
if ( i < 0 ) return 0;
if ( Costs is not null && i < Costs.Length ) return Costs[i];
var shipped = DefaultCosts;
return i < shipped.Length ? shipped[i] : 0;
}
/// <summary>
/// Total damage multiplier for a weapon at <paramref name="level"/>. Level 0 is x1.
///
/// ⚠️ FALLS BACK TO THE SHIPPED LADDER, not to x1. A short or missing array would otherwise silently
/// undo every upgrade the player paid for — a packed gun dealing base damage reads as the whole
/// Pack-a-Punch system being broken.
///
/// ⛔ AND NO LONGER TO THE OLD CURVE, `2.5 ^ level`, which stopped being the default when the ladder replaced it: basalt's
/// save holds five multipliers, and its MK6 would have dealt x244 off the curve where the ladder says x44.09
/// (<see cref="DefaultMultipliers"/>). Past the shipped ladder too, its top.
/// </summary>
public float MultiplierAt( int level )
{
if ( level <= 0 ) return 1f;
int i = level - 1;
if ( Multipliers is not null && i < Multipliers.Length && Multipliers[i] > 0f )
return Multipliers[i];
var shipped = DefaultMultipliers;
return shipped[Math.Min( i, shipped.Length - 1 )];
}
}
/// <summary>
/// AMMO BOX — pricing for the box that refills the held weapon's reserve.
///
/// ⛔ GLOBAL, NOT PER SPOT, unlike <see cref="WunderfizzSpot"/>'s BasePrice. The price is a function
/// of the WEAPON's Pack-a-Punch level, not of which box you walked to, so per-spot prices would let
/// a mapper make one box cheap for an MK5 and turn the escalation into "walk to the other box".
/// Placement data stays in <see cref="AmmoBoxSpot"/>; the numbers live here.
/// </summary>
public class AmmoBoxSettings
{
/// <summary>
/// What a refill costs, indexed BY PACK-A-PUNCH LEVEL — [0] is an unpacked weapon.
///
/// ⚠️ SEVEN ENTRIES FOR SIX TIERS. Level 0 is a real, common case (most of a game is spent
/// holding something unpacked), so the array is 1 longer than `PapSettings.MaxTiers` and is
/// indexed directly by level rather than by level-1. Off-by-one here would charge MK1 prices
/// for an unpacked gun.
/// </summary>
public int[] BasePrices { get; set; } = DefaultBasePrices;
/// <summary>
/// The shipped <see cref="BasePrices"/>, unpacked to MK6 — and what a level past a saved array costs (<see cref="BaseFor"/>).
/// MK6's 6,000 carries the ladder on, a thousand a tier. A property, as `PapSettings.DefaultCosts` is and for its reason.
/// </summary>
public static int[] DefaultBasePrices => new[] { 500, 1000, 2000, 3000, 4000, 5000, 6000 };
/// <summary>
/// What each refill AFTER the first in the same round multiplies the price by.
///
/// ⚠️ COMPOUNDING, NOT ADDITIVE. 1.5 means 1000 -> 1500 -> 2250 -> 3375, so leaning on the box
/// gets expensive fast rather than linearly. 1.0 = a flat price forever.
/// </summary>
public float RepeatMultiplier { get; set; } = 1.5f;
/// <summary>
/// Hard ceiling on one refill. 0 = uncapped.
///
/// ⛔ EXISTS BECAUSE COMPOUNDING HAS NO NATURAL LIMIT. An MK5 base of 5,000 reaches 85,000 by
/// the eighth use in a round and overflows int in the low twenties; a cap is the difference
/// between "unaffordable" and "nonsense on the prompt". Off by default so the raw rule is what
/// ships, but the guard in `AmmoBox.PriceFor` clamps regardless.
/// </summary>
public int MaxPrice { get; set; }
/// <summary>Base price for a weapon at this Pack-a-Punch level.</summary>
public int BaseFor( int papLevel )
{
if ( BasePrices is null || BasePrices.Length == 0 ) return 500;
// ⚠️ A LEVEL PAST A SAVED ARRAY COSTS WHAT IT SHIPS AT — a config from before MK4/MK5 existed carries four entries, and
// basalt's save six, where the Easter egg's MK6 needs a seventh: 6,000, not MK5's 5,000 again. Past the shipped ladder
// too, CLAMPED to the top rather than nothing.
var shipped = DefaultBasePrices;
if ( papLevel >= BasePrices.Length && papLevel < shipped.Length )
return shipped[papLevel];
var i = Math.Clamp( papLevel, 0, BasePrices.Length - 1 );
return Math.Max( 0, BasePrices[i] );
}
}
/// <summary>
/// One placed ammo box.
///
/// ⚠️ NO PRICE FIELDS — see <see cref="AmmoBoxSettings"/> for why the numbers are global.
/// </summary>
/// <summary>
/// A BUYABLE ENDING — the thing you walk up to and pay to finish the run.
///
/// Ported from the original's `buyable_ending` entity
/// (entities/entities/buyable_ending/shared.lua) and `sh_tools_ending.lua`.
/// </summary>
public class EndingSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>The floor's normal here, so it sits flat on a slope.</summary>
public Vector3 Normal { get; set; } = Vector3.Up;
/// <summary>What it costs to end the run. The original's default is 500.
///
/// ⚠️ 0 IS FREE, NOT "not offered" — unlike `WunderfizzSpot.PerkSlotPrice`, where 0
/// means the machine does not sell slots at all. An ending with no price is a
/// legitimate map: some maps want the exit to be reachable rather than earned, and
/// "place it or don't" already expresses "not offered".</summary>
public int Price { get; set; } = 500;
/// <summary>The prop the mapper stands there.
///
/// ⚠️ A PATH, NOT A FIXED MODEL. The original exposes this as a free-text field in
/// its tool and only DEFAULTS to the teddy bear; a map may put the exit on a door,
/// a radio or a helicopter. Hardcoding the bear would be inventing a restriction
/// upstream does not have.</summary>
public string Model { get; set; } = BuyableEnding.DefaultModel;
/// <summary>Let the run continue after the ending is bought.</summary>
public bool KeepPlaying { get; set; }
/// <summary>Hand every player every perk when it is bought.</summary>
public bool RewardPerks { get; set; }
/// <summary>Stop players losing perks when downed, from here on.</summary>
public bool PermaPerks { get; set; }
/// <summary>The line shown on the use prompt. Blank falls back to "End game".</summary>
public string Hint { get; set; } = "End game";
/// <summary>Shown on the game-over screen instead of the map's default text.</summary>
public string CustomText { get; set; } = "";
/// <summary>Earliest round it can be used. 0/1 = from the start.</summary>
public int StartRound { get; set; }
/// <summary>Needs the power on before it will serve.
///
/// ⚠️ FALSE BY DEFAULT. The original has no power gate on this entity at all; the
/// field exists because every other placeable here has one and a mapper will look
/// for it, but leaving it off keeps the default behaviour identical to upstream.</summary>
public bool RequiresPower { get; set; }
/// <summary>Door flag that must be open before it serves. Empty = always.</summary>
[JsonConverter( typeof( LinkConverter ) )]
public string Link { get; set; } = DoorLinks.Unlinked;
}
public class AmmoBoxSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>The floor's normal here, so it sits flat on a slope.</summary>
public Vector3 Normal { get; set; } = Vector3.Up;
/// <summary>
/// Needs the power on before it will serve.
///
/// ⛔ FALSE BY DEFAULT, unlike the Wunderfizz and Pack-a-Punch. Ammo is what keeps you alive
/// in the rounds BEFORE the power is on, and a box that refuses until then is a box that does
/// nothing during the only part of the game it would matter most.
/// </summary>
public bool RequiresPower { get; set; }
/// <summary>Earliest round it serves. 0/1 = from the start.</summary>
public int StartRound { get; set; }
/// <summary>Door flag that must be open before it serves. Empty = always.</summary>
public string Link { get; set; } = "";
}
/// <summary>
/// One placed trading table.
///
/// ⚠️ PLACEMENT ONLY — no price, no contents. Using the table costs nothing, and WHAT it is holding is
/// runtime state on the component that is deliberately never saved with the map.
/// </summary>
/// <summary>
/// Where a building table stands — the bench the wonder weapon is assembled on.
/// </summary>
///
/// ⚠️ THE SAME FIELDS AS `TradeTableSpot`, AND NOT SHARED WITH IT. They describe the same thing
/// today (a prop on a floor with a facing), but they are going to diverge the moment the table
/// knows which build it serves — a map with two of these for two different weapons is the obvious
/// next ask, and a shared record would have to grow a field the trading table has no use for.
/// <summary>Where one piece of a buildable lies. See `BuildPartManager`.</summary>
public class BuildPartSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>
/// Which piece this is, 1 to 3.
/// </summary>
///
/// ⚠️ NUMBERED FROM ONE, because every place a person sees it — the tool, the console, the
/// prompt — counts from one, and "part 0" reads as a bug rather than a piece.
public int Part { get; set; } = 1;
/// <summary>Needs the power on before it can be taken.</summary>
public bool RequiresPower { get; set; }
/// <summary>Door flag that must be open first. Empty = always.</summary>
public string Link { get; set; } = "";
}
public class BuildTableSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>The floor's normal here, so it sits flat on a slope.</summary>
public Vector3 Normal { get; set; } = Vector3.Up;
/// <summary>
/// Needs the power on before it will serve.
/// </summary>
///
/// ⚠️ FALSE BY DEFAULT, like the trading table and the ammo box. Whether a buildable should
/// be gated on power is a per-map decision, and defaulting it on would silently break every
/// map that places one before finding the switch.
public bool RequiresPower { get; set; }
/// <summary>Door flag that must be open before it serves. Empty = always.</summary>
public string Link { get; set; } = "";
/// <summary>
/// Does the bench keep handing the weapon out. False = one time only.
/// </summary>
///
/// ⛔ ONE TIME IS THE DEFAULT, AND THAT IS THE SAFE WAY ROUND. A buildable wonder weapon is a
/// reward for a hunt; a bench left permanent by accident hands every player an unlimited
/// supply and there is nothing on screen to suggest that was not intended. Getting it wrong
/// the other way is one obvious complaint instead of a silently broken run.
///
/// ⚠️ PERMANENT DOES NOT MEAN REBUILDABLE — it means the weapon never leaves the bench. It is
/// built once and then taken as often as anyone likes, by anyone, without parts.
public bool Permanent { get; set; }
}
public class TradeTableSpot
{
public Vector3 Position { get; set; }
public float Yaw { get; set; }
/// <summary>The floor's normal here, so it sits flat on a slope.</summary>
public Vector3 Normal { get; set; } = Vector3.Up;
// ⛔ NO `Price` FIELD. Upstream has one as a per-entity NetworkVar; the table is free here by
// request, and an unused-but-present price would read as a feature someone forgot to wire.
/// <summary>
/// Needs the power on before it will serve.
///
/// ⛔ FALSE BY DEFAULT, like the ammo box. Its use is stashing a gun you cannot carry yet, which
/// is most valuable early — and upstream gates it on nothing at all.
/// </summary>
public bool RequiresPower { get; set; }
/// <summary>Door flag that must be open before it serves. Empty = always.</summary>
public string Link { get; set; } = "";
}