{"TotalCount":588,"Files":[{"Ident":"cifosi2500.nzombies_sbox","Path":"swb_base/ui/customizationmenu.cs.scss","FileName":"customizationmenu.cs.scss","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"$item-width: 280px;\n$item-bg-full-dark: rgb(33, 33, 33);\n$item-bg-extra-dark: rgba(33, 33, 33,0.95);\n$item-bg-dark: rgba(33, 33, 33,0.8);\n\n$col-green: #2ecc71;\n$col-green-trans: #2ecc7196;\n$col-red: #e84118;\n\nCustomizationMenu {\n\tposition: absolute;\n\ttop: 40px;\n\tleft: 60px;\n\tpointer-events: all;\n\talign-items: flex-start; // Enables columns with diff heights\n\ttransition: opacity 0.2s ease-out 0s;\n\ttext-shadow: 0px 1px 1px rgba(0,0,0,0.3);\n\tgap: 4px;\n\n\t\u0026:intro {\n\t\topacity: 0.01;\n\t}\n\n\t.categoryWrapper {\n\t\tflex-direction: column;\n\n\t\t.weaponName {\n\t\t\tmin-width: $item-width;\n\t\t\talign-items: center;\n\t\t\tcolor: #eccc68;\n\t\t\ttext-shadow: 1px 1px 1px rgba(0,0,0,0.5);\n\t\t\tfont-weight: 500;\n\t\t\tfont-size: 28px;\n\t\t\tfont-family: FONTSPRING DEMO - Integral CF;\n\t\t\tpadding: 0 12px 2px 12px;\n\t\t\tborder-radius: 2px;\n\t\t\tmin-height: 60px;\n\t\t\tbackground-color: rgba( #2222, 0.75 );\n\t\t\tbackdrop-filter: blur( 16px );\n\t\t\tmargin-bottom: 4px;\n\t\t}\n\n\t\t.category {\n\t\t\tmargin-bottom: 4px;\n\t\t\twidth: $item-width;\n\t\t\tborder-radius: 4px;\n\t\t\tcursor: pointer;\n\t\t\talign-items: center;\n\t\t\tmin-height: 44px;\n\t\t\ttransition: all 0.1s ease-out 0s;\n\t\t\tbackground-color: rgba( #2222, 0.5 );\n\t\t\tbackdrop-filter: blur( 16px );\n\n\t\t\t\u0026.active {\n\t\t\t\tborder-left: 3px solid $col-green;\n\t\t\t\tborder-bottom-left-radius: 0;\n\t\t\t\tborder-top-left-radius: 0;\n\n\t\t\t\t.name {\n\t\t\t\t\tcolor: $col-green;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t\u0026:hover {\n\n\t\t\t\t.name {\n\t\t\t\t\tcolor: $col-green;\n\t\t\t\t}\n\n\t\t\t\t\u0026.hasAttachment {\n\t\t\t\t\t.name {\n\t\t\t\t\t\tcolor: white;\n\t\t\t\t\t}\n\n\t\t\t\t\t.activeAttachment {\n\t\t\t\t\t\t.name {\n\t\t\t\t\t\t\tcolor: $col-green;\n\t\t\t\t\t\t\ttransform: scale(1.04);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t.iconWrapper {\n\t\t\t\t\t\t\tfilter: drop-shadow(0px 0px 6px $col-green);\n\n\t\t\t\t\t\t\t.icon {\n\t\t\t\t\t\t\t\theight: 60px;\n\t\t\t\t\t\t\t\twidth: 60px;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t\u0026.hasAttachment {\n\t\t\t\tmin-height: 60px;\n\t\t\t\toverflow: hidden; // too long attach name\n\n\t\t\t\t\u0026.active {\n\t\t\t\t\t.name {\n\t\t\t\t\t\tcolor: white;\n\t\t\t\t\t}\n\n\t\t\t\t\t.activeAttachment {\n\t\t\t\t\t\t.name {\n\t\t\t\t\t\t\tcolor: $col-green;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t.name {\n\t\t\t\t\tfont-size: 16px;\n\t\t\t\t\tmargin-bottom: auto;\n\t\t\t\t\tmargin-top: 4px;\n\t\t\t\t\tcolor: rgb(200,200,200);\n\t\t\t\t}\n\n\t\t\t\t.activeAttachment {\n\t\t\t\t\topacity: 1;\n\n\t\t\t\t\t.iconWrapper {\n\t\t\t\t\t\tfilter: drop-shadow(0px 0px 6px #eccc68);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t.name {\n\t\t\t\tcolor: white;\n\t\t\t\tfont-family: Poppins;\n\t\t\t\tfont-size: 22px;\n\t\t\t\tmargin-left: 10px;\n\t\t\t\tborder-radius: 2px;\n\t\t\t\ttransition: font-size 0.05s ease;\n\t\t\t\tpointer-events: none;\n\t\t\t\ttext-shadow: 1px 1px 1px rgba(0,0,0,0.5);\n\t\t\t}\n\n\t\t\t.activeAttachment {\n\t\t\t\tposition: absolute;\n\t\t\t\twidth: $item-width;\n\t\t\t\theight: 60px;\n\t\t\t\tborder-radius: 2px;\n\t\t\t\topacity: 0;\n\t\t\t\ttransition: all 0.1s ease-out 0s;\n\t\t\t\tpointer-events: none;\n\n\t\t\t\t.name {\n\t\t\t\t\tcolor: white;\n\t\t\t\t\tfont-family: Poppins;\n\t\t\t\t\tfont-size: 20px;\n\t\t\t\t\tmargin: 28px 0 0 10px;\n\t\t\t\t\ttransition: all 0.1s ease-out 0s;\n\t\t\t\t}\n\n\t\t\t\t.iconWrapper {\n\t\t\t\t\theight: 60px;\n\t\t\t\t\twidth: 60px;\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t\tmargin: 0 6px 0 auto;\n\t\t\t\t\t//margin-right: -1px;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\ttransition: all 0.1s ease-out 0s;\n\n\t\t\t\t\t.icon {\n\t\t\t\t\t\theight: 56px;\n\t\t\t\t\t\twidth: 56px;\n\t\t\t\t\t\tbackground-repeat: no-repeat;\n\t\t\t\t\t\tbackground-size: cover;\n\t\t\t\t\t\ttransition: all 0.1s ease-out 0s;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t.attachmentWrapper {\n\t\tflex-direction: column;\n\n\t\t.attachment {\n\t\t\tmargin-bottom: 4px;\n\t\t\twidth: 260px;\n\t\t\tborder-radius: 4px;\n\t\t\tcursor: pointer;\n\t\t\talign-items: center;\n\t\t\tmin-height: 40px;\n\t\t\ttransition: all 0.1s ease-out 0s;\n\t\t\tbackground-color: rgba( #2222, 0.5 );\n\t\t\tbackdrop-filter: blur( 16px );\n\n\t\t\t\u0026:hover {\n\t\t\t\t.name {\n\t\t\t\t\tcolor: $col-green;\n\t\t\t\t}\n\n\t\t\t\t.iconWrapper {\n\t\t\t\t\tfilter: drop-shadow(0px 0px 6px $col-green);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t\u0026.active {\n\t\t\t\tmin-height: 46px;\n\t\t\t\tborder-left: 3px solid $col-green;\n\t\t\t\tborder-bottom-left-radius: 0;\n\t\t\t\tborder-top-left-radius: 0;\n\n\t\t\t\t.name {\n\t\t\t\t\tcolor: $col-green;\n\t\t\t\t\tfont-weight: bold;\n\t\t\t\t}\n\n\t\t\t\t.iconWrapper {\n\t\t\t\t\tfilter: drop-shadow(0px 0px 6px $col-green);\n\t\t\t\t\tmin-height: 46px;\n\t\t\t\t\twidth: 62px;\n\n\t\t\t\t\t.icon {\n\t\t\t\t\t\theight: 46px;\n\t\t\t\t\t\twidth: 46px;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t.name {\n\t\t\t\tcolor: white;\n\t\t\t\tfont-family: Poppins;\n\t\t\t\tfont-size: 18px;\n\t\t\t\tpadding-top: 2px;\n\t\t\t\tmargin-left: 10px;\n\t\t\t\tborder-radius: 2px;\n\t\t\t\ttransition: font-size 0.05s ease;\n\t\t\t\tpointer-events: none;\n\t\t\t\ttext-shadow: 1px 1px 1px rgba(0,0,0,0.5);\n\t\t\t}\n\n\t\t\t.iconWrapper {\n\t\t\t\t// height: 100% -\u003E broken somehow\n\t\t\t\tmin-height: 40px;\n\t\t\t\twidth: 52px;\n\t\t\t\tborder-radius: 2px;\n\t\t\t\tmargin-left: auto;\n\t\t\t\tjustify-content: center;\n\t\t\t\talign-items: center;\n\t\t\t\ttransition: all 0.1s ease-out 0s;\n\t\t\t\tpointer-events: none;\n\t\t\t\tfilter: drop-shadow(0px 0px 1px white);\n\n\t\t\t\t.icon {\n\t\t\t\t\theight: 42px;\n\t\t\t\t\twidth: 42px;\n\t\t\t\t\tbackground-repeat: no-repeat;\n\t\t\t\t\tbackground-size: cover;\n\t\t\t\t\ttransition: all 0.1s ease-out 0s;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t.descriptionWrapper {\n\t\tposition: relative;\n\t\twidth: 400px;\n\t\tmargin-left: 4px;\n\t\tflex-direction: column;\n\t\tborder-radius: 2px;\n\t\tpadding: 10px;\n\t\tfont-family: Poppins;\n\t\tbackground-color: rgba( #2222, 0.5 );\n\t\tbackdrop-filter: blur( 16px );\n\t\ttext-shadow: 1px 1px 1px rgba(0,0,0,0.5);\n\t\tfont-size: 16px;\n\n\t\t\u0026:empty {\n\t\t\topacity: 0;\n\t\t}\n\n\t\t.description {\n\t\t\tcolor: white;\n\t\t}\n\n\t\t.posWrapper {\n\t\t\tmargin-top: 10px;\n\t\t\tpadding-left: 4px;\n\t\t\tflex-direction: column;\n\t\t\tborder-left: 2px solid $col-green;\n\n\t\t\t.label {\n\t\t\t\tcolor: $col-green;\n\t\t\t\tfont-weight: 500;\n\t\t\t}\n\t\t}\n\n\t\t.negWrapper {\n\t\t\tmargin-top: 10px;\n\t\t\tpadding-left: 4px;\n\t\t\tflex-direction: column;\n\t\t\tborder-left: 2px solid $col-red;\n\n\t\t\t\u0026:empty {\n\t\t\t\tmargin-top: 0;\n\t\t\t}\n\n\t\t\t.label {\n\t\t\t\tcolor: $col-red;\n\t\t\t\tfont-weight: 500;\n\t\t\t}\n\t\t}\n\t}\n}\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Audio/EggFanfare.cs","FileName":"EggFanfare.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\r\nusing System;\r\n\r\nnamespace NZombies;\r\n\r\n/// \u003Csummary\u003E\r\n/// BASALT\u0027S EASTER EGG FANFARES \u2014 each step done plays the map\u0027s motif again, a little more of it on a little more, and at steps 7,\r\n/// 10 and 13 the beast roars and the map shakes, each longer, harder and louder than the last: *\u0022at the end of the following steps i\r\n/// want to hear the roar and the map shake, each step becoming a longer more agressive shake and louder roar, the first one being\r\n/// something light \u2014 7, 10, 13 \u2014 i would also like for you to compose a motif for the map that will play with diferent slight\r\n/// variations instruments and intensity as we progress trough the steps\u0022* (2026-09-28).\r\n///\r\n/// The sounds are \u0060Tools/basalt_egg_motifs.py\u0060\u0027s: the box\u0027s figure (D A F A, G D C D) as the power motif has it, on the box\u0027s stones\r\n/// alone at the first step and on the whole ensemble at the teleporter, turning to D major for his death and the end; the roars\r\n/// are Oberon\u0027s own voice, lowered, in a cave, over falling rock.\r\n///\r\n/// \u26D4 THE HOST SAYS WHEN, EVERY MACHINE PLAYS ITS OWN. \u0060HexPlatforms\u0060 sends the step where it decides it is done\r\n/// (\u0060NZNet.EggStepDone\u0060). Each machine then plays the motif and the roar, and shakes its own player\u0027s view, on its own clock from\r\n/// that word. It is an event, so a joiner does not replay one. Nothing plays while testing (the selftest, \u0060nz_hex_skipto\u0060),\r\n/// just as no step\u0027s other sounds do.\r\n///\r\n/// \u26A0\uFE0F IN TURN, NOT ON TOP:\r\n/// 1. The step\u0027s own clicking plays first (\u0060nz.hex.done\u0060, 5.2 s): at once, or \u0060HexPlatforms.DoneCueDelay\u0060 later, whichever the\r\n///    step does.\r\n/// 2. The roar comes once the clicking\u0027s loud first three seconds are past, and the shaking comes with it.\r\n/// 3. The motif comes out of the roar\u0027s tail. With no roar, it comes after the clicking.\r\n/// \u003Csee cref=\u0022For\u0022/\u003E says what each step plays.\r\n///\r\n/// \u26A0\uFE0F STEP 10\u0027S SHAKE IS THE MASTERMIND\u0027S QUAKE, MOVED HERE. It used to come with the clicking; now the same quake\r\n/// (\u0060HexPlatforms.QuakePeak\u0060) comes with the roar, and the roar\u0027s rumble is held after it.\r\n/// \u26A0\uFE0F THE TUNABLES ARE NULLABLE-BACKED (INSTRUCTIONS \u00A71), and the tables are switches, which a hotload sees.\r\n/// \u003C/summary\u003E\r\npublic static class EggFanfare\r\n{\r\n\t/// \u003Csummary\u003EWhen a step\u0027s clicking plays, as the step itself plays it: not at all, at once, or \u0060DoneCueDelay\u0060 after.\u003C/summary\u003E\r\n\tpublic enum Click { None, Now, Later }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// A step\u0027s fanfare:\r\n\t/// - its clicking;\r\n\t/// - its roar (0 for none, else 1-3);\r\n\t/// - its motif\u0027s cue (null for none);\r\n\t/// - how long a motif waits when the step has neither clicking nor roar.\r\n\t/// \u003C/summary\u003E\r\n\tpublic readonly record struct Plan( Click Click, int Roar, string Motif, float Wait = 0.4f );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Step by step, numbered as in the Easter egg\u0027s table. \u26A0\uFE0F STEPS 6 AND 14 PLAY NOTHING:\r\n\t/// - 6: the altar\u0027s defense begins at once, with its own music;\r\n\t/// - 14: the arena\u0027s quiet before the fight belongs to the fight.\r\n\t/// Step 15 is his death, with the fight\u0027s music leaving under it. Step 16 is everyone home, a moment after the teleporter\r\n\t/// sets them down.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static Plan For( int step ) =\u003E step switch\r\n\t{\r\n\t\t1 =\u003E new( Click.Later, 0, \u0022nz.basalt.motif.1\u0022 ),          // Color Smash \u2014 the box\u0027s stones alone\r\n\t\t2 =\u003E new( Click.None, 0, \u0022nz.basalt.motif.2\u0022 ),           // Bonfire \u2014 a lyre over embers; the step has no clicking\r\n\t\t3 =\u003E new( Click.Now, 0, \u0022nz.basalt.motif.3\u0022 ),            // Color Rings \u2014 stones and lyre in canon\r\n\t\t4 =\u003E new( Click.Later, 0, \u0022nz.basalt.motif.4\u0022 ),          // Torch Carry \u2014 one dark horn and the fire\r\n\t\t5 =\u003E new( Click.Now, 0, \u0022nz.basalt.motif.5\u0022 ),            // Shield Lock \u2014 bright, the answer rising to the octave\r\n\t\t7 =\u003E new( Click.Later, 1, \u0022nz.basalt.motif.7\u0022 ),          // Altar Defense \u2014 THE FIRST ROAR, light; then the horns\r\n\t\t8 =\u003E new( Click.Now, 0, \u0022nz.basalt.motif.8\u0022 ),            // Twin Shield \u2014 the horns, a lyre running under\r\n\t\t9 =\u003E new( Click.Later, 0, \u0022nz.basalt.motif.9\u0022 ),          // Shrieker Platform \u2014 quicker, and the climb\r\n\t\t10 =\u003E new( Click.Now, 2, \u0022nz.basalt.motif.10\u0022 ),          // The Mastermind \u2014 THE SECOND ROAR and its quake; heavy, dark\r\n\t\t11 =\u003E new( Click.None, 0, \u0022nz.basalt.motif.11\u0022 ),         // Rising Lava \u2014 the whole shape, falling as it sinks\r\n\t\t12 =\u003E new( Click.Later, 0, \u0022nz.basalt.motif.12\u0022 ),        // The Junctions \u2014 the stones ringing above the horns\r\n\t\t13 =\u003E new( Click.Later, 3, \u0022nz.basalt.motif.13\u0022 ),        // Teleporter Buttons \u2014 THE THIRD ROAR, the loudest; everything\r\n\t\t15 =\u003E new( Click.Now, 0, \u0022nz.basalt.motif.15\u0022 ),          // Oberon dead \u2014 the turn to D major\r\n\t\t16 =\u003E new( Click.None, 0, \u0022nz.basalt.motif.16\u0022, 2.2f ),   // The core \u2014 everyone home: the finale\r\n\t\t_ =\u003E new( Click.None, 0, null ),\r\n\t};\r\n\r\n\t/// \u003Csummary\u003EEach roar\u0027s sound: far off and short, closer and angrier, the loudest and the longest.\u003C/summary\u003E\r\n\tpublic static string RoarCue( int roar ) =\u003E roar switch\r\n\t{\r\n\t\t1 =\u003E \u0022nz.basalt.roar.1\u0022,\r\n\t\t2 =\u003E \u0022nz.basalt.roar.2\u0022,\r\n\t\t3 =\u003E \u0022nz.basalt.roar.3\u0022,\r\n\t\t_ =\u003E null,\r\n\t};\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// A roar\u0027s shaking, in four parts:\r\n\t/// - a held rumble (\u0060CameraShake.Rumble\u0060: up over its first fifth, down over its last half);\r\n\t/// - a quake on top, which starts at its height and eases out (\u0060HexPlatforms.StartQuake\u0060), as a fraction of \u0060QuakePeak\u0060;\r\n\t/// - for the third roar, a second quake at its second breath;\r\n\t/// - the ceiling\u0027s dust (\u0060CeilingDust\u0060), as a multiple of the spawn-in\u0027s.\r\n\t/// \u003C/summary\u003E\r\n\tpublic readonly record struct Shake( float Rumble, float Seconds, float Quake, float QuakeSeconds, float Again, float AgainAt,\r\n\t\tfloat AgainSeconds, float Dust );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Each roar\u0027s shaking. Trauma 1 is 26\u00B0 of swing; the spawn-in shakes at 0.08, the power at 0.1.\r\n\t/// - 1, light: a rumble of 0.07 for 6 s, and a little dust.\r\n\t/// - 2: the Mastermind\u0027s quake at full for 3 s, then a rumble of 0.17 held to 9 s.\r\n\t/// - 3: a quake at full for 4.5 s, another at three quarters 4.2 s in, a rumble of 0.3 held to 13 s, and twice the dust.\r\n\t/// Each matches its roar\u0027s length (\u0060Tools/basalt_egg_motifs.py\u0060).\r\n\t/// \u003C/summary\u003E\r\n\tpublic static Shake ShakeFor( int roar ) =\u003E roar switch\r\n\t{\r\n\t\t1 =\u003E new( 0.07f, 6f, 0f, 0f, 0f, 0f, 0f, 0.6f ),\r\n\t\t2 =\u003E new( 0.17f, 9f, 1f, 3f, 0f, 0f, 0f, 1.2f ),\r\n\t\t3 =\u003E new( 0.3f, 13f, 1f, 4.5f, 0.75f, 4.2f, 3f, 2f ),\r\n\t\t_ =\u003E default,\r\n\t};\r\n\r\n\t/// \u003Csummary\u003EHow long after a roar starts its motif comes: out of its tail, once the voice is done.\u003C/summary\u003E\r\n\tstatic float MotifAfterRoar( int roar ) =\u003E roar switch { 1 =\u003E 4.8f, 2 =\u003E 6.5f, _ =\u003E 9.5f };\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_egg_fanfare off\u0060 silences them all (this session).\u003C/summary\u003E\r\n\tpublic static bool Enabled\r\n\t{\r\n\t\tget =\u003E _enabled ?? true;\r\n\t\tset =\u003E _enabled = value;\r\n\t}\r\n\r\n\tstatic bool? _enabled;\r\n\r\n\t/// \u003Csummary\u003EHow long after the clicking starts the roar comes: once its loud first three seconds are past (it runs 5.2 s).\u003C/summary\u003E\r\n\tpublic static float RoarAfterClick\r\n\t{\r\n\t\tget =\u003E _roarAfterClick ?? 3f;\r\n\t\tset =\u003E _roarAfterClick = value;\r\n\t}\r\n\r\n\tstatic float? _roarAfterClick;\r\n\r\n\t/// \u003Csummary\u003EHow long after the clicking starts a motif comes, when no roar does.\u003C/summary\u003E\r\n\tpublic static float MotifAfterClick\r\n\t{\r\n\t\tget =\u003E _motifAfterClick ?? 2.6f;\r\n\t\tset =\u003E _motifAfterClick = value;\r\n\t}\r\n\r\n\tstatic float? _motifAfterClick;\r\n\r\n\t/// \u003Csummary\u003EBumped by every fanfare, and by \u003Csee cref=\u0022Stop\u0022/\u003E: a wait that finds it has moved on does nothing.\u003C/summary\u003E\r\n\tstatic int _fanfare;\r\n\r\n\tstatic SoundHandle _motif, _roar;\r\n\r\n\tstatic bool InGame =\u003E NZGame.Mode is GameMode.Survival or GameMode.Spectator;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// A step done: its fanfare on this machine, starting now (\u0060NZNet.EggStepDone\u0060, the host\u0027s word). \u0060nz_egg_fanfare\u0060 passes \u0060click\u0060\r\n\t/// to play the step\u0027s own clicking too, as the step would, and \u0060anywhere\u0060 to play it outside a game.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void Play( int step, bool click = false, bool anywhere = false )\r\n\t{\r\n\t\tvar plan = For( step );\r\n\t\tif ( !Enabled || (plan.Roar \u003C= 0 \u0026\u0026 plan.Motif is null) ) return;\r\n\r\n\t\tvar id = \u002B\u002B_fanfare;\r\n\t\tvar clickAt = plan.Click switch { Click.Now =\u003E 0f, Click.Later =\u003E MathF.Max( 0f, HexPlatforms.DoneCueDelay ), _ =\u003E -1f };\r\n\t\tvar roarAt = plan.Roar \u003E 0 ? MathF.Max( 0f, clickAt ) \u002B RoarAfterClick : -1f;\r\n\t\tvar motifAt = plan.Motif is null ? -1f\r\n\t\t\t: plan.Roar \u003E 0 ? roarAt \u002B MotifAfterRoar( plan.Roar )\r\n\t\t\t: clickAt \u003E= 0f ? clickAt \u002B MotifAfterClick\r\n\t\t\t: plan.Wait;\r\n\r\n\t\tif ( click \u0026\u0026 clickAt \u003E= 0f ) Later( id, clickAt, anywhere, () =\u003E NZSound.PlayUi( HexPlatforms.DoneCue ) );\r\n\t\tif ( roarAt \u003E= 0f ) Later( id, roarAt, anywhere, () =\u003E Roar( plan.Roar, anywhere ) );\r\n\t\tif ( motifAt \u003E= 0f ) Later( id, motifAt, anywhere, () =\u003E Motif( plan.Motif ) );\r\n\r\n\t\tLog.Info( $\u0022[nz-egg] step {step}\u0027s fanfare\u0022\r\n\t\t\t\u002B (roarAt \u003E= 0f ? $\u0022 \u2014 the roar ({plan.Roar} of 3) in {roarAt:0.#}s\u0022 : \u0022\u0022)\r\n\t\t\t\u002B (motifAt \u003E= 0f ? $\u0022 \u2014 {plan.Motif} in {motifAt:0.#}s\u0022 : \u0022\u0022) );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Wait, then do it \u2014 unless another fanfare or a stop has come since, or the game has gone. \u26A0\uFE0F \u0060GameTask.Delay\u0060, the project\u0027s\r\n\t/// own (\u0060VultureStink.ReportAfter\u0060), RE-VALIDATED AFTER THE WAIT as \u0060HexPlatforms.DoneCueLater\u0060 is.\r\n\t/// \u003C/summary\u003E\r\n\tstatic async void Later( int id, float seconds, bool anywhere, Action then )\r\n\t{\r\n\t\tif ( seconds \u003E 0f ) await GameTask.Delay( (int)(seconds * 1000f) );\r\n\r\n\t\tif ( id != _fanfare || (!anywhere \u0026\u0026 !InGame) ) return;\r\n\t\tthen();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// A motif, now, on this machine, everywhere alike. A new one fades out one still playing rather than playing over it \u2014 his\r\n\t/// death\u0027s, say, when the orb is taken before it ends and the finale begins.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void Motif( string cue )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( cue ) ) return;\r\n\r\n\t\tFadeOut( _motif, 0.5f );\r\n\t\t_motif = NZSound.PlayUi( cue );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ETake a sound down to nothing over these seconds, then stop it. \u26A0\uFE0F Re-validated at every step: it may end first.\u003C/summary\u003E\r\n\tstatic async void FadeOut( SoundHandle h, float seconds )\r\n\t{\r\n\t\tif ( !h.IsValid() ) return;\r\n\r\n\t\tconst int steps = 10;\r\n\t\tvar from = h.Volume;\r\n\t\tfor ( var i = 1; i \u003C= steps; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tawait GameTask.Delay( (int)(seconds * 1000f / steps) );\r\n\t\t\tif ( !h.IsValid() ) return;\r\n\t\t\th.Volume = from * (1f - (float)i / steps);\r\n\t\t}\r\n\r\n\t\th.Stop();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// A roar and its shaking, now, on this machine: the sound everywhere alike, this machine\u0027s own player\u0027s view shaken, and the\r\n\t/// ceiling\u0027s dust shed over them.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void Roar( int roar, bool anywhere = false )\r\n\t{\r\n\t\tvar cue = RoarCue( roar );\r\n\t\tif ( cue is null ) return;\r\n\r\n\t\tFadeOut( _roar, 0.5f );\r\n\t\t_roar = NZSound.PlayUi( cue );\r\n\r\n\t\tvar s = ShakeFor( roar );\r\n\t\tCameraShake.Rumble( s.Rumble, s.Seconds );\r\n\t\tif ( s.Quake \u003E 0f ) HexPlatforms.Ensure()?.StartQuake( s.QuakeSeconds, s.Quake * HexPlatforms.QuakePeak );\r\n\t\tif ( s.Again \u003E 0f )\r\n\t\t\tLater( _fanfare, s.AgainAt, anywhere, () =\u003E HexPlatforms.Ensure()?.StartQuake( s.AgainSeconds, s.Again * HexPlatforms.QuakePeak ) );\r\n\t\tCeilingDust.Shed( s.Seconds, s.Dust );\r\n\r\n\t\tLog.Info( $\u0022[nz-egg] THE BEAST ROARS ({roar} of 3) \u2014 the ground shakes, {s.Rumble:0.00} held for {s.Seconds:0.#}s\u0022\r\n\t\t\t\u002B (s.Quake \u003E 0f ? $\u0022, a quake from {s.Quake * HexPlatforms.QuakePeak:0.##} over {s.QuakeSeconds:0.#}s\u0022 : \u0022\u0022)\r\n\t\t\t\u002B (s.Again \u003E 0f ? $\u0022, another {s.AgainAt:0.#}s in\u0022 : \u0022\u0022) );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ESilent, now: the motif and the roar stopped, and anything still waiting dropped. The shaking eases out by itself.\u003C/summary\u003E\r\n\tpublic static void Stop()\r\n\t{\r\n\t\t_fanfare\u002B\u002B;\r\n\t\tif ( _motif.IsValid() ) _motif.Stop();\r\n\t\tif ( _roar.IsValid() ) _roar.Stop();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_egg_fanfare [step [all] | roar 1-3 | motif step | stop | on | off]\u0060 \u2014 basalt\u0027s Easter egg fanfares, by hand.\r\n\t/// - Bare, it lists each step\u0027s plan and whether its sounds are there.\r\n\t/// - A step plays that step\u0027s fanfare on this machine, the step\u0027s own clicking included, on the step\u0027s clock.\r\n\t/// - \u0060all\u0060 after a step sends it to everyone, as the step itself does (HOST).\r\n\t/// - \u0060roar 1-3\u0060 plays a roar and its shaking at once; \u0060motif\u0060 and a step plays that step\u0027s motif alone.\r\n\t/// - \u0060stop\u0060 silences what plays; \u0060off\u0060 and \u0060on\u0060 switch them (this session).\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_egg_fanfare\u0022 )]\r\n\tpublic static void Cmd( string what = \u0022\u0022, string arg = \u0022\u0022 )\r\n\t{\r\n\t\tvar w = what.Trim().ToLowerInvariant();\r\n\t\tint.TryParse( arg.Trim(), out var n );\r\n\r\n\t\tswitch ( w )\r\n\t\t{\r\n\t\t\tcase \u0022\u0022:\r\n\t\t\t\tfor ( var step = 1; step \u003C= 16; step\u002B\u002B )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar p = For( step );\r\n\t\t\t\t\tvar roar = RoarCue( p.Roar );\r\n\t\t\t\t\tLog.Info( $\u0022[nz-egg] step {step,2}: \u0022\r\n\t\t\t\t\t\t\u002B (p.Motif is null ? \u0022nothing\u0022 : $\u0022{p.Motif} {(NZSound.Exists( p.Motif ) ? \u0022\u2713\u0022 : \u0022MISSING\u0022)}\u0022)\r\n\t\t\t\t\t\t\u002B (roar is null ? \u0022\u0022 : $\u0022 \u00B7 roar {p.Roar}, {roar} {(NZSound.Exists( roar ) ? \u0022\u2713\u0022 : \u0022MISSING\u0022)}\u0022)\r\n\t\t\t\t\t\t\u002B $\u0022 \u00B7 clicking {p.Click}\u0022 );\r\n\t\t\t\t}\r\n\t\t\t\tLog.Info( $\u0022[nz-egg] {(Enabled ? \u0022on\u0022 : \u0022OFF\u0022)} \u00B7 the roar {RoarAfterClick:0.#}s after the clicking, a motif\u0022\r\n\t\t\t\t\t\u002B $\u0022 {MotifAfterClick:0.#}s after it \u00B7 nz_egg_fanfare \u003Cstep\u003E to hear one\u0022 );\r\n\t\t\t\treturn;\r\n\r\n\t\t\tcase \u0022off\u0022:\r\n\t\t\t\tEnabled = false;\r\n\t\t\t\tStop();\r\n\t\t\t\tLog.Info( \u0022[nz-egg] the Easter egg\u0027s fanfares are OFF \u2014 nz_egg_fanfare on for them back\u0022 );\r\n\t\t\t\treturn;\r\n\r\n\t\t\tcase \u0022on\u0022:\r\n\t\t\t\tEnabled = true;\r\n\t\t\t\tLog.Info( \u0022[nz-egg] the Easter egg\u0027s fanfares are ON\u0022 );\r\n\t\t\t\treturn;\r\n\r\n\t\t\tcase \u0022stop\u0022:\r\n\t\t\t\tStop();\r\n\t\t\t\treturn;\r\n\r\n\t\t\tcase \u0022roar\u0022:\r\n\t\t\t\tif ( RoarCue( n ) is null ) { Log.Warning( \u0022[nz-egg] nz_egg_fanfare roar 1, 2 or 3\u0022 ); return; }\r\n\t\t\t\t_fanfare\u002B\u002B;\r\n\t\t\t\tRoar( n, anywhere: true );\r\n\t\t\t\treturn;\r\n\r\n\t\t\tcase \u0022motif\u0022:\r\n\t\t\t\tif ( For( n ).Motif is not string cue ) { Log.Warning( $\u0022[nz-egg] step {n} has no motif\u0022 ); return; }\r\n\t\t\t\tMotif( cue );\r\n\t\t\t\tLog.Info( $\u0022[nz-egg] {cue}\u0022 );\r\n\t\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( !int.TryParse( w, out var s ) || s \u003C 1 || s \u003E 16 )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[nz-egg] nz_egg_fanfare [step [all] | roar 1-3 | motif step | stop | on | off]\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( arg.Trim().Equals( \u0022all\u0022, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t{\r\n\t\t\tif ( NZGame.IsClient ) { Log.Warning( \u0022[nz-egg] the host sends a step\u0027s fanfare \u2014 run it on the host\u0022 ); return; }\r\n\t\t\tNZNet.EggStepDone( s );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar was = Enabled;\r\n\t\tEnabled = true;\r\n\t\tPlay( s, click: true, anywhere: true );\r\n\t\tEnabled = was;\r\n\t}\r\n}\r\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Buyables/MiseryDeviceManager.cs","FileName":"MiseryDeviceManager.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace NZombies;\n\n/// \u003Csummary\u003E\n/// Builds the config\u0027s misery acceleration devices.\n///\n/// \u26A0\uFE0F A PLAIN BOX FOR NOW, BY REQUEST \u2014 the model comes later. It is a \u0060Model.Cube\u0060\n/// scaled to the spot\u0027s \u0060Size\u0060, with a real \u0060BoxCollider\u0060 so you can walk up to it and\n/// it behaves like an object rather than a hologram.\n///\n/// \u26A0\uFE0F SAME SHAPE AS EVERY OTHER MACHINE MANAGER \u2014 Ensure creates on demand, NotSaved\n/// keeps it out of the map file, Rebuild is the single way anything gets built.\n/// \u003C/summary\u003E\npublic sealed class MiseryDeviceManager : Component\n{\n\tpublic static MiseryDeviceManager Instance { get; private set; }\n\n\tprotected override void OnAwake() =\u003E Instance = this;\n\tprotected override void OnDestroy() { if ( Instance == this ) Instance = null; }\n\n\tpublic static MiseryDeviceManager Ensure( Scene scene = null )\n\t{\n\t\tif ( Instance.IsValid() ) return Instance;\n\n\t\tscene ??= Game.ActiveScene;\n\t\tif ( !scene.IsValid() ) return null;\n\n\t\tvar go = scene.CreateObject();\n\t\tgo.Name = \u0022Misery Device Manager\u0022;\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\n\t\treturn go.Components.Create\u003CMiseryDeviceManager\u003E();\n\t}\n\n\treadonly List\u003CGameObject\u003E _built = new();\n\n\t/// \u003Csummary\u003EHow many are standing right now.\u003C/summary\u003E\n\tpublic int Built =\u003E _built.Count( g =\u003E g.IsValid() );\n\n\t/// \u003Csummary\u003EDestroy what is standing and build the config again.\u003C/summary\u003E\n\tpublic void Rebuild()\n\t{\n\t\tforeach ( var g in _built ) g?.Destroy();\n\t\t_built.Clear();\n\n\t\tvar list = ActiveConfig.Current?.Miseries;\n\t\tif ( list is null || list.Count == 0 ) return;\n\n\t\tforeach ( var spot in list )\n\t\t\tBuild( spot );\n\n\t\tLog.Info( $\u0022[nz] {Built} of {list.Count} misery device(s) built\u0022 );\n\t}\n\n\tvoid Build( MiserySpot spot )\n\t{\n\t\tvar go = Scene.CreateObject();\n\t\tgo.Name = \u0022Misery Acceleration Device\u0022;\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\n\t\tgo.NetworkMode = NetworkMode.Never;   // \u26D4 THIS MACHINE\u0027S OWN \u2014 out of a joiner\u0027s snapshot, where it would stand frozen (NZNetListener)\n\t\tgo.WorldPosition = spot.Position;\n\t\tgo.WorldRotation = OnFloor( spot );\n\n\t\tvar size = spot.Size;\n\n\t\t// \u26A0\uFE0F THE BOX IS A CHILD, LIFTED HALF ITS HEIGHT. A cube\u0027s origin is its middle and a\n\t\t// placeable\u0027s origin is its base \u2014 without the lift the device would be buried to\n\t\t// its waist in the floor, which is exactly how a placeholder gets mistaken for a\n\t\t// broken placement.\n\t\tvar vis = Scene.CreateObject();\n\t\tvis.Name = \u0022visual\u0022;\n\t\tvis.SetParent( go );\n\t\tvis.LocalPosition = Vector3.Up * size.z * 0.5f;\n\t\tvis.LocalRotation = Rotation.Identity;\n\n\t\tvar cube = Model.Cube.Bounds.Size;\n\t\tvis.LocalScale = new Vector3( size.x / cube.x, size.y / cube.y, size.z / cube.z );\n\n\t\tvar mr = vis.Components.Create\u003CModelRenderer\u003E();\n\t\tmr.Model = Model.Cube;\n\n\t\t// \u26A0\uFE0F TINTED SO IT READS AS A DEVICE, not as leftover dev geometry. It is the only\n\t\t// untextured thing a mapper will have placed on purpose.\n\t\tmr.Tint = new Color( 0.85f, 0.15f, 0.15f, 1f );\n\n\t\tvar solid = vis.Components.Create\u003CBoxCollider\u003E();\n\t\tsolid.Scale = cube;\n\n\t\tvar dev = go.Components.Create\u003CMiseryDevice\u003E();\n\t\tdev.Spot = spot;\n\n\t\t_built.Add( go );\n\t}\n\n\t/// \u003Csummary\u003EFace the spot\u0027s yaw while lying flat on its floor.\n\t///\n\t/// \u26A0\uFE0F LIFTED FROM \u0060AmmoBoxManager.OnFloor\u0060, deliberately identical \u2014 two placeables\n\t/// aligning differently on the same ramp is a bug nobody would think to look for.\u003C/summary\u003E\n\tstatic Rotation OnFloor( MiserySpot spot )\n\t{\n\t\tvar up = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;\n\t\tvar heading = Rotation.FromYaw( spot.Yaw ).Forward;\n\n\t\tvar forward = (heading - up * heading.Dot( up )).Normal;\n\t\tif ( forward.IsNearlyZero() )\n\t\t\treturn Rotation.From( 0f, spot.Yaw, 0f );\n\n\t\treturn Rotation.LookAt( forward, up );\n\t}\n}\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Buyables/PackAPunch.cs","FileName":"PackAPunch.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace NZombies;\r\n\r\n/// \u003Csummary\u003E\r\n/// PACK-A-PUNCH \u2014 feed it your gun, get it back hitting harder.\r\n///\r\n/// Ported from the original\u0027s \u0060pap\u0060 machine (perk_machine \u002B pap_weapon_fly).\r\n///\r\n/// \u26D4 IT IS REGISTERED AS A PERK IN THE ORIGINAL AND IT IS NOT ONE. \u0060sh_perks.lua\u0060\r\n/// declares it with \u0060specialmachine = true\u0060 and \u0060nobuy = true\u0060, which skips the\r\n/// entire own-a-perk path \u2014 no bottle, no icon slot, nothing persists on the\r\n/// player. It reuses the perk machine only because that entity already knew how to\r\n/// stand somewhere, gate on power and take money. So this is its own component\r\n/// rather than the first perk: modelling it as a perk would mean building the perk\r\n/// system around the one thing that is not one.\r\n///\r\n/// \u26A0\uFE0F The machine barely animates \u2014 the model carries a single \u0060idle\u0060 sequence.\r\n/// The gun going in and coming out is a SEPARATE entity there (\u0060pap_weapon_fly\u0060),\r\n/// and here it is the same floating-offer object the mystery box already uses.\r\n/// \u003C/summary\u003E\r\npublic sealed class PackAPunch : Component\r\n{\r\n\t/// \u003Csummary\u003EEvery live machine, for the use trace and the prompt.\u003C/summary\u003E\r\n\tpublic static readonly List\u003CPackAPunch\u003E All = new();\r\n\r\n\t// \u26D4 \u0060Costs\u0060 REMOVED. It was a \u0060[Property] List\u003Cint\u003E\u0060 on this component and is now\r\n\t// \u0060MapConfig.Pap.Costs\u0060, edited from the settings panel. Two places holding the price ladder\r\n\t// meant the panel could disagree with the machine standing in front of you, and nothing\r\n\t// serialized this one anyway (checked: no \u0022Costs\u0022 in Assets) because machines are built at\r\n\t// runtime by PackAPunchManager. \u00A73: one author per value.\r\n\r\n\t/// \u003Csummary\u003EHow close you must stand. Matches the box and the barricade.\u003C/summary\u003E\r\n\t[Property] public float Reach { get; set; } = 90f;\r\n\r\n\t/// \u003Csummary\u003EThe machine. BO2 vending PaP, same pack and author as the crate.\u003C/summary\u003E\r\n\t[Property] public string MachineModel { get; set; } = \u0022models/nz/pap/vending_pap.vmdl\u0022;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// How long the machine works before handing the gun back.\r\n\t///\r\n\t/// The original\u0027s \u0060PerkTime = CurTime() \u002B 3.5\u0060 (perk_machine:1166).\r\n\t/// \u003C/summary\u003E\r\n\t[Property] public float WorkTime { get; set; } = 3.5f;\r\n\r\n\t/// \u003Csummary\u003EHow long the finished gun waits to be collected.\u003C/summary\u003E\r\n\t[Property] public float ReadyTime { get; set; } = 20f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Where the weapon floats, above the machine\u0027s base.\r\n\t///\r\n\t/// \u26A0\uFE0F 42 IS THE THROAT OPENING, found with \u0060nz_pap_gun\u0060 against the real model\r\n\t/// rather than guessed \u2014 52 was a first estimate made before there was a machine\r\n\t/// to look at, and hung the gun up near the top edge. 37 was also tried and is\r\n\t/// too low: it rests the weapon on the bottom lip, which reads as a gun left\r\n\t/// lying on the machine rather than one being presented by it.\r\n\t/// \u003C/summary\u003E\r\n\t[Property] public float GunHeight { get; set; } = 42f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// How far IN FRONT of the machine the weapon sits when presented.\r\n\t///\r\n\t/// \u26A0\uFE0F The machine\u0027s local \u002Bx points at whoever placed it (\u0060nz_pap\u0060 sets the\r\n\t/// spot\u0027s yaw from the player), so forward is out towards the player and this is\r\n\t/// simply a positive offset \u2014 no sign juggling per placement.\r\n\t///\r\n\t/// \u26A0\uFE0F 28.8, tuned by eye in two passes: 40 hung the weapon clear of the machine\r\n\t/// and read as a gun floating NEAR a Pack-a-Punch, 14.4 overcorrected and tucked\r\n\t/// it almost against the throat. This sits between them \u2014 offered by the machine,\r\n\t/// with enough travel that the sink-back still reads as reclaiming it.\r\n\t/// \u003C/summary\u003E\r\n\t[Property] public float GunOutDistance { get; set; } = 28.8f;\r\n\r\n\t/// \u003Csummary\u003EHow deep into the roller throat it travels while being worked on.\u003C/summary\u003E\r\n\t[Property] public float GunInDepth { get; set; } = 2f;\r\n\r\n\t/// \u003Csummary\u003EHow long the weapon takes to travel in, and to come back out.\u003C/summary\u003E\r\n\t[Property] public float TravelTime { get; set; } = 0.7f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060TravelTime\u0060 after Timeslip m1 Overclock.\r\n\t///\r\n\t/// \u26D4 ONE PROPERTY, FOUR READERS. \u0060TravelTime\u0060 is read by the two \u0060_next\u0060 deadlines AND by\r\n\t/// the two animation lerps that slide the gun in and out. Scaling only the deadlines would\r\n\t/// run the animation at normal speed against a deadline 20x shorter \u2014 the gun would jump\r\n\t/// rather than travel, which looks like a broken machine rather than a fast one.\r\n\t///\r\n\t/// \u26A0 Resolved from \u0060_owner\u0060, the player who started the cycle, exactly as\r\n\t/// \u0060MachineCycleMultiplier\u0060 is. Null-safe: no owner resolves to the unscaled time.\r\n\t/// \u003C/summary\u003E\r\n\tfloat EffectiveTravel\r\n\t\t=\u003E MathF.Max( 0.01f, TravelTime * TimeAugments.PapCycleScale( _owner ) );\r\n\r\n\t/// \u003Csummary\u003EHow the presented weapon is angled. Same broadside idea as the box.\u003C/summary\u003E\r\n\t[Property] public Angles GunAngles { get; set; } = new( 0f, 90f, 0f );\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tif ( !All.Contains( this ) ) All.Add( this );\r\n\t\tBuildVisual();\r\n\r\n\t\t// \u26D4 SEEDED HERE, BECAUSE A DEFAULT \u0060TimeUntil\u0060 HAS ALREADY ELAPSED. Left unset,\r\n\t\t// \u0060_nextJingle\u0060 reads as due on the machine\u0027s very first tick \u2014 so every Pack-a-Punch on\r\n\t\t// the map jingled simultaneously the moment the power came on, and again in unison\r\n\t\t// whenever their random gaps happened to line up. That is half of \u0022the jingle plays\r\n\t\t// multiple times at once\u0022, and it was not the sound system\u0027s fault.\r\n\t\t_nextJingle = Game.Random.Float( JingleGap.x, JingleGap.y );\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tAll.Remove( this );\r\n\t\tClearGun();\r\n\t\tStopLoop();\r\n\t}\r\n\r\n\t// \u2500\u2500 ambience \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\tSoundHandle _loop;\r\n\tTimeUntil _nextJingle;\r\n\r\n\t// \u2550\u2550 ONE PACK-A-PUNCH CUE AT A TIME, ACROSS EVERY MACHINE \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\t//\r\n\t// \u26D4 STATIC ON PURPOSE, and it is the one thing on this component that should be. Per-machine\r\n\t// state cannot stop TWO machines singing over each other, which is exactly what was reported.\r\n\t// A map with three Pack-a-Punches had three independent jingle timers and nothing between\r\n\t// them.\r\n\t//\r\n\t// \u26A0 A HANDLE, NOT A GUESSED DURATION. \u0060NZSound.Play\u0060 returns a \u0060SoundHandle\u0060, so the engine\r\n\t// tells us when the cue is done \u2014 no table of sample lengths to keep in step with the assets.\r\n\t// \u0060_loop\u0060 above is managed the same way and its note says why: checking IsValid is cheaper\r\n\t// than tracking a duration, and it self-heals if the handle is lost.\r\n\r\n\tstatic SoundHandle _cue;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Upper bound on how long a cue can block the jingle.\r\n\t///\r\n\t/// \u26D4 A FAILSAFE FOR THE STATIC ABOVE. A static SoundHandle survives a hotload (\u00A71), so a\r\n\t/// leaked or stale handle that keeps reading valid would silence every jingle on the map\r\n\t/// FOREVER \u2014 and silence is the one bug that never reports itself. A \u0060TimeUntil\u0060 always\r\n\t/// elapses, so the worst case is one jingle skipped rather than all of them.\r\n\t/// \u003C/summary\u003E\r\n\tstatic TimeUntil _cueCap;\r\n\r\n\t/// \u003Csummary\u003ESeconds after which a cue stops counting as playing. Generous on purpose.\u003C/summary\u003E\r\n\tpublic static float CueCapSeconds { get; set; } = 15f;\r\n\r\n\t/// \u003Csummary\u003EIs any Pack-a-Punch cue currently sounding, on any machine.\u003C/summary\u003E\r\n\tstatic bool CuePlaying =\u003E _cue.IsValid() \u0026\u0026 !_cueCap;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Play a Pack-a-Punch cue and record it, so the idle jingle knows to stay quiet.\r\n\t///\r\n\t/// \u26A0 EVERY PaP CUE GOES THROUGH HERE. A cue played with a bare \u0060NZSound.Play\u0060 is invisible\r\n\t/// to \u0060CuePlaying\u0060, and the jingle would talk over it \u2014 which is the other half of what was\r\n\t/// reported.\r\n\t/// \u003C/summary\u003E\r\n\tstatic void PlayCue( string cue, Vector3 at )\r\n\t{\r\n\t\t_cue = NZSound.Play( cue, at );\r\n\t\t_cueCap = CueCapSeconds;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Seconds between jingles, picked fresh each time within this range.\r\n\t///\r\n\t/// \u26A0\uFE0F LONG AND RANDOM, as the original does it (\u0060NextJingle = CurTime() \u002B\r\n\t/// math.random(0,600)\u0060). The jingle is meant to be something you occasionally\r\n\t/// notice from across the map, not a soundtrack \u2014 on a short fixed timer it\r\n\t/// stops being an event and starts being wallpaper.\r\n\t/// \u003C/summary\u003E\r\n\t[Property] public Vector2 JingleGap { get; set; } = new( 90f, 300f );\r\n\r\n\tvoid StopLoop()\r\n\t{\r\n\t\tif ( _loop.IsValid() ) _loop.Stop();\r\n\t\t_loop = default;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Keep the hum running while there is power, and drop a jingle now and then.\r\n\t///\r\n\t/// \u26D4 GATED ON POWER, like every machine in the original. A Pack-a-Punch humming\r\n\t/// away before the power is on is a promise the map cannot keep \u2014 and it is the\r\n\t/// sound, not the model, that makes a player walk over to try.\r\n\t///\r\n\t/// \u26A0\uFE0F The loop is RESTARTED rather than left running, because a 1.23s sample\r\n\t/// ends. Checking IsValid each tick and replaying is cheaper than tracking its\r\n\t/// duration, and it self-heals if the handle is ever lost.\r\n\t/// \u003C/summary\u003E\r\n\tvoid TickAmbience()\r\n\t{\r\n\t\tif ( !Power.IsOn )\r\n\t\t{\r\n\t\t\tStopLoop();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// \u26D4 PlayAmbient, NOT Play \u2014 it drops cues emitted beyond their own falloff.\r\n\t\t// The sample is 1.23s, so \u0022keep it running\u0022 means restarting it about once a\r\n\t\t// second, forever, per machine. Unculled that is a stream of silent sounds\r\n\t\t// for anyone not standing there, and with the audio trace on it buries every\r\n\t\t// other cue in the console \u2014 which is how it was noticed.\r\n\t\tif ( !_loop.IsValid() )\r\n\t\t\t_loop = NZSound.PlayAmbient( NZSound.PapLoop, WorldPosition );\r\n\r\n\t\t// \u26A0 THE TIMER IS ONLY RESET WHEN THE JINGLE ACTUALLY PLAYS. Resetting it on a blocked\r\n\t\t// attempt would push the next one a further 90-300s away for no reason the player could\r\n\t\t// see; leaving it due means the jingle happens as soon as the machine is quiet again.\r\n\t\tif ( _nextJingle \u0026\u0026 !CuePlaying )\r\n\t\t{\r\n\t\t\t_nextJingle = Game.Random.Float( JingleGap.x, JingleGap.y );\r\n\t\t\tPlayCue( NZSound.PapJingle, WorldPosition );\r\n\t\t}\r\n\t}\r\n\r\n\tGameObject _visual;\r\n\tGameObject _gunGO;\r\n\tModelRenderer _gunRenderer;\r\n\r\n\tvoid BuildVisual()\r\n\t{\r\n\t\t_visual?.Destroy();\r\n\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = \u0022machine\u0022;\r\n\t\tgo.SetParent( GameObject );\r\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\r\n\t\tgo.NetworkMode = NetworkMode.Never;   // \u26D4 THIS MACHINE\u0027S OWN \u2014 out of a joiner\u0027s snapshot, where it would stand frozen (NZNetListener)\r\n\r\n\t\t// \u26A0\uFE0F Local AFTER parenting. SetParent preserves WORLD position, so a child\r\n\t\t// made by Scene.CreateObject stays at the origin and merely acquires a\r\n\t\t// compensating offset \u2014 the bug that made the mystery box invisible for two\r\n\t\t// rounds of diagnosis.\r\n\t\tgo.LocalPosition = Vector3.Zero;\r\n\t\tgo.LocalRotation = Rotation.Identity;\r\n\r\n\t\tvar model = Model.Load( MachineModel );\r\n\r\n\t\tvar r = go.Components.Create\u003CModelRenderer\u003E();\r\n\t\tr.Model = model;\r\n\r\n\t\t// \u26D4 IT HAD NO COLLIDER AND YOU WALKED STRAIGHT THROUGH IT. Found only\r\n\t\t// because someone asked for a sound when they ran into it \u2014 a machine you\r\n\t\t// can stand inside is worse than one that makes no noise.\r\n\t\t//\r\n\t\t// \u26D4 A BOX, NOT A ModelCollider. Our vmdl is a mesh-only OBJ import with no\r\n\t\t// PhysicsShapeList, so a ModelCollider has no hulls to build from. A box is\r\n\t\t// also the better answer on its own merits: a vending machine IS a box, and\r\n\t\t// a mesh collider off this model would snag the player on the sign bracket\r\n\t\t// and the trestle legs for no gain.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F Sized from the model\u0027s own bounds, so it stays correct if the machine\r\n\t\t// model is ever swapped for one of the other PaP variants.\r\n\t\tif ( model is not null )\r\n\t\t{\r\n\t\t\tvar box = go.Components.Create\u003CBoxCollider\u003E();\r\n\t\t\tbox.Scale = model.Bounds.Size;\r\n\t\t\tbox.Center = model.Bounds.Center;\r\n\t\t\tbox.Static = true;\r\n\t\t}\r\n\r\n\t\t_visual = go;\r\n\t}\r\n\r\n\t// \u2500\u2500 walking into it \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\t/// \u003Csummary\u003EHow close counts as bumping it. Machine half-width plus a body.\u003C/summary\u003E\r\n\t[Property] public float BumpReach { get; set; } = 62f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// How fast you must be closing to make it clang.\r\n\t///\r\n\t/// \u26A0\uFE0F SPEED TOWARD THE MACHINE, not raw speed. Standing at the machine to use it\r\n\t/// means being inside BumpReach for as long as you are there \u2014 without the\r\n\t/// approach test it would clang while you stood still deciding, and again on\r\n\t/// every strafe.\r\n\t/// \u003C/summary\u003E\r\n\t[Property] public float BumpSpeed { get; set; } = 60f;\r\n\r\n\t/// \u003Csummary\u003EQuietest gap between two clangs, so a wall-hug does not rattle.\u003C/summary\u003E\r\n\t[Property] public float BumpCooldown { get; set; } = 0.7f;\r\n\r\n\tTimeUntil _bumpReady;\r\n\r\n\tvoid TickBump()\r\n\t{\r\n\t\tif ( !_bumpReady ) return;\r\n\r\n\t\tforeach ( var player in Scene.GetAllComponents\u003CNZPlayer\u003E() )\r\n\t\t{\r\n\t\t\tif ( !player.IsValid() ) continue;\r\n\r\n\t\t\tvar toMachine = WorldPosition.WithZ( player.WorldPosition.z ) - player.WorldPosition;\r\n\t\t\tif ( toMachine.Length \u003E BumpReach ) continue;\r\n\r\n\t\t\t// \u26A0\uFE0F Velocity PROJECTED onto the approach direction. A player running\r\n\t\t\t// past the machine at speed is not running into it, and would otherwise\r\n\t\t\t// set it off every time they rounded the corner.\r\n\t\t\tvar controller = player.Components.Get\u003CPlayerController\u003E();\r\n\t\t\tvar vel = controller?.Velocity ?? Vector3.Zero;\r\n\r\n\t\t\tif ( vel.Dot( toMachine.Normal ) \u003C BumpSpeed ) continue;\r\n\r\n\t\t\t_bumpReady = BumpCooldown;\r\n\t\t\tNZSound.Play( NZSound.MachineBump, WorldPosition );\r\n\t\t\treturn;\r\n\t\t}\r\n\t}\r\n\r\n\t// \u2500\u2500 state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// What the machine is doing. Idle is the only state that takes a gun.\r\n\t///\r\n\t/// \u26A0\uFE0F Inserting and Ejecting are TRAVEL states, and they exist so the weapon is\r\n\t/// seen going in and coming out rather than teleporting. The original splits\r\n\t/// them the same way \u2014 \u0060take_gun\u0060 and \u0060eject_gun\u0060 are separate sequences either\r\n\t/// side of the work, not one animation.\r\n\t/// \u003C/summary\u003E\r\n\tpublic enum PapState { Idle, Inserting, Working, Ejecting, Ready }\r\n\r\n\t[Property, ReadOnly] public PapState State { get; private set; } = PapState.Idle;\r\n\r\n\tTimeUntil _next;\r\n\tNZPlayer _owner;\r\n\tstring _weapon = \u0022\u0022;\r\n\tint _newLevel;\r\n\r\n\t/// \u003Csummary\u003EIs there a finished gun sitting here?\u003C/summary\u003E\r\n\tpublic bool HasFinishedGun =\u003E State == PapState.Ready;\r\n\r\n\t/// \u003Csummary\u003EMid-cycle \u2014 no new gun can go in.\u003C/summary\u003E\r\n\tpublic bool IsBusy =\u003E State != PapState.Idle;\r\n\r\n\t/// \u003Csummary\u003EWhose gun is in there. Nobody else may take it.\u003C/summary\u003E\r\n\tpublic NZPlayer Owner =\u003E _owner;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// How far out in front the gun\u0027s MESH currently is, or null.\r\n\t///\r\n\t/// \u26A0\uFE0F Measured along the machine\u0027s own FORWARD, not world x \u2014 the machine is\r\n\t/// placed facing whoever put it down, so a world-axis reading changes meaning\r\n\t/// with every placement. Same lesson the mystery box\u0027s watch learned about\r\n\t/// measuring a child in its parent\u0027s frame.\r\n\t/// \u003C/summary\u003E\r\n\tpublic float? GunOut\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( !_gunGO.IsValid() || !_gunRenderer.IsValid() ) return null;\r\n\r\n\t\t\tvar model = _gunRenderer.Model;\r\n\t\t\tif ( model is null ) return null;\r\n\r\n\t\t\tvar centre = _gunGO.WorldPosition \u002B _gunGO.WorldRotation * model.Bounds.Center;\r\n\t\t\treturn (centre - WorldPosition).Dot( WorldRotation.Forward );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EWhat the next pack would cost this player, or 0 if they cannot.\u003C/summary\u003E\r\n\tpublic int PriceFor( NZPlayer player )\r\n\t{\r\n\t\tif ( !player.IsValid() || string.IsNullOrWhiteSpace( player.StartingWeapon ) ) return 0;\r\n\r\n\t\t// \u26A0\uFE0F \u0060CostToReach\u0060 RETURNS 0 BOTH FOR \u0022fully packed\u0022 AND for a malformed array, so callers\r\n\t\t// must check \u0060IsMaxed\u0060 first \u2014 0 reads as free rather than as refused. \u0060Insert\u0060 does.\r\n\t\treturn ActiveConfig.Pap?.CostToReach( player.PapLevelFor( player.StartingWeapon ), NZPlayer.PapMaxLevel ) ?? 0;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EIs this weapon already fully packed?\u003C/summary\u003E\r\n\tpublic static bool IsMaxed( NZPlayer player )\r\n\t\t=\u003E player.IsValid()\r\n\t\t\t\u0026\u0026 player.PapLevelFor( player.StartingWeapon ) \u003E= NZPlayer.PapMaxLevel;\r\n\r\n\t/// \u003Csummary\u003EThe highest tier the CURRENT round allows to be bought.\u003C/summary\u003E\r\n\tpublic static int TierCapForNow()\r\n\t{\r\n\t\tvar pap = ActiveConfig.Pap;\r\n\t\tif ( pap is null ) return NZPlayer.PapMaxLevel;\r\n\r\n\t\t// \u26D4 CREATIVE IGNORES THE ROUND GATE ENTIRELY. The gate is a PACING rule \u2014 it exists so a\r\n\t\t// run has something left to unlock at round 45 \u2014 and creative has no run and no rounds to\r\n\t\t// wait through. Left gated, the round reads 0, \u0060MaxTierForRound\u0060 clamps to MK1, and the\r\n\t\t// mode built for testing the other four tiers cannot reach them.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F THE SAME REASON WALLBUYS ARE FREE IN CREATIVE (\u0060WallBuy.cs\u0060: \u0060var free =\r\n\t\t// NZGame.IsCreative\u0060). An economy rule and a pacing rule are both rules about a RUN, and\r\n\t\t// creative is not one.\r\n\t\tif ( NZGame.IsCreative ) return NZPlayer.PapMaxLevel;\r\n\r\n\t\tvar round = RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 0;\r\n\t\t// \u26A0\uFE0F ON THE LADDER AS IT STANDS \u2014 six with basalt\u0027s Easter egg complete. The config\u0027s own Tiers would stop the gate at\r\n\t\t// MK5, and MK6 could never be bought.\r\n\t\treturn Math.Min( NZPlayer.PapMaxLevel, pap.MaxTierForRound( round, NZPlayer.PapMaxLevel ) );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Is the next tier locked behind a round this player has not reached?\r\n\t///\r\n\t/// \u26D4 SEPARATE FROM \u0060IsMaxed\u0060, AND THE DIFFERENCE IS THE WHOLE FEATURE. \u0022Fully upgraded\u0022\r\n\t/// and \u0022not yet\u0022 are different answers and must read differently at the machine \u2014\r\n\t/// collapsing them into one refusal would tell a round-12 player their MK1 rifle is\r\n\t/// finished, which is both wrong and unrecoverable advice.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static bool IsRoundLocked( NZPlayer player )\r\n\t{\r\n\t\tif ( !player.IsValid() || IsMaxed( player ) || NZGame.IsCreative ) return false;\r\n\r\n\t\t// \u26D4 THE NEXT TIER\u0027S OWN GATE, NOT THE CAP FOR NOW. MK6 has no round \u2014 basalt\u0027s Easter egg is its only gate, and\r\n\t\t// \u0060IsMaxed\u0060 has said whether that is open \u2014 so a gun that came by an MK5 early (the Wildcard, the trade table) packs to\r\n\t\t// MK6 at once, where the cap for now, counted up the ladder, stops at the first locked round below it.\r\n\t\tvar pap = ActiveConfig.Pap;\r\n\t\tif ( pap is null ) return false;\r\n\r\n\t\tvar round = RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 0;\r\n\t\treturn !pap.TierOpenAt( player.PapLevelFor( player.StartingWeapon ) \u002B 1, round );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Put the held weapon in. Returns what happened, for the log and the prompt.\r\n\t///\r\n\t/// \u26A0\uFE0F THE WEAPON IS TAKEN OFF THE PLAYER IMMEDIATELY. That is the cost of using\r\n\t/// the machine and it is the original\u0027s behaviour too (\u0060StripWeapon(class)\u0060) \u2014\r\n\t/// standing at a PaP with a horde inbound should be a decision, not a freebie.\r\n\t/// \u003C/summary\u003E\r\n\tpublic string Insert( NZPlayer player )\r\n\t{\r\n\t\tif ( !player.IsValid() ) return \u0022no player\u0022;\r\n\t\tif ( State != PapState.Idle ) return \u0022\u0022;\r\n\r\n\t\tif ( !Power.IsOn ) return \u0022the machine has no power\u0022;\r\n\r\n\t\tvar prefab = player.StartingWeapon;\r\n\t\tif ( string.IsNullOrWhiteSpace( prefab ) ) return \u0022you have nothing to pack\u0022;\r\n\r\n\t\tif ( IsMaxed( player ) )\r\n\t\t\treturn $\u0022already fully upgraded (MK{NZPlayer.PapMaxLevel})\u0022;\r\n\r\n\t\t// \u26D4 CHECKED BEFORE THE PRICE, so a locked tier never takes points. \u0060TrySpend\u0060 fuses\r\n\t\t// the check with the deduction, so any refusal reached after it has already charged.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F IT NAMES THE ROUND. \u0022Not available yet\u0022 tells the player nothing they can act on;\r\n\t\t// the round number turns a refusal into a goal.\r\n\t\tif ( IsRoundLocked( player ) )\r\n\t\t{\r\n\t\t\tvar next = player.PapLevelFor( player.StartingWeapon ) \u002B 1;\r\n\t\t\tvar at = ActiveConfig.Pap?.UnlockRoundFor( next - 1 ) ?? 0;\r\n\t\t\treturn $\u0022MK{next} unlocks at round {at}\u0022;\r\n\t\t}\r\n\r\n\t\tint price = PriceFor( player );\r\n\t\tif ( price \u003C= 0 ) return \u0022nothing to upgrade\u0022;\r\n\r\n\t\t// \u26A0\uFE0F TrySpend fuses the check with the deduction, as everywhere else \u2014\r\n\t\t// asking CanAfford separately is how you get free packs.\r\n\t\tif ( !player.TrySpend( price ) )\r\n\t\t\treturn $\u0022not enough points \u2014 MK{player.PapLevelFor( prefab ) \u002B 1} costs {price}\u0022;\r\n\r\n\t\t// \u26A0 TIMESLIP m2 TIME OUT \u2014 AFTER THE SPEND SUCCEEDED, never before. A refused purchase\r\n\t\t// must not buy 15 seconds of invisibility; that is the same reasoning the box\u0027s own\r\n\t\t// \u0060Uses\u002B\u002B\u0060 comment gives two lines down.\r\n\t\tTimeAugments.OnMachineUsed( player, \u0022pack-a-punch\u0022 );\r\n\t\t_owner = player;\r\n\t\t_weapon = prefab;\r\n\t\t_newLevel = player.PapLevelFor( prefab ) \u002B 1;\r\n\r\n\t\tShowGun( prefab );\r\n\r\n\t\t// \u26D4 STRIPPED, NOT HIDDEN. Leaving the weapon equipped while its model sits\r\n\t\t// in the machine lets you keep shooting the gun you just handed over.\r\n\t\tStripWeapon( player );\r\n\r\n\t\t// \u26A0\uFE0F WHEN THE GUN GOES IN, NOT WHEN IT COMES OUT. \u0060Finish()\u0060 is the machine returning to Idle\r\n\t\t// \u2014 several seconds later, after the player has already walked off with the weapon. The line\r\n\t\t// is a reaction to handing it over.\r\n\t\tCharacterVoice.Say( \u0022upgrading\u0022, player );\r\n\r\n\t\tState = PapState.Inserting;\r\n\t\t_next = EffectiveTravel;\r\n\t\t_sinceStage = 0f;\r\n\r\n\t\t// \u26A0\uFE0F Sting AND work loop together \u2014 the sting is the machine acknowledging\r\n\t\t// the keypress, the work sound is what it does afterwards. The original\r\n\t\t// plays them as separate cues for the same reason.\r\n\t\t// \u26A0 THE WORK LOOP IS THE ONE RECORDED, and the sting rides along unrecorded. They start\r\n\t\t// together and there is one cue slot, so the pair is bounded by whichever is recorded \u2014\r\n\t\t// the work sound, because it is the one that lasts for the cycle. Recording the sting\r\n\t\t// instead would let the jingle cut in over the tail of the work loop.\r\n\t\tNZSound.Play( NZSound.PapSting, WorldPosition );\r\n\t\tPlayCue( NZSound.PapWork, WorldPosition );\r\n\t\treturn $\u0022packing {WeaponName( prefab )} -\u003E MK{_newLevel} for {price}\u0022;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Apply the pack level and put the gun in the player\u0027s hands. Returns its new name.\r\n\t///\r\n\t/// \u26D4 SHARED BY \u0060Collect\u0060 AND TIMESLIP m1\u0027s INSTANT PATH, and that sharing is the point.\r\n\t/// m1 skips the machine entirely, so without one implementation there would be two answers\r\n\t/// to \u0022what does packing a gun do\u0022 \u2014 and the one that drifted would be the augment\u0027s, because\r\n\t/// the machine\u0027s is the one that gets played every round. \u00A73 with a purchase attached.\r\n\t///\r\n\t/// \u26A0 \u0060GiveWeapon\u0060, not StartingWeapon \u002B re-equip. On the machine path the weapon was\r\n\t/// stripped on insert so the slot is free; on m1\u0027s path \u0060GiveOrReplace\u0060 swaps the same prefab\r\n\t/// in place. Either way the OTHER weapon the player kept is untouched.\r\n\t///\r\n\t/// \u26A0 THE LEVEL IS READ BACK FROM \u0060AddPapLevel\u0060 rather than computed here. That method owns\r\n\t/// the ceiling, so a gun already at MK3 reports MK3 instead of an invented MK4.\r\n\t/// \u003C/summary\u003E\r\n\tstring Upgrade( NZPlayer player, string prefab )\r\n\t{\r\n\t\tint level = player.AddPapLevel( prefab );\r\n\r\n\t\tplayer.GiveWeapon( prefab );\r\n\r\n\t\treturn $\u0022{WeaponName( prefab )} MK{level}\u0022;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Collect the finished weapon.\r\n\t///\r\n\t/// \u26D4 ONLY THE OWNER. \u0060pap_weapon_fly:Use\u0060 checks \u0060ply == self:GetPaPOwner()\u0060,\r\n\t/// and it matters: without it a teammate walks off with the gun you paid\r\n\t/// 30,000 points to upgrade.\r\n\t/// \u003C/summary\u003E\r\n\tpublic string Collect( NZPlayer player )\r\n\t{\r\n\t\tif ( !player.IsValid() || State != PapState.Ready ) return \u0022\u0022;\r\n\t\tif ( player != _owner ) return \u0022that is not your weapon\u0022;\r\n\r\n\t\tvar name = Upgrade( player, _weapon );\r\n\r\n\t\tFinish();\r\n\t\tNZSound.Play( NZSound.Purchase, WorldPosition );\r\n\r\n\t\treturn $\u0022took the {name}\u0022;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Nobody collected it. The weapon is returned UNUPGRADED and the points stay\r\n\t/// spent.\r\n\t///\r\n\t/// \u26A0\uFE0F RETURNED, NOT DESTROYED \u2014 the original leaves you a backup weapon and\r\n\t/// removes the fly entity, which amounts to the same thing: you are not left\r\n\t/// unarmed by walking away. Losing the money is the punishment; losing the gun\r\n\t/// as well would be a trap rather than a cost.\r\n\t/// \u003C/summary\u003E\r\n\tvoid Expire()\r\n\t{\r\n\t\tif ( _owner.IsValid() \u0026\u0026 !string.IsNullOrEmpty( _weapon ) )\r\n\t\t{\r\n\t\t\t_owner.GiveWeapon( _weapon );\r\n\t\t\tLog.Info( $\u0022[nz] nobody collected the {WeaponName( _weapon )} \u2014 \u0022\r\n\t\t\t\t\u002B \u0022returned unupgraded\u0022 );\r\n\t\t}\r\n\r\n\t\tFinish();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Back to idle, holding nothing.\r\n\t///\r\n\t/// \u26D4 NOT CALLED \u0060Reset\u0060 \u2014 that HIDES \u0060Component.Reset()\u0060, and this is the THIRD\r\n\t/// time in this project: \u0060DamageOverlay.Enabled\u0060 shadowed \u0060Component.Enabled\u0060,\r\n\t/// \u0060Barricade.Reset\u0060 shadowed this same method, and here it is again. The\r\n\t/// compiler only warns, so the shadowed version compiles and runs and the\r\n\t/// engine\u0027s own call goes somewhere unexpected. Check the base class before\r\n\t/// naming any public member on a Component.\r\n\t/// \u003C/summary\u003E\r\n\tvoid Finish()\r\n\t{\r\n\t\tClearGun();\r\n\t\tState = PapState.Idle;\r\n\t\t_owner = null;\r\n\t\t_weapon = \u0022\u0022;\r\n\t\t_newLevel = 0;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tTickGun();\r\n\t\tTickAmbience();\r\n\t\tTickBump();\r\n\r\n\t\tif ( State == PapState.Idle ) return;\r\n\t\tif ( !_next ) return;\r\n\r\n\t\tswitch ( State )\r\n\t\t{\r\n\t\t\tcase PapState.Inserting:\r\n\t\t\t\tState = PapState.Working;\r\n\t\t\t\t// Timeslip Tonic halves the upgrade. Resolved from _owner, set by\r\n\t\t\t\t// Insert() before this state is ever reached, so it is the player who\r\n\t\t\t\t// put the gun in \u2014 not whoever is standing at the machine now.\r\n\t\t\t\t//\r\n\t\t\t\t// \u26A0\uFE0F ReadyTime is deliberately NOT scaled: that is the window to\r\n\t\t\t\t// COLLECT the weapon, and shortening it would make the perk a\r\n\t\t\t\t// downgrade. See PerkEffects.MachineCycleMultiplier.\r\n\t\t\t\t// \u26A0 m1 OVERCLOCK SCALES THE SAME PRODUCT as the base perk. \u0060MathF.Max\u0060 keeps a\r\n\t\t\t\t// zero work time from stalling the state machine on a deadline that has already\r\n\t\t\t\t// elapsed.\r\n\t\t\t\t_next = MathF.Max( 0.01f,\r\n\t\t\t\t\tWorkTime\r\n\t\t\t\t\t\t* PerkEffects.MachineCycleMultiplier( _owner )\r\n\t\t\t\t\t\t* TimeAugments.PapCycleScale( _owner ) );\r\n\t\t\t\t_sinceStage = 0f;\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase PapState.Working:\r\n\t\t\t\tState = PapState.Ejecting;\r\n\r\n\t\t\t\t// \u26D4 \u0060EffectiveTravel\u0060, NOT \u0060TravelTime\u0060 \u2014 THE ONE READER THAT WAS MISSED. Its own\r\n\t\t\t\t// header says \u0022one property, four readers\u0022; this was the fourth and it took the raw\r\n\t\t\t\t// value. So under Timeslip m1 Overclock the eject DEADLINE stayed 0.7s while the\r\n\t\t\t\t// eject ANIMATION (\u0060_sinceStage / EffectiveTravel\u0060, twice below) ran on the\r\n\t\t\t\t// shortened one \u2014 the weapon finished sliding out and then sat there, or the state\r\n\t\t\t\t// advanced mid-slide, depending on which way the augment scaled.\r\n\t\t\t\t_next = EffectiveTravel;\r\n\t\t\t\t_sinceStage = 0f;\r\n\r\n\t\t\t\t// \u26D4 THE CAMO GOES ON AS IT STARTS COMING OUT, not on arrival. This is the frame\r\n\t\t\t\t// the weapon stops being the one you handed over and becomes the upgraded one, so\r\n\t\t\t\t// it must emerge ALREADY packed \u2014 painting it at Ready instead makes the gun\r\n\t\t\t\t// travel out plain and then flip mid-air, which reads as a bug rather than a\r\n\t\t\t\t// reveal. It was previously never painted at all: the machine\u0027s display gun is a\r\n\t\t\t\t// plain ModelRenderer that no code touched, so the one moment the player gets a\r\n\t\t\t\t// clear, close look at their upgrade showed them an un-upgraded gun.\r\n\t\t\t\tPaintGun();\r\n\r\n\t\t\t\t// \u26A0\uFE0F The ready cue fires as it STARTS coming out, not when it\r\n\t\t\t\t// arrives. The sound is what makes you look up, and by the time you\r\n\t\t\t\t// do the weapon should already be moving toward you.\r\n\t\t\t\tNZSound.Play( NZSound.PapReady, WorldPosition );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase PapState.Ejecting:\r\n\t\t\t\tState = PapState.Ready;\r\n\t\t\t\t_next = ReadyTime;\r\n\r\n\t\t\t\t// \u26A0\uFE0F Reset, because Ready is now a TRAVEL stage too \u2014 the weapon\r\n\t\t\t\t// sinks back in across the whole grab window and needs its own clock.\r\n\t\t\t\t_sinceStage = 0f;\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase PapState.Ready:\r\n\t\t\t\tExpire();\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\t}\r\n\r\n\t// \u2500\u2500 the weapon in the machine \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\tvoid ShowGun( string prefab )\r\n\t{\r\n\t\tClearGun();\r\n\r\n\t\tvar model = ModelForPrefab( prefab );\r\n\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = \u0022pap gun\u0022;\r\n\t\tgo.SetParent( GameObject );\r\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\r\n\t\tgo.NetworkMode = NetworkMode.Never;   // \u26D4 THIS MACHINE\u0027S OWN \u2014 out of a joiner\u0027s snapshot, where it would stand frozen (NZNetListener)\r\n\t\tgo.LocalRotation = GunAngles.ToRotation();\r\n\r\n\t\t_gunRenderer = go.Components.Create\u003CModelRenderer\u003E();\r\n\t\t_gunGO = go;\r\n\r\n\t\t// \u26D4 MODEL FIRST, THEN POSITION \u2014 GunLocalPos reads the renderer\u0027s current\r\n\t\t// model to centre the mesh, so placing first would align this gun using\r\n\t\t// whatever came before it. Three separate paths in the mystery box had this\r\n\t\t// bug; it is the same shape here and worth not re-learning.\r\n\t\t// \u26A0\uFE0F Born at the OUT position \u2014 the travel starts from where the player\r\n\t\t// handed it over, not from inside the machine. Spawning it already in the\r\n\t\t// throat makes the insert look like it began before you pressed the key.\r\n\t\tif ( model is not null ) _gunRenderer.Model = model;\r\n\t\tgo.LocalPosition = GunLocalPos( OutPos );\r\n\r\n\t\t_gunRenderer.Enabled = model is not null;\r\n\t\t_sinceStage = 0f;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Put the camo for the level being bought onto the machine\u0027s display gun.\r\n\t///\r\n\t/// \u26A0\uFE0F \u0060_newLevel\u0060, NOT the weapon\u0027s current level. Insert() sets it to\r\n\t/// \u0060PapLevelFor( prefab ) \u002B 1\u0060 \u2014 the tier the player is paying FOR \u2014 and that is what should\r\n\t/// come out of the machine. Using the current level would show MK1\u0027s camo on the gun that is\r\n\t/// about to become MK2, i.e. always exactly one tier stale.\r\n\t///\r\n\t/// \u26A0\uFE0F HONOURS \u0060PapCamo.CamoEnabled\u0060 like the held weapon does. \u0060nz_camo 0\u0060 turning the gun in\r\n\t/// your hands plain while the machine still hands you a painted one would make the toggle look\r\n\t/// broken; the two paths must agree.\r\n\t///\r\n\t/// \u26A0\uFE0F MaterialOverride REPLACES EVERY SURFACE, which is the same trade PapCamo.Repaint makes on\r\n\t/// the view and world models \u2014 a scope\u0027s lens gets painted too. Matching the held weapon\u0027s\r\n\t/// appearance is the point, so the display gun takes the identical compromise rather than a\r\n\t/// per-submaterial treatment that would look different from what you receive.\r\n\t/// \u003C/summary\u003E\r\n\tvoid PaintGun()\r\n\t{\r\n\t\tif ( !_gunRenderer.IsValid() ) return;\r\n\r\n\t\t_gunRenderer.MaterialOverride = NZombies.PapCamo.CamoEnabled\r\n\t\t\t? NZombies.PapCamo.MaterialFor( _newLevel )\r\n\t\t\t: null;\r\n\t}\r\n\r\n\tvoid ClearGun()\r\n\t{\r\n\t\t_gunGO?.Destroy();\r\n\t\t_gunGO = null;\r\n\t\t_gunRenderer = null;\r\n\t}\r\n\r\n\tTimeSince _sinceStage;\r\n\r\n\t/// \u003Csummary\u003EPresented: out in front of the machine, at working height.\u003C/summary\u003E\r\n\tVector3 OutPos =\u003E Vector3.Up * GunHeight \u002B Vector3.Forward * GunOutDistance;\r\n\r\n\t/// \u003Csummary\u003EBeing worked on: back in the roller throat.\u003C/summary\u003E\r\n\tVector3 InPos =\u003E Vector3.Up * GunHeight - Vector3.Forward * GunInDepth;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Move and turn the weapon for whatever stage the machine is in.\r\n\t///\r\n\t/// \u26D4 RUNS OUTSIDE THE STAGE GATE, like every other per-frame motion in this\r\n\t/// project. The gate in OnUpdate fires only on the frame a stage ENDS \u2014 a\r\n\t/// travel driven from inside it is a weapon that teleports once.\r\n\t///\r\n\t/// \u26A0\uFE0F The insert also swings the weapon through 90 degrees of yaw, which is the\r\n\t/// original\u0027s own motion (\u0060pap_weapon_fly\u0060 lerps a -90 -\u003E 0 yaw offset in, and\r\n\t/// back out on eject). It reads as the machine taking the gun rather than the\r\n\t/// gun sliding on rails.\r\n\t/// \u003C/summary\u003E\r\n\tvoid TickGun()\r\n\t{\r\n\t\tif ( !_gunGO.IsValid() ) return;\r\n\r\n\t\tvar pose = GunAngles.ToRotation();\r\n\r\n\t\tswitch ( State )\r\n\t\t{\r\n\t\t\tcase PapState.Inserting:\r\n\t\t\t{\r\n\t\t\t\tfloat t = Smooth( _sinceStage / EffectiveTravel );\r\n\t\t\t\tPlace( Vector3.Lerp( OutPos, InPos, t ),\r\n\t\t\t\t\tpose * Rotation.FromYaw( MathX.Lerp( -90f, 0f, t ) ) );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tcase PapState.Working:\r\n\t\t\t\t// \u26D4 IT DOES NOT SPIN. It used to turn at 220 deg/s while the machine\r\n\t\t\t\t// worked, which reads as a prize on a pedestal \u2014 the mystery box\r\n\t\t\t\t// already learned the same lesson about its offer. The machine is\r\n\t\t\t\t// doing the work; the weapon sits in the throat and waits.\r\n\t\t\t\tPlace( InPos, pose );\r\n\t\t\t\tbreak;\r\n\r\n\t\t\tcase PapState.Ejecting:\r\n\t\t\t{\r\n\t\t\t\tfloat t = Smooth( _sinceStage / EffectiveTravel );\r\n\t\t\t\tPlace( Vector3.Lerp( InPos, OutPos, t ),\r\n\t\t\t\t\tpose * Rotation.FromYaw( MathX.Lerp( 90f, 0f, t ) ) );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tcase PapState.Ready:\r\n\t\t\t{\r\n\t\t\t\t// \u26D4 THE GRAB WINDOW **IS** THE WEAPON SINKING BACK IN, exactly as the\r\n\t\t\t\t// mystery box works. It used to hang motionless at the presented\r\n\t\t\t\t// position for the full 20s and then vanish, so there was no way to\r\n\t\t\t\t// tell four seconds left from eighteen \u2014 the deadline existed only as\r\n\t\t\t\t// a hidden timer. Watching it go is the timer.\r\n\t\t\t\t//\r\n\t\t\t\t// \u26A0\uFE0F LINEAR, unlike the insert and eject travels. Those are flourishes\r\n\t\t\t\t// and get smoothstep; this is a clock, and a clock that speeds up or\r\n\t\t\t\t// slows down misreports the one thing it exists to communicate.\r\n\t\t\t\tfloat t = MathX.Clamp( _sinceStage / MathF.Max( ReadyTime, 0.01f ), 0f, 1f );\r\n\t\t\t\tPlace( Vector3.Lerp( OutPos, InPos, t ), pose );\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ESmoothstep. The original eases its rotation the same way.\u003C/summary\u003E\r\n\tstatic float Smooth( float t )\r\n\t{\r\n\t\tt = MathX.Clamp( t, 0f, 1f );\r\n\t\treturn t * t * (3f - 2f * t);\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Set the pose, compensating the mesh offset LAST.\r\n\t///\r\n\t/// \u26A0\uFE0F Rotation first, because the mesh-centre correction is rotated by whatever\r\n\t/// pose the object is wearing \u2014 computing the offset against the previous\r\n\t/// rotation puts the gun a little further out of place the faster it is turning.\r\n\t/// \u003C/summary\u003E\r\n\tvoid Place( Vector3 at, Rotation rot )\r\n\t{\r\n\t\t_gunGO.LocalRotation = rot;\r\n\t\t_gunGO.LocalPosition = GunLocalPos( at, rot );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Local position that puts the gun\u0027s MESH at \u003Csee cref=\u0022GunHeight\u0022/\u003E.\r\n\t///\r\n\t/// \u26A0\uFE0F Viewmodels are authored around a camera and a pair of hands, so their\r\n\t/// geometry sits anywhere relative to their origin \u2014 measured across our pool,\r\n\t/// 29 of 31 fall in a 5.4u band and the Uzi and ASP are ~50 units up. Placing\r\n\t/// by origin would hang those two through the machine\u0027s ceiling.\r\n\t/// \u003C/summary\u003E\r\n\tVector3 GunLocalPos( Vector3 at ) =\u003E GunLocalPos( at, GunAngles.ToRotation() );\r\n\r\n\tVector3 GunLocalPos( Vector3 at, Rotation rot )\r\n\t{\r\n\t\tvar model = _gunRenderer.IsValid() ? _gunRenderer.Model : null;\r\n\t\tif ( model is null ) return at;\r\n\r\n\t\treturn at - rot * model.Bounds.Center;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// A weapon prefab\u0027s viewmodel.\r\n\t///\r\n\t/// \u26A0\uFE0F SAME TWO-CANDIDATE LOOKUP AS THE BOX, and for the same reason: 31 of the\r\n\t/// 32 folders drop the \u0060nz_\u0060 prefix and the MPL keeps it\r\n\t/// (\u0060weapons/nz_mpl/v_nz_mpl.vmdl\u0060). Stripping unconditionally makes exactly\r\n\t/// one weapon invisible, which nobody notices until it is the one they packed.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static Model ModelForPrefab( string prefab )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( prefab ) ) return null;\r\n\r\n\t\tvar raw = prefab;\r\n\t\tvar slash = raw.LastIndexOf( \u0027/\u0027 );\r\n\t\tif ( slash \u003E= 0 ) raw = raw[(slash \u002B 1)..];\r\n\t\traw = raw.Replace( \u0022.prefab\u0022, \u0022\u0022 );\r\n\r\n\t\tvar bare = raw.StartsWith( \u0022nz_\u0022 ) ? raw[3..] : raw;\r\n\r\n\t\tforeach ( var stem in new[] { bare, raw } )\r\n\t\t{\r\n\t\t\t// \u26A0\uFE0F ITS DISPLAY MODEL WHEN IT HAS ONE (\u0060WeaponDisplay\u0060): an MW viewmodel\u0027s bind pose floats in pieces\r\n\t\t\tvar m = WeaponDisplay.Load( $\u0022weapons/{stem}/v_{stem}.vmdl\u0022 );\r\n\t\t\tif ( m is not null \u0026\u0026 !m.IsError ) return m;\r\n\t\t}\r\n\r\n\t\treturn null;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EA weapon\u0027s display name, from the library.\u003C/summary\u003E\r\n\tpublic static string WeaponName( string prefab )\r\n\t{\r\n\t\tvar entry = WeaponLibrary.All.FirstOrDefault( e =\u003E e.Prefab == prefab );\r\n\r\n\t\t// \u26A0\uFE0F THE PRISMA IS NOT IN THE LIBRARY (WeaponLibrary leaves the egg\u0027s weapon out), so it\r\n\t\t// names itself here rather than printing its prefab path.\r\n\t\treturn entry?.Name ?? (BuildParts.IsWonderWeapon( prefab ) ? BuildParts.WeaponName : prefab);\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Take the weapon off the player.\r\n\t///\r\n\t/// \u26A0\uFE0F THE SAME disable-unparent-destroy SEQUENCE WallBuy AND THE BOX USE. A\r\n\t/// Destroy is deferred to end of frame, so the equip guard would otherwise\r\n\t/// still find the old weapon and silently skip spawning the replacement.\r\n\t/// \u003C/summary\u003E\r\n\tstatic void StripWeapon( NZPlayer player )\r\n\t{\r\n\t\t// \u26D4 ONLY THE WEAPON BEING PACKED, NOT EVERY WEAPON. This destroyed all of\r\n\t\t// them, which was right with one slot and now throws away the gun you did\r\n\t\t// not put in the machine \u2014 insert your pistol and your Galil vanished.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F Through the inventory so \u0060Items\u0060 is updated too. Destroying the object\r\n\t\t// behind the inventory\u0027s back leaves a dead entry in a slot, which reads as\r\n\t\t// a phantom weapon you cannot switch to.\r\n\t\tvar inv = player.Inventory;\r\n\t\tif ( inv.Active.IsValid() ) inv.Remove( inv.Active );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EThe nearest machine a point could use, or null.\u003C/summary\u003E\r\n\tpublic static PackAPunch Near( Vector3 point )\r\n\t{\r\n\t\tPackAPunch best = null;\r\n\t\tfloat bestDist = float.MaxValue;\r\n\r\n\t\tforeach ( var m in All )\r\n\t\t{\r\n\t\t\tif ( !m.IsValid() ) continue;\r\n\r\n\t\t\tfloat d = m.WorldPosition.Distance( point );\r\n\t\t\tif ( d \u003E m.Reach || d \u003E= bestDist ) continue;\r\n\r\n\t\t\tbestDist = d;\r\n\t\t\tbest = m;\r\n\t\t}\r\n\r\n\t\treturn best;\r\n\t}\r\n}\r\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Buyables/WunderfizzManager.cs","FileName":"WunderfizzManager.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\nusing System.Linq;\nusing System.Collections.Generic;\n\nnamespace NZombies;\n\n/// \u003Csummary\u003E\n/// DER WUNDERFIZZ \u2014 the machine that sells a RANDOM perk.\n///\n/// \u26A0\uFE0F Deliberately the same shape as PackAPunchManager / MysteryBoxManager /\n/// DebrisManager: Ensure creates on demand, NotSaved keeps it out of the map,\n/// Rebuild is the single entry point. A manager refreshed differently from its\n/// siblings is one more thing to remember at every call site that puts a config\n/// into the world \u2014 and this project has already been bitten by one that was.\n/// \u003C/summary\u003E\npublic sealed class WunderfizzManager : Component\n{\n\tpublic static WunderfizzManager Instance { get; private set; }\n\n\t/// \u003Csummary\u003EThe BO6/Jupiter machine \u2014 the variant nZombies reaches for first\n\t/// (\u0060wunderfizz_machine/shared.lua:64\u0060).\u003C/summary\u003E\n\tpublic const string ModelPath = \u0022models/nz/wunderfizz/wunderfizz.vmdl\u0022;\n\n\tprotected override void OnAwake() =\u003E Instance = this;\n\tprotected override void OnDestroy() { if ( Instance == this ) Instance = null; }\n\n\tpublic static WunderfizzManager Ensure( Scene scene )\n\t{\n\t\tif ( Instance.IsValid() ) return Instance;\n\t\tif ( !scene.IsValid() ) return null;\n\n\t\tvar go = scene.CreateObject();\n\t\tgo.Name = \u0022Wunderfizz Manager\u0022;\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\n\t\treturn go.Components.Create\u003CWunderfizzManager\u003E();\n\t}\n\n\treadonly List\u003CGameObject\u003E _built = new();\n\n\t/// \u003Csummary\u003EHow many are standing right now.\u003C/summary\u003E\n\tpublic int Built =\u003E _built.Count( g =\u003E g.IsValid() );\n\n\t/// \u003Csummary\u003EDestroy what is standing and build the config again.\u003C/summary\u003E\n\tpublic void Rebuild()\n\t{\n\t\tforeach ( var g in _built ) g?.Destroy();\n\t\t_built.Clear();\n\n\t\tvar list = ActiveConfig.Current?.Wunderfizzes;\n\t\tif ( list is null || list.Count == 0 ) return;\n\n\t\tforeach ( var spot in list )\n\t\t\tBuild( spot );\n\n\t\t// \u26A0\uFE0F Says how many are STANDING, not how many are configured. A model that\n\t\t// fails to load leaves a spot in the config and nothing in the world, and\n\t\t// those two numbers disagreeing is the cheapest way to see it.\n\t\tLog.Info( $\u0022[nz] {Built} of {list.Count} Wunderfizz machine(s) built\u0022 );\n\t}\n\n\tvoid Build( WunderfizzSpot spot )\n\t{\n\t\tvar go = Scene.CreateObject();\n\t\tgo.Name = \u0022Der Wunderfizz\u0022;\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\n\t\tgo.NetworkMode = NetworkMode.Never;   // \u26D4 THIS MACHINE\u0027S OWN \u2014 out of a joiner\u0027s snapshot, where it would stand frozen (NZNetListener)\n\t\tgo.WorldPosition = spot.Position;\n\t\tgo.WorldRotation = OnFloor( spot );\n\n\t\tvar model = Model.Load( ModelPath );\n\n\t\t// \u26A0\uFE0F Model.Load returns null on a bad path but an ERROR MODEL on a\n\t\t// compiled-but-broken one, and the error model renders happily as a\n\t\t// checkerboard. Checking only for null reports success on a machine that is\n\t\t// visibly wrong \u2014 the same check ZombieAI.EnsureBody documents.\n\t\tif ( model is null || model.IsError )\n\t\t{\n\t\t\tLog.Warning( $\u0022[nz] Wunderfizz model {ModelPath} \u0022\n\t\t\t\t\u002B $\u0022({(model is null ? \u0022null\u0022 : \u0022error model\u0022)}) \u2014 recompile it\u0022 );\n\t\t\tgo.Destroy();\n\t\t\treturn;\n\t\t}\n\n\t\tvar r = go.Components.Create\u003CModelRenderer\u003E();\n\t\tr.Model = model;\n\n\t\t// Solid, so you cannot walk through it. Static \u2014 a vending machine has no\n\t\t// reason to simulate, and one that could be shoved would end up inside a\n\t\t// wall on any map with a slope.\n\t\tvar box = go.Components.Create\u003CBoxCollider\u003E();\n\t\tbox.Scale = model.Bounds.Size;\n\t\tbox.Center = model.Bounds.Center;\n\n\t\tvar fizz = go.Components.Create\u003CWunderfizz\u003E();\n\t\tfizz.Spot = spot;\n\n\t\t_built.Add( go );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A rotation facing \u0060yaw\u0060 but lying flat on the spot\u0027s floor.\n\t///\n\t/// \u26D4 The SAME maths PackAPunchManager and MysteryBoxManager document \u2014 the\n\t/// heading is projected onto the surface plane before LookAt sees it, because\n\t/// feeding LookAt a yaw that is not perpendicular to the up vector lets it\n\t/// renormalise however it likes and twists the machine on a slope.\n\t/// \u003C/summary\u003E\n\tstatic Rotation OnFloor( WunderfizzSpot spot )\n\t{\n\t\tvar up = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;\n\t\tvar heading = Rotation.FromYaw( spot.Yaw ).Forward;\n\n\t\tvar forward = (heading - up * heading.Dot( up )).Normal;\n\t\tif ( forward.IsNearlyZero() )\n\t\t\treturn Rotation.From( 0f, spot.Yaw, 0f );\n\n\t\treturn Rotation.LookAt( forward, up );\n\t}\n}\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Config/LinkConverter.cs","FileName":"LinkConverter.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using System;\nusing System.Text.Json;\nusing System.Text.Json.Serialization;\n\nnamespace NZombies;\n\n/// \u003Csummary\u003E\n/// Reads a door flag from JSON whether it was written as a string or a number.\n///\n/// \u26A0\uFE0F EXISTS PURELY SO SAVED CONFIGS SURVIVE. Flags were ints with 0 meaning\n/// unlinked; they are now names, because \u0022power_room\u0022 is what a map author\n/// actually thinks and \u00223\u0022 is still a perfectly good name. Without this, every\n/// config saved before the change fails to deserialize \u2014 System.Text.Json will\n/// not put a JSON number into a string property \u2014 and the map comes back with\n/// no spawns and no barriers, which looks like data loss rather than a format\n/// change.\n///\n/// The old sentinel maps across: 0 becomes blank, which is what unlinked means\n/// now.\n/// \u003C/summary\u003E\npublic sealed class LinkConverter : JsonConverter\u003Cstring\u003E\n{\n\tpublic override string Read( ref Utf8JsonReader reader, Type type, JsonSerializerOptions options )\n\t{\n\t\tswitch ( reader.TokenType )\n\t\t{\n\t\t\tcase JsonTokenType.String:\n\t\t\t\treturn reader.GetString()?.Trim() ?? DoorLinks.Unlinked;\n\n\t\t\tcase JsonTokenType.Number:\n\t\t\t\t// 0 was \u0022unlinked\u0022 in the int form.\n\t\t\t\tvar n = reader.GetInt32();\n\t\t\t\treturn n == 0 ? DoorLinks.Unlinked : n.ToString();\n\n\t\t\tcase JsonTokenType.Null:\n\t\t\t\treturn DoorLinks.Unlinked;\n\n\t\t\tdefault:\n\t\t\t\treader.Skip();\n\t\t\t\treturn DoorLinks.Unlinked;\n\t\t}\n\t}\n\n\tpublic override void Write( Utf8JsonWriter writer, string value, JsonSerializerOptions options )\n\t\t=\u003E writer.WriteStringValue( value ?? DoorLinks.Unlinked );\n}\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Diagnostics/HoldPhoto.cs","FileName":"HoldPhoto.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\r\nusing System;\r\nusing System.Linq;\r\n\r\nnamespace NZombies;\r\n\r\n/// \u003Csummary\u003E\r\n/// \u0060nz_3p_photo\u0060 \u2014 A CAMERA OF ITS OWN ON YOUR BODY, for judging how a gun sits in the hand (2026-10-05). The user:\r\n/// *\u0022at the moment players are hardly holding their weapons, a lot of times the weapon is far from the player / To test this\r\n/// grab a random sample of 10 weapons from different classes and packs, and render a player holding each\u0022*.\r\n///\r\n/// \u26D4 IN THE GAME, NOT THROUGH THE EDITOR\u0027S SCENE TOOLS. In play the editor holds two copies of the scene with the SAME object\r\n/// ids, so a camera made over MCP \u0022in the scene\u0022 landed in the editor\u0027s copy and shot empty sky, and the player it found by\r\n/// id was the editor\u0027s template body, 1,400 units up. This runs in \u0060Game.ActiveScene\u0060.\r\n///\r\n/// \u26D4 ITS OWN CAMERA, NOT THE PLAYER\u0027S. Moving the main camera with \u0060UseCameraControls\u0060 off did nothing you could see: the\r\n/// game hands the camera back to your controller every frame (\u0060NZPlayers.Control\u0060), so eleven shots came out as the same\r\n/// third-person view from behind. This camera is a separate, unsaved, never-networked object that nothing else drives; it\r\n/// is not the main camera, so your own view is untouched, and MCP\u0027s \u0060camera_screenshot\u0060 shoots it by the id this prints.\r\n///\r\n///     nz_3p_photo spawn                  stand on the config\u0027s first player spawn, facing its way\r\n///     nz_3p_photo [yaw] [dist] [height]  place the photo camera on your body: \u0060yaw\u0060 degrees round from your face toward your\r\n///                                        gun hand (35), \u0060dist\u0060 units out (80), \u0060height\u0060 up from your feet (58)\r\n///     nz_3p_photo off                    remove the photo camera\r\n///\r\n/// \u26A0\uFE0F TURN THIRD PERSON ON FIRST (\u0060nz_thirdperson 1\u0060): in first person your own body is hidden from the cameras.\r\n/// \u003C/summary\u003E\r\npublic static class HoldPhoto\r\n{\r\n\tconst string CameraName = \u0022nz 3p photo camera\u0022;\r\n\r\n\t[ConCmd( \u0022nz_3p_photo\u0022 )]\r\n\tpublic static void Photo( string what = \u0022\u0022, float dist = 80f, float height = 58f )\r\n\t{\r\n\t\tvar scene = Game.ActiveScene;\r\n\t\tvar bodies = PlayerSpawner.AllBodies();\r\n\t\tvar go = bodies.FirstOrDefault( PlayerPresence.Mine ) ?? bodies.FirstOrDefault();\r\n\t\tvar ctrl = go.IsValid() ? go.Components.Get\u003CPlayerController\u003E( FindMode.EverythingInSelfAndDescendants ) : null;\r\n\r\n\t\tvar w = (what ?? \u0022\u0022).Trim().ToLowerInvariant();\r\n\r\n\t\tif ( w == \u0022off\u0022 )\r\n\t\t{\r\n\t\t\tforeach ( var c in Existing( scene ) ) c.Destroy();\r\n\t\t\tLog.Info( \u0022[nz-3p] photo: camera removed\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( !go.IsValid() || !ctrl.IsValid() )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[nz-3p] photo: no body or player controller \u2014 in play, after Creative?\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( w == \u0022spawn\u0022 )\r\n\t\t{\r\n\t\t\t// \u26D4 THE CONFIG\u0027S SPAWN, ELSE THE GROUND STRAIGHT BELOW. Entering Creative the moment play starts drops the body\r\n\t\t\t// before the map has collision, and it falls through the world for good: measured at z \u221290,000 twelve seconds in,\r\n\t\t\t// with the photo camera placed right each time and the body already out of frame when the shot was taken. An\r\n\t\t\t// editor session\u0027s config often has no player spawn at all, so a trace down at the body\u0027s own x, y is the fallback.\r\n\t\t\t// \u0060MoveTo\u0060 lands it with no velocity (\u0060PlayerSpawner.PlaceLocal\u0060), so the fall speed it built up goes too.\r\n\t\t\tvar sp = ActiveConfig.Current?.PlayerSpawns?.FirstOrDefault();\r\n\t\t\tif ( sp is not null )\r\n\t\t\t{\r\n\t\t\t\tPlayerSpawner.MoveTo( go, sp.Position \u002B Vector3.Up * 4f, Rotation.FromYaw( sp.Yaw ) );\r\n\t\t\t\tLog.Info( $\u0022[nz-3p] photo: standing on the first player spawn, {sp.Position}, facing {sp.Yaw:0}\u0022 );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tvar top = go.WorldPosition.WithZ( 4000f );\r\n\t\t\tvar tr = scene.Trace.Ray( top, top.WithZ( -4000f ) )\r\n\t\t\t\t.WithoutTags( \u0022player\u0022, \u0022zombie\u0022, \u0022trigger\u0022, \u0022corpse\u0022, \u0022ragdoll\u0022 ).Run();\r\n\r\n\t\t\tif ( !tr.Hit )\r\n\t\t\t{\r\n\t\t\t\tLog.Warning( \u0022[nz-3p] photo: no spawn in this config and no ground under you yet \u2014 is the map still loading?\u0022 );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tvar yaw = ctrl.EyeAngles.yaw;\r\n\t\t\tPlayerSpawner.MoveTo( go, tr.HitPosition \u002B Vector3.Up * 4f, Rotation.FromYaw( yaw ) );\r\n\t\t\tLog.Info( $\u0022[nz-3p] photo: standing on the ground below, {tr.HitPosition}, facing {yaw:0}\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar round = float.TryParse( w, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture,\r\n\t\t\tout var y ) ? y : 35f;\r\n\r\n\t\t// \u26A0\uFE0F THE BODY\u0027S FACING, NOT THE EYES\u0027. The controller turns the body after the eyes, so straight after a turn the two\r\n\t\t// disagree, and the gun is on the body.\r\n\t\tvar body = ctrl.Renderer.IsValid() ? ctrl.Renderer.GameObject : go;\r\n\t\tvar face = body.WorldRotation.Angles().yaw;\r\n\t\tvar at = go.WorldPosition;\r\n\r\n\t\t// \u002By is LEFT in s\u0026box, so \u0022toward the gun hand\u0022 (the right) is a negative turn.\r\n\t\tvar from = Rotation.FromYaw( face - round );\r\n\t\tvar right = Rotation.FromYaw( face - 90f ).Forward;\r\n\t\tvar eye = at \u002B from.Forward * dist \u002B Vector3.Up * height;\r\n\t\tvar look = at \u002B Vector3.Up * 46f \u002B right * 6f;\r\n\r\n\t\tvar cam = PhotoCamera( scene );\r\n\t\tcam.WorldPosition = eye;\r\n\t\tcam.WorldRotation = Rotation.LookAt( look - eye, Vector3.Up );\r\n\r\n\t\tLog.Info( $\u0022[nz-3p] photo: camera {cam.Id} placed {round:0}\u00B0 round toward the gun hand, {dist:0}u out, {height:0}u up\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_3p_face [yaw] [dist]\u0060 \u2014 the photo camera on your FACE instead of the gun hand, for judging a character model\r\n\t/// (2026-10-05: *\u0022Richthofen has a red thing in the model\u0027s face that seems like missing textures\u0022*). Aimed at the head\r\n\t/// bone, \u0060yaw\u0060 degrees round from straight in front (0) toward the gun hand, \u0060dist\u0060 units out (24). Same camera as\r\n\t/// \u0060nz_3p_photo\u0060, so \u0060nz_3p_photo off\u0060 removes it.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_3p_face\u0022 )]\r\n\tpublic static void Face( float yaw = 0f, float dist = 24f )\r\n\t{\r\n\t\tvar bodies = PlayerSpawner.AllBodies();\r\n\t\tvar go = bodies.FirstOrDefault( PlayerPresence.Mine ) ?? bodies.FirstOrDefault();\r\n\t\tvar ctrl = go.IsValid() ? go.Components.Get\u003CPlayerController\u003E( FindMode.EverythingInSelfAndDescendants ) : null;\r\n\t\tif ( !ctrl.IsValid() || !ctrl.Renderer.IsValid() || !ctrl.Renderer.TryGetBoneTransform( \u0022head\u0022, out var head ) )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[nz-3p] face: no body, or no head bone on it \u2014 in play, after Creative, third person on?\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// The face is a little above and in front of the head bone, which sits at the top of the neck.\r\n\t\tvar facing = ctrl.Renderer.GameObject.WorldRotation.Angles().yaw;\r\n\t\tvar look = head.Position \u002B Vector3.Up * 3f \u002B Rotation.FromYaw( facing ).Forward * 2f;\r\n\t\tvar eye = look \u002B Rotation.FromYaw( facing - yaw ).Forward * dist;\r\n\r\n\t\tvar cam = PhotoCamera( Game.ActiveScene );\r\n\t\tcam.WorldPosition = eye;\r\n\t\tcam.WorldRotation = Rotation.LookAt( look - eye, Vector3.Up );\r\n\r\n\t\tLog.Info( $\u0022[nz-3p] photo: camera {cam.Id} placed on the face, {yaw:0}\u00B0 round toward the gun hand, {dist:0}u out\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 body \u0027{ctrl.Renderer.Model?.ResourcePath}\u0027\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_3p_eye\u0060 \u2014 the photo camera exactly where your own view is, with its field of view, for first-person shots: the\r\n\t/// hands a character wears (2026-10-05, Takeo given Nikolai\u0027s). MCP\u0027s shot of the main camera answers from the editor\u0027s\r\n\t/// copy of the scene, which draws no viewmodel; this camera lives only in the game\u0027s scene.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_3p_eye\u0022 )]\r\n\tpublic static void Eye()\r\n\t{\r\n\t\tvar main = Game.ActiveScene?.Camera;\r\n\t\tif ( !main.IsValid() )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[nz-3p] eye: no main camera\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar cam = PhotoCamera( Game.ActiveScene );\r\n\t\tcam.WorldPosition = main.WorldPosition;\r\n\t\tcam.WorldRotation = main.WorldRotation;\r\n\t\tif ( cam.Components.Get\u003CCameraComponent\u003E() is { } c ) c.FieldOfView = main.FieldOfView;\r\n\r\n\t\tLog.Info( $\u0022[nz-3p] photo: camera {cam.Id} placed at your eye, fov {main.FieldOfView:0}\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EThe one photo camera: made on first use, unsaved and never networked, and never the main camera.\u003C/summary\u003E\r\n\tstatic GameObject PhotoCamera( Scene scene )\r\n\t{\r\n\t\tvar cam = Existing( scene ).FirstOrDefault();\r\n\t\tif ( cam.IsValid() ) return cam;\r\n\r\n\t\tcam = scene.CreateObject();\r\n\t\tcam.Name = CameraName;\r\n\t\tcam.Flags |= GameObjectFlags.NotSaved;\r\n\t\tcam.NetworkMode = NetworkMode.Never;\r\n\r\n\t\tvar c = cam.Components.Create\u003CCameraComponent\u003E();\r\n\t\tc.IsMainCamera = false;\r\n\t\tc.FieldOfView = 50f;\r\n\t\treturn cam;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_3p_axes\u0060 \u2014 WHERE THE GUN IN YOUR HAND POINTS, in your body\u0027s frame and in the gun model\u0027s own axes (2026-10-05).\r\n\t///\r\n\t/// \u26A0\uFE0F THE MODEL AXIS IS THE ONE THE CHALK WORRIES ABOUT. \u0060WallBuyManager.PreTweaks\u0060 turns five packs 90\u00B0 before flattening\r\n\t/// because their meshes run the barrel down another axis than the ARC9 packs\u0027 \u002BX. Printing the barrel\u0027s model axis for each\r\n\t/// gun in the hand says whether the third-person misses group the same way.\r\n\t///\r\n\t/// The muzzle is the model\u0027s \u0060muzzle\u0060 attachment (what SWB fires from), else the far end of the mesh\u0027s longest side from the\r\n\t/// hand. Directions are from the hand (\u0060hold_R\u0060), where the grip is put.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_3p_axes\u0022 )]\r\n\tpublic static void Axes()\r\n\t{\r\n\t\tvar bodies = PlayerSpawner.AllBodies();\r\n\t\tvar go = bodies.FirstOrDefault( PlayerPresence.Mine ) ?? bodies.FirstOrDefault();\r\n\t\tvar np = go.IsValid() ? go.Components.Get\u003CNZPlayer\u003E( FindMode.EverythingInSelf ) : null;\r\n\t\tvar ctrl = go.IsValid() ? go.Components.Get\u003CPlayerController\u003E( FindMode.EverythingInSelfAndDescendants ) : null;\r\n\t\tvar gun = ThirdPersonWeapon.GunOf( np );\r\n\t\tvar r = gun.IsValid() ? gun.Components.Get\u003CSkinnedModelRenderer\u003E( FindMode.EverythingInSelf ) : null;\r\n\r\n\t\tif ( !r.IsValid() || !ctrl.IsValid() || !ctrl.Renderer.IsValid()\r\n\t\t\t|| !ctrl.Renderer.TryGetBoneTransform( \u0022hold_R\u0022, out var hand ) )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[nz-3p] axes: no gun in the hand, no body, or no hold_R bone\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar body = ctrl.Renderer.GameObject.WorldRotation;\r\n\t\tstring InBody( Vector3 v ) =\u003E $\u0022fwd {Vector3.Dot( v, body.Forward ):\u002B0.00;-0.00} right {Vector3.Dot( v, body.Right ):\u002B0.00;-0.00}\u0022\r\n\t\t\t\u002B $\u0022 up {Vector3.Dot( v, body.Up ):\u002B0.00;-0.00}\u0022;\r\n\r\n\t\tVector3 muzzle;\r\n\t\tstring from;\r\n\t\tif ( r.GetAttachment( \u0022muzzle\u0022 ) is Transform m )\r\n\t\t{\r\n\t\t\tmuzzle = m.Position;\r\n\t\t\tfrom = \u0022muzzle attachment\u0022;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tvar b = r.Model.Bounds;\r\n\t\t\tvar size = b.Size;\r\n\t\t\tvar axis = size.x \u003E= size.y \u0026\u0026 size.x \u003E= size.z ? Vector3.Forward : size.y \u003E= size.z ? Vector3.Left : Vector3.Up;\r\n\t\t\tvar half = Vector3.Dot( size, axis ) * 0.5f;\r\n\t\t\tvar a = gun.WorldTransform.PointToWorld( b.Center \u002B axis * half );\r\n\t\t\tvar c = gun.WorldTransform.PointToWorld( b.Center - axis * half );\r\n\t\t\tmuzzle = a.Distance( hand.Position ) \u003E= c.Distance( hand.Position ) ? a : c;\r\n\t\t\tfrom = \u0022far end of the mesh\u0022;\r\n\t\t}\r\n\r\n\t\tvar dir = (muzzle - hand.Position).Normal;\r\n\t\tvar local = gun.WorldRotation.Inverse * dir;\r\n\t\tvar ax = MathF.Abs( local.x ) \u003E= MathF.Abs( local.y ) \u0026\u0026 MathF.Abs( local.x ) \u003E= MathF.Abs( local.z )\r\n\t\t\t? (local.x \u003E 0 ? \u0022\u002BX\u0022 : \u0022-X\u0022)\r\n\t\t\t: MathF.Abs( local.y ) \u003E= MathF.Abs( local.z ) ? (local.y \u003E 0 ? \u0022\u002BY\u0022 : \u0022-Y\u0022) : (local.z \u003E 0 ? \u0022\u002BZ\u0022 : \u0022-Z\u0022);\r\n\t\tvar off = MathF.Acos( Math.Clamp( Vector3.Dot( dir, body.Forward ), -1f, 1f ) ) * 180f / MathF.PI;\r\n\r\n\t\tLog.Info( $\u0022[nz-3p] axes: {r.Model.ResourceName}  barrel along model {ax} ({from}, {muzzle.Distance( hand.Position ):0}u from the hand)\u0022 );\r\n\t\tLog.Info( $\u0022[nz-3p] axes:   muzzle points {InBody( dir )}  \u00B7  {off:0}\u00B0 off the body\u0027s forward\u0022 );\r\n\t\tLog.Info( $\u0022[nz-3p] axes:   gun \u002BX {InBody( gun.WorldRotation.Forward )} \u00B7 \u002BY {InBody( gun.WorldRotation.Left )}\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 \u002BZ {InBody( gun.WorldRotation.Up )}\u0022 );\r\n\t\tLog.Info( $\u0022[nz-3p] axes:   hand \u002BX {InBody( hand.Rotation.Forward )} \u00B7 \u002BZ {InBody( hand.Rotation.Up )}\u0022 );\r\n\r\n\t\t// The left hand: did GunGrip.Hands get as far as an IK target, and is the hand on it?\r\n\t\tvar st = gun.Components.Get\u003CGunGripState\u003E( FindMode.EverythingInSelf );\r\n\t\tvar hasL = ctrl.Renderer.TryGetBoneTransform( \u0022hand_L\u0022, out var handL );\r\n\t\tvar tgt = GunGrip.LeftTarget( gun );\r\n\t\tLog.Info( \u0022[nz-3p] axes:   left hand: \u0022\r\n\t\t\t\u002B (st is null ? \u0022\u26D4 no GunGripState on the gun (GunGrip.Hands never ran)\u0022\r\n\t\t\t\t: $\u0022shape {(st.Shape is null ? \u0022\u26D4 none\u0022 : \u0022ok\u0022)} \u00B7 turn {(st.LeftTurn is null ? \u0022\u26D4 not captured\u0022 : \u0022captured\u0022)}\u0022)\r\n\t\t\t\u002B (hasL ? \u0022\u0022 : \u0022 \u00B7 \u26D4 no hand_L bone on the body\u0022)\r\n\t\t\t\u002B (tgt is Transform t \u0026\u0026 hasL ? $\u0022 \u00B7 hand_L {handL.Position.Distance( t.Position ):0.0}u from its target\u0022 : \u0022\u0022) );\r\n\r\n\t\t// The muzzle attachment\u0027s own forward, in the gun\u0027s space, against the hand\u2192muzzle line: does it run down the barrel?\r\n\t\tif ( r.GetAttachment( \u0022muzzle\u0022 ) is Transform ma )\r\n\t\t{\r\n\t\t\tvar mf = gun.WorldRotation.Inverse * ma.Rotation.Forward;\r\n\t\t\tvar ang = MathF.Acos( Math.Clamp( Vector3.Dot( mf.Normal, local.Normal ), -1f, 1f ) ) * 180f / MathF.PI;\r\n\t\t\tLog.Info( $\u0022[nz-3p] axes:   muzzle attachment forward (model) {mf.x:0.00},{mf.y:0.00},{mf.z:0.00}\u0022\r\n\t\t\t\t\u002B $\u0022 \u00B7 {ang:0}\u00B0 from the hand\u2192muzzle line\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_3p_ik [world|raw x y z | off]\u0060 \u2014 WHERE THE LEFT HAND\u0027S IK PUTS THE HAND, against where it was asked to (2026-10-05).\r\n\t///\r\n\t/// \u26A0\uFE0F IT EXISTS BECAUSE THE HAND WENT OVER THE HEAD. With its target under the handguard the IK lifted the left hand above the\r\n\t/// player\u0027s head, 23\u201339 u from the target, and neither the engine docs nor the controller say which space the graph reads\r\n\t/// \u0060ik.hand_left.position\u0060 in (the controller was ruled out: \u0060UseAnimatorControls\u0060 off changed nothing). \u0060world x y z\u0060 sends\r\n\t/// a point in the body\u0027s frame (forward, left, up) through \u0060SetIk\u0060 as a world transform; \u0060raw x y z\u0060 writes the three graph\r\n\t/// parameters as they are; \u0060off\u0060 gives the hand back to \u0060GunGrip\u0060. Every form prints what the graph holds and where the arm is.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_3p_ik\u0022 )]\r\n\tpublic static void Ik( string mode = \u0022\u0022, float x = 0f, float y = 0f, float z = 0f )\r\n\t{\r\n\t\tmode = (mode ?? \u0022\u0022).ToLowerInvariant();\r\n\t\tif ( mode is \u0022world\u0022 or \u0022raw\u0022 )\r\n\t\t{\r\n\t\t\tGunGrip.Probe = new Vector3( x, y, z );\r\n\t\t\tGunGrip.ProbeRaw = mode == \u0022raw\u0022;\r\n\t\t}\r\n\t\telse if ( mode == \u0022off\u0022 ) GunGrip.Probe = null;\r\n\r\n\t\tvar bodies = PlayerSpawner.AllBodies();\r\n\t\tvar go = bodies.FirstOrDefault( PlayerPresence.Mine ) ?? bodies.FirstOrDefault();\r\n\t\tvar ctrl = go.IsValid() ? go.Components.Get\u003CPlayerController\u003E( FindMode.EverythingInSelfAndDescendants ) : null;\r\n\t\tvar r = ctrl.IsValid() ? ctrl.Renderer : null;\r\n\t\tif ( !r.IsValid() || !r.SceneModel.IsValid() ) { Log.Warning( \u0022[nz-3p] ik: no body\u0022 ); return; }\r\n\r\n\t\tvar tx = r.GameObject.WorldTransform;\r\n\t\tstring Local( Vector3 world ) { var p = tx.PointToLocal( world ); return $\u0022({p.x:0.0}, {p.y:0.0}, {p.z:0.0})\u0022; }\r\n\r\n\t\tvar sm = r.SceneModel;\r\n\t\tvar pos = sm.GetVector3( \u0022ik.hand_left.position\u0022 );\r\n\t\tLog.Info( $\u0022[nz-3p] ik: probe {(GunGrip.Probe is Vector3 pr ? $\u0022{(GunGrip.ProbeRaw ? \u0022raw\u0022 : \u0022world\u0022)} {pr}\u0022 : \u0022off\u0022)}\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 the graph holds enabled {sm.GetBool( \u0022ik.hand_left.enabled\u0022 )} position ({pos.x:0.0}, {pos.y:0.0}, {pos.z:0.0})\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 body at {r.WorldPosition} scale {r.WorldScale.x:0.00}\u0022 );\r\n\t\tforeach ( var bone in new[] { \u0022clavicle_L\u0022, \u0022arm_upper_L\u0022, \u0022arm_lower_L\u0022, \u0022hand_L\u0022, \u0022hold_L\u0022, \u0022hand_R\u0022, \u0022hold_R\u0022 } )\r\n\t\t\tif ( r.TryGetBoneTransform( bone, out var b ) ) Log.Info( $\u0022[nz-3p] ik:   {bone,-12} at {Local( b.Position )} in the body\u0027s frame (fwd, left, up)\u0022 );\r\n\r\n\t\t// The left hand\u0027s own axes and its fingers, to turn it deliberately: which local axis runs down the fingers, which way the\r\n\t\t// palm faces. Bone names from the body itself \u2014 every left-hand bone, by position in the hand\u0027s own frame.\r\n\t\tif ( r.TryGetBoneTransform( \u0022hand_L\u0022, out var hl ) \u0026\u0026 r.Model?.Bones is { } bones )\r\n\t\t{\r\n\t\t\tstring H( Vector3 world ) { var p = hl.Rotation.Inverse * (world - hl.Position); return $\u0022({p.x:0.0}, {p.y:0.0}, {p.z:0.0})\u0022; }\r\n\t\t\tstring Axis( Vector3 v ) { var p = tx.Rotation.Inverse * v; return $\u0022({p.x:\u002B0.00;-0.00}, {p.y:\u002B0.00;-0.00}, {p.z:\u002B0.00;-0.00})\u0022; }\r\n\t\t\tLog.Info( $\u0022[nz-3p] ik:   hand_L axes in the body\u0027s frame: X {Axis( hl.Rotation.Forward )} \u00B7 Y {Axis( hl.Rotation.Left )} \u00B7 Z {Axis( hl.Rotation.Up )}\u0022 );\r\n\t\t\tforeach ( var bone in bones.AllBones.Where( b =\u003E b.Name.EndsWith( \u0022_L\u0022, StringComparison.Ordinal )\r\n\t\t\t\t\u0026\u0026 (b.Name.StartsWith( \u0022finger\u0022, StringComparison.Ordinal ) || b.Name.StartsWith( \u0022hold\u0022, StringComparison.Ordinal )) ) )\r\n\t\t\t\tif ( r.TryGetBoneTransform( bone.Name, out var fb ) ) Log.Info( $\u0022[nz-3p] ik:   {bone.Name,-20} at {H( fb.Position )} in hand_L\u0027s own frame\u0022 );\r\n\t\t}\r\n\r\n\t\tvar gun = ThirdPersonWeapon.GunOf( go.Components.Get\u003CNZPlayer\u003E( FindMode.EverythingInSelf ) );\r\n\t\tif ( GunGrip.LeftTarget( gun ) is Transform t ) Log.Info( $\u0022[nz-3p] ik:   gun\u0027s target at {Local( t.Position )}\u0022 );\r\n\t\tif ( gun.IsValid() ) Log.Info( $\u0022[nz-3p] ik:   {GunGrip.Describe( gun )}\u0022 );\r\n\r\n\t\t// The gun\u0027s measured shape: the one kept on it at its first frame, and one measured now, while it stands still.\r\n\t\tif ( gun.IsValid() \u0026\u0026 gun.Components.Get\u003CSkinnedModelRenderer\u003E( FindMode.EverythingInSelf ) is { } gr \u0026\u0026 gr.Model is not null )\r\n\t\t{\r\n\t\t\tstatic string V( Vector3 v ) =\u003E $\u0022({v.x:0.0}, {v.y:0.0}, {v.z:0.0})\u0022;\r\n\t\t\tstatic string S( GunGrip.Shape? s ) =\u003E s is GunGrip.Shape v\r\n\t\t\t\t? $\u0022muzzle {V( v.Muzzle )} barrel {V( v.Barrel )} up {V( v.Up )} \u00B7 t {v.TBack:0.0} \u2026 {v.TMuzzle:0.0} \u00B7 below {v.Below:0.0}\u0022\r\n\t\t\t\t: \u0022none\u0022;\r\n\t\t\tvar st = gun.Components.Get\u003CGunGripState\u003E( FindMode.EverythingInSelf );\r\n\t\t\tLog.Info( $\u0022[nz-3p] ik:   gun {gr.Model.ResourceName} \u00B7 bounds {V( gr.Model.Bounds.Mins )} \u2026 {V( gr.Model.Bounds.Maxs )}\u0022\r\n\t\t\t\t\u002B $\u0022 \u00B7 object scale {gun.WorldScale.x:0.00}\u0022 );\r\n\t\t\tLog.Info( $\u0022[nz-3p] ik:   shape kept {S( st?.Shape )}\u0022 );\r\n\t\t\tLog.Info( $\u0022[nz-3p] ik:   shape now  {S( GunGrip.Measure( gr ) )}\u0022 );\r\n\t\t\tif ( gr.GetAttachment( \u0022muzzle\u0022 ) is Transform mz ) Log.Info( $\u0022[nz-3p] ik:   muzzle now at {Local( mz.Position )} in the body\u0027s frame\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic System.Collections.Generic.List\u003CGameObject\u003E Existing( Scene scene )\r\n\t\t=\u003E scene.IsValid()\r\n\t\t\t? scene.GetAllObjects( false ).Where( o =\u003E o.IsValid() \u0026\u0026 o.Name == CameraName ).ToList()\r\n\t\t\t: new System.Collections.Generic.List\u003CGameObject\u003E();\r\n}\r\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Diagnostics/ShootAnimToggle.cs","FileName":"ShootAnimToggle.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\nusing System.Linq;\n\nnamespace NZombies;\n\n/// \u003Csummary\u003E\n/// Turn the weapon\u0027s fire ANIMATION off and on. OFF by default since 2026-09-14; \u0060nz_shootanim 1\u0060\n/// puts the authored clips back.\n///\n/// \u26D4 THE ONE THING NO OTHER SETTING CAN REACH. Three separate systems move the viewmodel when you\n/// shoot -- \u0060VisualRecoil*\u0060 kicks the model, \u0060ApplyEyeAnglesOffset\u0060 kicks the camera and the sway\n/// system swings the gun in response, and the model plays its own authored \u0060fire\u0060 clip. The first\n/// two are numbers in a prefab; the third is baked into the .vmdl at compile time and has no\n/// runtime amplitude at all. Turning it off is therefore the only way to tell it apart from the\n/// other two, and the only way to confirm a fix has to happen at the SMD.\n///\n/// \u26A0\uFE0F THE WEAPON STILL FIRES NORMALLY. The bullet, muzzle flash, sound, ammo and recoil are all\n/// applied elsewhere in the shoot path -- only the clip is withheld. So if the gun stops lurching\n/// with this off, the lurch is in the clip.\n/// \u003C/summary\u003E\npublic static class ShootAnimToggle\n{\n\tstatic bool? _enabled;\n\n\t/// \u003Csummary\u003E\n\t/// Whether weapons play their fire clip. OFF \u2014 requested 2026-09-14: *\u0022i want to disable the\n\t/// animations and make our own recoil better, because some of them ruin the weapons\u0022*.\n\t///\n\t/// \u26D4 THE CLIP IS THE ONE RECOIL CHANNEL WITH NO AMPLITUDE. \u0060RecoilScale\u0060, the bases, the\n\t/// jitter and the stability settle are all numbers that can be dialled; the authored \u0060fire\u0060\n\t/// sequence is baked into the .vmdl at compile time and plays at exactly the size its animator\n\t/// chose, on 446 of the 496 weapons. Once the other three channels are tuned to a single base,\n\t/// a per-weapon lurch nobody can scale is the only thing left that differs between two guns\n\t/// that are supposed to feel the same.\n\t///\n\t/// \u26A0\uFE0F THE WEAPON STILL FIRES NORMALLY \u2014 bullet, flash, sound, ammo, recoil and the visual\n\t/// kick are all applied elsewhere in the shoot path. Only the clip is withheld, so what is lost\n\t/// is the lurch and not the feedback.\n\t///\n\t/// \u26A0\uFE0F NULLABLE-BACKED, NOT \u0060= false\u0060. A static auto-property\u0027s initialiser DOES NOT RE-RUN\n\t/// ON HOTLOAD, so editing the literal would leave the running session on whatever it already\n\t/// had while the source said otherwise \u2014 a trap this project has now paid for three times\n\t/// (PhdAugments, PapMuzzleFlash.Palettes, BulletDecals.GlowEnabled). A getter is code, and code\n\t/// is swapped.\n\t/// \u003C/summary\u003E\n\tpublic static bool Enabled\n\t{\n\t\tget =\u003E _enabled ?? false;\n\t\tset =\u003E _enabled = value;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// \u0060nz_shootanim\u0060 toggles, \u0060nz_shootanim 0\u0060 / \u0060nz_shootanim 1\u0060 set it explicitly.\n\t///\n\t/// \u26A0\uFE0F A BARE CALL TOGGLES rather than defaulting to on, because the whole point is flipping it\n\t/// back and forth while firing \u2014 needing to remember which way it currently is defeats that.\n\t/// \u003C/summary\u003E\n\t[ConCmd( \u0022nz_shootanim\u0022 )]\n\tpublic static void Set( int on = -1 )\n\t{\n\t\tEnabled = on \u003C 0 ? !Enabled : on != 0;\n\n\t\tvar held = Game.ActiveScene?.GetAllComponents\u003CSWB.Base.Weapon\u003E()\n\t\t\t.FirstOrDefault( w =\u003E w.IsValid() \u0026\u0026 w.GameObject.Enabled );\n\n\t\tLog.Info( $\u0022[shootanim] fire animations {(Enabled ? \u0022ON (as authored)\u0022 : \u0022OFF\u0022)}\u0022\n\t\t\t\u002B (held.IsValid() ? $\u0022   holding {held.DisplayName}\u0022 : \u0022\u0022) );\n\n\t\t// \u26D4 NAME THE CLIPS THIS WEAPON WOULD HAVE PLAYED. \u0022It still moves with animations off\u0022 and\n\t\t// \u0022this weapon has no fire clip to begin with\u0022 look identical on screen, and only one of\n\t\t// them means the movement is coming from somewhere else.\n\t\tif ( held.IsValid() \u0026\u0026 held.Primary is not null )\n\t\t{\n\t\t\tvar p = held.Primary;\n\t\t\tLog.Info( $\u0022[shootanim]   ShootAnim \u0027{Empty( p.ShootAnim )}\u0027\u0022\n\t\t\t\t\u002B $\u0022  ShootAimedAnim \u0027{Empty( p.ShootAimedAnim )}\u0027\u0022\n\t\t\t\t\u002B $\u0022  ShootEmptyAnim \u0027{Empty( p.ShootEmptyAnim )}\u0027\u0022 );\n\n\t\t\tvar vm = held.ViewModelRenderer;\n\t\t\tif ( vm.IsValid() \u0026\u0026 vm.Model is not null )\n\t\t\t\tLog.Info( $\u0022[shootanim]   current sequence \u0027{vm.Sequence.Name}\u0027\u0022 );\n\t\t}\n\t}\n\n\tstatic string Empty( string s ) =\u003E string.IsNullOrEmpty( s ) ? \u0022-none-\u0022 : s;\n}\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Doors/DebrisManager.cs","FileName":"DebrisManager.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\r\nusing Sandbox.Volumes;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace NZombies;\r\n\r\n/// \u003Csummary\u003E\r\n/// DOORS/DEBRIS \u2014 spawns the config\u0027s barriers and sells them.\r\n///\r\n/// A barrier is a prop in the world with a price. Buy it and it vanishes,\r\n/// opening its link \u2014 which is what makes the zombie spawns behind it eligible.\r\n/// That is the whole map-progression loop in nZombies.\r\n///\r\n/// \u26A0\uFE0F The props are BUILT AT RUNTIME from the config, not saved into the scene.\r\n/// The config has to work on any map, so nothing here may depend on the scene\r\n/// file having been edited \u2014 that is the same reason spawns are config data.\r\n/// \u003C/summary\u003E\r\npublic sealed class DebrisManager : Component\r\n{\r\n\tpublic static DebrisManager Instance { get; private set; }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The manager, creating it if the scene has none yet.\r\n\t///\r\n\t/// \u26A0\uFE0F Barriers are rebuilt from config, so whoever needs them first has to\r\n\t/// be able to bring the manager into existence. \u0060Instance?.Rebuild()\u0060 looks\r\n\t/// equivalent and is not \u2014 on a scene where nothing had touched the debris\r\n\t/// commands, it silently does nothing and the map plays with no walls.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static DebrisManager Ensure( Scene scene )\r\n\t{\r\n\t\tif ( Instance.IsValid() ) return Instance;\r\n\t\tif ( !scene.IsValid() ) return null;\r\n\r\n\t\tvar go = scene.CreateObject();\r\n\t\tgo.Name = \u0022Debris Manager\u0022;\r\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\r\n\t\treturn go.Components.Create\u003CDebrisManager\u003E();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EHow far the aim ray is cast. Only picks WHICH barrier you mean \u2014\r\n\t/// \u003Csee cref=\u0022Reach\u0022/\u003E is what decides if you are close enough.\u003C/summary\u003E\r\n\t[Property] public float BuyRange { get; set; } = 160f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// How close you must actually stand. Measured from the player to the\r\n\t/// surface point under the crosshair.\r\n\t///\r\n\t/// 80 is Source\u0027s own \u002Buse distance, which is what the original means by\r\n\t/// \u0022the normal use distance\u0022. UsePrompt reads this too, so the prompt and\r\n\t/// the purchase can never disagree \u2014 a prompt you can read but not act on is\r\n\t/// worse than none.\r\n\t/// \u003C/summary\u003E\r\n\t[Property] public float Reach { get; set; } = 80f;\r\n\r\n\treadonly Dictionary\u003Cint, GameObject\u003E _props = new();\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The barrier still standing at this index, or null.\r\n\t///\r\n\t/// \u26A0\uFE0F READ-ONLY ACCESS FOR DIAGNOSTICS. \u0060_props\u0060 is the authority on what is actually in the\r\n\t/// world \u2014 \u0060DoorLinks\u0060 only says what SHOULD be \u2014 and \u0060nz_debris_why\u0060 exists to compare the two.\r\n\t/// \u003C/summary\u003E\r\n\tpublic GameObject PropAt( int index ) =\u003E _props.TryGetValue( index, out var go ) ? go : null;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// How long after a door opens before every zombie is made to rethink its route. 0.75s.\r\n\t///\r\n\t/// \u26A0\uFE0F LONG ENOUGH FOR THE TILES, SHORT ENOUGH NOT TO BE NOTICED. \u0060RebuildNavAround\u0060 only\r\n\t/// REQUESTS generation; the mesh is not walkable the instant the barrier is disabled. Repathing\r\n\t/// too early is worse than not repathing, because every zombie then commits to a fresh route\r\n\t/// computed around a hole that is about to be filled in.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static float RepathDelay { get; set; } = 0.75f;\r\n\r\n\t/// \u003Csummary\u003EHow often to check that no barrier is standing on an open link. 2s.\u003C/summary\u003E\r\n\tpublic static float HealInterval { get; set; } = 2f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u26D4 ARMED BY A BOOL, NOT BY THE TIMER\u0027S SIGN, AND THE TIMER ALONE CANNOT DO IT. A \u0060TimeUntil\u0060\r\n\t/// reads as the time REMAINING, so it counts down through zero and keeps going negative \u2014 which\r\n\t/// makes \u0022already elapsed\u0022 and \u0022never armed\u0022 the same value. The guard here was\r\n\t/// \u0060_repathDue \u003E= 0f \u0026\u0026 _repathDue \u003C= 0f\u0060, true only in the exact frame the remaining time is\r\n\t/// zero, which floating point essentially never delivers.\r\n\t///\r\n\t/// \u26D4 SO THE WHOLE DEFERRED PASS HAS NEVER RUN. Opening a door was supposed to ask every zombie\r\n\t/// to rethink its route once the navmesh caught up; it never did, and the symptom \u2014 a horde that\r\n\t/// keeps going the long way after a door opens \u2014 is exactly what that code\u0027s own comment\r\n\t/// describes as \u0022a report nobody can reproduce on demand\u0022.\r\n\t/// \u003C/summary\u003E\r\n\tbool _repathArmed;\r\n\r\n\tTimeUntil _repathDue;\r\n\r\n\t/// \u003Csummary\u003ETicks of \u0060OnUpdate\u0060. Exists so \u0060nz_debris_state\u0060 can answer the one question a\r\n\t/// deferred job raises when it does not happen: is the tick running at all?\u003C/summary\u003E\r\n\tint _ticks;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_debris_state\u0060 \u2014 is the deferred pass alive, and is it armed?\r\n\t///\r\n\t/// \u26D4 THE QUESTION A DEFERRED JOB ALWAYS RAISES AND NOTHING COULD ANSWER: when the work does not\r\n\t/// happen, is it because the trigger never armed, because the timer never elapsed, or because\r\n\t/// the component is not ticking at all? Those have three different fixes and look identical\r\n\t/// from outside.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_debris_state\u0022 )]\r\n\tpublic static void State()\r\n\t{\r\n\t\tvar m = Instance;\r\n\t\tif ( !m.IsValid() ) { Log.Warning( \u0022[nz-debris] no DebrisManager in the scene\u0022 ); return; }\r\n\r\n\t\tLog.Info( $\u0022[nz-debris] enabled {m.Enabled}   ticks {m._ticks}\u0022\r\n\t\t\t\u002B $\u0022   repath armed {m._repathArmed}   due in {(float)m._repathDue:0.##}s\u0022 );\r\n\t\tLog.Info( $\u0022[nz-debris]   NavLinkManager {( NavLinkManager.Instance.IsValid() ? \u0022present\u0022 : \u0022MISSING\u0022 )}\u0022\r\n\t\t\t\u002B $\u0022   barriers standing {m._props.Count}\u0022 );\r\n\t}\r\n\tTimeUntil _healDue;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Two jobs, both cheap, both belt-and-braces for a bug nobody can reproduce.\r\n\t///\r\n\t/// \u26A0\uFE0F A \u0060Component\u0060 WITH NO \u0060OnUpdate\u0060 UNTIL NOW, deliberately kept that way \u2014 this manager\r\n\t/// builds and forgets. It earns a tick because the two things below cannot be done at the\r\n\t/// moment a door opens: one has to happen AFTER the navmesh catches up, and the other is a\r\n\t/// standing check that only pays off on the frame something has already gone wrong.\r\n\t/// \u003C/summary\u003E\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t_ticks\u002B\u002B;\r\n\r\n\t\t// \u26A0\uFE0F THE HOST DECIDES WHERE ZOMBIES GO, so only the host repaths. A client running this\r\n\t\t// would rebuild routes for proxy zombies it does not steer.\r\n\t\tif ( _repathArmed \u0026\u0026 _repathDue \u003C= 0f )\r\n\t\t{\r\n\t\t\t_repathArmed = false;\r\n\r\n\t\t\t// \u26D4 THE NAV LINKS ARE REBUILT HERE, AND BUYING A DOOR NEVER DID IT. \u0060NavLinkManager\u0060\r\n\t\t\t// skips any link whose flag is closed AT BUILD TIME \u2014 it is the only flag consumer that\r\n\t\t\t// does not re-test per frame \u2014 so a gated link does not exist until something rebuilds\r\n\t\t\t// it. \u0060OpenLink\u0060 and \u0060OpenLinkFromHost\u0060 both remembered to; \u0060OpenFree\u0060, which its own\r\n\t\t\t// header calls \u0022the path a purchase actually takes\u0022, did not. So every link behind a\r\n\t\t\t// door the player BOUGHT stayed switched off for the whole game, while the same link\r\n\t\t\t// came up instantly if the flag was opened from the console. That is exactly the shape\r\n\t\t\t// of \u0022they work when I test them and not when I play\u0022.\r\n\t\t\t//\r\n\t\t\t// \u26A0\uFE0F HERE AND NOT AT THE MOMENT THE DOOR OPENS, for the reason this delay exists at\r\n\t\t\t// all: \u0060RebuildNavAround\u0060 requests tile generation ASYNCHRONOUSLY, and a link rebuilt\r\n\t\t\t// on that frame snaps its ends against a mesh that still has the barrier\u0027s hole in it.\r\n\t\t\t// The repath already waits for the same thing; there was never a second timer needed.\r\n\t\t\t//\r\n\t\t\t// \u26A0\uFE0F NOT HOST-ONLY, unlike the repath below. A client draws and paths against its own\r\n\t\t\t// link objects, so a client that never rebuilds keeps a wall across a doorway the host\r\n\t\t\t// has opened \u2014 the same class of bug \u0060OpenLink\u0060\u0027s network announce was added to fix.\r\n\t\t\tNavLinkManager.Instance?.Rebuild();\r\n\r\n\t\t\tif ( !Networking.IsActive || NZGame.IsHost )\r\n\t\t\t{\r\n\t\t\t\tZombieAI.ForceRetargetAll();\r\n\t\t\t\tLog.Info( \u0022[nz] door opened \u2014 every zombie asked to rethink its route\u0022 );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( _healDue \u003E 0f ) return;\r\n\t\t_healDue = HealInterval;\r\n\r\n\t\tHeal();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// If a link is open and its barrier is somehow still standing, take it out.\r\n\t///\r\n\t/// \u26D4 IT DEFENDS AGAINST A CAUSE THAT HAS NOT BEEN FOUND, AND THAT IS THE POINT. Two full\r\n\t/// sessions of \u0060nz_watch\u0060 produced zero \u0060GHOST\u0060 rows, so the removal path is not visibly\r\n\t/// broken \u2014 but the report is real and the symptom is exactly \u0022the barrier is gone as far as\r\n\t/// the flags are concerned and still there as far as the world is concerned\u0022. Whatever route\r\n\t/// produces that \u2014 a rebuild racing an open, a path this file does not know about, an engine\r\n\t/// change in tile generation \u2014 ends in a state this check can see and undo.\r\n\t///\r\n\t/// \u26A0\uFE0F IT SHOUTS. A self-heal that fixes silently is a bug that never gets diagnosed; the\r\n\t/// warning names the index and the link so the next session\u0027s log has the evidence the last\r\n\t/// two did not.\r\n\t///\r\n\t/// \u26A0\uFE0F AND IT IS NOT A SUBSTITUTE FOR THE FIX. If this ever fires, the removal path has a\r\n\t/// hole in it and this line is hiding it. \u0060nz_watch\u0060\u0027s GHOST row is the same condition seen\r\n\t/// from outside, so the two agree by construction.\r\n\t/// \u003C/summary\u003E\r\n\tvoid Heal()\r\n\t{\r\n\t\tvar list = ActiveConfig.Current?.Debris;\r\n\t\tif ( list is null ) return;\r\n\r\n\t\t// \u26A0\uFE0F CREATIVE IS EXEMPT, because \u0060Rebuild\u0060 deliberately BUILDS debris on open links\r\n\t\t// there \u2014 the map editor has to be able to see and move a barrier whose door is open.\r\n\t\t// Healing in Creative would delete the thing the editor just asked for.\r\n\t\tif ( NZGame.IsCreative ) return;\r\n\r\n\t\tfor ( int i = 0; i \u003C list.Count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tif ( !DoorLinks.IsOpen( list[i].Link ) ) continue;\r\n\r\n\t\t\tvar go = PropAt( i );\r\n\t\t\tif ( !go.IsValid() ) continue;\r\n\r\n\t\t\tvar at = go.WorldPosition;\r\n\t\t\tvar size = list[i].IsBlock ? list[i].Size : new Vector3( 128f );\r\n\r\n\t\t\tLog.Warning( $\u0022[nz] SELF-HEAL: debris #{i} was still standing on OPEN link\u0022\r\n\t\t\t\t\u002B $\u0022 \u0027{list[i].Link}\u0027 at {at:0} \u2014 removing it. The removal path has a hole;\u0022\r\n\t\t\t\t\u002B \u0022 this is a patch over it, not the fix (nz_watch, GHOST).\u0022 );\r\n\r\n\t\t\t// \u26A0\uFE0F DISABLE BEFORE DESTROY, then rebuild \u2014 the same order and the same reason as\r\n\t\t\t// \u0060OpenAllOnLink\u0060: \u0060Destroy()\u0060 is deferred a frame and the tiles would regenerate\r\n\t\t\t// around a barrier that is still solid.\r\n\t\t\tgo.Enabled = false;\r\n\t\t\tgo.Destroy();\r\n\t\t\t_props.Remove( i );\r\n\t\t\t_shapes.Remove( i );\r\n\r\n\t\t\tRebuildNavAround( at, size );\r\n\t\t\t_repathDue = RepathDelay;\r\n\t\t\t_repathArmed = true;\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The extruded model of each SHAPED barrier, by index.\r\n\t///\r\n\t/// \u26A0\uFE0F Kept separately rather than read back off the renderer, because a\r\n\t/// barrier that has a footprint but failed to build one falls back to a\r\n\t/// scaled \u0060Model.Cube\u0060 \u2014 and handing that to the authoring overlay would draw\r\n\t/// a unit cube at the barrier\u0027s origin, since the overlay has no way to know\r\n\t/// the scale lives on the child object. Absent here means \u0022not shaped\u0022.\r\n\t/// \u003C/summary\u003E\r\n\treadonly Dictionary\u003Cint, Model\u003E _shapes = new();\r\n\r\n\tprotected override void OnAwake() =\u003E Instance = this;\r\n\tprotected override void OnDestroy()\r\n\t{\r\n\t\tClear();\r\n\t\tif ( Instance == this ) Instance = null;\r\n\t}\r\n\r\n\tprotected override void OnStart() =\u003E Rebuild();\r\n\r\n\t// \u2500\u2500 building \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\t/// \u003Csummary\u003EDrop every unbought barrier into the world.\u003C/summary\u003E\r\n\tpublic void Rebuild()\r\n\t{\r\n\t\tClear();\r\n\t\tExcludeDoorsFromNavMesh();\r\n\r\n\t\tvar list = ActiveConfig.Current.Debris;\r\n\t\tvar unlinked = 0;\r\n\r\n\t\tfor ( int i = 0; i \u003C list.Count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar link = list[i].Link;\r\n\r\n\t\t\tif ( DoorLinks.IsUnlinked( link ) ) unlinked\u002B\u002B;\r\n\r\n\t\t\t// \u26D4 CREATIVE BUILDS EVERY BARRIER, WHATEVER THE LINKS SAY.\r\n\t\t\t//\r\n\t\t\t// \u0060DoorLinks\u0060 is RUNTIME state held in a static, so it outlives the\r\n\t\t\t// game that opened it. Play a round, buy a door, go back to creative\r\n\t\t\t// to keep building, and that barrier was still \u0022open\u0022 \u2014 so this loop\r\n\t\t\t// skipped it and the wall you had just drawn was simply not there.\r\n\t\t\t// The log said it outright and in sequence: \u0060debris: 0 of 1 standing\u0060\r\n\t\t\t// in creative, then \u0060links reset (1 were open)\u0060, then \u00601 of 1\r\n\t\t\t// standing\u0060 the instant Survival began.\r\n\t\t\t//\r\n\t\t\t// Which links are open is a fact about a GAME IN PROGRESS. It has\r\n\t\t\t// nothing to say about what a map contains, and creative is where you\r\n\t\t\t// edit what a map contains.\r\n\t\t\t//\r\n\t\t\t// \u26A0\uFE0F This also subsumes the unlinked case. IsOpen returns true for a\r\n\t\t\t// blank or \u00220\u0022 flag, so the old code needed a separate branch to stop\r\n\t\t\t// the gameplay rule deleting every barrier you had not yet flagged \u2014\r\n\t\t\t// with the whole rule off in creative, one check covers both.\r\n\t\t\tif ( !NZGame.IsCreative \u0026\u0026 DoorLinks.IsOpen( link ) ) continue;\r\n\r\n\t\t\tSpawn( i, list[i] );\r\n\t\t}\r\n\r\n\t\t// \u26A0\uFE0F Says how many are SHAPED, not just how many exist. \u0022It has no\r\n\t\t// collision\u0022 and \u0022the shape silently fell back to a box\u0022 look identical\r\n\t\t// from inside the game, and this line is the cheapest place to tell them\r\n\t\t// apart without arming a debug flag first.\r\n\t\tif ( list.Count \u003E 0 )\r\n\t\t\tLog.Info( $\u0022[nz] debris: {_props.Count} of {list.Count} standing\u0022\r\n\t\t\t\t\u002B (_shapes.Count \u003E 0 ? $\u0022, {_shapes.Count} shaped (solid \u002B nav)\u0022 : \u0022\u0022) );\r\n\r\n\t\tif ( unlinked \u003E 0 )\r\n\t\t\tLog.Warning( $\u0022[nz] \u26A0 {unlinked} barrier(s) have NO FLAG (blank or 0) \u2014 \u0022\r\n\t\t\t\t\u002B \u0022they gate nothing and vanish in Survival. Set a flag with the \u0022\r\n\t\t\t\t\u002B \u0022debris tool\u0027s Flag field, or nz_door_price \u003Ci\u003E \u003Cprice\u003E \u003Cflag\u003E.\u0022 );\r\n\t}\r\n\r\n\tvoid Spawn( int index, Debris d )\r\n\t{\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = $\u0022Debris {index} (link {d.Link})\u0022;\r\n\t\tgo.WorldPosition = d.Position;\r\n\t\tgo.WorldRotation = d.Rotation;\r\n\r\n\t\t// \u26A0\uFE0F NotSaved: these are rebuilt from config every game. Without it a\r\n\t\t// play session would bake them into the scene file and they would come\r\n\t\t// back permanently, unbuyable, on top of the real ones.\r\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\r\n\t\tgo.NetworkMode = NetworkMode.Never;   // \u26D4 THIS MACHINE\u0027S OWN \u2014 out of a joiner\u0027s snapshot, where it would stand frozen (NZNetListener)\r\n\r\n\t\tVector3 size;\r\n\t\tVector3 centre = Vector3.Zero;\r\n\r\n\t\tif ( d.IsBlock )\r\n\t\t{\r\n\t\t\tsize = d.Size;\r\n\r\n\t\t\t// \u26A0\uFE0F The VISUAL is scaled on a CHILD, and the parent stays at scale\r\n\t\t\t// 1. Colliders multiply by their object\u0027s scale, so scaling the\r\n\t\t\t// parent would silently square the collider size (Scale = size on a\r\n\t\t\t// 3x object gives a 3x-too-big box). Keeping the parent unscaled\r\n\t\t\t// means collider and nav volume can be stated in plain world units.\r\n\t\t\tvar vis = Scene.CreateObject();\r\n\t\t\tvis.Name = \u0022visual\u0022;\r\n\t\t\tvis.SetParent( go );\r\n\t\t\tvis.LocalPosition = Vector3.Zero;\r\n\t\t\tvis.LocalRotation = Rotation.Identity;\r\n\r\n\t\t\t// \u26D4 A DRAWN FOOTPRINT IS THE SHAPE, NOT A HINT AT ONE. Barriers used\r\n\t\t\t// to be a cube scaled to the corners\u0027 bounding box, so four corners\r\n\t\t\t// describing an L or a wedge came out as the rectangle around them \u2014\r\n\t\t\t// reported as \u0022it always makes a rectangle with the 2 furthest from\r\n\t\t\t// each other\u0022. With 3\u002B corners the polygon is extruded verbatim.\r\n\t\t\t//\r\n\t\t\t// \u26A0\uFE0F The box path stays for two-corner barriers and every barrier\r\n\t\t\t// already in a saved config, which have no Footprint \u2014 this had to be\r\n\t\t\t// additive or every existing map would need rebuilding by hand.\r\n\t\t\tvar shaped = d.HasFootprint\r\n\t\t\t\t? DebrisMesh.Build( d.Footprint, size.z, MaterialFor( d ) )\r\n\t\t\t\t: null;\r\n\r\n\t\t\t// \u26A0\uFE0F SAID OUT LOUD. A footprint that cannot be extruded \u2014 a bowtie that\r\n\t\t\t// got past the tool, a config hand-edited into a self-crossing shape \u2014\r\n\t\t\t// silently becomes a box, and a box where you drew an L looks exactly\r\n\t\t\t// like the feature was never built.\r\n\t\t\tif ( d.HasFootprint \u0026\u0026 shaped is null )\r\n\t\t\t\tLog.Warning( $\u0022[nz] debris #{index}: its {d.Footprint.Count}-point \u0022\r\n\t\t\t\t\t\u002B \u0022footprint could not be extruded (do its corners cross over \u0022\r\n\t\t\t\t\t\u002B \u0022each other?) \u2014 standing as a plain box instead\u0022 );\r\n\r\n\t\t\tif ( shaped is not null )\r\n\t\t\t{\r\n\t\t\t\t_shapes[index] = shaped;\r\n\r\n\t\t\t\t// \u26A0\uFE0F The prism is built from z=0 UP, but a barrier\u0027s origin is its\r\n\t\t\t\t// MIDDLE (Position is centred, see MapEditor.BuildBlock). Drop the\r\n\t\t\t\t// visual by half the height so the two agree \u2014 without this every\r\n\t\t\t\t// shaped barrier floats a half-height above its own footprint.\r\n\t\t\t\tvis.LocalPosition = Vector3.Down * size.z * 0.5f;\r\n\t\t\t\tvis.LocalScale = Vector3.One;\r\n\r\n\t\t\t\t// \u26D4 THE RENDERER IS CONDITIONAL, THE COLLIDER BELOW IS NOT. \u0060Visible\u0060\r\n\t\t\t\t// decides whether the barrier is DRAWN; it still blocks, still costs\r\n\t\t\t\t// points and still opens. Gating the collider too would turn a barrier\r\n\t\t\t\t// into nothing at all, which is a very confusing way to delete one.\r\n\t\t\t\tif ( d.Visible )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar mr = vis.Components.Create\u003CModelRenderer\u003E();\r\n\t\t\t\t\tmr.Model = shaped;\r\n\t\t\t\t\tmr.Tint = d.Tint;\r\n\t\t\t\t\tApplyMaterial( mr, d );\r\n\t\t\t\t}\r\n\r\n\t\t\t\t// \u26A0\uFE0F ModelCollider, not BoxCollider. The mesh carries a convex hull\r\n\t\t\t\t// per triangle (DebrisMesh), so the collision follows the dent in\r\n\t\t\t\t// an L \u2014 a box here would let you walk through the drawn shape and\r\n\t\t\t\t// bump into thin air beside it.\r\n\t\t\t\tvar hull = vis.Components.Create\u003CModelCollider\u003E();\r\n\t\t\t\thull.Model = shaped;\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\t// Scale derived from the cube\u0027s OWN bounds, not assumed to be 1 or\r\n\t\t\t\t// 100 \u2014 Model.Cube is neither, and hardcoding a divisor already\r\n\t\t\t\t// produced a fraction-sized barrier once.\r\n\t\t\t\tvar cube = Model.Cube.Bounds.Size;\r\n\t\t\t\tvis.LocalScale = new Vector3(\r\n\t\t\t\t\tsize.x / cube.x, size.y / cube.y, size.z / cube.z );\r\n\r\n\t\t\t\tif ( d.Visible )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar vr = vis.Components.Create\u003CModelRenderer\u003E();\r\n\t\t\t\t\tvr.Model = Model.Cube;\r\n\t\t\t\t\tvr.Tint = d.Tint;\r\n\t\t\t\t\tApplyMaterial( vr, d );\r\n\t\t\t\t}\r\n\r\n\t\t\t\tvar solid = go.Components.Create\u003CBoxCollider\u003E();\r\n\t\t\t\tsolid.Scale = size;\r\n\t\t\t}\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\t// \u26D4 THE MODEL IS LOADED INTO A LOCAL, NOT READ BACK OFF THE RENDERER. This\r\n\t\t\t// branch used \u0060c.Model = r.Model\u0060 \u2014 so skipping the renderer for an\r\n\t\t\t// invisible barrier would have left the COLLIDER with no model, and a prop\r\n\t\t\t// barrier would have become invisible AND intangible. The collision comes\r\n\t\t\t// from the model, not from the thing drawing it.\r\n\t\t\tvar propModel = Model.Load( d.Model );\r\n\r\n\t\t\tif ( d.Visible )\r\n\t\t\t{\r\n\t\t\t\tvar r = go.Components.Create\u003CModelRenderer\u003E();\r\n\t\t\t\tr.Model = propModel;\r\n\t\t\t\tApplyMaterial( r, d );\r\n\t\t\t}\r\n\r\n\t\t\tvar c = go.Components.Create\u003CModelCollider\u003E();\r\n\t\t\tc.Model = propModel;\r\n\r\n\t\t\t// \u26A0\uFE0F THIS WAS A REAL BUG, NOT JUST A DEPRECATION. \u0060new BBox( -32f, 32f )\u0060\r\n\t\t// bound the obsolete BBox(Vector3 centre, float size) overload \u2014 float\r\n\t\t// converts implicitly to Vector3 \u2014 so it meant \u0022a 32-unit cube centred at\r\n\t\t// (-32,-32,-32)\u0022, i.e. mins (-48,-48,-48) to maxs (-16,-16,-16). The\r\n\t\t// intent was plainly a 64-unit cube around the origin. Only the fallback\r\n\t\t// for a model with no bounds, which is why nobody noticed.\r\n\t\t// \u26A0\uFE0F FROM \u0060propModel\u0060, NOT THE RENDERER \u2014 same reason the collider is. An\r\n\t\t// invisible barrier has no ModelRenderer at all, and its SIZE still has to\r\n\t\t// come out right: this bounds feeds the marker and the remove radius.\r\n\t\tvar b = propModel?.Bounds ?? new BBox( new Vector3( -32f ), new Vector3( 32f ) );\r\n\t\t\tsize = new Vector3(\r\n\t\t\t\tMathF.Max( b.Size.x, 16f ),\r\n\t\t\t\tMathF.Max( b.Size.y, 16f ),\r\n\t\t\t\tMathF.Max( b.Size.z, 16f ) );\r\n\r\n\t\t\t// A prop\u0027s bounds are not necessarily centred on its origin.\r\n\t\t\tcentre = b.Center;\r\n\t\t}\r\n\r\n\t\t// \u26A0\uFE0F THE NAV BLOCK IS STILL THE BOUNDING BOX, EVEN FOR A DRAWN SHAPE.\r\n\t\t// SceneVolume offers Box, Sphere, Capsule and Infinite \u2014 there is no\r\n\t\t// polygon volume \u2014 so a concave barrier blocks pathing through its notch\r\n\t\t// as well, and zombies will not use a gap you can see and walk through.\r\n\t\t// Convex shapes (the normal case: angled doorways, wedges, trapezoids)\r\n\t\t// only over-block at the corners, exactly as every barrier did before\r\n\t\t// footprints existed. MapEditor warns when a concave one is built rather\r\n\t\t// than letting the author discover it as a pathing bug.\r\n\t\t// \u26D4 NO NAV BLOCKER ON ANY BARRIER, AND THE FIRST VERSION OF THIS CHANGE GOT IT WRONG.\r\n\t\t// A \u0060NavMeshArea\u0060 blocker deletes the floor inside its BOUNDING BOX. That was the right\r\n\t\t// tool while every barrier was a door that had to stop the horde until it was bought; it is\r\n\t\t// the wrong tool for permanent scenery, which wants its REAL collision baked so the mesh\r\n\t\t// flows over and around the actual shape. Keeping the blocker for the high-flag half meant\r\n\t\t// 58 panels each deleting a box of navmesh \u2014 and seven of them sit inside a doorway, so a\r\n\t\t// door stayed impassable after it was paid for. Reported as *\u0022zombies are not passing\r\n\t\t// through... the ones with debris that was bought, as if it\u0027s still there\u0022*.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F SO THE TWO HALVES DIFFER ONLY IN WHETHER THE COLLIDER IS BAKED. Scenery: baked, mesh\r\n\t\t// shaped by the geometry. Door: tagged out, mesh built as if it were not there.\r\n\t\tif ( ShapesNav( d ) )\r\n\t\t{\r\n\t\t\tRebuildNavAround( go.WorldPosition, size );\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tgo.Tags.Add( InvisibleWallManager.NavIgnoreTag );\r\n\t\t}\r\n\r\n\t\t_props[index] = go;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Tell the generator to skip door bodies, without depending on another manager to do it.\r\n\t/// \u003C/summary\u003E\r\n\t///\r\n\t/// \u26D4 \u0060InvisibleWallManager\u0060 REGISTERS THE SAME TAG, AND RELYING ON THAT WAS A LATENT BUG.\r\n\t/// It does it from its own \u0060Rebuild\u0060, so the tag only exists if that component happens to have\r\n\t/// started first \u2014 and on a map with no invisible walls at all, whether it runs is not\r\n\t/// something this file should be betting on. The failure would be silent and delayed: doors\r\n\t/// baked back into the mesh on the next tile regeneration, long after the map loaded.\r\n\t///\r\n\t/// \u26A0\uFE0F IDEMPOTENT, AND RE-APPLIED ON EVERY REBUILD for the reason the wall manager already\r\n\t/// documents: the NavMesh\u0027s tag sets are scene data, so a scene reload or a map change comes\r\n\t/// back without them.\r\n\tvoid ExcludeDoorsFromNavMesh()\r\n\t{\r\n\t\tvar nav = Scene?.NavMesh;\r\n\t\tif ( nav is null ) return;\r\n\t\tif ( nav.ExcludedBodies.Has( InvisibleWallManager.NavIgnoreTag ) ) return;\r\n\r\n\t\tnav.ExcludedBodies.Add( InvisibleWallManager.NavIgnoreTag );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Below this, a barrier\u0027s flag is a real door and the navmesh is built as if it were open.\r\n\t/// \u003C/summary\u003E\r\n\t///\r\n\t/// \u26A0\uFE0F 1000 IS A CONVENTION THE CONFIGS ALREADY FOLLOWED before anything read it. Real door\r\n\t/// flags are small integers \u2014 Basalt runs 1 to 9, Canyon 1 to 7 \u2014 while permanent scenery\r\n\t/// carries a keyboard mash: \u0060123123123\u0060, \u006012412412414\u0060, \u0060131231414\u0060. Nothing enforced the\r\n\t/// split; it just happened, which is why a threshold can be put under it now without\r\n\t/// re-authoring a single map.\r\n\tpublic const int DoorFlagCeiling = 1000;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Does this barrier shape the navmesh, or is the mesh generated as if it were not there?\r\n\t/// \u003C/summary\u003E\r\n\t///\r\n\t/// \u26D4 THE POINT IS THE ONE MESH BAKED AT CONFIG LOAD. Every barrier used to carve a hole in the\r\n\t/// floor and hand it back on purchase, so the mesh was only ever correct for the doors bought\r\n\t/// so far and every purchase paid for a tile regeneration. A door\u0027s flag now takes it out of\r\n\t/// generation entirely: the mesh is baked once, open everywhere, and buying a door costs\r\n\t/// nothing because nothing about the floor has to change.\r\n\t///\r\n\t/// \u26A0\uFE0F IT MEANS A CLOSED DOOR NO LONGER NAV-LOCKS THE HORDE. That was \u0060AddNavBlocker\u0060\u0027s whole\r\n\t/// job. Zombies will path through a doorway that is still boarded and stop against the\r\n\t/// collider, so whatever gates a room has to be the spawn side, not the mesh.\r\n\t///\r\n\t/// \u26A0\uFE0F A FLAG THAT IS NOT A NUMBER COUNTS AS SCENERY, blank included. The rule is an opt-OUT for\r\n\t/// door flags, and a door flag is always a small integer; \u006024124211Q2\u0060 in cs_cruise is a mash\r\n\t/// with a letter in it, and an unlinked barrier never opens at all. Both are permanent, and\r\n\t/// both should keep carving.\r\n\tpublic static bool ShapesNav( Debris d )\r\n\t\t=\u003E !(int.TryParse( d?.Link, out var flag ) \u0026\u0026 flag \u003C DoorFlagCeiling);\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Regenerate the navmesh tiles a barrier covers.\r\n\t///\r\n\t/// \u26A0\uFE0F REQUIRED. Creating a NavMeshArea at runtime does NOT rebuild anything\r\n\t/// on its own \u2014 the mesh stays exactly as baked, and the blocker has no\r\n\t/// effect at all. This cost an hour: the area reported IsBlocker true with a\r\n\t/// correct volume, and paths were byte-identical. It only looked like it\r\n\t/// worked when a blocker was added through the EDITOR, because a scene edit\r\n\t/// forces a rebuild that runtime creation does not.\r\n\t///\r\n\t/// Bounds are padded because a tile is regenerated as a whole: a barrier\r\n\t/// sitting just inside a tile edge still affects the neighbour.\r\n\t/// \u003C/summary\u003E\r\n\tstatic void RebuildNavAround( Vector3 at, Vector3 size )\r\n\t{\r\n\t\tvar nav = Game.ActiveScene?.NavMesh;\r\n\t\tif ( nav is null ) return;\r\n\r\n\t\tvar extent = size * 0.5f \u002B new Vector3( 128f );\r\n\t\tnav.RequestTilesGeneration( new BBox( at - extent, at \u002B extent ) );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// THE NAVLOCK. Stops zombies pathing through a barrier that is still up.\r\n\t///\r\n\t/// \u26A0\uFE0F This replaces nZombies\u0027 whole nav-lock system, and works differently.\r\n\t/// The original severs the CONNECTIONS between nav areas and restores them\r\n\t/// when the door opens (nzNav.NavLockApply). s\u0026box exposes no per-area API\r\n\t/// at all \u2014 no area ids, no adjacency, nothing to disconnect \u2014 because its\r\n\t/// mesh is Recast: adjacency is derived from which polygons physically\r\n\t/// touch, and regenerated with the geometry.\r\n\t///\r\n\t/// So instead of cutting an edge, we DELETE THE FLOOR. A NavMeshArea with\r\n\t/// IsBlocker stops navmesh generating inside its volume, which leaves the\r\n\t/// space beyond as an unreachable island. That is a stronger guarantee than\r\n\t/// the original\u0027s: there is no walkable surface, so a path cannot merely be\r\n\t/// ignored \u2014 it does not exist.\r\n\t///\r\n\t/// \u26A0\uFE0F The volume must cover the CHOKEPOINT, not the room. Covering the room\r\n\t/// would delete its navmesh entirely and strand anything spawned there even\r\n\t/// after the barrier is bought.\r\n\t///\r\n\t/// \u26D4 NOTHING CALLS THIS SINCE 2026-09-23, AND IT IS KEPT AS THE RECORD OF WHY. Doors left the\r\n\t/// navmesh entirely (\u0060ShapesNav\u0060), so there is no longer anything for it to lock; permanent\r\n\t/// scenery wants its real collision baked rather than a bounding box deleted, and using this\r\n\t/// for that made seven of Basalt\u0027s ten doorways impassable after they were bought. Several\r\n\t/// other files still point at this method for the explanation of how the nav-lock worked \u2014\r\n\t/// \u0060MapConfig\u0060, \u0060DoorCommands\u0060 \u2014 so deleting it would strand three comments that are still\r\n\t/// accurate about the mechanism, just no longer about what runs.\r\n\t/// \u003C/summary\u003E\r\n\tstatic void AddNavBlocker( GameObject go, Vector3 size, Vector3 centre )\r\n\t{\r\n\t\tvar nav = go.Components.Create\u003CNavMeshArea\u003E();\r\n\t\tnav.IsBlocker = true;\r\n\r\n\t\t// \u26A0\uFE0F SET SceneVolume DIRECTLY \u2014 do NOT rely on LinkedCollider.\r\n\t\t//\r\n\t\t// LinkedCollider looks like the intended route (the docs even say \u0022in\r\n\t\t// almost every case, you will want to use a trigger collider\u0022), and\r\n\t\t// setting it appears to work: the reference is stored and readable. But\r\n\t\t// the volume never changes. The giveaway is the member named\r\n\t\t// ConvertColliderToSceneVolumeLoadTask \u2014 the collider is baked into\r\n\t\t// SceneVolume as a LOAD task, so it happens when a saved scene is\r\n\t\t// deserialised, not when the property is assigned at runtime.\r\n\t\t//\r\n\t\t// Cost of missing this: an area that reports IsBlocker true, has a\r\n\t\t// correct LinkedCollider, and silently blocks a 100\u00B3 box at the origin\r\n\t\t// instead of the barrier \u2014 paths were completely unaffected.\r\n\t\tnav.SceneVolume = new SceneVolume\r\n\t\t{\r\n\t\t\tType = SceneVolume.VolumeTypes.Box,\r\n\t\t\tBox = new BBox( centre - size * 0.5f, centre \u002B size * 0.5f ),\r\n\t\t};\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Remove every standing barrier.\r\n\t///\r\n\t/// \u26A0\uFE0F MUST regenerate the tiles it empties. Destroying a NavMeshArea does\r\n\t/// not restore the navmesh any more than creating one blocks it \u2014 the hole\r\n\t/// stays until the tiles are rebuilt.\r\n\t///\r\n\t/// Missing this poisoned the map: nz_debris_clear removed the props, left\r\n\t/// the nav holes behind, and every subsequent path measured a 7000u detour\r\n\t/// around barriers that no longer existed. Only Buy() rebuilt, so the bug\r\n\t/// hid behind the one path that was being tested.\r\n\t/// \u003C/summary\u003E\r\n\t/// \u003Csummary\u003E\r\n\t/// Put the barrier\u0027s material on a renderer.\r\n\t///\r\n\t/// \u26A0\uFE0F A bad path must not wipe the surface. Material.Load returns null when\r\n\t/// it cannot resolve, and assigning that leaves an untextured white block\r\n\t/// with no error \u2014 so a typo would look like the material system is broken.\r\n\t/// Keep the model\u0027s own material and say so instead.\r\n\t/// \u003C/summary\u003E\r\n\tstatic void ApplyMaterial( ModelRenderer r, Debris d )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( d.Material ) ) return;\r\n\r\n\t\tvar mat = Material.Load( d.Material );\r\n\t\tif ( mat is null )\r\n\t\t{\r\n\t\t\tLog.Warning( $\u0022[nz] material not found: {d.Material} \u2014 left untextured\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tr.MaterialOverride = mat;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The material a built mesh is created with.\r\n\t///\r\n\t/// \u26A0\uFE0F NEVER NULL. ApplyMaterial can decline (blank or unresolvable path) and\r\n\t/// leave a loaded model showing its own surface \u2014 a runtime mesh has no own\r\n\t/// surface to fall back to, so the same decline would produce an invisible\r\n\t/// barrier instead of an untextured one. Grey dev material rather than\r\n\t/// nothing: a plain grey wall reads as \u0022no material set\u0022, an invisible one\r\n\t/// reads as \u0022the tool is broken\u0022.\r\n\t/// \u003C/summary\u003E\r\n\tstatic Material MaterialFor( Debris d )\r\n\t{\r\n\t\tif ( !string.IsNullOrWhiteSpace( d.Material ) )\r\n\t\t{\r\n\t\t\tvar mat = Material.Load( d.Material );\r\n\t\t\tif ( mat is not null ) return mat;\r\n\t\t}\r\n\r\n\t\treturn Material.Load( \u0022materials/dev/gray_50.vmat\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The built mesh of a shaped barrier, or null if it has none standing.\r\n\t///\r\n\t/// \u26A0\uFE0F Read off the live renderer rather than rebuilt on demand. The authoring\r\n\t/// marker draws this every frame, and a marker built from its own copy of the\r\n\t/// geometry can drift from the barrier it claims to describe \u2014 which is the\r\n\t/// one failure an authoring overlay must not have.\r\n\t/// \u003C/summary\u003E\r\n\tpublic Model ShapeOf( int index )\r\n\t\t=\u003E _shapes.TryGetValue( index, out var m ) ? m : null;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Is this barrier actually in the world right now?\r\n\t///\r\n\t/// \u26A0\uFE0F ASK THIS RATHER THAN \u0060DoorLinks.IsOpen\u0060. They agree during a game and\r\n\t/// disagree in creative, where every barrier is built whatever the links say\r\n\t/// \u2014 so a marker gated on the links hides itself over a wall that is standing\r\n\t/// right there. The world is the authority on what the world contains.\r\n\t/// \u003C/summary\u003E\r\n\tpublic bool IsStanding( int index )\r\n\t\t=\u003E _props.TryGetValue( index, out var go ) \u0026\u0026 go.IsValid();\r\n\r\n\t/// \u003Csummary\u003EWhich barrier a world object belongs to, or -1. Lets a click on\r\n\t/// a standing barrier edit it instead of building another one on top.\u003C/summary\u003E\r\n\tpublic int IndexOfObject( GameObject go )\r\n\t{\r\n\t\tif ( !go.IsValid() ) return -1;\r\n\r\n\t\tforeach ( var (i, obj) in _props )\r\n\t\t{\r\n\t\t\tif ( !obj.IsValid() ) continue;\r\n\r\n\t\t\t// \u26A0\uFE0F Walk the WHOLE ancestor chain, not just the direct parent. A\r\n\t\t\t// block\u0027s visual is a child today, but anything that nests one level\r\n\t\t\t// deeper \u2014 a model with its own child renderer, a future prop\r\n\t\t\t// wrapper \u2014 would stop matching, and the only symptom is that\r\n\t\t\t// clicking the wall quietly builds a new one next to it.\r\n\t\t\tfor ( var n = go; n.IsValid(); n = n.Parent )\r\n\t\t\t\tif ( n == obj ) return i;\r\n\t\t}\r\n\r\n\t\treturn -1;\r\n\t}\r\n\r\n\tpublic void Clear()\r\n\t{\r\n\t\tvar list = ActiveConfig.Current.Debris;\r\n\r\n\t\tforeach ( var (i, go) in _props )\r\n\t\t{\r\n\t\t\tif ( !go.IsValid() ) continue;\r\n\r\n\t\t\tvar at = go.WorldPosition;\r\n\t\t\tvar size = i \u003C list.Count \u0026\u0026 list[i].IsBlock\r\n\t\t\t\t? list[i].Size\r\n\t\t\t\t: new Vector3( 128f );\r\n\r\n\t\t\tgo.Destroy();\r\n\t\t\tRebuildNavAround( at, size );\r\n\t\t}\r\n\r\n\t\t_props.Clear();\r\n\r\n\t\t// \u26A0\uFE0F Alongside _props, always. A shape left behind for an index that gets\r\n\t\t// reused by a different barrier would draw the OLD outline over the new\r\n\t\t// one \u2014 and an authoring overlay that lies is worse than none.\r\n\t\t_shapes.Clear();\r\n\t}\r\n\r\n\t// \u2500\u2500 buying \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Buy a barrier by index. Returns a message describing what happened \u2014\r\n\t/// the caller decides whether to log it or show it.\r\n\t/// \u003C/summary\u003E\r\n\tpublic string Buy( int index, NZPlayer player )\r\n\t{\r\n\t\tvar list = ActiveConfig.Current.Debris;\r\n\t\tif ( index \u003C 0 || index \u003E= list.Count ) return $\u0022no debris #{index}\u0022;\r\n\r\n\t\tvar d = list[index];\r\n\r\n\t\tif ( DoorLinks.IsOpen( d.Link ) ) return $\u0022#{index} already open\u0022;\r\n\t\tif ( !player.IsValid() ) return \u0022no player\u0022;\r\n\r\n\t\t// \u26D4 NOT BUYABLE IS NOT \u0022FREE\u0022 \u2014 it is not for sale from this side at\r\n\t\t// all. AimedBuyable already hides it from the prompt and the use key,\r\n\t\t// but nz_buy reaches this directly, and a rule enforced only where the\r\n\t\t// UI enforces it is enforced only on the paths the UI can see.\r\n\t\tif ( !d.Buyable )\r\n\t\t\treturn $\u0022#{index} is not buyable \u2014 it opens when link {d.Link} is bought elsewhere\u0022;\r\n\r\n\t\tif ( d.RequiresPower \u0026\u0026 !Power.IsOn )\r\n\t\t\treturn $\u0022#{index} needs power\u0022;\r\n\r\n\t\tif ( !player.TrySpend( d.Price ) )\r\n\t\t\treturn $\u0022#{index} costs {d.Price}, you have {player.Points}\u0022;\r\n\r\n\t\t// Order matters: take the points, THEN open. Opening first and failing\r\n\t\t// to charge is the bug that gives away free doors.\r\n\t\t//\r\n\t\t// \u26D4 A CLIENT ASKS; IT DOES NOT DECIDE. \u0060OpenFree\u0060 flips \u0060DoorLinks\u0060, which is a static\r\n\t\t// table local to this process \u2014 so a client opening it locally cleared its own doorway and\r\n\t\t// nobody else\u0027s. It has already paid by this line; the host owns the world half and\r\n\t\t// broadcasts it back through \u0060LinkOpened\u0060, which is how the buyer\u0027s own wall comes down too.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F THE BUYER SEES ITS WALL GO ONE ROUND TRIP LATE. That is the honest cost of the host\r\n\t\t// owning the world, and it is the same shape as a barricade repair on a client.\r\n\t\tif ( NZGame.IsClient )\r\n\t\t\tNZNet.BuyDebrisAsk( index );\r\n\t\telse\r\n\t\t\tOpenFree( index );\r\n\r\n\t\treturn $\u0022bought #{index} for {d.Price} \u2014 link {d.Link} open, \u0022\r\n\t\t\t\u002B $\u0022{player.Points} left\u0022;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Open a barrier without charging for it \u2014 the power opening a shutter, or\r\n\t/// a console override.\r\n\t///\r\n\t/// \u26A0\uFE0F NO POINTS, NO POWER AND NO ALREADY-OPEN CHECK. Every guard lives in\r\n\t/// Buy; this is the shared consequence of a decision already made. Calling it\r\n\t/// directly from anywhere that should be charging is how doors become free.\r\n\t/// \u003C/summary\u003E\r\n\tpublic void OpenFree( int index )\r\n\t{\r\n\t\tvar list = ActiveConfig.Current.Debris;\r\n\t\tif ( index \u003C 0 || index \u003E= list.Count ) return;\r\n\r\n\t\tvar d = list[index];\r\n\t\tvar fresh = DoorLinks.Open( d.Link );\r\n\r\n\t\t// \u26A0\uFE0F AND WHAT WAITS INSIDE (\u0060FlagAmbush\u0060, 2026-09-28) \u2014 basalt\u0027s Reliquary holds pests. The first opening only, the host\u0027s.\r\n\t\tif ( fresh ) FlagAmbush.OnOpened( d.Link );\r\n\r\n\t\t// \u26D4 THIS OPENS A FLAG TOO, AND IS THE PATH A PURCHASE ACTUALLY TAKES. \u0060OpenLink\u0060 announces\r\n\t\t// to the network; \u0060OpenFree\u0060 does the same three things by hand and does not call it \u2014 which\r\n\t\t// is precisely the drift \u0060OpenLink\u0060\u0027s own header describes, where three call sites each\r\n\t\t// remembered a different subset. Putting the announce only in \u0060OpenLink\u0060 would have synced\r\n\t\t// soul boxes and console overrides and silently missed every door anybody bought.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F NOT MERGED INTO \u0060OpenLink\u0060 HERE. This method destroys ONE specific barrier by index,\r\n\t\t// with its sound and its local nav rebuild, before sweeping the siblings; \u0060OpenLink\u0060 has no\r\n\t\t// index and cannot do that half. Making one call the other is a refactor with a behaviour\r\n\t\t// change in it, and this is a network fix.\r\n\t\tif ( fresh \u0026\u0026 Networking.IsActive \u0026\u0026 NZGame.IsHost )\r\n\t\t\tNZNet.LinkOpened( d.Link );\r\n\r\n\t\tif ( _props.TryGetValue( index, out var go ) )\r\n\t\t{\r\n\t\t\t// \u26A0\uFE0F AT THE BARRIER, AND BEFORE IT IS DESTROYED. The original emits\r\n\t\t\t// this from the prop itself, so it belongs where the wall was rather\r\n\t\t\t// than in the player\u0027s head \u2014 and a destroyed object has no transform\r\n\t\t\t// left to ask for.\r\n\t\t\t// \u26A0\uFE0F THE MAP\u0027S OWN, IF IT HAS ONE (\u0060Gameplay.DebrisSound\u0060, 2026-09-27) \u2014 basalt\u0027s stone collapse.\r\n\t\t\t// \u26D4 AND ON EVERY OTHER MACHINE (\u0060PlayShared\u0060, the co-op pass, 2026-09-28). Only the host runs this for a purchase, and a\r\n\t\t\t// client\u0027s own opening (\u0060OpenLinkFromHost\u0060) plays nothing, so every door came down in silence on every screen but the\r\n\t\t\t// host\u0027s \u2014 basalt\u0027s stone collapse included.\r\n\t\t\tif ( go.IsValid() )\r\n\t\t\t\tNZSound.PlayShared( NZSound.MapCue( ActiveConfig.Gameplay.DebrisSound, NZSound.DebrisClear ), go.WorldPosition );\r\n\r\n\t\t\t// Note the bounds BEFORE destroying \u2014 a destroyed object has no\r\n\t\t\t// transform to ask, and the tiles it occupied are exactly the ones\r\n\t\t\t// that must be rebuilt for the way through to open.\r\n\t\t\tvar at = go.WorldPosition;\r\n\t\t\tvar size = d.IsBlock ? d.Size : new Vector3( 128f );\r\n\r\n\t\t\t// \u26D4 THE WHOLE BARRIER OUT OF THE WORLD FIRST, AND *NOW*. \u0060GameObject.Destroy()\u0060 is\r\n\t\t\t// deferred to the start of the NEXT frame \u2014 the engine\u0027s own docs say so \u2014 while\r\n\t\t\t// \u0060RebuildNavAround\u0060 requests tile generation on THIS one. So the tiles were being\r\n\t\t\t// regenerated with the barrier still standing, which regenerates the hole rather than\r\n\t\t\t// the floor. User: *\u0022the zombies path is still blocked as if the debris is there.\u0022*\r\n\t\t\t//\r\n\t\t\t// \u26A0\uFE0F THE WHOLE OBJECT, NOT JUST ITS \u0060NavMeshArea\u0060. A first pass disabled only the nav\r\n\t\t\t// area and that is not enough: the barrier\u0027s COLLIDER is geometry too, and Recast\r\n\t\t\t// carves the mesh around solid world just as readily as around a blocker volume.\r\n\t\t\t// Disabling the GameObject takes both out at once.\r\n\t\t\t//\r\n\t\t\t// \u26A0\uFE0F \u0060Enabled = false\u0060 RATHER THAN \u0060DestroyImmediate\u0060. Immediate destruction is\r\n\t\t\t// documented as risky when anything still expects the object; disabling takes effect\r\n\t\t\t// this frame and the deferred \u0060Destroy()\u0060 below still does the cleanup.\r\n\t\t\tif ( go.IsValid() ) go.Enabled = false;\r\n\r\n\t\t\tgo?.Destroy();\r\n\t\t\t_props.Remove( index );\r\n\t\t\t_shapes.Remove( index );\r\n\r\n\t\t\tRebuildNavAround( at, size );\r\n\t\t}\r\n\r\n\t\t// Anything else sharing this link opens too \u2014 the original\u0027s\r\n\t\t// OpenLinkedDoors (sv_gameplay.lua:36). One purchase, one wall gone,\r\n\t\t// but every barrier on that link goes with it.\r\n\t\tOpenAllOnLink( d.Link );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Remove every standing barrier that shares an opened link.\r\n\t///\r\n\t/// \u26D4 PUBLIC BECAUSE A DEBRIS PURCHASE IS NOT THE ONLY THING THAT OPENS A FLAG. Soul boxes open\r\n\t/// one when the last box on it fills, and SoulBoxManager was calling \u0060DoorLinks.Open( link )\u0060 and\r\n\t/// nothing else \u2014 so the flag flipped, everything that tests IsOpen per frame (perk machines,\r\n\t/// wallbuys, teleporters) unlocked correctly, and the DEBRIS just stood there. Completing five\r\n\t/// soul boxes left the door shut. Opening a flag has always meant three things, and only this\r\n\t/// class knew the other two.\r\n\t///\r\n\t/// \u26D4 AND IT REBUILDS NAV NOW, WHICH IT NEVER DID. OpenFree calls RebuildNavAround for the\r\n\t/// barrier the player actually bought and then called this for the siblings \u2014 which destroyed\r\n\t/// them and left the navmesh carved exactly where they had been. A doorway that is visibly gone\r\n\t/// and still impassable to zombies is the same class of bug as the barricade windows: geometry\r\n\t/// removed, graph not told.\r\n\t/// \u003C/summary\u003E\r\n\tpublic void OpenAllOnLink( string link )\r\n\t{\r\n\t\tvar list = ActiveConfig.Current.Debris;\r\n\r\n\t\tforeach ( var i in _props.Keys.ToList() )\r\n\t\t{\r\n\t\t\t// \u26A0\uFE0F DoorLinks.Same, not ==. Flags are typed by hand, so they differ\r\n\t\t\t// by case and stray whitespace far more often than they differ in\r\n\t\t\t// substance.\r\n\t\t\tif ( i \u003E= list.Count || !DoorLinks.Same( list[i].Link, link ) ) continue;\r\n\r\n\t\t\t// \u26A0\uFE0F NOTED BEFORE THE DESTROY, for the reason OpenFree gives at its own call site: a\r\n\t\t\t// destroyed object has no transform left to ask, and the tiles it occupied are precisely\r\n\t\t\t// the ones that have to be regenerated for the way through to exist.\r\n\t\t\tvar go = _props[i];\r\n\t\t\tvar at = go.IsValid() ? go.WorldPosition : Vector3.Zero;\r\n\t\t\tvar size = list[i].IsBlock ? list[i].Size : new Vector3( 128f );\r\n\t\t\tvar had = go.IsValid();\r\n\r\n\t\t\t// \u26D4 THE WHOLE BARRIER OUT OF THE WORLD FIRST, AND *NOW*. \u0060GameObject.Destroy()\u0060 is\r\n\t\t\t// deferred to the start of the NEXT frame \u2014 the engine\u0027s own docs say so \u2014 while\r\n\t\t\t// \u0060RebuildNavAround\u0060 requests tile generation on THIS one. So the tiles were being\r\n\t\t\t// regenerated with the barrier still standing, which regenerates the hole rather than\r\n\t\t\t// the floor. User: *\u0022the zombies path is still blocked as if the debris is there.\u0022*\r\n\t\t\t//\r\n\t\t\t// \u26A0\uFE0F THE WHOLE OBJECT, NOT JUST ITS \u0060NavMeshArea\u0060. A first pass disabled only the nav\r\n\t\t\t// area and that is not enough: the barrier\u0027s COLLIDER is geometry too, and Recast\r\n\t\t\t// carves the mesh around solid world just as readily as around a blocker volume.\r\n\t\t\t// Disabling the GameObject takes both out at once.\r\n\t\t\t//\r\n\t\t\t// \u26A0\uFE0F \u0060Enabled = false\u0060 RATHER THAN \u0060DestroyImmediate\u0060. Immediate destruction is\r\n\t\t\t// documented as risky when anything still expects the object; disabling takes effect\r\n\t\t\t// this frame and the deferred \u0060Destroy()\u0060 below still does the cleanup.\r\n\t\t\tif ( go.IsValid() ) go.Enabled = false;\r\n\r\n\t\t\tgo?.Destroy();\r\n\t\t\t_props.Remove( i );\r\n\t\t\t_shapes.Remove( i );\r\n\r\n\t\t\tif ( had ) RebuildNavAround( at, size );\r\n\r\n\t\t\t// \u26D4 AND EVERY ZOMBIE MUST BE TOLD TO THINK AGAIN, WHICH NOTHING DID. A path is\r\n\t\t\t// computed once and followed; regenerating the tiles under a zombie does not make it\r\n\t\t\t// reconsider a route it already holds. So a horde that worked out how to get around a\r\n\t\t\t// closed door keeps going the long way after it opens, for as long as their current\r\n\t\t\t// paths last \u2014 which is indistinguishable from \u0022the zombies path as if the doors are\r\n\t\t\t// closed\u0022 and is the single most likely explanation for a report nobody can reproduce\r\n\t\t\t// on demand, because it depends entirely on WHEN each zombie last repathed.\r\n\t\t\t//\r\n\t\t\t// \u26A0\uFE0F QUEUED, NOT CALLED HERE, for two reasons. \u0060RequestTilesGeneration\u0060 is\r\n\t\t\t// asynchronous \u2014 repathing on this frame would rebuild every route against the mesh\r\n\t\t\t// that still has the hole in it, which is worse than not repathing at all. And this\r\n\t\t\t// loop runs once per barrier on the link, so calling it inline would sweep every\r\n\t\t\t// zombie in the map five times for a five-piece door.\r\n\t\t\t_repathDue = RepathDelay;\r\n\t\t\t_repathArmed = true;\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Open a flag FOR REAL \u2014 the flag itself, the barriers on it, and the nav that depends on them.\r\n\t///\r\n\t/// \u26D4 THE ONE CALL ANYTHING OUTSIDE THIS CLASS SHOULD MAKE. Three separate places opened a flag\r\n\t/// and each remembered a different subset of the consequences: DebrisManager.OpenFree did all of\r\n\t/// it, DoorCommands did the flag plus a full Rebuild, and SoulBoxManager did the flag alone and\r\n\t/// silently failed to open any door. A flag is not a boolean, it is an event.\r\n\t///\r\n\t/// \u26A0\uFE0F NAV LINKS ARE REBUILT TOO. NavLinkManager skips any link whose flag is closed AT BUILD\r\n\t/// TIME \u2014 it is the only flag consumer that does not re-test per frame \u2014 so a jump or drop route\r\n\t/// gated behind this flag does not exist until something rebuilds it.\r\n\t///\r\n\t/// \u26A0\uFE0F RETURNS FALSE IF THE FLAG WAS ALREADY OPEN, so a caller can tell \u0022I opened it\u0022 from\r\n\t/// \u0022someone else already had\u0022. The barriers are still swept in that case: a barrier placed on an\r\n\t/// already-open flag is exactly the drift this is meant to correct.\r\n\t/// \u003C/summary\u003E\r\n\tpublic bool OpenLink( string link )\r\n\t{\r\n\t\tvar fresh = DoorLinks.Open( link );\r\n\r\n\t\t// \u26A0\uFE0F AND WHAT WAITS INSIDE, however the flag came open \u2014 the power, a soul box, the console (\u0060FlagAmbush\u0060, 2026-09-28)\r\n\t\tif ( fresh ) FlagAmbush.OnOpened( link );\r\n\r\n\t\t// \u26A0\uFE0F NO REBUILD HERE ANY MORE. \u0060OpenAllOnLink\u0060 arms the deferred one in \u0060OnUpdate\u0060, which\r\n\t\t// happens AFTER the navmesh has caught up \u2014 rebuilding on this frame snapped link ends\r\n\t\t// against the mesh that still had the barrier in it. One place, correctly timed.\r\n\t\tOpenAllOnLink( link );\r\n\r\n\t\t// \u26D4 AND EVERY OTHER MACHINE OPENS IT TOO. \u0060DoorLinks\u0060 is a STATIC TABLE \u2014 local to the\r\n\t\t// process it runs in and replicated to nobody \u2014 so before this the host cleared a doorway\r\n\t\t// and the client still had a wall across it, with zombies pathing through geometry the\r\n\t\t// client could see.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F ONLY WHEN IT WAS ACTUALLY FRESH. Every barrier on a link calls through here, and\r\n\t\t// \u0060OpenAllOnLink\u0060 re-sweeps an already-open flag on purpose; announcing each of those would\r\n\t\t// send the same message once per wall.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F AND ONLY FROM THE HOST. A client reaches this through \u0060OpenLinkFromHost\u0060, which is\r\n\t\t// the host\u0027s own message arriving \u2014 re-broadcasting it would be an echo.\r\n\t\tif ( fresh \u0026\u0026 Networking.IsActive \u0026\u0026 NZGame.IsHost )\r\n\t\t\tNZNet.LinkOpened( link );\r\n\r\n\t\treturn fresh;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The host says this flag is open. Clients only.\r\n\t///\r\n\t/// \u26A0\uFE0F IT GOES THROUGH THE SAME \u0060OpenLink\u0060 EVERY OTHER PATH USES, so a client reaches exactly\r\n\t/// the state the host reached \u2014 flag, barriers and nav \u2014 rather than a hand-rolled subset. The\r\n\t/// only difference is that it does not announce it again.\r\n\t/// \u003C/summary\u003E\r\n\tpublic void OpenLinkFromHost( string link )\r\n\t{\r\n\t\tif ( DoorLinks.IsOpen( link ) ) return;\r\n\r\n\t\tDoorLinks.Open( link );\r\n\t\tOpenAllOnLink( link );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The barrier the player is looking at AND is standing next to, or -1.\r\n\t///\r\n\t/// \u26A0\uFE0F TWO SEPARATE TESTS, ON PURPOSE. The ray says which barrier you mean;\r\n\t/// \u003Csee cref=\u0022Reach\u0022/\u003E says whether you are close enough to it. Ray length\r\n\t/// alone is not a radius \u2014 it is measured from the CAMERA along the aim\r\n\t/// direction, so it grew and shrank with where the camera sat relative to\r\n\t/// the body, and a wide barrier could be \u0022in range\u0022 while you stood well\r\n\t/// back from the part you were pointing at.\r\n\t///\r\n\t/// The distance is to the SURFACE POINT the ray hit, not to the object\u0027s\r\n\t/// origin \u2014 a 300-unit wall has its origin 150 units from the bit you are\r\n\t/// touching, and measuring to that would refuse a door you are leaning on.\r\n\t/// \u003C/summary\u003E\r\n\tpublic int Aimed( NZPlayer player )\r\n\t{\r\n\t\tif ( !player.IsValid() ) return -1;\r\n\r\n\t\tvar cam = Scene.Camera;\r\n\t\tif ( !cam.IsValid() ) return -1;\r\n\r\n\t\t// \u26A0\uFE0F IGNORE THE PLAYER, OR THE RAY HITS THEM IMMEDIATELY. The camera sits\r\n\t\t// at eye height INSIDE the player\u0027s own collider, so an un-ignored trace\r\n\t\t// stops at zero distance on the body it started in and never reaches\r\n\t\t// anything. This went unnoticed because every door had so far been\r\n\t\t// bought by index from the console; it broke the moment a use key\r\n\t\t// existed.\r\n\t\tvar tr = Scene.Trace\r\n\t\t\t.Ray( cam.WorldPosition, cam.WorldPosition \u002B cam.WorldRotation.Forward * BuyRange )\r\n\t\t\t.IgnoreGameObjectHierarchy( player.GameObject )\r\n\t\t\t.Run();\r\n\r\n\t\tif ( !tr.Hit || tr.GameObject is null ) return -1;\r\n\t\tif ( tr.EndPosition.Distance( player.WorldPosition ) \u003E Reach ) return -1;\r\n\r\n\t\t// \u26D4 IndexOfObject, NOT \u0060go == tr.GameObject\u0060. This used to compare the hit\r\n\t\t// against the barrier\u0027s ROOT object, which worked only because a block\u0027s\r\n\t\t// collider happened to live on that root. A shaped barrier\u0027s ModelCollider\r\n\t\t// sits on its \u0060visual\u0060 CHILD, so the ray hits the child, the comparison\r\n\t\t// fails, and the barrier becomes invisible to the whole use system: no\r\n\t\t// price prompt under the crosshair, and E does nothing.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F IndexOfObject ALREADY walked the ancestor chain \u2014 and its comment\r\n\t\t// already warned that a block\u0027s visual is a child. Two lookups asking the\r\n\t\t// same question, one hardened and one not; the un-hardened one is the one\r\n\t\t// that broke. Now there is a single answer to \u0022which barrier is this\r\n\t\t// object\u0022, so the next thing that nests deeper cannot split them again.\r\n\t\treturn IndexOfObject( tr.GameObject );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The barrier under the crosshair, IF it is one the player may act on.\r\n\t///\r\n\t/// \u26A0\uFE0F SEPARATE FROM Aimed ON PURPOSE. Aimed answers \u0022what am I pointing at\u0022,\r\n\t/// which is what the AUTHORING commands want \u2014 nz_debris_edit and\r\n\t/// RemoveAimed must still reach a non-buyable barrier, or the toggle would\r\n\t/// make it impossible to click back off. This answers \u0022what can I buy\u0022,\r\n\t/// which is what the prompt and the use key want.\r\n\t/// \u003C/summary\u003E\r\n\tpublic int AimedBuyable( NZPlayer player )\r\n\t{\r\n\t\tvar index = Aimed( player );\r\n\t\tif ( index \u003C 0 ) return -1;\r\n\r\n\t\tvar list = ActiveConfig.Current.Debris;\r\n\t\treturn index \u003C list.Count \u0026\u0026 list[index].Buyable ? index : -1;\r\n\t}\r\n}\r\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"EasterEgg/EggInteractable.cs","FileName":"EggInteractable.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\nusing System;\nusing System.Linq;\n\nnamespace NZombies;\n\n/// \u003Csummary\u003E\n/// THE SHARED BODY OF AN EASTER-EGG INTERACTABLE \u2014 the conditions every one of them carries,\n/// in one place.\n///\n/// \u26D4 THIS EXISTS BECAUSE THE SECOND INTERACTABLE WOULD OTHERWISE HAVE COPIED THE FIRST. The\n/// spec files the Shootable as \u0022same conditions as Pressable\u0022 minus the hold, so building it\n/// standalone meant a second copy of the repeat count, the overshoot rule, the retry lockout,\n/// the round reset, the timed delay, the group wiring and the vanish-on-completion. Two copies\n/// of a condition engine is exactly the shape \u0060INSTRUCTIONS.md\u0060 \u00A73 records diverging: one of\n/// them gets the fix. There are seven more interactables in the spec.\n///\n/// \u26A0\uFE0F WHAT STAYS IN THE SUBCLASS IS THE TRIGGER, and only that. A pressable is triggered by\n/// the use key and can be held; a shootable is triggered by a bullet and knows what fired it.\n/// Everything between \u0022something happened\u0022 and \u0022the step moved\u0022 is here.\n/// \u003C/summary\u003E\npublic abstract class EggInteractable : Component, IEggInteractable\n{\n\t/// \u003Csummary\u003EThe config row, as the shared fields. Each subclass returns its own typed\n\t/// spot; every condition below is read through this.\u003C/summary\u003E\n\tpublic abstract EggSpot Config { get; }\n\n\t/// \u003Csummary\u003EThe step this carries \u2014 \u003Csee cref=\u0022IEggInteractable\u0022/\u003E.\u003C/summary\u003E\n\tpublic EggStep Step =\u003E Config?.Step;\n\n\t/// \u003Csummary\u003ESeconds to finish the step once started \u2014 \u003Csee cref=\u0022IEggInteractable\u0022/\u003E.\u003C/summary\u003E\n\tpublic float TimeWindow =\u003E Config?.TimeWindow ?? 0f;\n\n\t/// \u003Csummary\u003E\n\t/// The child object holding the model and the collider \u2014 everything a player can see or\n\t/// bump into or shoot. Set by the manager at build time.\n\t///\n\t/// \u26A0\uFE0F ONE OBJECT, NOT A RENDERER AND A COLLIDER SEPARATELY. Switching the child off takes\n\t/// both with it, so a finished interactable cannot be seen, shot, or walked into \u2014 one\n\t/// that is invisible but still solid is worse than one that never vanished.\n\t/// \u003C/summary\u003E\n\t[Property] public GameObject Visual { get; set; }\n\n\t/// \u003Csummary\u003ETriggers banked toward \u003Csee cref=\u0022EggSpot.RepeatCount\u0022/\u003E.\u003C/summary\u003E\n\tpublic int Progress { get; protected set; }\n\n\t/// \u003Csummary\u003ESeconds left on a failed-attempt cooldown. Only used under\n\t/// \u003Csee cref=\u0022EggRetry.Seconds\u0022/\u003E.\u003C/summary\u003E\n\tpublic float Cooldown { get; private set; }\n\n\t/// \u003Csummary\u003ELocked out until the round turns, after failing under\n\t/// \u003Csee cref=\u0022EggRetry.NextRound\u0022/\u003E.\n\t///\n\t/// \u26A0\uFE0F A FLAG, NOT A HUGE \u0060Cooldown\u0060 VALUE. Expressing \u0022until the next round\u0022 as a number of\n\t/// seconds would need a guess at how long a round lasts, and would be wrong every time the\n\t/// players cleared one faster or slower than the guess.\u003C/summary\u003E\n\tpublic bool LockedUntilRound { get; private set; }\n\n\t/// \u003Csummary\u003EWhen this step\u0027s Required flags first became satisfied \u2014 the clock the Timed\n\t/// condition counts from. Negative = they never have been.\u003C/summary\u003E\n\tprotected float AvailableSince = -1f;\n\n\t// \u26A0\uFE0F REGISTERED WITH THE GROUP COORDINATOR IN THE BASE, so a new interactable joins the\n\t// step system by existing rather than by remembering to. A member that outlived its\n\t// GameObject would leave a group that could never finish.\n\tprotected override void OnEnabled() =\u003E EggGroups.Register( this );\n\tprotected override void OnDisabled() =\u003E EggGroups.Forget( this );\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( Config is null ) return;\n\n\t\tTickVisibility();\n\n\t\t// \u26A0\uFE0F EVERY MEMBER CALLS IT AND THE COORDINATOR RUNS IT ONCE. There is no component\n\t\t// behind \u0060EggGroups\u0060, so the step clock has to be driven by whatever is alive \u2014 and the\n\t\t// things that are alive are its own members.\n\t\tEggGroups.Tick();\n\n\t\tvar dt = Time.Delta;\n\n\t\tif ( Cooldown \u003E 0f ) Cooldown = MathF.Max( 0f, Cooldown - dt );\n\n\t\t// \u26A0\uFE0F THE TIMED CLOCK STARTS WHEN \u0060Required\u0060 IS FIRST SATISFIED, not when the map loads.\n\t\t// \u0022Unlocks X seconds after its Required flags are set\u0022 is only meaningful measured from\n\t\t// that moment; counting from load would make a step gated behind an hour of play\n\t\t// available the instant its flags landed.\n\t\tvar gated = EggFlags.AllSet( Step.Required ) \u0026\u0026 !EggFlags.AnySet( Step.Excluded );\n\n\t\tif ( gated \u0026\u0026 AvailableSince \u003C 0f ) AvailableSince = Time.Now;\n\t\telse if ( !gated ) AvailableSince = -1f;\n\n\t\tOnTick( dt );\n\t}\n\n\t/// \u003Csummary\u003EPer-frame work only this kind of interactable has \u2014 the pressable\u0027s hold. Most\n\t/// have none.\u003C/summary\u003E\n\tprotected virtual void OnTick( float dt ) { }\n\n\t/// \u003Csummary\u003E\n\t/// A completed step is GONE from the world \u2014 its parts stop being there once the step is\n\t/// done.\n\t///\n\t/// \u26D4 STATE-DRIVEN EVERY FRAME, NOT AN EVENT ON COMPLETION. The same choice the damage wall\n\t/// was rebuilt around: hiding on a \u0022step completed\u0022 callback means every other way the state\n\t/// can move \u2014 a group reset, \u0060Fail\u0060 with RevertToPrevious, \u0060nz_egg_reset\u0060, a config rebuild\n\t/// \u2014 needs its own matching un-hide, and the one that gets forgotten leaves a part missing\n\t/// from a map with nothing to explain it. Reading the state instead means one rule that\n\t/// cannot fall out of step.\n\t///\n\t/// \u26A0\uFE0F A GROUP VANISHES TOGETHER FOR FREE, because every member completes in the same call.\n\t///\n\t/// \u26A0\uFE0F CREATIVE KEEPS THEM. A mapper testing their own egg still has to see the parts to move\n\t/// or delete them, and \u0060ShowAuthoringVisuals\u0060 is off in preview, so a preview still shows\n\t/// what a player would see.\n\t/// \u003C/summary\u003E\n\tprotected void TickVisibility()\n\t{\n\t\tif ( !Visual.IsValid() ) return;\n\n\t\tvar show = !Step.Completed || NZGame.ShowAuthoringVisuals;\n\t\tif ( Visual.Enabled != show ) Visual.Enabled = show;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Why this cannot be triggered right now, or empty when it can. Answers for the prompt and\n\t/// for the trigger, so a prompt can never offer what the trigger then refuses.\n\t/// \u003C/summary\u003E\n\tpublic string Unavailable( NZPlayer player ) =\u003E Unavailable( player, HeldWeapon( player ) );\n\n\t/// \u003Csummary\u003E\n\t/// The same question, told which weapon to judge.\n\t///\n\t/// \u26D4 THE WEAPON IS A PARAMETER BECAUSE THE TWO TRIGGERS MEAN DIFFERENT THINGS BY IT. A\n\t/// pressable asks about the gun in your hands right now; a shootable asks about the gun the\n\t/// BULLET came from, which with a fast switch is not the same object. Resolving it inside\n\t/// would silently make the shootable read the wrong one.\n\t/// \u003C/summary\u003E\n\tpublic virtual string Unavailable( NZPlayer player, SWB.Base.Weapon weapon )\n\t{\n\t\tif ( !player.IsValid() || Config is null ) return \u0022no player\u0022;\n\n\t\t// \u26D4 A COMPLETED STEP REFUSES, IT DOES NOT RETURN \u0022\u0022. Empty means \u0022go ahead\u0022 to the\n\t\t// trigger, and this line used to return it \u2014 so a finished button could be pressed for\n\t\t// ever, answering \u0022DONE \u2014 set flag_c\u0022 every time while setting nothing. It went\n\t\t// unnoticed because \u0060EggStep.Complete\u0060 early-outs, so the only symptom was a lie in the\n\t\t// reply. \u0060UsePrompt\u0060 tests \u0060Completed\u0060 itself before asking, so the prompt still shows\n\t\t// nothing on a finished one.\n\t\tif ( Step.Completed ) return \u0022Already done\u0022;\n\n\t\t// \u26A0\uFE0F A SATISFIED MEMBER SAYS SO RATHER THAN GOING SILENT. It has been done correctly and\n\t\t// is waiting on the rest of its step; without this it reads exactly like something that\n\t\t// stopped working, and the player\u0027s own correct input is what broke it.\n\t\tif ( Step.Satisfied )\n\t\t\treturn $\u0022Done \u2014 waiting on the rest of step {Step.StepNumber}\u0022;\n\n\t\t// \u26A0\uFE0F THE DOOR FLAG IS CHECKED HERE, NOT IN \u0060EggStep.IsAvailable\u0060 \u2014 see \u0060EggSpot.Link\u0060.\n\t\t// It is the General pool, which the spec keeps out of the egg\u0027s own inputs, so it sits\n\t\t// at the interaction layer beside the perk and weapon checks.\n\t\t//\n\t\t// \u26A0\uFE0F IT GETS ITS OWN WORD. \u0022Locked\u0022 and \u0022Nothing to do here\u0022 look identical from in\n\t\t// front of an unresponsive object but are different causes \u2014 one is waiting on a door\n\t\t// being bought, the other on the egg reaching this step.\n\t\tif ( !DoorLinks.IsOpen( Config.Link ) ) return \u0022Locked\u0022;\n\n\t\tif ( !Step.IsAvailable() ) return \u0022Nothing to do here\u0022;\n\n\t\tif ( Cooldown \u003E 0f ) return $\u0022Wait {Cooldown:0.0}s\u0022;\n\n\t\t// \u26A0\uFE0F SAYS THE ROUND, NOT A TIME. There is no number to show \u2014 that is the whole point of\n\t\t// the setting \u2014 and a spinner counting down to nothing would be worse than the truth.\n\t\tif ( LockedUntilRound ) return \u0022Wait for the next round\u0022;\n\n\t\tif ( Config.TimedDelay \u003E 0f )\n\t\t{\n\t\t\tif ( AvailableSince \u003C 0f ) return \u0022Nothing to do here\u0022;\n\n\t\t\tvar waited = Time.Now - AvailableSince;\n\t\t\tif ( waited \u003C Config.TimedDelay )\n\t\t\t\treturn $\u0022Wait {Config.TimedDelay - waited:0.0}s\u0022;\n\t\t}\n\n\t\tif ( !string.IsNullOrWhiteSpace( Config.RequiredPerk ) \u0026\u0026 !player.HasPerk( Config.RequiredPerk ) )\n\t\t\treturn $\u0022Needs {PerkRegistry.All.FirstOrDefault( p =\u003E p.Id == Config.RequiredPerk )?.Name ?? Config.RequiredPerk}\u0022;\n\n\t\tif ( !string.IsNullOrWhiteSpace( Config.RequiredWeapon ) \u0026\u0026 !Matches( weapon, Config.RequiredWeapon ) )\n\t\t\treturn $\u0022Needs {Config.RequiredWeapon}\u0022;\n\n\t\treturn \u0022\u0022;\n\t}\n\n\t/// \u003Csummary\u003EThe weapon in the player\u0027s hands, or null.\u003C/summary\u003E\n\tprotected static SWB.Base.Weapon HeldWeapon( NZPlayer player )\n\t{\n\t\tvar held = player.IsValid()\n\t\t\t? player.Components.Get\u003CNZInventory\u003E( FindMode.EverythingInSelf )?.Active\n\t\t\t: null;\n\n\t\treturn held.IsValid() ? held.Components.Get\u003CSWB.Base.Weapon\u003E( FindMode.EverythingInSelf ) : null;\n\t}\n\n\t/// \u003Csummary\u003EDoes this weapon answer to the name a mapper typed?\n\t///\n\t/// \u26A0\uFE0F MATCHED ON THE CLASS NAME, CASE-INSENSITIVELY AND BY SUBSTRING, so \u0022awm\u0022 matches\n\t/// \u0022nz_awm\u0022 and a mapper does not have to know the prefab prefix.\u003C/summary\u003E\n\tprotected static bool Matches( SWB.Base.Weapon weapon, string wanted )\n\t{\n\t\tvar cls = weapon.IsValid() ? weapon.ClassName : null;\n\n\t\treturn !string.IsNullOrEmpty( cls )\n\t\t\t\u0026\u0026 cls.Contains( wanted, StringComparison.OrdinalIgnoreCase );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Count a trigger that landed and decide what it means.\n\t///\n\t/// \u26D4 OVERSHOOT FAILS, IT DOES NOT CAP. The spec: \u0022Overshooting past N invalidates the\n\t/// current attempt, it does not cap at max.\u0022 Clamping would make a repeat-count target\n\t/// impossible to get wrong, which is the entire puzzle.\n\t/// \u003C/summary\u003E\n\tprotected string Bank()\n\t{\n\t\tNZSound.Play( NZSound.Purchase, WorldPosition );\n\n\t\tvar need = Math.Max( 1, Config.RepeatCount );\n\t\tProgress\u002B\u002B;\n\n\t\tif ( Progress \u003E need ) return FailAttempt( $\u0022overshot ({Progress}/{need})\u0022 );\n\n\t\t// \u26D4 THE STEP CLOCK STARTS HERE, ON A TRIGGER THAT COUNTED \u2014 not on the first member to\n\t\t// finish. With four statues of three presses each, the first of those twelve presses is\n\t\t// what starts the step; anything later would give a free run-up.\n\t\tEggGroups.NoteProgress( this );\n\n\t\tif ( Progress \u003C need )\n\t\t\treturn $\u0022{Progress}/{need}\u0022;\n\n\t\tProgress = 0;\n\n\t\t// \u26D4 THE GROUP DECIDES, NOT THE PART. \u0060EggGroups.Satisfy\u0060 completes an ungrouped step on\n\t\t// the spot and holds a grouped one until its last sibling lands \u2014 no interactable should\n\t\t// know which of those it is, or grouping would be re-implemented in every one of them.\n\t\tvar fired = EggGroups.Satisfy( this );\n\n\t\tif ( !fired )\n\t\t{\n\t\t\tvar left = EggGroups.InGroup( Step.StepNumber ).Count( m =\u003E !m.Step.Satisfied );\n\n\t\t\treturn $\u0022DONE \u2014 waiting on {left} more in step {Step.StepNumber}\u0022;\n\t\t}\n\n\t\tvar rewards = Step.Reward.Count \u003E 0 ? string.Join( \u0022, \u0022, Step.Reward ) : \u0022no flag\u0022;\n\n\t\treturn $\u0022DONE \u2014 set {rewards}\u0022\n\t\t\t\u002B ( string.IsNullOrWhiteSpace( Step.GeneralReward )\n\t\t\t\t? \u0022\u0022 : $\u0022 \u00B7 opened {Step.GeneralReward}\u0022 )\n\t\t\t\u002B ( Step.StepNumber \u003E 0 ? $\u0022 \u00B7 step {Step.StepNumber} complete\u0022 : \u0022\u0022 );\n\t}\n\n\t/// \u003Csummary\u003EFail the current attempt: zero the progress, start the retry lockout, and let\n\t/// the step\u0027s own OnFail decide whether prior steps re-open.\u003C/summary\u003E\n\tprotected string FailAttempt( string why )\n\t{\n\t\tProgress = 0;\n\t\tClearOwnProgress();\n\n\t\t// \u26D4 ONE OR THE OTHER, NEVER BOTH. \u0060EggRetry\u0060 exists so a mapper answers \u0022how is a\n\t\t// failure locked out\u0022 once; setting a clock as well under NextRound would make it\n\t\t// retry-able the moment the clock ran out, silently ignoring the choice.\n\t\tif ( Config.Retry == EggRetry.NextRound )\n\t\t{\n\t\t\tLockedUntilRound = true;\n\t\t\tCooldown = 0f;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tCooldown = MathF.Max( 0f, Config.CooldownSeconds );\n\t\t\tLockedUntilRound = false;\n\t\t}\n\n\t\tStep.Fail();\n\n\t\treturn $\u0022FAILED \u2014 {why}\u0022\n\t\t\t\u002B ( LockedUntilRound ? \u0022 \u00B7 retry next round\u0022 : \u0022\u0022 )\n\t\t\t\u002B ( Cooldown \u003E 0f ? $\u0022 \u00B7 retry in {Cooldown:0.#}s\u0022 : \u0022\u0022 );\n\t}\n\n\t/// \u003Csummary\u003EFail this member on the group\u0027s behalf \u2014 \u003Csee cref=\u0022IEggInteractable\u0022/\u003E. It goes\n\t/// through the same \u0060FailAttempt\u0060 a wrong trigger does, so the retry lockout and\n\t/// \u0060OnFail: RevertToPrevious\u0060 behave identically however the failure arrived.\u003C/summary\u003E\n\tpublic void Fail( string why ) =\u003E FailAttempt( why );\n\n\t/// \u003Csummary\u003EZero everything this holds. Returns true if there was anything to zero.\u003C/summary\u003E\n\tpublic bool ClearProgress()\n\t{\n\t\tvar had = Progress \u003E 0 || LockedUntilRound || HasOwnProgress;\n\n\t\tProgress = 0;\n\t\tClearOwnProgress();\n\n\t\t// \u26A0\uFE0F THE LOCK GOES TOO. A group being wound back is being handed to the players again\n\t\t// from scratch; leaving one member still serving a lockout would make the reset look\n\t\t// like it had missed one.\n\t\tLockedUntilRound = false;\n\t\treturn had;\n\t}\n\n\t/// \u003Csummary\u003EPartial state only this kind holds \u2014 the pressable\u0027s hold timer.\u003C/summary\u003E\n\tprotected virtual bool HasOwnProgress =\u003E false;\n\n\t/// \u003Csummary\u003EDrop that state.\u003C/summary\u003E\n\tprotected virtual void ClearOwnProgress() { }\n\n\t/// \u003Csummary\u003EOne line for the group diagnostics.\u003C/summary\u003E\n\tpublic abstract string Describe();\n\n\t/// \u003Csummary\u003E\n\t/// Round rollover, for every interactable at once.\n\t///\n\t/// \u26A0\uFE0F CALLED FROM \u0060RoundManager.BeginRound\u0060, beside \u0060AmmoBox.OnRoundStart\u0060. The spec says\n\t/// \u0022resets at END of round\u0022; the start of the next one is the same boundary and is where\n\t/// this project already has a hook, so there is no second notion of when a round turns over.\n\t///\n\t/// \u26A0\uFE0F IT IS NOT A \u0060Fail\u0060. Progress goes to 0 and the step stays available \u2014 running out of\n\t/// round is not the same as triggering wrong, and calling Fail would fire RevertToPrevious\n\t/// and unwind earlier steps for simply taking too long.\n\t///\n\t/// \u26D4 IT WALKS \u0060EggGroups.All\u0060, NOT A PER-TYPE LIST. Every interactable registers there by\n\t/// existing, so a new kind is covered by the round rules the day it is written rather than\n\t/// the day somebody remembers to add a second hook in \u0060RoundManager\u0060.\n\t/// \u003C/summary\u003E\n\tpublic static void OnRoundStart()\n\t{\n\t\tint zeroed = 0, unlocked = 0;\n\n\t\tforeach ( var i in EggGroups.All.OfType\u003CEggInteractable\u003E().ToList() )\n\t\t{\n\t\t\tif ( !i.IsValid() || i.Config is null ) continue;\n\n\t\t\t// \u26D4 THE ROUND LOCK LIFTS FOR EVERYONE, BEFORE THE \u0060ResetOnRound\u0060 TEST. It is not\n\t\t\t// part of the Round condition \u2014 it is the retry rule, and one whose mapper never\n\t\t\t// ticked \u0022Reset each round\u0022 would otherwise stay locked out of the egg for ever\n\t\t\t// after a single wrong trigger.\n\t\t\tif ( i.LockedUntilRound )\n\t\t\t{\n\t\t\t\ti.LockedUntilRound = false;\n\t\t\t\tunlocked\u002B\u002B;\n\t\t\t}\n\n\t\t\tif ( !i.Config.ResetOnRound || i.Step.Completed ) continue;\n\t\t\tif ( i.ClearProgress() ) zeroed\u002B\u002B;\n\t\t}\n\n\t\tif ( unlocked \u003E 0 )\n\t\t\tLog.Info( $\u0022[nz-ee] {unlocked} interactable(s) came off their round lockout\u0022 );\n\n\t\tif ( zeroed \u003E 0 )\n\t\t\tLog.Info( $\u0022[nz-ee] {zeroed} interactable(s) reset on the round turn\u0022 );\n\t}\n}\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"EasterEgg/PressableCommands.cs","FileName":"PressableCommands.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace NZombies;\r\n\r\n/// \u003Csummary\u003E\r\n/// Console access to the easter-egg pressables. Every setting in the tool panel has an\r\n/// equivalent here, so a button can be placed, inspected and PRESSED without clicking \u2014\r\n/// the dev menu has been promising \u0060nz_press\u0060, \u0060nz_press_list\u0060 and \u0060nz_press_flags\u0060 in its\r\n/// tooltip since the tool was wired.\r\n///\r\n/// \u26D4 THE FLAG DUMP IS THE POINT OF THIS FILE. A button reads TWO separate pools \u2014 the door\r\n/// pool for whether it exists, the egg pool for whether its step is current \u2014 and from in\r\n/// front of an unresponsive button both look the same. \u0060nz_press_where\u0060 names which one is\r\n/// holding it shut.\r\n/// \u003C/summary\u003E\r\npublic static class PressableCommands\r\n{\r\n\tstatic MapEditor Editor\r\n\t\t=\u003E Game.ActiveScene?.GetAllComponents\u003CMapEditor\u003E().FirstOrDefault();\r\n\r\n\t// \u26D4 \u0060PlayerCharacters.Local()\u0060, NOT \u0060GetAllComponents\u003CNZPlayer\u003E().FirstOrDefault()\u0060 \u2014 and\r\n\t// TODAY THEY RETURN THE SAME THING. \u0060Local()\u0060 resolves through \u0060PlayerPresence.Find()\u0060, which\r\n\t// is still a \u0060FirstOrDefault\u0060. Calling it anyway is deliberate: it is the one accessor\r\n\t// multiplayer will make genuinely local, so all 20 of its call sites become correct in a\r\n\t// single edit instead of 20. A raw FirstOrDefault here would have to be found again.\r\n\t//\r\n\t// \u26A0\uFE0F It is already better in one respect: \u0060Find()\u0060 falls back to disabled objects, which\r\n\t// \u0060GetAllComponents\u0060 skips \u2014 so it works in the lobby, where the player body is switched off.\r\n\tstatic NZPlayer Player =\u003E PlayerCharacters.Local();\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Drop one at your feet: \u0060nz_press [door flag] [required] [reward] [step]\u0060.\r\n\t///\r\n\t/// \u26D4 THE FIRST ARGUMENT IS THE DOOR FLAG, THE OTHER TWO ARE EGG FLAGS \u2014 the same order\r\n\t/// as the tool panel\u0027s rows, and the same two separate pools. \u0060nz_press mine gate opened\u0060\r\n\t/// makes a button that only exists once the door flag \u0060mine\u0060 is bought, is only press-able\r\n\t/// once the EGG flag \u0060gate\u0060 is set, and sets the EGG flag \u0060opened\u0060 when pressed.\r\n\t///\r\n\t/// \u26A0\uFE0F COMMA-SEPARATED, NO SPACES. A console argument splits on the space \u2014 that is how\r\n\t/// \u0060nz_clue\u0060 shipped placing a clue reading one word \u2014 so a list is \u0060a,b,c\u0060 and each\r\n\t/// argument stays one token.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_press\u0022 )]\r\n\tpublic static void Place( string flag = null, string required = null, string reward = null,\r\n\t\tint step = -1 )\r\n\t{\r\n\t\tvar ed = Editor;\r\n\t\tvar p = Player;\r\n\t\tif ( !ed.IsValid() || !p.IsValid() ) { Log.Warning( \u0022[nz-press] no editor/player\u0022 ); return; }\r\n\r\n\t\tif ( flag is not null ) ed.SpawnLink = flag;\r\n\t\tif ( required is not null ) ed.EggRequired = required;\r\n\t\tif ( reward is not null ) ed.EggReward = reward;\r\n\t\tif ( step \u003E= 0 ) ed.EggStepNumber = step;\r\n\r\n\t\t// \u26A0\uFE0F At the PLAYER\u0027s feet with an UP normal \u2014 the command exists so this can be\r\n\t\t// driven headlessly, and a crosshair trace needs somewhere to be aiming.\r\n\t\ted.AddPressableAt( p.WorldPosition, Vector3.Up );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EWhat is placed: \u0060nz_press_list\u0060.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_press_list\u0022 )]\r\n\tpublic static void List()\r\n\t{\r\n\t\tvar list = ActiveConfig.Current.Pressables;\r\n\r\n\t\tif ( list.Count == 0 )\r\n\t\t{\r\n\t\t\tLog.Info( \u0022[nz-press] none placed \u2014 Q \u003E Easter egg \u003E Interactables \u003E Pressable, or nz_press\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tfor ( int i = 0; i \u003C list.Count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar s = list[i];\r\n\t\t\tvar step = s.Step;\r\n\r\n\t\t\tLog.Info( $\u0022[nz-press] [{i}] flag {DoorLinks.Display( s.Link )}\u0022\r\n\t\t\t\t\u002B ( step.StepNumber \u003E 0 ? $\u0022  STEP {step.StepNumber}\u0022 : \u0022\u0022 )\r\n\t\t\t\t\u002B $\u0022  needs [{Join( step.Required )}]\u0022\r\n\t\t\t\t\u002B $\u0022  gives [{Join( step.Reward )}]\u0022\r\n\t\t\t\t\u002B ( step.Excluded.Count \u003E 0 ? $\u0022  locked by [{Join( step.Excluded )}]\u0022 : \u0022\u0022 )\r\n\t\t\t\t\u002B ( string.IsNullOrWhiteSpace( step.GeneralReward ) ? \u0022\u0022 : $\u0022  opens {step.GeneralReward}\u0022 )\r\n\t\t\t\t\u002B ( s.RepeatCount \u003E 1 ? $\u0022  x{s.RepeatCount}\u0022 : \u0022\u0022 )\r\n\t\t\t\t\u002B ( s.HoldSeconds \u003E 0f ? $\u0022  hold {s.HoldSeconds:0.#}s\u0022 : \u0022\u0022 )\r\n\t\t\t\t\u002B ( s.TimeWindow \u003E 0f ? $\u0022  limit {s.TimeWindow:0.#}s\u0022 : \u0022\u0022 )\r\n\t\t\t\t\u002B ( s.Retry == EggRetry.NextRound\r\n\t\t\t\t\t? \u0022  retry next round\u0022\r\n\t\t\t\t\t: s.CooldownSeconds \u003E 0f ? $\u0022  retry {s.CooldownSeconds:0.#}s\u0022 : \u0022\u0022 )\r\n\t\t\t\t\u002B ( step.Completed ? \u0022  DONE\u0022 : step.Satisfied ? \u0022  satisfied, waiting\u0022 : \u0022\u0022 ) );\r\n\t\t}\r\n\r\n\t\tvar mgr = PressableManager.Instance;\r\n\r\n\t\tLog.Info( $\u0022[nz-press] {list.Count} configured, \u0022\r\n\t\t\t\u002B $\u0022{( mgr.IsValid() ? mgr.Built.ToString() : \u0022?\u0022 )} standing\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_press_flags\u0060 \u2014 both pools, side by side.\r\n\t///\r\n\t/// \u26D4 TWO POOLS, PRINTED TOGETHER AND LABELLED. \u0060Docs/EASTER_EGG_TOOLSET.md\u0060: a General\r\n\t/// flag \u00222\u0022 and an egg flag \u00222\u0022 are unrelated values in separate namespaces. Printing\r\n\t/// only one of them is how someone concludes a flag \u0022is set\u0022 while the button is reading\r\n\t/// the other pool and disagreeing.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_press_flags\u0022 )]\r\n\tpublic static void Flags()\r\n\t{\r\n\t\tvar egg = EggFlags.Snapshot().ToList();\r\n\r\n\t\tLog.Info( $\u0022[nz-press] EGG pool \u2014 {egg.Count( f =\u003E f.Value )} of {egg.Count} set\u0022 );\r\n\r\n\t\tforeach ( var f in egg.OrderBy( f =\u003E f.Index ) )\r\n\t\t\tLog.Info( $\u0022[nz-press]   [{f.Index}] {f.Flag} = {( f.Value ? \u0022SET\u0022 : \u0022unset\u0022 )}\u0022 );\r\n\r\n\t\tif ( egg.Count == 0 )\r\n\t\t\tLog.Info( \u0022[nz-press]   (none registered \u2014 flags register when the pressables build)\u0022 );\r\n\r\n\t\tvar doors = DoorLinks.Opened;\r\n\r\n\t\tLog.Info( $\u0022[nz-press] DOOR pool \u2014 {doors.Count} open: {DoorLinks.Summary}\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_press_where\u0060 \u2014 the nearest button, and WHICH gate is holding it.\r\n\t///\r\n\t/// \u26D4 EVERY GATE IS PRINTED SEPARATELY, not just the first refusal. \u0060Unavailable\u0060 returns\r\n\t/// one string because a prompt can only show one line, but a mapper debugging a button\r\n\t/// that will not press needs to see that the door is shut AND two required flags are\r\n\t/// missing \u2014 fixing the first and finding it still dead is the slow way to learn that.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_press_where\u0022 )]\r\n\tpublic static void Where()\r\n\t{\r\n\t\tvar p = Player;\r\n\t\tif ( !p.IsValid() ) { Log.Warning( \u0022[nz-press] no player\u0022 ); return; }\r\n\r\n\t\t// \u26A0\uFE0F \u0060NearAny\u0060, so a step that has already been done can still be inspected \u2014\r\n\t\t// \u0022why did nothing happen\u0022 is asked most often about a button that is finished.\r\n\t\tvar press = Pressable.NearAny( p.WorldPosition );\r\n\r\n\t\tif ( press is null )\r\n\t\t{\r\n\t\t\tvar n = ActiveConfig.Current.Pressables.Count;\r\n\r\n\t\t\tLog.Info( n == 0\r\n\t\t\t\t? \u0022[nz-press] none placed\u0022\r\n\t\t\t\t: $\u0022[nz-press] none within {Pressable.UseRange:0}u \u2014 {n} placed, walk up to one\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar s = press.Spot;\r\n\t\tif ( s is null ) { Log.Warning( \u0022[nz-press] nearest button has no config row\u0022 ); return; }\r\n\r\n\t\tvar step = s.Step;\r\n\r\n\t\tLog.Info( $\u0022[nz-press] nearest is {press.WorldPosition.Distance( p.WorldPosition ):0}u away\u0022 );\r\n\r\n\t\t// \u26A0\uFE0F WHETHER IT IS ACTUALLY THERE, said first. A completed step removes its parts\r\n\t\t// from the world, and creative keeps them \u2014 so \u0022the button is still standing in front of\r\n\t\t// me\u0022 and \u0022the button exists\u0022 are different questions, and only this answers the second.\r\n\t\tif ( press.Visual.IsValid() )\r\n\t\t\tLog.Info( $\u0022[nz-press]   body {( press.Visual.Enabled ? \u0022drawn\u0022 : \u0022HIDDEN \u2014 its step is done\u0022 )}\u0022\r\n\t\t\t\t\u002B ( press.Step is not null \u0026\u0026 press.Step.Completed \u0026\u0026 press.Visual.Enabled\r\n\t\t\t\t\t? \u0022 (kept because creative shows authoring visuals)\u0022 : \u0022\u0022 ) );\r\n\r\n\t\t// \u2500\u2500 the door pool \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t\tLog.Info( $\u0022[nz-press]   door flag {DoorLinks.Display( s.Link )}\u0022\r\n\t\t\t\u002B ( DoorLinks.IsOpen( s.Link ) ? \u0022 \u2014 open\u0022 : \u0022 \u2014 SHUT, the button is inert\u0022 ) );\r\n\r\n\t\t// \u2500\u2500 the egg pool \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t\tforeach ( var f in step.Required )\r\n\t\t\tLog.Info( $\u0022[nz-press]   requires {f} = {( EggFlags.IsSet( f ) ? \u0022SET\u0022 : \u0022MISSING\u0022 )}\u0022 );\r\n\r\n\t\tforeach ( var f in step.Excluded )\r\n\t\t\tLog.Info( $\u0022[nz-press]   excluded by {f} = {( EggFlags.IsSet( f ) ? \u0022SET \u2014 LOCKED OUT\u0022 : \u0022unset\u0022 )}\u0022 );\r\n\r\n\t\tLog.Info( $\u0022[nz-press]   rewards [{Join( step.Reward )}]\u0022\r\n\t\t\t\u002B ( string.IsNullOrWhiteSpace( step.GeneralReward ) ? \u0022\u0022 : $\u0022 \u00B7 opens door {step.GeneralReward}\u0022 )\r\n\t\t\t\u002B ( step.StepNumber \u003E 0 ? $\u0022 \u00B7 group {step.StepNumber}\u0022 : \u0022\u0022 ) );\r\n\r\n\t\t// \u2500\u2500 the group \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t\t// \u26D4 NAMED, NOT COUNTED. \u00223 of 4\u0022 tells a mapper the group is stuck and nothing\r\n\t\t// about WHICH member is holding it \u2014 and the missing one is usually the one placed\r\n\t\t// somewhere nobody walks past.\r\n\t\tif ( step.StepNumber \u003E 0 )\r\n\t\t{\r\n\t\t\tvar members = EggGroups.InGroup( step.StepNumber ).ToList();\r\n\r\n\t\t\tLog.Info( $\u0022[nz-press]   STEP {step.StepNumber} \u2014 \u0022\r\n\t\t\t\t\u002B $\u0022{members.Count( m =\u003E m.Step.Satisfied )}/{members.Count} satisfied\u0022\r\n\t\t\t\t\u002B ( members.All( m =\u003E m.Step.Completed ) ? \u0022 \u00B7 COMPLETE\u0022 : \u0022\u0022 ) );\r\n\r\n\t\t\tforeach ( var m in members )\r\n\t\t\t\tLog.Info( $\u0022[nz-press]     {( m.Step.Satisfied ? \u0022done \u0022 : \u0022OPEN \u0022 )}{m.Describe()}\u0022 );\r\n\t\t}\r\n\r\n\t\t// \u2500\u2500 the conditions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t\tvar need = System.Math.Max( 1, s.RepeatCount );\r\n\r\n\t\tLog.Info( $\u0022[nz-press]   presses {press.Presses}/{need}\u0022\r\n\t\t\t\u002B ( s.HoldSeconds \u003E 0f ? $\u0022 \u00B7 hold {press.HeldFor:0.0}/{s.HoldSeconds:0.#}s\u0022 : \u0022\u0022 )\r\n\t\t\t\u002B ( s.TimedDelay \u003E 0f ? $\u0022 \u00B7 unlock delay {s.TimedDelay:0.#}s\u0022 : \u0022\u0022 )\r\n\t\t\t\u002B ( s.ResetOnRound ? \u0022 \u00B7 resets each round\u0022 : \u0022\u0022 ) );\r\n\r\n\t\t// \u26A0\uFE0F THE RULE AND THE CURRENT STATE ON ONE LINE. \u0022Retry: next round\u0022 says how it is\r\n\t\t// set; \u0022LOCKED\u0022 says whether it is serving one right now \u2014 and a button that will not\r\n\t\t// press asks the second question, not the first.\r\n\t\t// \u26D4 THE LIMIT AND WHAT IS LEFT OF IT ARE DIFFERENT FACTS. \u0022limit 10s\u0022 is how it was\r\n\t\t// authored; \u00224.2s left\u0022 only exists while somebody is mid-attempt, and a step that keeps\r\n\t\t// failing is diagnosed by watching the second number, not the first.\r\n\t\tvar left = EggGroups.Remaining( press );\r\n\r\n\t\tif ( s.TimeWindow \u003E 0f || left \u003E= 0f )\r\n\t\t\tLog.Info( $\u0022[nz-press]   step time limit {s.TimeWindow:0.#}s\u0022\r\n\t\t\t\t\u002B ( left \u003E= 0f ? $\u0022 \u00B7 RUNNING, {left:0.0}s left\u0022 : \u0022 \u00B7 not started\u0022 ) );\r\n\r\n\t\tLog.Info( $\u0022[nz-press]   retry: \u0022\r\n\t\t\t\u002B ( s.Retry == EggRetry.NextRound\r\n\t\t\t\t? \u0022next round\u0022\r\n\t\t\t\t: s.CooldownSeconds \u003E 0f ? $\u0022{s.CooldownSeconds:0.#}s\u0022 : \u0022at once\u0022 )\r\n\t\t\t\u002B ( press.LockedUntilRound ? \u0022 \u00B7 LOCKED until the round turns\u0022 : \u0022\u0022 )\r\n\t\t\t\u002B ( press.Cooldown \u003E 0f ? $\u0022 \u00B7 LOCKED for {press.Cooldown:0.0}s more\u0022 : \u0022\u0022 ) );\r\n\r\n\t\tif ( !string.IsNullOrWhiteSpace( s.RequiredWeapon ) )\r\n\t\t\tLog.Info( $\u0022[nz-press]   needs weapon \u0027{s.RequiredWeapon}\u0027 in hand\u0022 );\r\n\r\n\t\tif ( !string.IsNullOrWhiteSpace( s.RequiredPerk ) )\r\n\t\t\tLog.Info( $\u0022[nz-press]   needs perk \u0027{s.RequiredPerk}\u0027\u0022 );\r\n\r\n\t\t// \u2500\u2500 the verdict the prompt would show \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t\tvar blocked = press.Unavailable( p );\r\n\r\n\t\tLog.Info( step.Completed\r\n\t\t\t? \u0022[nz-press]   VERDICT: already completed\u0022\r\n\t\t\t: string.IsNullOrEmpty( blocked )\r\n\t\t\t\t? \u0022[nz-press]   VERDICT: press-able right now\u0022\r\n\t\t\t\t: $\u0022[nz-press]   VERDICT: \u0027{blocked}\u0027\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_press_use [times] [index]\u0060 \u2014 press a button from the console. Index -1 = the nearest.\r\n\t///\r\n\t/// \u26A0\uFE0F IT GOES THROUGH \u0060Press\u0060, not through the flags. Setting the reward flag by hand\r\n\t/// would test nothing: the repeat count, the cooldown, the overshoot rule and the door\r\n\t/// gate all live on the path the use key takes, and this exists to exercise exactly that\r\n\t/// path without needing to walk up and mash E through a hold timer.\r\n\t///\r\n\t/// \u26D4 THE INDEX EXISTS BECAUSE A STEP GROUP CANNOT BE TESTED WITHOUT IT. Driving this\r\n\t/// headlessly places every button at the player\u0027s feet, and \u0060Near\u0060 can only ever return one\r\n\t/// of a stack \u2014 so a four-member group would have three members no command could reach.\r\n\t/// It resolves through the config ROW, not through list order, so it still names the right\r\n\t/// button after a rebuild has recreated every component.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_press_use\u0022 )]\r\n\tpublic static void Use( int times = 1, int index = -1 )\r\n\t{\r\n\t\tvar p = Player;\r\n\t\tif ( !p.IsValid() ) { Log.Warning( \u0022[nz-press] no player\u0022 ); return; }\r\n\r\n\t\tPressable press;\r\n\r\n\t\tif ( index \u003E= 0 )\r\n\t\t{\r\n\t\t\tvar list = ActiveConfig.Current.Pressables;\r\n\r\n\t\t\tif ( index \u003E= list.Count )\r\n\t\t\t{\r\n\t\t\t\tLog.Warning( $\u0022[nz-press] no button [{index}] \u2014 there are {list.Count}\u0022 );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tvar row = list[index];\r\n\t\t\tpress = Pressable.All.FirstOrDefault( b =\u003E b.IsValid() \u0026\u0026 b.Spot == row );\r\n\r\n\t\t\tif ( press is null )\r\n\t\t\t{\r\n\t\t\t\tLog.Warning( $\u0022[nz-press] button [{index}] is configured but not standing\u0022 );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tpress = Pressable.Near( p.WorldPosition );\r\n\t\t\tif ( press is null ) { Log.Info( \u0022[nz-press] nothing within reach\u0022 ); return; }\r\n\t\t}\r\n\r\n\t\tfor ( int i = 0; i \u003C times.Clamp( 1, 50 ); i\u002B\u002B )\r\n\t\t\tLog.Info( $\u0022[nz-press] {press.Press( p )}\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_egg_groups\u0060 \u2014 every step number in the map and how far along it is.\r\n\t///\r\n\t/// \u26A0\uFE0F IT LISTS THE MEMBERS, NOT JUST THE TALLY. A step that will not finish is almost\r\n\t/// always one member the mapper forgot they placed, or placed twice \u2014 both of which read as\r\n\t/// \u00224 of 5\u0022 and neither of which a count can tell apart.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_egg_groups\u0022 )]\r\n\tpublic static void Groups()\r\n\t{\r\n\t\tvar any = false;\r\n\r\n\t\tforeach ( var g in EggGroups.Snapshot() )\r\n\t\t{\r\n\t\t\tany = true;\r\n\r\n\t\t\tLog.Info( $\u0022[nz-press] step {g.Number} \u2014 {g.Satisfied}/{g.Total} satisfied\u0022\r\n\t\t\t\t\u002B ( g.Complete ? \u0022 \u00B7 COMPLETE\u0022 : \u0022\u0022 ) );\r\n\r\n\t\t\tforeach ( var m in EggGroups.InGroup( g.Number ) )\r\n\t\t\t\tLog.Info( $\u0022[nz-press]   {( m.Step.Satisfied ? \u0022done \u0022 : \u0022OPEN \u0022 )}{m.Describe()}\u0022 );\r\n\t\t}\r\n\r\n\t\tvar loners = EggGroups.All.Count( m =\u003E m.Step.StepNumber \u003C= 0 );\r\n\r\n\t\tif ( !any )\r\n\t\t\tLog.Info( \u0022[nz-press] no step groups \u2014 every placed interactable is on its own \u0022\r\n\t\t\t\t\u002B \u0022(Easter egg step = 0)\u0022 );\r\n\r\n\t\tLog.Info( $\u0022[nz-press] {loners} ungrouped interactable(s)\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_press_set [count] [hold] [retry seconds] [step time limit]\u0060 \u2014 retune every placed\r\n\t/// button AND the tool default. \u22121 (or omitted) leaves a value alone.\r\n\t///\r\n\t/// \u26A0\uFE0F BOTH, DELIBERATELY \u2014 the trap \u0060nz_dmgwall_set\u0060 and \u0060nz_fizz_slotprice\u0060 both\r\n\t/// document. The config is what the buttons already standing do; the \u0060MapEditor\u0060 fields are\r\n\t/// what the NEXT one placed is stamped with. Setting one alone means the behaviour changes\r\n\t/// now and silently reverts later.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_press_set\u0022 )]\r\n\tpublic static void Set( int count = -1, float hold = -1f, float retrySeconds = -1f,\r\n\t\tfloat window = -1f )\r\n\t{\r\n\t\tforeach ( var p in ActiveConfig.Current.Pressables )\r\n\t\t{\r\n\t\t\tif ( count \u003E= 0 ) p.RepeatCount = count;\r\n\t\t\tif ( hold \u003E= 0f ) p.HoldSeconds = hold;\r\n\t\t\tif ( retrySeconds \u003E= 0f ) p.CooldownSeconds = retrySeconds;\r\n\r\n\t\t\t// \u26A0\uFE0F SET ON EVERY MEMBER, which is what \u0060EggGroups.WindowFor\u0060 wants: it warns\r\n\t\t\t// when a step\u0027s members disagree, and a command that changed only some of them\r\n\t\t\t// would be the thing producing that warning.\r\n\t\t\tif ( window \u003E= 0f ) p.TimeWindow = window;\r\n\t\t}\r\n\r\n\t\tvar ed = Editor;\r\n\r\n\t\tif ( ed.IsValid() )\r\n\t\t{\r\n\t\t\tif ( count \u003E= 0 ) ed.EggRepeat = count;\r\n\t\t\tif ( hold \u003E= 0f ) ed.PressHold = hold;\r\n\t\t\tif ( retrySeconds \u003E= 0f ) ed.EggCooldown = retrySeconds;\r\n\t\t\tif ( window \u003E= 0f ) ed.EggWindow = window;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[nz-press] no MapEditor \u2014 the buttons already placed were retuned, \u0022\r\n\t\t\t\t\u002B \u0022but the NEXT one placed will use the old numbers\u0022 );\r\n\t\t}\r\n\r\n\t\tList();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_press_retry \u0026lt;seconds|round\u0026gt;\u0060 \u2014 how a FAILED attempt is locked out, on every placed\r\n\t/// button and the tool default.\r\n\t///\r\n\t/// \u26D4 THE TWO ARE MUTUALLY EXCLUSIVE, so this takes a word rather than a number. Passing\r\n\t/// \u0022round\u0022 does not also clear the seconds value: it is simply not read, and a mapper who\r\n\t/// switches back gets their old number rather than a zero they have to retype.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_press_retry\u0022 )]\r\n\tpublic static void Retry( string mode = \u0022\u0022 )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( mode ) )\r\n\t\t{\r\n\t\t\tLog.Info( \u0022[nz-press] usage: nz_press_retry seconds | nz_press_retry round\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar round = mode.StartsWith( \u0022r\u0022, System.StringComparison.OrdinalIgnoreCase );\r\n\t\tvar value = round ? EggRetry.NextRound : EggRetry.Seconds;\r\n\r\n\t\tforeach ( var p in ActiveConfig.Current.Pressables )\r\n\t\t\tp.Retry = value;\r\n\r\n\t\tvar ed = Editor;\r\n\r\n\t\tif ( ed.IsValid() )\r\n\t\t\ted.EggRetryMode = ToolSettings.RetryModes[round ? 1 : 0];\r\n\t\telse\r\n\t\t\tLog.Warning( \u0022[nz-press] no MapEditor \u2014 placed buttons were changed, the next one \u0022\r\n\t\t\t\t\u002B \u0022placed will not be\u0022 );\r\n\r\n\t\tLog.Info( $\u0022[nz-press] retry after failing: {( round ? \u0022next round\u0022 : \u0022a clock\u0022 )}\u0022 );\r\n\t\tList();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ERemove them all: \u0060nz_press_clear\u0060.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_press_clear\u0022 )]\r\n\tpublic static void Clear()\r\n\t{\r\n\t\tvar n = ActiveConfig.Current.Pressables.Count;\r\n\t\tActiveConfig.Current.Pressables.Clear();\r\n\t\tPressableManager.Ensure( Game.ActiveScene )?.Rebuild();\r\n\r\n\t\tLog.Info( $\u0022[nz-press] removed {n}\u0022 );\r\n\t}\r\n\r\n\tstatic string Join( List\u003Cstring\u003E flags ) =\u003E flags.Count == 0 ? \u0022\u0022 : string.Join( \u0022,\u0022, flags );\r\n}\r\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"EasterEgg/PressableManager.cs","FileName":"PressableManager.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace NZombies;\r\n\r\n/// \u003Csummary\u003E\r\n/// Builds the config\u0027s pressables.\r\n///\r\n/// \u26A0\uFE0F A PLACEHOLDER BOX FOR NOW, like the misery device \u2014 a real button prop comes later.\r\n/// Kept small and wall-mountable, because a button belongs on a wall far more often than\r\n/// on the floor.\r\n/// \u003C/summary\u003E\r\npublic sealed class PressableManager : Component\r\n{\r\n\tpublic static PressableManager Instance { get; private set; }\r\n\r\n\tprotected override void OnAwake() =\u003E Instance = this;\r\n\tprotected override void OnDestroy() { if ( Instance == this ) Instance = null; }\r\n\r\n\tpublic static PressableManager Ensure( Scene scene = null )\r\n\t{\r\n\t\tif ( Instance.IsValid() ) return Instance;\r\n\r\n\t\tscene ??= Game.ActiveScene;\r\n\t\tif ( !scene.IsValid() ) return null;\r\n\r\n\t\tvar go = scene.CreateObject();\r\n\t\tgo.Name = \u0022Pressable Manager\u0022;\r\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\r\n\t\treturn go.Components.Create\u003CPressableManager\u003E();\r\n\t}\r\n\r\n\treadonly List\u003CGameObject\u003E _built = new();\r\n\r\n\t/// \u003Csummary\u003EHow many are standing right now.\u003C/summary\u003E\r\n\tpublic int Built =\u003E _built.Count( g =\u003E g.IsValid() );\r\n\r\n\t/// \u003Csummary\u003EDestroy what is standing and build the config again.\u003C/summary\u003E\r\n\tpublic void Rebuild()\r\n\t{\r\n\t\tforeach ( var g in _built ) g?.Destroy();\r\n\t\t_built.Clear();\r\n\r\n\t\tvar list = ActiveConfig.Current?.Pressables;\r\n\t\tif ( list is null || list.Count == 0 ) return;\r\n\r\n\t\tforeach ( var spot in list )\r\n\t\t{\r\n\t\t\t// \u26A0\uFE0F REGISTERED ON BUILD, so every flag a step names has a tie-break index before\r\n\t\t\t// play starts. \u0060EggFlags.Register\u0060 is order-dependent and idempotent \u2014 doing it\r\n\t\t\t// here means the indices follow config order rather than whatever order the\r\n\t\t\t// player happens to complete steps in.\r\n\t\t\tspot.Step?.RegisterFlags();\r\n\t\t\tBuild( spot );\r\n\t\t}\r\n\r\n\t\tLog.Info( $\u0022[nz] {Built} of {list.Count} pressable(s) built\u0022 );\r\n\t}\r\n\r\n\tvoid Build( PressableSpot spot )\r\n\t{\r\n\t\tvar go = Scene.CreateObject();\r\n\t\tgo.Name = \u0022Pressable\u0022;\r\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\r\n\t\tgo.NetworkMode = NetworkMode.Never;   // \u26D4 THIS MACHINE\u0027S OWN \u2014 out of a joiner\u0027s snapshot, where it would stand frozen (NZNetListener)\r\n\r\n\t\tvar n = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;\r\n\t\tvar wall = n.z \u003C= 0.7f;\r\n\r\n\t\tgo.WorldPosition = spot.Position;\r\n\t\tgo.WorldRotation = OnSurface( spot );\r\n\r\n\t\tvar size = spot.Size;\r\n\r\n\t\tvar vis = Scene.CreateObject();\r\n\t\tvis.Name = \u0022visual\u0022;\r\n\t\tvis.SetParent( go );\r\n\t\tvis.LocalRotation = Rotation.Identity;\r\n\r\n\t\t// \u26A0\uFE0F A WALL BUTTON PUSHES OUT ALONG ITS OWN FORWARD; A FLOOR ONE SITS ON ITS BASE.\r\n\t\t// \u0060OnSurface\u0060 has already turned the object to face out of the wall, so \u0022out\u0022 is\r\n\t\t// local forward there and local up on a floor \u2014 one offset expressed in the frame\r\n\t\t// the rotation just established, rather than two special cases in world space.\r\n\t\tvis.LocalPosition = wall\r\n\t\t\t? Vector3.Forward * size.x * 0.5f\r\n\t\t\t: Vector3.Up * size.z * 0.5f;\r\n\r\n\t\tvar cube = Model.Cube.Bounds.Size;\r\n\t\tvis.LocalScale = new Vector3( size.x / cube.x, size.y / cube.y, size.z / cube.z );\r\n\r\n\t\tvar mr = vis.Components.Create\u003CModelRenderer\u003E();\r\n\t\tmr.Model = Model.Cube;\r\n\t\tmr.Tint = new Color( 0.85f, 0.72f, 0.20f, 1f );\r\n\r\n\t\tvar solid = vis.Components.Create\u003CBoxCollider\u003E();\r\n\t\tsolid.Scale = cube;\r\n\r\n\t\tvar press = go.Components.Create\u003CPressable\u003E();\r\n\t\tpress.Spot = spot;\r\n\r\n\t\t// \u26A0\uFE0F THE BODY IS HANDED OVER, not searched for. \u0060Pressable\u0060 switches this whole\r\n\t\t// child off when its step completes, and a lookup by type would find whatever a future\r\n\t\t// prop happens to be built from rather than the thing this method actually made.\r\n\t\tpress.Visual = vis;\r\n\r\n\t\t_built.Add( go );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EWall or floor, the buyable ending\u0027s split \u2014 see \u0060ClueManager.OnSurface\u0060.\u003C/summary\u003E\r\n\tstatic Rotation OnSurface( PressableSpot spot )\r\n\t{\r\n\t\tvar n = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;\r\n\r\n\t\tif ( n.z \u003C= 0.7f ) return Rotation.LookAt( n );\r\n\r\n\t\tvar heading = Rotation.FromYaw( spot.Yaw ).Forward;\r\n\t\tvar forward = (heading - n * heading.Dot( n )).Normal;\r\n\r\n\t\tif ( forward.IsNearlyZero() )\r\n\t\t\treturn Rotation.From( 0f, spot.Yaw, 0f );\r\n\r\n\t\treturn Rotation.LookAt( forward, n );\r\n\t}\r\n}\r\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Effects/FogAreaManager.cs","FileName":"FogAreaManager.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace NZombies;\n\n/// \u003Csummary\u003E\n/// Cross-fades the scene\u0027s fog to whatever area the listener is standing in.\n///\n/// \u26D4 THERE IS ONE FOG IN THE SCENE, NOT ONE PER AREA, AND THAT IS THE ENGINE\u0027S DOING.\n/// \u0060VolumetricFogVolume\u0060 is the component built to hang fog inside a box, and on the scene path it\n/// renders NOTHING \u2014 tested with Bounds set and Strength at 25, with and without a\n/// \u0060VolumetricFogController\u0060, and the frame came back pixel-identical. Its own controller explains\n/// it: the thing exists \u0022to fetch the baked fog texture FROM THE MAP FILE\u0022. Volumetric fog joins\n/// lightmaps and shadow baking on the list of map-compile features this project cannot reach.\n///\n/// \u0060GradientFog\u0060 does work, and it is global. So the areas vote on what the one global fog looks\n/// like, weighted by how deep inside you are \u2014 and that alone was invisible from outside, which is\n/// the limitation this file carried from the day it was written.\n///\n/// \u26D4 SO EACH AREA ALSO GETS GEOMETRY NOW, AND THAT IS WHAT MAKES IT VISIBLE FROM OUTSIDE. Stacked\n/// flat sheets across the drawn footprint, shaded by \u0060lavafog.shader\u0060 \u2014 the same layered trick the\n/// lava fog proved out, for the same reason: a hollow box only has surfaces at its edges, so a\n/// volume has to be SAMPLED at many heights rather than enclosed once.\n///\n/// \u26A0\uFE0F THE TWO HALVES DO DIFFERENT JOBS AND BOTH ARE NEEDED. The sheets are the bank you see across\n/// the room; the GradientFog is what closes in once you are inside it, which no amount of geometry\n/// gives you because you cannot draw sheets between the camera and its own near plane. Screen-space\n/// for being in it, geometry for looking at it \u2014 the same split the ash settled into.\n///\n/// \u26A0\uFE0F SO THE COST IS FLAT, WHICH IS THE POINT. One component, a handful of floats lerped ten times\n/// a second, and a distance check per area. Twenty fog areas cost the same to render as one, and\n/// none of them cost anything when you are not in them. On a map that has to stay smooth, that is\n/// the whole reason this shape was chosen rather than worked around.\n///\n///     # MAPPORT: fog placeable\n/// \u003C/summary\u003E\npublic sealed class FogAreaManager : Component\n{\n\tpublic static FogAreaManager Instance { get; private set; }\n\n\tprotected override void OnAwake() =\u003E Instance = this;\n\n\tpublic static FogAreaManager Ensure( Scene scene = null )\n\t{\n\t\tif ( Instance.IsValid() ) return Instance;\n\n\t\tscene ??= Game.ActiveScene;\n\t\tif ( !scene.IsValid() ) return null;\n\n\t\tvar go = scene.CreateObject();\n\t\tgo.Name = \u0022Fog Area Manager\u0022;\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\n\t\treturn go.Components.Create\u003CFogAreaManager\u003E();\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// \u0060nz_fogzone 0\u0060 to rule the drawn zones out without clearing anything.\n\t///\n\t/// \u26D4 NOT \u0060nz_fog\u0060, WHICH WAS TAKEN AND SILENTLY LOST. \u0060SpecialFog\u0060 \u2014 the round manager\u0027s fog \u2014\n\t/// already registers \u0060nz_fog\u0060 as a ConCmd, so this ConVar never registered at all: the engine\n\t/// logged \u0060Convar nz_fog already exists - not overwriting\u0060 once at startup and then every\n\t/// \u0060nz_fog 0\u0060 went to the OTHER system, which answered about SpecialFog. The switch did nothing\n\t/// and said something plausible while doing it.\n\t///\n\t/// \u26A0\uFE0F THE WARNING IS ONE LINE AT LOAD, AMONG HUNDREDS. Console noise at startup is where a\n\t/// collision like this hides \u2014 it was on screen for weeks before a command that happened to\n\t/// answer wrongly gave it away.\n\t/// \u003C/summary\u003E\n\t// \u26A0\uFE0F \u0060new\u0060 BECAUSE THIS DELIBERATELY HIDES \u0060Component.Enabled\u0060, AND THE COMPILER WAS RIGHT TO\n\t// ASK. Every bare \u0060Enabled\u0060 inside this class means THE CONVAR \u2014 which is what the call sites\n\t// want \u2014 but a future \u0060Enabled = false\u0060 written to disable the component would instead switch\n\t// the feature off globally for everyone. The keyword states the intent; if that trap ever bites,\n\t// rename this rather than removing the keyword.\n\t[ConVar( \u0022nz_fogzone\u0022 )] public static new bool Enabled { get; set; } = true;\n\n\t/// \u003Csummary\u003E\u0060nz_fog_visible 0\u0060 drops the geometry and leaves only the blended GradientFog.\u003C/summary\u003E\n\t[ConVar( \u0022nz_fog_visible\u0022 )] public static bool Visible { get; set; } = true;\n\n\t/// \u003Csummary\u003E\n\t/// Sheets per area.\n\t///\n\t/// \u26D4 THE RESOLUTION OF THE VOLUME AND THE COST. Each sheet is a full-area translucent pass, so\n\t/// overdraw is this times the zone\u0027s screen coverage times the number of zones you can see at\n\t/// once \u2014 and unlike the lava, a map can have many zones. Ten reads as continuous; drop it first\n\t/// if a foggy room gets heavy.\n\t/// \u003C/summary\u003E\n\t[ConVar( \u0022nz_fog_layers\u0022 )] public static int Layers { get; set; } = 10;\n\n\t/// \u003Csummary\u003E\n\t/// How thick each sheet is drawn.\n\t///\n\t/// \u26A0\uFE0F MUCH LOWER THAN IT LOOKS, BECAUSE TEN OF THEM ACCUMULATE. The drawn zones already carry\n\t/// their own \u0060Density\u0060; this scales it per sheet so the stack lands somewhere sane rather than\n\t/// ten times too solid.\n\t/// \u003C/summary\u003E\n\t[ConVar( \u0022nz_fog_sheet\u0022 )] public static float SheetDensity { get; set; } = 0.11f;\n\n\t/// \u003Csummary\u003E\n\t/// Seconds the fog holds at full strength after you leave a zone, before it starts fading.\n\t///\n\t/// \u26D4 ON THE WAY OUT ONLY \u2014 ENTERING IS STILL IMMEDIATE. A delay in both directions is just a\n\t/// slower blend, and it would mean walking into a bank and standing in clear air for two seconds\n\t/// while the haze catches up. Air you have just left staying thick is how fog behaves; air you\n\t/// have just entered staying clear is not.\n\t///\n\t/// \u26A0\uFE0F IT HOLDS, THEN FADES \u2014 it does not slow the fade down. After the hold the area\u0027s own\n\t/// \u0060Blend\u0060 runs exactly as before, so the character of the fade is unchanged and only its start\n\t/// is late.\n\t/// \u003C/summary\u003E\n\t[ConVar( \u0022nz_fog_hold\u0022 )] public static float Hold { get; set; } = 2f;\n\n\t/// \u003Csummary\u003E\n\t/// While you are inside ANY zone, the fog never thins \u2014 it only holds or gets thicker.\n\t///\n\t/// \u26D4 BECAUSE A ZONE\u0027S EDGE WEIGHS ALMOST NOTHING, AND THAT IS WHAT MADE IT CLEAR. The weight is\n\t/// a smoothstep from the boundary inward, so the moment you cross into a new zone its target is\n\t/// near ZERO \u2014 lower than the fog you are already standing in. Walking from one bank straight\n\t/// into another therefore asked the fog to fade, and the two-second hold only postponed it:\n\t/// stand near the edge of the second zone and it clears while you are inside fog.\n\t///\n\t/// \u26A0\uFE0F THE TRADE IS THAT A DENSE ZONE DOES NOT THIN OUT INTO A LIGHT ONE UNTIL YOU LEAVE BOTH.\n\t/// That is the behaviour that was asked for \u2014 fog that persists while you are in fog \u2014 and it is\n\t/// the right call for walking a foggy map, but it does mean the zones\u0027 densities stop being a\n\t/// gradient you can walk down. \u0060nz_fog_keep 0\u0060 restores the strict per-zone behaviour.\n\t/// \u003C/summary\u003E\n\t[ConVar( \u0022nz_fog_keep\u0022 )] public static bool KeepWhileInside { get; set; } = true;\n\n\tTimeSince _sinceFalling;\n\n\tpublic const string SheetMaterial = \u0022materials/nz/lavafog.vmat\u0022;\n\n\treadonly List\u003CGameObject\u003E _sheets = new();\n\n\t/// \u003Csummary\u003EHow many sheet objects are standing.\u003C/summary\u003E\n\tpublic int SheetCount =\u003E _sheets.Count;\n\n\tGradientFog _fog;\n\n\t/// \u003Csummary\u003EWhat the blend is currently at \u2014 kept so a report can print it.\u003C/summary\u003E\n\tpublic float Weight { get; private set; }\n\n\t/// \u003Csummary\u003E\n\t/// An area that holds the listener wherever they stand, or null: its look at full weight, over every drawn area \u2014 and\n\t/// with it the ash overlay and the ash motes, which both follow \u003Csee cref=\u0022Weight\u0022/\u003E. Set by basalt\u0027s rising lava for\n\t/// the rounds it is out of its bed (\u0060HexPlatforms.Lava.cs\u0060). LOCAL, like the fog itself.\n\t/// \u003C/summary\u003E\n\tpublic static FogArea Forced { get; set; }\n\n\t/// \u003Csummary\u003E\n\t/// True while the fog is being held after leaving a zone.\n\t///\n\t/// \u26A0\uFE0F WORTH REPORTING, because during the hold the fog and the weight disagree with where you\n\t/// are standing \u2014 which is the intended behaviour and looks exactly like the blend being stuck.\n\t/// \u003C/summary\u003E\n\tpublic bool Holding { get; private set; }\n\tpublic Color Tint { get; private set; }\n\tpublic float EndDistance { get; private set; }\n\tpublic string Inside { get; private set; } = \u0022-\u0022;\n\n\tTimeSince _sinceTick;\n\n\t/// \u003Csummary\u003E\n\t/// The fog component, created next to the manager.\n\t///\n\t/// \u26A0\uFE0F NOT ON THE CAMERA. \u0060NZPostProcess\u0060 attaches tonemapping and bloom to \u0060Scene.Camera\u0060\n\t/// because those are camera effects; \u0060GradientFog\u0060 is a world effect and survives the camera\n\t/// being replaced \u2014 which happens on every respawn and every map load.\n\t/// \u003C/summary\u003E\n\tGradientFog Fog()\n\t{\n\t\tif ( _fog.IsValid() ) return _fog;\n\n\t\t_fog = GameObject.Components.GetOrCreate\u003CGradientFog\u003E();\n\t\treturn _fog;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\t// Ten times a second. The blend below is time-based, not tick-based, so a coarse tick\n\t\t// changes nothing about how smooth the fade looks.\n\t\tif ( _sinceTick \u003C 0.1f ) return;\n\n\t\tvar dt = (float)_sinceTick;\n\t\t_sinceTick = 0f;\n\n\t\tTick( dt );\n\t}\n\n\tvoid Tick( float dt )\n\t{\n\t\tvar fog = Fog();\n\t\tif ( !fog.IsValid() ) return;\n\n\t\tvar list = ActiveConfig.Current?.Fog;\n\t\tvar p = NZPlayer.Local;\n\n\t\tFogArea best = null;\n\t\tvar bestWeight = 0f;\n\n\t\tif ( Enabled \u0026\u0026 list is { Count: \u003E 0 } \u0026\u0026 p.IsValid() )\n\t\t{\n\t\t\tvar ear = p.WorldPosition;\n\n\t\t\tforeach ( var a in list )\n\t\t\t{\n\t\t\t\tvar w = WeightAt( a, ear );\n\n\t\t\t\t// \u26A0\uFE0F THE STRONGEST AREA WINS RATHER THAN THE SUM. Two overlapping patches of soot\n\t\t\t\t// should not be twice as thick as either \u2014 that is how a quiet overlap becomes an\n\t\t\t\t// opaque wall exactly where two authored areas meet, which is the seam a mapper is\n\t\t\t\t// least likely to look at and most likely to walk through.\n\t\t\t\tif ( w \u003C= bestWeight ) continue;\n\n\t\t\t\tbestWeight = w;\n\t\t\t\tbest = a;\n\t\t\t}\n\t\t}\n\n\t\t// \u26D4 A FORCED AREA WINS OUTRIGHT, WHEREVER THE LISTENER STANDS \u2014 above the loop\u0027s result, at full weight\n\t\tif ( Forced is not null \u0026\u0026 Enabled )\n\t\t{\n\t\t\tbest = Forced;\n\t\t\tbestWeight = 1f;\n\t\t}\n\n\t\t// The target: the winning area\u0027s look at its weight, or clear air at zero.\n\t\tvar targetWeight = best is null ? 0f : bestWeight * best.Density.Clamp( 0f, 1f );\n\t\tvar targetTint = best is null ? Tint : ( Color.Parse( best.Color ) ?? Color.Black );\n\t\t// \u26D4 THE TOP OF THE DRAWN PRISM, IN WORLD Z. \u0060GradientFog.Height\u0060 is the world altitude at\n\t\t// which the haze has thinned away, not a thickness above wherever the volume sits \u2014 so this\n\t\t// has to be an absolute, and an area drawn on a floor at z\u22481200 has to say 1200-and-up. An\n\t\t// earlier version passed an authored thickness straight through, which on Basalt described a\n\t\t// fog column ending a thousand units UNDERGROUND: nothing rendered, nothing errored, and\n\t\t// every number in the config read correctly.\n\t\t//\n\t\t// \u26A0\uFE0F Now it comes from the shape the mapper actually drew, so there is no second number to\n\t\t// keep in step with the volume.\n\t\tvar targetHeight = best is null\n\t\t\t? 1200f\n\t\t\t: best.Position.z \u002B ( best.Size.z * 0.5f );\n\n\t\t// \u26D4 DENSITY IS EXPRESSED AS A DISTANCE, BECAUSE THAT IS WHAT THE COMPONENT TAKES.\n\t\t// \u0060GradientFog\u0060 has no opacity \u2014 it has a start and an end distance, and \u0022thicker\u0022 means\n\t\t// \u0022the end is nearer\u0022.\n\t\t//\n\t\t// \u26D4 AND THE RELATIONSHIP IS INVERSE, NOT LINEAR. The first version mapped 0..1 onto\n\t\t// 4000..300 units, which put the default density of 0.18 at an end distance of 3334 \u2014 and\n\t\t// MEASURED on a row of figures at 200/500/900/1600 units, 3334 is invisible and so is 2150.\n\t\t// Fog only began to read at about 900. A linear map spends almost its whole range in the\n\t\t// region where nothing is visible, so every density below 0.7 looked identical and the\n\t\t// setting felt broken rather than subtle.\n\t\t//\n\t\t// 160/d is the inverse curve that actually spreads the control out:\n\t\t//     0.05 -\u003E 3200u (a suggestion)   0.18 -\u003E 900u (the default, light but present)\n\t\t//     0.50 -\u003E 320u  (heavy)          1.00 -\u003E 160u (a wall)\n\t\tvar targetEnd = targetWeight \u003C= 0.001f\n\t\t\t? 20000f\n\t\t\t: ( 160f / targetWeight ).Clamp( 120f, 20000f );\n\n\t\t// \u26D4 THE HOLD PINS THE TARGET, IT DOES NOT SKIP THE UPDATE. Skipping would freeze the TINT and\n\t\t// the end distance too, so a zone left while its colour was still crossfading would sit\n\t\t// half-way between two colours for the whole hold. Pinning only the weight lets everything\n\t\t// else keep resolving.\n\t\tvar falling = targetWeight \u003C Weight;\n\n\t\t// \u26A0\uFE0F INSIDE ANY ZONE AT ALL, however weakly. \u0060bestWeight\u0060 is the raw containment weight\n\t\t// before density is applied, so this is \u0022is the player within some zone\u0027s bounds\u0022 rather\n\t\t// than \u0022is the fog thick\u0022 \u2014 which is the question that matters here.\n\t\tvar inside = bestWeight \u003E 0f;\n\n\t\tif ( falling \u0026\u0026 ( ( KeepWhileInside \u0026\u0026 inside ) || _sinceFalling \u003C Hold ) )\n\t\t{\n\t\t\ttargetWeight = Weight;\n\t\t\tHolding = true;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tHolding = false;\n\n\t\t\t// \u26D4 THE TIMER RESETS WHILE INSIDE A ZONE TOO, NOT ONLY WHEN RISING. Otherwise stepping\n\t\t\t// out of a second zone would carry over however much of the hold had already elapsed\n\t\t\t// crossing the first one, and the two-second delay would be whatever was left of it.\n\t\t\tif ( !falling || inside ) _sinceFalling = 0f;\n\t\t}\n\n\t\t// Blend time comes from the area being entered, or from the one being left.\n\t\tvar blend = MathF.Max( 0.05f, best?.Blend ?? 0.75f );\n\t\tvar t = ( dt / blend ).Clamp( 0f, 1f );\n\n\t\tWeight = Weight.LerpTo( targetWeight, t );\n\t\tTint = Color.Lerp( Tint, targetTint, t );\n\t\tEndDistance = EndDistance.LerpTo( targetEnd, t );\n\t\tInside = best is null\n\t\t\t? \u0022-\u0022\n\t\t\t: $\u0022{best.Color} d{best.Density:0.##} {best.Footprint.Count}-sided {best.Size.z:0} tall\u0022;\n\n\t\tfog.Color = Tint;\n\t\tfog.Height = targetHeight;\n\n\t\t// \u26A0\uFE0F FLAT 1, AND 2.5 WAS TRIED AND MADE IT WORSE. Pooling the haze toward the floor sounds\n\t\t// truer \u2014 soot settles \u2014 but on a GradientFog whose Height is already the top of a drawn\n\t\t// volume, a steep exponent just thins the whole column and the areas read as weaker rather\n\t\t// than as lower. Reported plainly: \u0022the fog that existed became worse\u0022.\n\t\tfog.VerticalFalloffExponent = 1f;\n\t\tfog.StartDistance = 0f;\n\t\tfog.EndDistance = EndDistance;\n\t\tfog.FalloffExponent = 1.6f;\n\n\t\t// \u26A0\uFE0F DISABLED RATHER THAN SET TO NOTHING WHEN THERE IS NO FOG. A GradientFog with a huge end\n\t\t// distance is still a fullscreen pass doing arithmetic that resolves to \u0022unchanged\u0022; a map\n\t\t// with no fog areas should pay nothing at all for the feature.\n\t\tfog.Enabled = Weight \u003E 0.002f;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// How much say this area has at a point \u2014 1 well inside it, falling to 0 at the boundary.\n\t///\n\t/// \u26D4 MEASURED INWARD FROM EVERY FACE, INCLUDING THE CAPS. The nearest boundary might be a wall\n\t/// of the polygon, the top of the prism or its floor, and only the smallest of those describes\n\t/// how deep inside you actually are. Using the polygon alone would snap the haze on and off as\n\t/// you step over the top edge of an area drawn around a walkway.\n\t///\n\t/// \u26A0\uFE0F SMOOTHSTEP, NOT LINEAR. A linear ramp has a visible corner at both ends, and on a fog\n\t/// colour that reads as the fog \u0022starting\u0022 at a particular step rather than gathering.\n\t/// \u003C/summary\u003E\n\tpublic static float WeightAt( FogArea a, Vector3 point )\n\t{\n\t\tvar local = a.Rotation.Inverse * ( point - a.Position );\n\t\tvar half = a.Size.z * 0.5f;\n\n\t\t// Outside the prism vertically.\n\t\tif ( local.z \u003C -half || local.z \u003E half ) return 0f;\n\n\t\t// \u26D4 THE TOP FEATHERS AND THE FLOOR DOES NOT, AND FEATHERING BOTH WAS WRONG. An area is drawn\n\t\t// standing ON a floor, so its lower cap sits at the surface the player walks on \u2014 and fading\n\t\t// from it meant the haze was at its THINNEST exactly at eye height. Measured: an area 440\n\t\t// tall drawn at a spawn gave a weight of 0.104 to a player standing in the middle of it,\n\t\t// which reads as the feature barely working.\n\t\t//\n\t\t// \u26A0\uFE0F The floor is a hard boundary because it is a floor; there is no \u0022just below\u0022 to fade\n\t\t// into. The top still fades, because ash genuinely does thin out as it rises.\n\t\tvar dz = half - local.z;\n\n\t\tvar xy = new Vector2( local.x, local.y );\n\t\tfloat dEdge;\n\n\t\tif ( a.HasFootprint )\n\t\t{\n\t\t\tif ( !InPolygon( xy, a.Footprint ) ) return 0f;\n\t\t\tdEdge = DistanceToEdge( xy, a.Footprint );\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// \u26A0\uFE0F A DRAWN AREA ALWAYS HAS A FOOTPRINT; THIS IS FOR ONE BUILT BY HAND. \u0060nz_fog_set\u0060\n\t\t\t// can only reach an existing entry, but a config edited by hand can carry a Size with\n\t\t\t// no outline, and falling through to \u0022no fog\u0022 would look like the area being ignored.\n\t\t\tvar hx = ( a.Size.x * 0.5f ) - MathF.Abs( local.x );\n\t\t\tvar hy = ( a.Size.y * 0.5f ) - MathF.Abs( local.y );\n\n\t\t\tif ( hx \u003C= 0f || hy \u003C= 0f ) return 0f;\n\t\t\tdEdge = MathF.Min( hx, hy );\n\t\t}\n\n\t\tvar feather = MathF.Max( 1f, a.Feather );\n\t\tvar x = ( MathF.Min( dEdge, dz ) / feather ).Clamp( 0f, 1f );\n\n\t\treturn x * x * ( 3f - 2f * x );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Crossing-number point-in-polygon.\n\t///\n\t/// \u26A0\uFE0F WORKS FOR CONCAVE SHAPES, which matters: the block builder explicitly allows a non-convex\n\t/// footprint, so a convex-only test would quietly mis-handle exactly the shapes this is for.\n\t/// The \u0060(a.y \u003E y) != (b.y \u003E y)\u0060 form counts each edge once at a shared vertex, so a point level\n\t/// with a corner is not double-counted and reported outside. Same test as\n\t/// \u0060DamageWallVolume.InPolygon\u0060 \u2014 deliberately, because a fog area and a damage wall are drawn\n\t/// by the same tool and must agree about what \u0022inside\u0022 means.\n\t/// \u003C/summary\u003E\n\tstatic bool InPolygon( Vector2 pt, IReadOnlyList\u003CVector2\u003E poly )\n\t{\n\t\tif ( poly is null || poly.Count \u003C 3 ) return false;\n\n\t\tvar inside = false;\n\n\t\tfor ( int i = 0, j = poly.Count - 1; i \u003C poly.Count; j = i\u002B\u002B )\n\t\t{\n\t\t\tVector2 a = poly[i], b = poly[j];\n\n\t\t\tif ( ( a.y \u003E pt.y ) != ( b.y \u003E pt.y )\n\t\t\t\t\u0026\u0026 pt.x \u003C ( b.x - a.x ) * ( pt.y - a.y ) / ( b.y - a.y ) \u002B a.x )\n\t\t\t{\n\t\t\t\tinside = !inside;\n\t\t\t}\n\t\t}\n\n\t\treturn inside;\n\t}\n\n\t/// \u003Csummary\u003EShortest distance from an interior point to the outline.\u003C/summary\u003E\n\tstatic float DistanceToEdge( Vector2 pt, IReadOnlyList\u003CVector2\u003E poly )\n\t{\n\t\tvar best = float.MaxValue;\n\n\t\tfor ( int i = 0; i \u003C poly.Count; i\u002B\u002B )\n\t\t{\n\t\t\tvar a = poly[i];\n\t\t\tvar b = poly[( i \u002B 1 ) % poly.Count];\n\n\t\t\tvar ab = b - a;\n\t\t\tvar len2 = ab.LengthSquared;\n\n\t\t\t// A degenerate edge \u2014 two corners clicked in the same place \u2014 would divide by zero.\n\t\t\tvar t = len2 \u003C= 0.0001f ? 0f : ( ( pt - a ).Dot( ab ) / len2 ).Clamp( 0f, 1f );\n\n\t\t\tbest = MathF.Min( best, pt.Distance( a \u002B ab * t ) );\n\t\t}\n\n\t\treturn best;\n\t}\n\n\n\n\t/// \u003Csummary\u003EDrop the blend so a rebuild does not fade from the old map\u0027s fog.\u003C/summary\u003E\n\tpublic void Rebuild()\n\t{\n\t\tWeight = 0f;\n\t\tEndDistance = 20000f;\n\n\t\tif ( _fog.IsValid() ) _fog.Enabled = false;\n\n\t\tBuildSheets();\n\n\t\tvar n = ActiveConfig.Current?.Fog?.Count ?? 0;\n\n\t\tLog.Info( $\u0022[nz-fog] {n} fog area(s), {SheetCount} sheet(s)\u0022\n\t\t\t\u002B ( n == 0 ? \u0022\u0022 : $\u0022 \u2014 visible from outside, {Layers} per area\u0022 ) );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// The geometry that makes a zone visible from across the room.\n\t///\n\t/// \u26A0\uFE0F ONE MESH PER AREA, REUSED BY ITS SHEETS. \u0060DebrisMesh.Build\u0060 triangulates the footprint,\n\t/// which is real work on a many-sided outline \u2014 building it ten times per zone would be ten\n\t/// times the cost for identical vertices.\n\t/// \u003C/summary\u003E\n\tvoid BuildSheets()\n\t{\n\t\tforeach ( var g in _sheets ) g?.Destroy();\n\t\t_sheets.Clear();\n\n\t\tvar list = ActiveConfig.Current?.Fog;\n\t\tif ( !Enabled || !Visible || list is null || list.Count == 0 ) return;\n\n\t\tvar mat = Material.Load( SheetMaterial );\n\n\t\tif ( mat is null )\n\t\t{\n\t\t\tLog.Warning( $\u0022[nz-fog] \u0027{SheetMaterial}\u0027 did not load \u2014 zones will only show from inside\u0022 );\n\t\t\treturn;\n\t\t}\n\n\t\tvar n = Layers.Clamp( 1, 48 );\n\n\t\tfor ( int i = 0; i \u003C list.Count; i\u002B\u002B )\n\t\t{\n\t\t\tvar a = list[i];\n\n\t\t\tif ( !a.HasFootprint || a.Density \u003C= 0.001f ) continue;\n\n\t\t\tvar model = DebrisMesh.Build( a.Footprint, 2f, mat );\n\t\t\tif ( model is null ) continue;\n\n\t\t\t// The prism runs from z=0 up and the area\u0027s origin is its middle \u2014 the convention the\n\t\t\t// wall tools set, and the one \u0060WeightAt\u0060 already assumes.\n\t\t\tvar baseZ = a.Position.z - ( a.Size.z * 0.5f );\n\t\t\tvar tint = Color.Parse( a.Color ) ?? Color.White;\n\n\t\t\tvar root = Scene.CreateObject();\n\t\t\troot.Name = $\u0022Fog Sheets {i}\u0022;\n\t\t\troot.Flags |= GameObjectFlags.NotSaved;\n\t\t\troot.NetworkMode = NetworkMode.Never;   // \u26D4 THIS MACHINE\u0027S OWN \u2014 out of a joiner\u0027s snapshot, where it would stand frozen (NZNetListener)\n\t\t\troot.SetParent( GameObject );\n\t\t\troot.WorldPosition = a.Position.WithZ( baseZ );\n\t\t\troot.WorldRotation = a.Rotation;\n\n\t\t\tfor ( int s = 0; s \u003C n; s\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar frac = n == 1 ? 0f : (float)s / ( n - 1 );\n\n\t\t\t\tvar sheet = Scene.CreateObject();\n\t\t\t\tsheet.Name = $\u0022sheet {s}\u0022;\n\t\t\t\tsheet.Flags |= GameObjectFlags.NotSaved;\n\t\t\t\tsheet.NetworkMode = NetworkMode.Never;   // \u26D4 THIS MACHINE\u0027S OWN \u2014 out of a joiner\u0027s snapshot, where it would stand frozen (NZNetListener)\n\t\t\t\tsheet.SetParent( root );\n\t\t\t\tsheet.LocalPosition = Vector3.Up * ( frac * a.Size.z );\n\t\t\t\tsheet.LocalRotation = Rotation.Identity;\n\n\t\t\t\tvar mr = sheet.Components.Create\u003CModelRenderer\u003E();\n\t\t\t\tmr.Model = model;\n\n\t\t\t\t// \u26D4 PER-RENDERER, BECAUSE ONE MATERIAL SERVES EVERY ZONE ON THE MAP. A material\n\t\t\t\t// parameter would give them all whichever zone\u0027s colour and height were written\n\t\t\t\t// last \u2014 and zones differ in both by design.\n\t\t\t\tif ( mr.SceneObject.IsValid() )\n\t\t\t\t{\n\t\t\t\t\tmr.SceneObject.Attributes.Set( \u0022g_flLavaFogBase\u0022, baseZ );\n\t\t\t\t\tmr.SceneObject.Attributes.Set( \u0022g_flLavaFogHeight\u0022, a.Size.z );\n\t\t\t\t\tmr.SceneObject.Attributes.Set( \u0022g_vLavaFogTint\u0022, new Vector3( tint.r, tint.g, tint.b ) );\n\t\t\t\t\tmr.SceneObject.Attributes.Set( \u0022g_flLavaFogDensity\u0022,\n\t\t\t\t\t\t( a.Density * SheetDensity ).Clamp( 0f, 1f ) );\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t_sheets.Add( root );\n\t\t}\n\t}\n\n\tprotected override void OnDestroy()\n\t{\n\t\t// \u26A0\uFE0F The sheets are children of this object and go with it, but the list must not survive\n\t\t// into a new manager holding destroyed handles.\n\t\t_sheets.Clear();\n\n\t\tif ( _fog.IsValid() ) _fog.Enabled = false;\n\t\tif ( Instance == this ) Instance = null;\n\t}\n}\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Effects/SoundSpotManager.cs","FileName":"SoundSpotManager.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace NZombies;\n\n/// \u003Csummary\u003E\n/// Builds the config\u0027s hand-placed looping sounds.\n///\n/// \u26A0\uFE0F A POINT SOURCE, UNLIKE THE DAMAGE WALL\u0027S. \u0060DamageWallVolume\u0060 walks its emitter to the nearest\n/// part of the volume each frame, because lava is an AREA and a centre-anchored emitter would be a\n/// thousand units from lava you are standing on. A placed sound is a POINT by definition \u2014 a vent, a\n/// generator, a dripping pipe \u2014 so it stays where it was put and needs no per-frame work at all.\n///\n///     # MAPPORT: sound placeable\n/// \u003C/summary\u003E\npublic sealed class SoundSpotManager : Component\n{\n\tpublic static SoundSpotManager Instance { get; private set; }\n\n\tprotected override void OnAwake() =\u003E Instance = this;\n\n\tpublic static SoundSpotManager Ensure( Scene scene = null )\n\t{\n\t\tif ( Instance.IsValid() ) return Instance;\n\n\t\tscene ??= Game.ActiveScene;\n\t\tif ( !scene.IsValid() ) return null;\n\n\t\tvar go = scene.CreateObject();\n\t\tgo.Name = \u0022Sound Spot Manager\u0022;\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\n\t\treturn go.Components.Create\u003CSoundSpotManager\u003E();\n\t}\n\n\treadonly List\u003CGameObject\u003E _built = new();\n\n\tpublic int Built =\u003E _built.Count( g =\u003E g.IsValid() );\n\n\tpublic void Rebuild()\n\t{\n\t\t// \u26D4 SILENCE BEFORE DROPPING THE LIST. \u0060_live\u0060 is the ONLY reference to the playing handles;\n\t\t// clearing it without stopping them leaves audio running that nothing can reach \u2014 the same\n\t\t// failure as the orphaned emitters, one layer further down.\n\t\tforeach ( var v in _live ) Silence( v );\n\t\t_live.Clear();\n\n\t\tforeach ( var g in _built ) g?.Destroy();\n\t\t_built.Clear();\n\n\t\tSweepStrays();\n\n\t\tvar list = ActiveConfig.Current?.Sounds;\n\t\tif ( list is null || list.Count == 0 ) return;\n\n\t\tvar missing = 0;\n\n\t\tfor ( int i = 0; i \u003C list.Count; i\u002B\u002B )\n\t\t\tif ( !Build( i, list[i] ) ) missing\u002B\u002B;\n\n\t\tLog.Info( $\u0022[nz-sound] {Built} of {list.Count} placed sound(s) built\u0022\n\t\t\t\u002B ( missing \u003E 0 ? $\u0022  \u26A0 {missing} with no sound event set\u0022 : \u0022\u0022 ) );\n\n\t\t// \u26D4 THE MIXER\u0027S BUDGET IS 64 VOICES AND THE SURPLUS IS NOT QUEUED, IT IS DROPPED. Every\n\t\t// looping spot holds a voice for as long as it plays, so a map with more of them than the\n\t\t// bus allows loses the difference to a priority race \u2014 silently, and not always the same\n\t\t// ones. See MixerBus: \u0022the loser keeps running on schedule and is simply never heard\u0022.\n\t\t//\n\t\t// \u26A0\uFE0F AND THE BUS IS SHARED. World also carries doors, machines and impacts, so ambience\n\t\t// filling it does not just cut itself off \u2014 it silences everything else routed there.\n\t\tif ( Built \u003E 48 )\n\t\t\tLog.Warning( $\u0022[nz-sound] \u26A0 {Built} looping emitters on one mixer \u2014 its budget is 64 \u0022\n\t\t\t\t\u002B \u0022VOICES and the surplus is dropped, not queued. Consider fewer spots with a \u0022\n\t\t\t\t\u002B \u0022larger Distance, or let a damage wall\u0027s own chasing emitter cover the area.\u0022 );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Destroy any emitter in the scene this manager is not holding.\n\t///\n\t/// \u26D4 BECAUSE A PREVIOUS MANAGER\u0027S EMITTERS OUTLIVED IT, AND THEY WERE STILL AUDIBLE. The\n\t/// spots were built at the scene root, so destroying a manager left all 79 of them standing with\n\t/// nothing holding a reference: \u0060nz_sound_voices_restart\u0060 threw the manager away WITHOUT\n\t/// rebuilding it first, the replacement built a second full set, and the scene ended up with 159\n\t/// SoundPointComponents where the config asks for 80. The orphans answer to no cap, no distance\n\t/// check and no stop \u2014 they are exactly the sounds that cannot be silenced.\n\t///\n\t/// \u26A0\uFE0F PARENTING THEM (see Build) STOPS NEW ONES LEAKING; THIS CLEARS WHAT ALREADY LEAKED.\n\t/// Both are needed: the parenting only governs emitters built after it shipped, and a session\n\t/// that has been running across a hotload still has the old flat ones in it.\n\t/// \u003C/summary\u003E\n\tvoid SweepStrays()\n\t{\n\t\t// \u26D4 AND IT ATE ITS OWN MANAGER. The manager object is called \u0022Sound Spot Manager\u0022, which\n\t\t// STARTS WITH \u0022Sound Spot \u0022 \u2014 so the first version of this swept the very component running\n\t\t// it, taking the 79 emitters it had just built down with it and leaving the scene with one\n\t\t// SoundPointComponent. A prefix is not a category: \u0060--only=lights\u0060 matched \u0060models/HardLight\u0060\n\t\t// in the material tool for the same reason, and a name test always needs its own exclusions.\n\t\tvar strays = Scene.GetAllObjects( false )\n\t\t\t.Where( g =\u003E g.IsValid() \u0026\u0026 g != GameObject )\n\t\t\t.Where( g =\u003E g.Name != null \u0026\u0026 g.Name.StartsWith( \u0022Sound Spot \u0022 )\n\t\t\t\t\u0026\u0026 g.Name != \u0022Sound Spot Manager\u0022 )\n\t\t\t.Where( g =\u003E !_built.Contains( g ) )\n\t\t\t.ToList();\n\n\t\tif ( strays.Count == 0 ) return;\n\n\t\tforeach ( var g in strays ) g.Destroy();\n\n\t\tLog.Warning( $\u0022[nz-sound] swept {strays.Count} orphaned emitter(s) left by an earlier manager\u0022\n\t\t\t\u002B \u0022 \u2014 those were playing outside the voice cap\u0022 );\n\t}\n\n\tbool Build( int index, SoundSpot spot )\n\t{\n\t\t// \u26A0\uFE0F A SPOT WITH NO EVENT IS STILL A PLACED THING. It gets no object, but it stays in the\n\t\t// config and the count above says so \u2014 silently dropping it would make a half-finished\n\t\t// placement look like one that never happened.\n\t\tif ( string.IsNullOrWhiteSpace( spot.Sound ) ) return false;\n\n\t\tvar evt = ResourceLibrary.Get\u003CSoundEvent\u003E( spot.Sound );\n\n\t\tif ( evt is null )\n\t\t{\n\t\t\tLog.Warning( $\u0022[nz-sound] #{index}: \u0027{spot.Sound}\u0027 did not load \u2014 silent\u0022 );\n\t\t\treturn false;\n\t\t}\n\n\t\tvar go = Scene.CreateObject();\n\t\tgo.Name = $\u0022Sound Spot {index}\u0022;\n\t\tgo.Flags |= GameObjectFlags.NotSaved;\n\t\tgo.NetworkMode = NetworkMode.Never;   // \u26D4 THIS MACHINE\u0027S OWN \u2014 out of a joiner\u0027s snapshot, where it would stand frozen (NZNetListener)\n\n\t\t// \u26D4 PARENTED TO THE MANAGER, AND IT WAS NOT. Created at the scene root, these outlive the\n\t\t// manager that owns them: recycling it ORPHANED 79 emitters and the new one built 79 more,\n\t\t// leaving 159 SoundPointComponents in the scene. The orphans still carried the older code\u0027s\n\t\t// \u0060PlayOnStart\u0060, so they went on sounding while the new set reported itself silent \u2014 the\n\t\t// audio you could hear and the list you were reading were describing different objects.\n\t\t//\n\t\t// \u26A0\uFE0F AND THE POSITION IS SET AFTER PARENTING. \u0060SetParent\u0060 keeps the WORLD transform, so the\n\t\t// order matters \u2014 the damage wall\u0027s emitter already sat at the world origin for exactly this.\n\t\tgo.SetParent( GameObject );\n\t\tgo.WorldPosition = spot.Position;\n\n\t\t// \u26D4 THE OBJECT IS A MARKER NOW, AND CARRIES NO SoundPointComponent. Playback is handles\n\t\t// owned by this manager (see Voice). The object stays because it is what makes a placed spot\n\t\t// findable in the scene tree, and because \u0060SweepStrays\u0060 cleans up by name.\n\n\t\t// \u26D4 \u0060spot.Repeat\u0060 AND ITS INTERVAL ARE NO LONGER USED, AND THAT IS THE FIX. The engine\u0027s\n\t\t// repeat scheduler did not survive this manager\u0027s \u0060StopSound\u0060/\u0060StartSound\u0060: a spot granted a\n\t\t// slot played its clip ONCE and then sat silent for as long as it held that slot, which is\n\t\t// the reported *\u0022the sound ends and just stays like that\u0022*. \u0060RepeatMin\u0060 is still read \u2014 as\n\t\t// the CLIP LENGTH, which is what the tool writes into it \u2014 but the looping itself is now\n\t\t// scheduled in \u0060Maintain\u0060 against the handle\u0027s own elapsed time.\n\t\t_built.Add( go );\n\n\t\t_live.Add( new Voice\n\t\t{\n\t\t\tSpot = spot,\n\t\t\tEvent = evt,\n\t\t\tLength = MathF.Max( 0.2f, spot.RepeatMin ),\n\t\t} );\n\n\t\treturn true;\n\t}\n\n\t// \u2500\u2500 nearest-N voice management \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n\t/// \u003Csummary\u003E\n\t/// One placed spot, and the handles currently sounding it.\n\t///\n\t/// \u26D4 HANDLES, NOT A \u0060SoundPointComponent\u0060, BECAUSE THE COMPONENT CANNOT BE ASKED ANYTHING.\n\t/// It has no \u0060IsPlaying\u0060, no \u0060Finished\u0060, no elapsed time, and its \u0060SoundHandle\u0060 is \u0060protected\u0060\n\t/// \u2014 all verified by compiling against it. You can tell it to start and you can tell it to stop,\n\t/// and nothing else. So \u0060Playing\u0060 was only ever a record of having CALLED StartSound, and when\n\t/// the clip ran out and its \u0060Repeat\u0060 did not re-fire, the manager went on believing it was\n\t/// sounding: reported as *\u0022the sound ends and just stays like that\u0022* while the report said\n\t/// PLAYING. A \u0060SoundHandle\u0060 from \u0060Sound.Play\u0060 answers all three questions.\n\t/// \u003C/summary\u003E\n\tsealed class Voice\n\t{\n\t\tpublic SoundSpot Spot;\n\t\tpublic SoundEvent Event;\n\t\tpublic float Length;\n\n\t\t/// \u003Csummary\u003ELive handles \u2014 briefly TWO, where the next pass has started under the tail\n\t\t/// of the one it replaces. That overlap is the thing that removes the seam.\u003C/summary\u003E\n\t\tpublic readonly List\u003CSoundHandle\u003E Handles = new();\n\n\t\tpublic bool Playing;\n\t}\n\n\treadonly List\u003CVoice\u003E _live = new();\n\n\t/// \u003Csummary\u003E\n\t/// \u0060nz_sound_voices\u0060 \u2014 how many placed sounds may play at once, per player.\n\t///\n\t/// \u26D4 A HARD CAP EXISTS BECAUSE THE MIXER\u0027S DOES NOT BEHAVE LIKE ONE. \u0060World\u0060 allows 64 voices\n\t/// and the surplus is not queued \u2014 it loses a priority race, keeps running on schedule, and is\n\t/// never heard. That makes over-subscription silent, intermittent, and not always the same\n\t/// emitters. Capping well under the budget means the ones we DO play can never lose that race,\n\t/// and leaves room for the doors, machines and impacts sharing the bus.\n\t/// \u003C/summary\u003E\n\t[ConVar( \u0022nz_sound_voices\u0022 )] public static int MaxVoices { get; set; } = 6;\n\n\t/// \u003Csummary\u003E\n\t/// How much closer a rival must be before it takes a playing emitter\u0027s slot.\n\t///\n\t/// \u26D4 WITHOUT THIS, TWO SPOTS AT NEARLY EQUAL RANGE SWAP EVERY TICK AND BOTH STUTTER. Walking\n\t/// the boundary between them, the ranking flips back and forth and each restart cuts the clip\n\t/// off at the top \u2014 which is precisely the \u0022stopping in the middle\u0022 this whole change is meant\n\t/// to remove. A playing emitter is ranked as if 15% nearer, so it keeps its slot until something\n\t/// is clearly closer rather than marginally.\n\t/// \u003C/summary\u003E\n\t[ConVar( \u0022nz_sound_hysteresis\u0022 )] public static float Hysteresis { get; set; } = 0.85f;\n\n\t/// \u003Csummary\u003E\n\t/// \u0060nz_sound_overlap\u0060 \u2014 seconds the next pass starts BEFORE the current one ends.\n\t///\n\t/// \u26A0\uFE0F THIS IS THE \u0022CUT THE CORNERS\u0022 KNOB. The loop is an mp3, which carries no loop points, so\n\t/// seamlessness has to be manufactured: the next pass begins while the previous is still\n\t/// sounding and they sum for this long. On a noise bed \u2014 lava, wind, machinery \u2014 that is\n\t/// inaudible; on anything with a beat or a pitched tail it would double briefly, which is why it\n\t/// is a knob and not a constant.\n\t/// \u003C/summary\u003E\n\t[ConVar( \u0022nz_sound_overlap\u0022 )] public static float Overlap { get; set; } = 1.5f;\n\n\t/// \u003Csummary\u003E\n\t/// \u0060nz_sound_fade\u0060 \u2014 seconds to fade a voice out when a nearer spot takes its slot.\n\t///\n\t/// \u26A0\uFE0F A HARD STOP MID-CLIP IS A CLICK, and the cap makes those happen constantly as the\n\t/// player walks. \u0060SoundHandle.Stop( float )\u0060 ramps instead.\n\t/// \u003C/summary\u003E\n\t[ConVar( \u0022nz_sound_fade\u0022 )] public static float FadeOut { get; set; } = 0.4f;\n\n\tTimeSince _sinceVoices;\n\n\t/// \u003Csummary\u003E\n\t/// How many times the voice loop has run.\n\t///\n\t/// \u26D4 THE ONE FACT THAT SEPARATES \u0022THE RANKING IS WRONG\u0022 FROM \u0022THE LOOP IS NOT RUNNING\u0022, and\n\t/// nothing else in the readout distinguishes them: a manager whose \u0060OnUpdate\u0060 never fires looks\n\t/// exactly like one whose candidates are all out of range \u2014 valid instance, populated list, a\n\t/// report that works because a ConCmd does not need the hook.\n\t/// \u003C/summary\u003E\n\tint _ticks;\n\n\t/// \u003Csummary\u003E\n\t/// \u0060nz_sound_voices_restart\u0060 \u2014 throw the manager away and build it again.\n\t///\n\t/// \u26D4 A HOTLOAD KEEPS A COMPONENT INSTANCE BUT NOT HOOKS IT NEVER HAD. \u0060OnUpdate\u0060 was added to\n\t/// this class while an instance was already live, and the engine went on running the old\n\t/// registration \u2014 so the voice tick never fired and every spot reported silent with twelve of\n\t/// them in range. Nothing about that is visible: the manager is valid, its list is populated,\n\t/// and the report works, because the report is a ConCmd and does not depend on the hook.\n\t///\n\t/// \u26A0\uFE0F WHICH MAKES THIS THE FIRST THING TO TRY when a manager stops responding after a code\n\t/// change, rather than restarting play and losing what is placed.\n\t/// \u003C/summary\u003E\n\t[ConCmd( \u0022nz_sound_voices_restart\u0022 )]\n\tpublic static void VoicesRestart()\n\t{\n\t\tvar scene = Game.ActiveScene;\n\t\tif ( !scene.IsValid() ) { Log.Warning( \u0022[nz-sound] no scene\u0022 ); return; }\n\n\t\t// \u26D4 REBUILDS IN PLACE, AND IT USED TO DESTROY THE MANAGER FIRST. That destroy is what\n\t\t// orphaned 79 emitters: it threw away the only object holding them WITHOUT rebuilding it\n\t\t// first, so nothing ever ran their \u0060_built\u0060 cleanup and the replacement built a second full\n\t\t// set on top. Rebuild() drops this manager\u0027s own emitters and sweeps anything else, which is\n\t\t// the whole point of the command with none of the lifetime risk.\n\t\tvar mgr = Ensure( scene );\n\t\tif ( !mgr.IsValid() ) { Log.Warning( \u0022[nz-sound] no manager\u0022 ); return; }\n\n\t\tmgr.Rebuild();\n\t\tLog.Info( \u0022[nz-sound] rebuilt\u0022 );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// \u0060nz_sound_voices_report\u0060 \u2014 which spots are sounding right now, and why.\n\t///\n\t/// \u26D4 THE ONLY WAY TO SEE A CAP WORKING. With 79 placed and 6 audible, \u0022I cannot hear that one\u0022\n\t/// is the expected behaviour and a bug look identical from inside the game. This prints the\n\t/// ranking the manager actually used, so the answer is a list rather than a guess.\n\t/// \u003C/summary\u003E\n\t[ConCmd( \u0022nz_sound_voices_report\u0022 )]\n\tpublic static void VoicesReport()\n\t{\n\t\tvar mgr = Instance;\n\t\tif ( !mgr.IsValid() ) { Log.Warning( \u0022[nz-sound] no manager\u0022 ); return; }\n\n\t\tvar p = NZPlayer.Local;\n\t\tif ( !p.IsValid() ) { Log.Warning( \u0022[nz-sound] no local player \u2014 all silenced\u0022 ); return; }\n\n\t\tvar ear = p.WorldPosition;\n\n\t\tvar rows = mgr._live\n\t\t\t.Select( v =\u003E new { V = v, Raw = ear.Distance( v.Spot.Position ) } )\n\t\t\t.OrderBy( x =\u003E x.Raw )\n\t\t\t.Take( 12 )\n\t\t\t.ToList();\n\n\t\t// \u26A0\uFE0F COUNTS LIVE HANDLES, NOT THE FLAG. The flag says this manager believes the voice is\n\t\t// sounding; the handles say whether anything actually is. When those two disagree the loop\n\t\t// has stalled, which is exactly the fault that hid behind the old readout.\n\t\tvar handles = mgr._live.Sum( v =\u003E v.Handles.Count( h =\u003E h.IsValid() \u0026\u0026 h.IsPlaying ) );\n\n\t\tLog.Info( $\u0022[nz-sound] {mgr._live.Count( v =\u003E v.Playing )} of {mgr._live.Count} sounding\u0022\n\t\t\t\u002B $\u0022   {handles} live handle(s)\u0022\n\t\t\t\u002B $\u0022   cap {MaxVoices}   overlap {Overlap:0.##}s\u0022\n\t\t\t\u002B $\u0022   hysteresis {Hysteresis:0.##}   ticks {mgr._ticks}\u0022 );\n\n\t\tif ( mgr._ticks == 0 )\n\t\t\tLog.Warning( \u0022[nz-sound] \u26A0 the voice loop has NEVER RUN \u2014 OnUpdate is not firing on this \u0022\n\t\t\t\t\u002B \u0022manager. \u0060nz_sound_voices_restart\u0060.\u0022 );\n\n\t\tforeach ( var r in rows )\n\t\t\tLog.Info( $\u0022[nz-sound]   {( r.V.Playing ? \u0022\u25B6 PLAYING\u0022 : \u0022  silent \u0022 )}\u0022\n\t\t\t\t\u002B $\u0022  {r.V.Handles.Count( h =\u003E h.IsValid() \u0026\u0026 h.IsPlaying )}h\u0022\n\t\t\t\t\u002B $\u0022  {r.Raw:0}u\u0022\n\t\t\t\t\u002B $\u0022  range {r.V.Spot.Distance:0}\u0022\n\t\t\t\t\u002B ( r.Raw \u003E r.V.Spot.Distance ? \u0022  (out of range)\u0022 : \u0022\u0022 ) );\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\t// \u26A0\uFE0F TEN TIMES A SECOND, NOT EVERY FRAME. This is a distance sort over every placed spot;\n\t\t// at 0.1s a sprinting player has moved ~30 units, far less than the hysteresis margin, so\n\t\t// nothing is audibly late.\n\t\tif ( _sinceVoices \u003C 0.1f ) return;\n\t\t_sinceVoices = 0f;\n\t\t_ticks\u002B\u002B;\n\n\t\tTickVoices();\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Keep the nearest \u003Csee cref=\u0022MaxVoices\u0022/\u003E emitters sounding and silence the rest.\n\t///\n\t/// \u26A0\uFE0F THE LOCAL PLAYER, AND THAT IS NOT A MULTIPLAYER SLIP. Which sounds a machine can hear is\n\t/// that machine\u0027s business \u2014 each client ranks against its own listener and there is nothing to\n\t/// network. \u0060NZPlayer.Local\u0060 is the right accessor for exactly this.\n\t/// \u003C/summary\u003E\n\tvoid TickVoices()\n\t{\n\t\tvar p = NZPlayer.Local;\n\n\t\t// \u26A0\uFE0F NO LISTENER MEANS SILENCE, NOT \u0022LEAVE IT AS IT WAS\u0022. In the lobby, or between spawns,\n\t\t// leaving whatever was playing running would carry one map\u0027s ambience into the menu.\n\t\tif ( !p.IsValid() )\n\t\t{\n\t\t\tforeach ( var v in _live ) Silence( v );\n\t\t\treturn;\n\t\t}\n\n\t\tvar ear = p.WorldPosition;\n\n\t\t// \u26D4 OUT OF ITS OWN RANGE IS NOT A CANDIDATE AT ALL. \u0022The six nearest\u0022 on an empty stretch\n\t\t// of map would otherwise hold slots open for emitters nobody can hear, while a spot that\n\t\t// just came into earshot waits behind them.\n\t\tvar ranked = _live\n\t\t\t.Select( v =\u003E new\n\t\t\t{\n\t\t\t\tV = v,\n\t\t\t\tRaw = ear.Distance( v.Spot.Position ),\n\t\t\t} )\n\t\t\t.Where( x =\u003E x.Raw \u003C= x.V.Spot.Distance )\n\t\t\t.OrderBy( x =\u003E x.Raw * ( x.V.Playing ? Hysteresis : 1f ) )\n\t\t\t.ToList();\n\n\t\tvar keep = new HashSet\u003CVoice\u003E();\n\n\t\tfor ( int i = 0; i \u003C ranked.Count \u0026\u0026 i \u003C MaxVoices; i\u002B\u002B )\n\t\t\tkeep.Add( ranked[i].V );\n\n\t\tforeach ( var v in _live )\n\t\t{\n\t\t\tif ( keep.Contains( v ) ) Maintain( v );\n\t\t\telse Silence( v );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Keep one voice sounding, without a gap and without a restart.\n\t///\n\t/// \u26D4 THIS IS WHERE THE LOOP LIVES NOW. The engine\u0027s \u0060Repeat\u0060 was doing this job and stopped\n\t/// doing it the moment the manager took over starting and stopping, with no way to notice: the\n\t/// component cannot be asked whether it is still playing. Scheduling it here against\n\t/// \u0060SoundHandle.ElapsedTime\u0060 means the next pass is started by something that can SEE the\n\t/// current one.\n\t/// \u003C/summary\u003E\n\tvoid Maintain( Voice v )\n\t{\n\t\t// Drop handles that have run out. \u0060Finished\u0060 and \u0060IsPlaying\u0060 are both real on a handle \u2014\n\t\t// which is the whole reason playback moved off the component.\n\t\tv.Handles.RemoveAll( h =\u003E !h.IsValid() || h.Finished || !h.IsPlaying );\n\n\t\tvar newest = v.Handles.Count \u003E 0 ? v.Handles[^1] : null;\n\n\t\t// \u26A0\uFE0F THE SCHEDULED START IS THE ONE THAT MATTERS; THE EMPTY CASE IS A SAFETY NET. If the\n\t\t// length in the config is wrong, or a handle dies early, \u0060newest == null\u0060 catches it within\n\t\t// one tick \u2014 a 0.1s gap rather than a permanent one. Relying on that alone would be audible\n\t\t// on every pass, which is why the overlap below exists.\n\t\t// \u26A0\uFE0F \u0060Time\u0060, NOT \u0060ElapsedTime\u0060 \u2014 the latter compiles and is marked obsolete in this engine\n\t\t// build. Same value, and the deprecated one is the sort that disappears in an update.\n\t\tvar due = newest is null || newest.Time \u003E= v.Length - Overlap;\n\n\t\tif ( !due ) { v.Playing = true; return; }\n\n\t\tvar h = Sound.Play( v.Event, v.Spot.Position );\n\n\t\tif ( !h.IsValid() )\n\t\t{\n\t\t\t// The mixer refused it. Saying so beats a voice that silently never returns.\n\t\t\tif ( v.Playing ) Log.Warning( $\u0022[nz-sound] \u0027{v.Spot.Sound}\u0027 would not start \u2014 mixer full?\u0022 );\n\t\t\tv.Playing = false;\n\t\t\treturn;\n\t\t}\n\n\t\th.Volume = v.Spot.Volume;\n\t\th.DistanceAttenuation = true;\n\t\th.Distance = v.Spot.Distance;\n\t\th.OcclusionEnabled = true;\n\n\t\tv.Handles.Add( h );\n\t\tv.Playing = true;\n\t}\n\n\t/// \u003Csummary\u003EFade a voice out and forget its handles.\u003C/summary\u003E\n\tvoid Silence( Voice v )\n\t{\n\t\tif ( v.Handles.Count == 0 ) { v.Playing = false; return; }\n\n\t\t// \u26D4 \u0060Stop( fade )\u0060, NOT \u0060Stop()\u0060. The cap swaps slots constantly as the player walks, so a\n\t\t// hard cut here is not a rare edge case \u2014 it is a click every few seconds.\n\t\tforeach ( var h in v.Handles )\n\t\t\tif ( h.IsValid() ) h.Stop( FadeOut );\n\n\t\tv.Handles.Clear();\n\t\tv.Playing = false;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// \u26D4 HANDLES ARE NOT OWNED BY A GAMEOBJECT, SO NOTHING ELSE WILL EVER STOP THEM. Destroying\n\t/// this manager destroys its marker objects and leaves every playing handle running with no\n\t/// reference to it anywhere \u2014 the same unstoppable-audio failure the orphaned emitters caused,\n\t/// in a form the scene tree cannot even show.\n\t/// \u003C/summary\u003E\n\tprotected override void OnDestroy()\n\t{\n\t\tforeach ( var v in _live ) Silence( v );\n\t\t_live.Clear();\n\n\t\tif ( Instance == this ) Instance = null;\n\t}\n}\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Net/NZStartup.cs","FileName":"NZStartup.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\r\nusing System.Linq;\r\n\r\nnamespace NZombies;\r\n\r\n/// \u003Csummary\u003E\r\n/// HOST A LOBBY WHEN THE GAME STARTS, so the package page\u0027s **New Game** button produces something\r\n/// a second player can actually join.\r\n///\r\n/// \u26D4 THE MENU DOES NOT CREATE THE LOBBY, AND THAT IS THE WHOLE REASON THIS FILE EXISTS. The\r\n/// create-game dialog on a package page collects a server name, a privacy setting and a player cap,\r\n/// which reads exactly like a thing that is about to host \u2014 and it is not. Facepunch\u0027s own wording\r\n/// on the three launch modes is *\u0022None of these options will automatically create lobbies for\r\n/// you.\u0022* So a published build clicked through **New Game** created no lobby at all: the host\r\n/// landed in a solo session and the other account\u0027s **Join Game** showed \u00600 LOBBIES\u0060.\r\n///\r\n/// Every multiplayer session this project has ever had was started from the EDITOR\u0027s network panel,\r\n/// or later by \u0060nz_host_start\u0060. Neither exists for somebody who just installed the game.\r\n///\r\n/// \u26A0\uFE0F \u0060GameObjectSystem\u0060, NOT A COMPONENT, SO NO SCENE EDIT IS NEEDED. The engine instantiates one\r\n/// per scene on its own. A component would have to be placed in \u0060scenes/nzombies.scene\u0060 \u2014 and this\r\n/// project\u0027s rule is to never rewrite a \u0060.scene\u0060 from a script, for good reasons recorded in\r\n/// INSTRUCTIONS.md.\r\n///\r\n/// \u26A0\uFE0F AN EMPTY \u0060LobbyConfig\u0060, WHICH IS NOT THE SAME AS NO OPINION BY ACCIDENT \u2014 IT IS CHECKED.\r\n/// The parameterless \u0060CreateLobby()\u0060 is obsolete (CS0618), so this passes a config; the engine\u0027s own\r\n/// docs say what an unset one means, and it is exactly what we want: *Privacy* \u2014 \u0022this will be\r\n/// public by default\u0022; *MaxPlayers* \u2014 \u0022by default, this will be the Max Players set in the current\r\n/// Game Package\u0027s project settings\u0022; *Name* \u2014 \u0022a default lobby name will be chosen instead\u0022.\r\n///\r\n/// So every field we leave alone defers to the package or the menu rather than to us. Setting any\r\n/// of them here would override what the player chose in the create-game dialog with no way for them\r\n/// to tell. \u0060nz_host_start\u0060 still takes explicit settings, because there is no dialog behind a\r\n/// console command.\r\n///\r\n/// \u26D4 IT DOES NOT SOLVE \u00A79.5 AND MUST NOT BE READ AS DOING SO. A lobby existing from the moment the\r\n/// scene loads means somebody can join BEFORE a map is loaded \u2014 and \u0060nz_map_load\u0060 disconnects every\r\n/// client. The documented answer is \u0060SceneNetworkSystem.LoadSceneBroadcast\u0060, which we do not use.\r\n/// Until that is done, the working order is still: load the map, THEN let people in. The log below\r\n/// says so out loud rather than leaving it to be rediscovered.\r\n/// \u003C/summary\u003E\r\npublic sealed class NZStartup : GameObjectSystem\u003CNZStartup\u003E, ISceneStartup\r\n{\r\n\tpublic NZStartup( Scene scene ) : base( scene ) { }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Host a lobby automatically on startup. \u0060nz_autohost 0\u0060 never, \u00601\u0060 outside the editor\r\n\t/// (default), \u00602\u0060 always.\r\n\t///\r\n\t/// \u26D4 IT DEFAULTED TO ON EVERYWHERE AND THAT WAS A REAL REGRESSION, not just noise. The\r\n\t/// paragraph this replaces already said *\u0022the editor and a published build want opposite\r\n\t/// things\u0022* \u2014 and then defaulted to the published build\u0027s answer, which forced every editor\r\n\t/// Play into the multiplayer code path.\r\n\t///\r\n\t/// The cost is not the lobby, it is \u0060Networking.IsActive\u0060. Solo in the editor that used to be\r\n\t/// FALSE, and a great deal of this project branches on it \u2014 above all \u0060NZPlayers.EnsureHostBody\u0060,\r\n\t/// whose first line is \u0060if ( !Networking.IsActive || NZGame.IsClient ) return;\u0060. With networking\r\n\t/// off the player simply used the scene\u0027s own body. With it on, the body is CLONED, network-\r\n\t/// spawned, and the scene original destroyed. Zombies find their target through\r\n\t/// \u0060PlayerSpawner.AllBodies()\u0060, so anything that changes which bodies exist is a zombie-behaviour\r\n\t/// change wearing a networking hat.\r\n\t///\r\n\t/// \u26A0\uFE0F \u00602\u0060 EXISTS SO THE PUBLISHED PATH IS STILL TESTABLE FROM THE EDITOR. A switch that can\r\n\t/// only be turned off would mean the thing that actually ships is the thing never exercised here.\r\n\t/// \u003C/summary\u003E\r\n\t[ConVar( \u0022nz_autohost\u0022 )]\r\n\tpublic static int AutoHost { get; set; } = 1;\r\n\r\n\t/// \u003Csummary\u003EShould this machine host on startup, given where it is running.\u003C/summary\u003E\r\n\tpublic static bool ShouldAutoHost =\u003E AutoHost \u003E= 2 || (AutoHost == 1 \u0026\u0026 !Game.IsEditor);\r\n\r\n\t/// \u003Csummary\u003EDid we host, this session. Reported by \u0060nz_host\u0060.\u003C/summary\u003E\r\n\tpublic static bool DidAutoHost { get; private set; }\r\n\r\n\tvoid ISceneStartup.OnHostPreInitialize( SceneFile scene ) { }\r\n\r\n\tvoid ISceneStartup.OnClientInitialize() { }\r\n\r\n\tvoid ISceneStartup.OnHostInitialize()\r\n\t{\r\n\t\tif ( !ShouldAutoHost )\r\n\t\t{\r\n\t\t\tLog.Info( $\u0022[nz-net] not auto-hosting (nz_autohost {AutoHost}\u0022\r\n\t\t\t\t\u002B (Game.IsEditor \u0026\u0026 AutoHost == 1 ? \u0022, editor\u0022 : \u0022\u0022)\r\n\t\t\t\t\u002B \u0022) \u2014 nobody can join until you host.\u0022\r\n\t\t\t\t\u002B (Game.IsEditor ? \u0022  \u0060nz_host_start\u0060 when you want a session; \u0060nz_autohost 2\u0060 to\u0022\r\n\t\t\t\t\t\u002B \u0022 host on Play like a published build does.\u0022 : \u0022\u0022) );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// \u26A0\uFE0F CHECKED EVEN THOUGH THE DOCS\u0027 EXAMPLE DOES NOT. Their note says \u0060CreateLobby\u0060 handles\r\n\t\t// being called while already hosting; that is their word for their code, and a duplicate\r\n\t\t// lobby is not a failure mode worth trusting a sentence about. The check costs nothing and\r\n\t\t// the log line it produces is the answer to \u0022why did nz_host_start say already networked\u0022.\r\n\t\tif ( Networking.IsActive )\r\n\t\t{\r\n\t\t\tLog.Info( $\u0022[nz-net] already networked at startup \u2014 \u0022\r\n\t\t\t\t\u002B $\u0022{(Networking.IsHost ? \u0022hosting\u0022 : \u0022a client\u0022)}, not creating a second lobby.\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tNetworking.CreateLobby( new Sandbox.Network.LobbyConfig() );\r\n\t\tDidAutoHost = true;\r\n\r\n\t\tLog.Info( \u0022[nz-net] hosting \u2014 this is what makes the package page\u0027s Join Game find us.\u0022 );\r\n\r\n\t\t// \u26D4 THE MAP WARNING IS THE POINT OF LOGGING AT ALL. A lobby now exists, so somebody can\r\n\t\t// join RIGHT NOW \u2014 and \u0060nz_map_load\u0060 disconnects every client, so a map loaded after they\r\n\t\t// arrive throws them straight back out. See SBOX_MULTIPLAYER.md \u00A79.5.\r\n\t\tLog.Info( string.IsNullOrEmpty( NZMap.Current )\r\n\t\t\t? \u0022[nz-net] \u26D4 NO MAP LOADED. Load one BEFORE anyone joins \u2014 nz_map_load kicks clients.\u0022\r\n\t\t\t: $\u0022[nz-net] map \u0027{NZMap.Current}\u0027 is up \u2014 safe for people to join.\u0022 );\r\n\t}\r\n}\r\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Player/CharacterVoice.cs","FileName":"CharacterVoice.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace NZombies;\r\n\r\n/// \u003Csummary\u003E\r\n/// The crew\u0027s voice lines. 526 of them, four characters, nineteen situations.\r\n///\r\n/// \u26D4 ONE LINE AT A TIME, GLOBALLY. Your character has one mouth; two cues playing together is not\r\n/// twice the character, it is unintelligible. A kill quip and a \u0022no ammo\u0022 line landing in the same\r\n/// frame is not a corner case \u2014 it is what happens the moment a magazine runs dry mid-horde.\r\n///\r\n/// \u26D4 AND THE LOUDER EVENT WINS. Without priority, a 6%-chance kill quip occupies the mouth and the\r\n/// player goes down in silence a tenth of a second later. Powerups, downs and revives outrank\r\n/// chatter and cut it off; chatter never interrupts anything.\r\n///\r\n/// \u26A0\uFE0F PER-SITUATION CHANCE, NOT ONE GLOBAL RATE. A Max Ammo is rare and should ALWAYS be remarked\r\n/// on; a zombie kill happens hundreds of times a round and must not be. One rate makes the rare\r\n/// events feel broken or the common ones unbearable.\r\n///\r\n/// \u26A0\uFE0F 2D, NOT POSITIONAL \u2014 it is your own character speaking. Same reasoning as Brutus\u0027s helmet\r\n/// break: you do not hear yourself from three feet away.\r\n///\r\n/// \u26A0\uFE0F A STATIC CLASS, NOT A \u0060Component\u0060. Nothing here has per-instance state or needs a tick, and\r\n/// deriving from \u0060Component\u0060 made \u0060Enabled\u0060 hide \u0060Component.Enabled\u0060 \u2014 two properties one letter\r\n/// apart, where setting the wrong one silently does nothing.\r\n/// \u003C/summary\u003E\r\npublic static class CharacterVoice\r\n{\r\n\t/// \u003Csummary\u003EHow likely a situation is to be remarked on, and how long before it may repeat.\u003C/summary\u003E\r\n\tpublic readonly record struct Line( string Type, float Chance, float Cooldown, int Priority );\r\n\r\n\tpublic const int Chatter = 0;\r\n\tpublic const int Event = 1;\r\n\tpublic const int Urgent = 2;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The nineteen situations.\r\n\t///\r\n\t/// \u26A0\uFE0F THE NUMBERS ARE CHOSEN, NOT PORTED. The TFA VOX pack\u0027s Lua is compressed and unreadable,\r\n\t/// so its real rates could not be recovered. These respect how often each situation actually\r\n\t/// occurs; \u0060nz_voice_set\u0060 retunes any of them live.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static readonly List\u003CLine\u003E Lines = new()\r\n\t{\r\n\t\t// Rare, deliberate moments \u2014 always worth a line.\r\n\t\tnew( \u0022nuke\u0022, 1.00f, 0f, Event ),\r\n\t\tnew( \u0022instakill\u0022, 1.00f, 0f, Event ),\r\n\t\tnew( \u0022doublepoints\u0022, 1.00f, 0f, Event ),\r\n\t\tnew( \u0022maxammo\u0022, 1.00f, 0f, Event ),\r\n\t\tnew( \u0022firesale\u0022, 1.00f, 0f, Event ),\r\n\t\tnew( \u0022carpenter\u0022, 1.00f, 0f, Event ),\r\n\t\tnew( \u0022poweron\u0022, 1.00f, 0f, Event ),\r\n\t\tnew( \u0022upgrading\u0022, 1.00f, 0f, Event ),\r\n\t\tnew( \u0022perkdrink\u0022, 1.00f, 0f, Event ),\r\n\r\n\t\t// Life and death outranks everything.\r\n\t\tnew( \u0022downed\u0022, 1.00f, 0f, Urgent ),\r\n\t\tnew( \u0022revived\u0022, 1.00f, 0f, Urgent ),\r\n\t\tnew( \u0022reviving\u0022, 1.00f, 4f, Urgent ),\r\n\r\n\t\t// Constant occurrences \u2014 rationed hard.\r\n\t\tnew( \u0022kill\u0022, 0.06f, 7f, Chatter ),\r\n\t\tnew( \u0022pain\u0022, 0.20f, 6f, Chatter ),\r\n\t\tnew( \u0022surrounded\u0022, 0.50f, 20f, Chatter ),\r\n\t\tnew( \u0022noammo\u0022, 0.30f, 8f, Chatter ),\r\n\t\tnew( \u0022nomoney\u0022, 0.35f, 5f, Chatter ),\r\n\t\tnew( \u0022pickup\u0022, 0.35f, 5f, Chatter ),\r\n\t\tnew( \u0022doground\u0022, 0.15f, 10f, Chatter ),\r\n\t};\r\n\r\n\tstatic int IndexOfType( string type )\r\n\t{\r\n\t\tfor ( var i = 0; i \u003C Lines.Count; i\u002B\u002B )\r\n\t\t\tif ( Lines[i].Type.Equals( type, StringComparison.OrdinalIgnoreCase ) ) return i;\r\n\r\n\t\treturn -1;\r\n\t}\r\n\r\n\t// \u26A0\uFE0F STATIC, AND CORRECT HERE: tuning and playback bookkeeping for the LOCAL player\u0027s own voice,\r\n\t// not state a player owns in the \u0060SERVER_ROADMAP.md\u0060 \u00A74 sense. Split-screen would need it\r\n\t// per-player; nothing else would.\r\n\t/// \u003Csummary\u003E\r\n\t/// Shortest gap between ANY two chatter lines, whatever their situation.\r\n\t///\r\n\t/// \u26D4 THE PER-SITUATION COOLDOWNS ARE NOT ENOUGH ON THEIR OWN, AND THAT IS WHAT WENT WRONG.\r\n\t/// Every one of them worked \u2014 the counters showed cooldown and busy skips both firing \u2014 but a\r\n\t/// kill, a pickup, a grunt, a \u0022no ammo\u0022 and a barricade repair each have their OWN key, so eight\r\n\t/// situations can every one be within its limit while the player hears someone talking\r\n\t/// constantly. Sixteen lines in a short session, none of them a bug.\r\n\t///\r\n\t/// \u26A0\uFE0F EVENTS AND URGENT LINES IGNORE IT. A Max Ammo or going down is rare and load-bearing; making\r\n\t/// those wait behind a kill quip is the opposite of the priority system.\r\n\t/// \u003C/summary\u003E\r\n\t/// \u003Cremarks\u003E\r\n\t/// \u26D4 15s, AND IT NOW APPLIES TO EVERY LINE RATHER THAN TO CHATTER ONLY. The exemption for\r\n\t/// events and urgent lines is gone deliberately: it was the remaining way a character could\r\n\t/// speak twice in quick succession, which is the thing being removed.\r\n\t///\r\n\t/// \u26A0\uFE0F SO A DOWN OR A REVIVE CAN NOW BE SWALLOWED. Go down eight seconds after a kill quip and\r\n\t/// the character says nothing \u2014 those lines used to bypass this on purpose, being rare and\r\n\t/// load-bearing. If that reads wrong in play, \u0060nz_voice_gap\u0060 changes it live, and the exemption\r\n\t/// is one condition to restore. This is the trade the rule asks for, not an oversight.\r\n\t/// \u003C/remarks\u003E\r\n\t/// \u003Cremarks\u003E\r\n\t/// \u26D4 NULLABLE-BACKED, AND A PLAIN \u0060= 15f\u0060 DID NOT WORK. A static\u0027s VALUE survives a hotload but\r\n\t/// its INITIALISER does not re-run, so raising this from 12 left the running editor still\r\n\t/// reporting the old number while the source said 15 \u2014 the change appearing to do nothing, on a\r\n\t/// setting whose whole job is to be felt. \u0060VoiceRangeScale\u0060, \u0060SoundGate.Policies\u0060 and\r\n\t/// \u0060AmbientBudget\u0060 all carry the same defence, and this is at least the fourth time this trap\r\n\t/// has cost time in this project.\r\n\t/// \u003C/remarks\u003E\r\n\tpublic static float GlobalGap\r\n\t{\r\n\t\tget =\u003E _globalGap ??= 15f;\r\n\t\tset =\u003E _globalGap = value;\r\n\t}\r\n\r\n\tstatic float? _globalGap;\r\n\r\n\tstatic float _nextAnyChatter;\r\n\r\n\tstatic readonly Dictionary\u003Cstring, float\u003E _nextAllowed = new();\r\n\tstatic SoundHandle _current;\r\n\tstatic float _busyUntil;\r\n\r\n\t/// \u003Csummary\u003ESuppress every voice line. \u0060nz_voice_off\u0060.\u003C/summary\u003E\r\n\tpublic static bool Enabled { get; set; } = true;\r\n\r\n\tpublic static int Played, SkippedChance, SkippedCooldown, SkippedBusy, SkippedNoCharacter;\r\n\tpublic static int SkippedGlobal;\r\n\tpublic static int FailedHandles;\r\n\tpublic static string LastFailed = \u0022-\u0022;\r\n\tpublic static string LastSaid = \u0022-\u0022;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Say something, if this character has anything to say about it and the moment allows.\r\n\t///\r\n\t/// \u26A0\uFE0F IT TAKES THE SITUATION, NOT A SOUND NAME. The caller says what happened; which of four\r\n\t/// voices, which of fifteen takes, and whether to speak at all are decided here. A call site\r\n\t/// naming a cue would have to know the character, and every one would need editing to add a\r\n\t/// fifth.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void Say( string type, NZPlayer player = null )\r\n\t{\r\n\t\tif ( !Enabled ) return;\r\n\r\n\t\t// \u26A0\uFE0F VIA \u0060PlayerCharacters.Local\u0060, which sees a DISABLED player. In the lobby the player\r\n\t\t// object is switched off, and the plain scene query returns nothing there.\r\n\t\tplayer ??= PlayerCharacters.Local();\r\n\r\n\t\t// \u26D4 SOMEBODY ELSE\u0027S MOUTH IS NOT MINE TO OPEN. Half of this method reads state that only\r\n\t\t// exists on the speaker\u0027s own machine \u2014 which character they picked, which of their\r\n\t\t// cooldowns are spent, whether they are already mid-sentence \u2014 and a proxy answers all\r\n\t\t// three wrong. The visible symptom was the kill quip: \u0060AwardKillPoints\u0060 runs on the HOST for\r\n\t\t// every kill in the game, so the host\u0027s character chirped for a client\u0027s kills and the\r\n\t\t// client\u0027s own character never said a word.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F AN ASK, NOT A COMMAND. The owner still decides whether to speak at all; this only\r\n\t\t// moves the question to the machine that can answer it.\r\n\t\tif ( Networking.IsActive \u0026\u0026 player.IsValid()\r\n\t\t\t\u0026\u0026 PlayerPresence.Theirs( player.GameObject ) )\r\n\t\t{\r\n\t\t\tvar them = NZPlayers.OwnerOf( player.GameObject );\r\n\t\t\tif ( !string.IsNullOrEmpty( them ) ) NZNet.VoiceAsk( them, type );\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar who = PlayerCharacters.Find( player.IsValid() ? player.CharacterId : null );\r\n\r\n\t\t// \u26A0\uFE0F SILENCE, NOT A DEFAULT VOICE. Nobody picked a character, so nobody speaks.\r\n\t\tif ( who is null ) { SkippedNoCharacter\u002B\u002B; return; }\r\n\r\n\t\tvar idx = IndexOfType( type );\r\n\t\tif ( idx \u003C 0 ) return;\r\n\r\n\t\tvar line = Lines[idx];\r\n\r\n\t\t// \u26D4 THE BUSY CHECK COMES BEFORE THE DICE. Rolling first would burn the cooldown on a line\r\n\t\t// that was never going to be heard, silently spending a rare event while someone else talks.\r\n\t\t//\r\n\t\t// \u26D4 AND PRIORITY NO LONGER OVERRIDES IT. \u0060line.Priority \u003E _currentPriority\u0060 used to let an\r\n\t\t// important line through here, and the code below then STOPPED the sentence in progress to\r\n\t\t// make room \u2014 a character cut off mid-word. One mouth now means one mouth: whatever is\r\n\t\t// speaking finishes, and the new line is refused rather than granted at its expense.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F THE HANDLE IS ASKED, NOT A TIMER TRUSTED. \u0060_busyUntil\u0060 is a flat 2.5s guess made\r\n\t\t// because the clip length is not known here; \u0060SoundHandle.IsPlaying\u0060 is the real answer and\r\n\t\t// costs nothing. The timer stays as the cover for the frame a sound is starting in.\r\n\t\tif ( _current.IsValid() \u0026\u0026 _current.IsPlaying )\r\n\t\t{\r\n\t\t\tSkippedBusy\u002B\u002B;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( Time.Now \u003C _busyUntil )\r\n\t\t{\r\n\t\t\tSkippedBusy\u002B\u002B;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// \u26D4 THE GLOBAL GAP, NOW ON EVERY LINE. Checked before the per-situation cooldown because it\r\n\t\t// is the stricter of the two and the cheaper to answer. See \u0060GlobalGap\u0060 for what the loss of\r\n\t\t// the chatter-only exemption costs.\r\n\t\tif ( Time.Now \u003C _nextAnyChatter )\r\n\t\t{\r\n\t\t\tSkippedGlobal\u002B\u002B;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar key = $\u0022{who.Id}.{type}\u0022;\r\n\r\n\t\tif ( _nextAllowed.TryGetValue( key, out var next ) \u0026\u0026 Time.Now \u003C next )\r\n\t\t{\r\n\t\t\tSkippedCooldown\u002B\u002B;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( line.Chance \u003C 1f \u0026\u0026 Game.Random.Float() \u003E line.Chance )\r\n\t\t{\r\n\t\t\tSkippedChance\u002B\u002B;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// \u26D4 NOTHING IS STOPPED HERE ANY MORE. This used to be \u0060_current.Stop()\u0060 \u2014 \u0022one mouth\u0022,\r\n\t\t// enforced by cutting the sentence already coming out of it. The busy check above now owns\r\n\t\t// that rule and enforces it by refusing the new line instead, so reaching this point means\r\n\t\t// the previous one has genuinely finished.\r\n\t\tvar cue = $\u0022vo.primis.{who.Id}.{type}\u0022;\r\n\r\n\t\t// \u26D4 FETCH THE RESOURCE, DO NOT PLAY THE NAME. \u0060Sound.Play( \u0022vo.primis.dempsey.nuke\u0022 )\u0060\r\n\t\t// returned a DEAD HANDLE while the asset existed, compiled, and was findable by path \u2014 the\r\n\t\t// bare-name lookup does not resolve these. \u0060NZSound\u0060 already works this way at its own call\r\n\t\t// site for the same reason. Measured, not guessed: the handle-validity counter below is what\r\n\t\t// separated \u0022the cue is missing\u0022 from \u0022the name form is wrong\u0022.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F AND THE PATH IS \u0060sounds/vo/\u0060, NOT \u0060sounds/nz/\u0060. NZSound hardcodes the latter, which is\r\n\t\t// why these could not simply go through it.\r\n\t\tif ( !ResourceLibrary.TryGet\u003CSoundEvent\u003E( $\u0022sounds/vo/{cue}.sound\u0022, out var ev ) )\r\n\t\t{\r\n\t\t\tFailedHandles\u002B\u002B;\r\n\t\t\tLastFailed = cue;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_current = Sound.Play( ev );\r\n\r\n\t\tif ( !_current.IsValid() ) { FailedHandles\u002B\u002B; LastFailed = cue; }\r\n\r\n\t\t// \u26D4 AND NOW EVERYBODY ELSE HEARS IT. Voice lines have never left the machine that spoke\r\n\t\t// them, so a co-op crew of four was four people talking to themselves \u2014 no \u0022I\u0027m down!\u0022, no\r\n\t\t// \u0022reviving you\u0022, which are the two lines the whole downed system is built around.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F THE RESOLVED CUE TRAVELS, NOT THE SITUATION. Which character, which take and whether\r\n\t\t// to speak were all just decided here; re-deciding on each receiver would give one player\r\n\t\t// four different voices depending on who was listening.\r\n\t\tif ( Networking.IsActive \u0026\u0026 player.IsValid() )\r\n\t\t{\r\n\t\t\tvar mine = NZPlayers.OwnerOf( player.GameObject );\r\n\t\t\tif ( !string.IsNullOrEmpty( mine ) ) NZNet.VoiceLine( mine, cue, line.Priority );\r\n\t\t}\r\n\r\n\t\t// \u26A0\uFE0F A FLAT HOLD, BECAUSE THE CLIP LENGTH IS NOT KNOWN HERE. Most run 1-3 seconds; 2.5 keeps\r\n\t\t// lines off each other without per-clip durations we would have to measure and then keep in\r\n\t\t// step with the assets.\r\n\t\t_busyUntil = Time.Now \u002B 2.5f;\r\n\r\n\t\tif ( line.Cooldown \u003E 0f ) _nextAllowed[key] = Time.Now \u002B line.Cooldown;\r\n\r\n\t\t// \u26A0\uFE0F EVERY LINE PUSHES THE GAP OUT, which is now the same rule on the way in and the way\r\n\t\t// out. It used to be stamped by all lines but honoured only by chatter.\r\n\t\t_nextAnyChatter = Time.Now \u002B GlobalGap;\r\n\r\n\t\tPlayed\u002B\u002B;\r\n\t\tLastSaid = key;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// One speaker\u0027s playback state on THIS machine. Not their cooldowns \u2014 only their mouth.\r\n\t///\r\n\t/// \u26D4 SEPARATE FROM THE LOCAL STATICS ABOVE, WHICH IS THE POINT. \u0060_busyUntil\u0060 and friends are\r\n\t/// the DECISION state: my dice, my cooldowns, my sentence in progress. If a teammate speaking\r\n\t/// wrote to those, their line would silence mine and eat my global chatter gap \u2014 four players\r\n\t/// would share one turn to talk. A remote line needs exactly one rule, \u0022one voice per person\u0022,\r\n\t/// and that is all this holds.\r\n\t/// \u003C/summary\u003E\r\n\tsealed class RemoteMouth\r\n\t{\r\n\t\tpublic SoundHandle Handle;\r\n\t\tpublic float BusyUntil;\r\n\t\tpublic int Priority = -1;\r\n\t}\r\n\r\n\tstatic readonly Dictionary\u003Cstring, RemoteMouth\u003E _remote = new();\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Somebody else said something. Play it where they are standing.\r\n\t///\r\n\t/// \u26A0\uFE0F POSITIONED, UNLIKE THE LOCAL LINE. Your own character is inside your head and plays\r\n\t/// flat; a teammate shouting \u0022I\u0027m down!\u0022 is only useful if it tells you WHERE, and 2D audio for\r\n\t/// a voice coming from across the map is worse than silence \u2014 it reads as your own character.\r\n\t///\r\n\t/// \u26A0\uFE0F THE SAME PRIORITY RULE AS THE LOCAL MOUTH, kept here rather than sent: an urgent line\r\n\t/// cuts off a quip, a quip never cuts off an urgent line, one voice per person.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void PlayRemote( string ownerId, string cue, int priority )\r\n\t{\r\n\t\tif ( !Enabled || string.IsNullOrEmpty( cue ) ) return;\r\n\r\n\t\tvar body = NZPlayers.BodyOf( ownerId );\r\n\t\tif ( !body.IsValid() ) return;\r\n\r\n\t\tif ( !_remote.TryGetValue( ownerId, out var mouth ) )\r\n\t\t\t_remote[ownerId] = mouth = new RemoteMouth();\r\n\r\n\t\tif ( Time.Now \u003C mouth.BusyUntil \u0026\u0026 priority \u003C= mouth.Priority ) { SkippedBusy\u002B\u002B; return; }\r\n\r\n\t\tif ( !ResourceLibrary.TryGet\u003CSoundEvent\u003E( $\u0022sounds/vo/{cue}.sound\u0022, out var ev ) )\r\n\t\t{\r\n\t\t\tFailedHandles\u002B\u002B;\r\n\t\t\tLastFailed = cue;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( mouth.Handle.IsValid() ) mouth.Handle.Stop();\r\n\r\n\t\tmouth.Handle = Sound.Play( ev, body.WorldPosition \u002B Vector3.Up * 48f );\r\n\t\tmouth.Priority = priority;\r\n\t\tmouth.BusyUntil = Time.Now \u002B 2.5f;\r\n\r\n\t\tif ( !mouth.Handle.IsValid() ) { FailedHandles\u002B\u002B; LastFailed = cue; return; }\r\n\r\n\t\tPlayed\u002B\u002B;\r\n\t\tLastSaid = $\u0022{cue} (remote)\u0022;\r\n\t}\r\n\r\n\t// \u2500\u2500 diagnostics \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_voice [situation]\u0060 \u2014 say one now, or list every situation and its tuning.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_voice\u0022 )]\r\n\tpublic static void VoiceCmd( string type = \u0022\u0022 )\r\n\t{\r\n\t\tvar player = PlayerCharacters.Local();\r\n\t\tvar who = PlayerCharacters.Find( player.IsValid() ? player.CharacterId : null );\r\n\r\n\t\tif ( string.IsNullOrWhiteSpace( type ) )\r\n\t\t{\r\n\t\t\tLog.Info( $\u0022[nz-voice] speaking as: {who?.Name ?? \u0022NOBODY \u2014 pick one with nz_character\u0022}\u0022\r\n\t\t\t\t\u002B $\u0022 \u00B7 voice {(Enabled ? \u0022on\u0022 : \u0022OFF\u0022)} \u00B7 global gap {GlobalGap:0.#}s\u0022 );\r\n\r\n\t\t\tLog.Info( $\u0022[nz-voice] played {Played} \u00B7 skipped {SkippedChance} chance /\u0022\r\n\t\t\t\t\u002B $\u0022 {SkippedCooldown} cooldown / {SkippedBusy} busy /\u0022\r\n\t\t\t\t\u002B $\u0022 {SkippedGlobal} global-gap / {SkippedNoCharacter} no-character\u0022\r\n\t\t\t\t\u002B $\u0022 \u00B7 last \u0027{LastSaid}\u0027\u0022 );\r\n\r\n\t\t\tif ( FailedHandles \u003E 0 )\r\n\t\t\t\tLog.Warning( $\u0022[nz-voice] \u26D4 {FailedHandles} cue(s) returned a DEAD HANDLE\u0022\r\n\t\t\t\t\t\u002B $\u0022 \u2014 last \u0027{LastFailed}\u0027 does not resolve to a sound event\u0022 );\r\n\r\n\t\t\tforeach ( var l in Lines.OrderByDescending( l =\u003E l.Priority ).ThenBy( l =\u003E l.Type ) )\r\n\t\t\t\tLog.Info( $\u0022[nz-voice]   {l.Type,-13} {l.Chance * 100f,4:0}%\u0022\r\n\t\t\t\t\t\u002B $\u0022 \u00B7 {(l.Cooldown \u003E 0f ? $\u0022{l.Cooldown:0}s\u0022 : \u0022none\u0022),5} cooldown\u0022\r\n\t\t\t\t\t\u002B $\u0022 \u00B7 {(l.Priority == Urgent ? \u0022urgent\u0022 : l.Priority == Event ? \u0022event\u0022 : \u0022chatter\u0022)}\u0022 );\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar idx = IndexOfType( type );\r\n\r\n\t\tif ( idx \u003C 0 )\r\n\t\t{\r\n\t\t\tLog.Warning( $\u0022[nz-voice] \u0027{type}\u0027 is not a situation. Try: \u0022\r\n\t\t\t\t\u002B string.Join( \u0022, \u0022, Lines.Select( l =\u003E l.Type ) ) );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// \u26D4 BYPASSES CHANCE AND COOLDOWN, AND RESTORES THEM AFTER. A test that rolled the dice is\r\n\t\t// useless for hearing a 6% line, and permanently clearing them would corrupt the very tuning\r\n\t\t// you are testing.\r\n\t\tvar before = Played;\r\n\t\tvar saved = Lines[idx];\r\n\r\n\t\t// \u26A0\uFE0F THE GLOBAL GAP IS CLEARED TOO, AND IT HAD TO BE. This command exists to make a line play\r\n\t\t// on demand; with the gap now applying to EVERY line rather than to chatter, leaving it set\r\n\t\t// would have \u0060nz_voice \u003Ctype\u003E\u0060 silently refuse for up to fifteen seconds after any line \u2014\r\n\t\t// a test command that reports nothing and does nothing.\r\n\t\t_busyUntil = 0f;\r\n\t\t_nextAnyChatter = 0f;\r\n\t\t_nextAllowed.Remove( $\u0022{who?.Id}.{type}\u0022 );\r\n\r\n\t\tLines[idx] = saved with { Chance = 1f, Cooldown = 0f };\r\n\t\tSay( type, player );\r\n\t\tLines[idx] = saved;\r\n\r\n\t\tLog.Info( Played \u003E before\r\n\t\t\t? $\u0022[nz-voice] {who?.Name} says \u0027{type}\u0027\u0022\r\n\t\t\t: \u0022[nz-voice] nothing played \u2014 is a character selected? (nz_character)\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_voice_gap \u0026lt;seconds\u0026gt;\u0060 \u2014 shortest gap between any two chatter lines.\u003C/summary\u003E\r\n\t/// \u003Csummary\u003E\u0060nz_voice_gap [seconds]\u0060 \u2014 the minimum silence between ANY two lines from one\r\n\t/// character. See \u003Csee cref=\u0022GlobalGap\u0022/\u003E for why it is no longer chatter-only.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_voice_gap\u0022 )]\r\n\tpublic static void GapCmd( float seconds = -1f )\r\n\t{\r\n\t\tif ( seconds \u003E= 0f ) GlobalGap = seconds;\r\n\r\n\t\tLog.Info( $\u0022[nz-voice] minimum {GlobalGap:0.#}s between ANY two lines, one at a time\u0022\r\n\t\t\t\u002B \u0022 - nothing is exempt, and nothing interrupts\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_voice_set \u0026lt;situation\u0026gt; \u0026lt;chance 0-1\u0026gt; [cooldown]\u0060 \u2014 retune live.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_voice_set\u0022 )]\r\n\tpublic static void SetCmd( string type = \u0022\u0022, float chance = -1f, float cooldown = -1f )\r\n\t{\r\n\t\tvar idx = IndexOfType( type );\r\n\r\n\t\tif ( idx \u003C 0 ) { Log.Warning( $\u0022[nz-voice] \u0027{type}\u0027 is not a situation\u0022 ); return; }\r\n\r\n\t\tLines[idx] = Lines[idx] with\r\n\t\t{\r\n\t\t\tChance = chance \u003E= 0f ? MathX.Clamp( chance, 0f, 1f ) : Lines[idx].Chance,\r\n\t\t\tCooldown = cooldown \u003E= 0f ? cooldown : Lines[idx].Cooldown,\r\n\t\t};\r\n\r\n\t\tLog.Info( $\u0022[nz-voice] {Lines[idx].Type} = {Lines[idx].Chance * 100f:0}%\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 {Lines[idx].Cooldown:0}s cooldown\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_sound_test \u0026lt;asset path\u0026gt;\u0060 \u2014 play any sound event and report exactly what came back.\r\n\t///\r\n\t/// \u26D4 A CONTROL, NOT A FEATURE. A voice cue produced a dead handle from an asset that exists and\r\n\t/// compiles, which has two very different explanations: the audio path is broken for everything,\r\n\t/// or it is broken for these files specifically. Playing a KNOWN-WORKING event through the same\r\n\t/// line separates them, and nothing else does.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_sound_test\u0022 )]\r\n\tpublic static void SoundTest( string path = \u0022sounds/nz/nz.brutus.helmet.sound\u0022 )\r\n\t{\r\n\t\t// \u26D4 THE WHOLE BODY IS GUARDED. Every previous version died with \u0022Exception when calling\r\n\t\t// command\u0022, which is the least useful sentence the console can print \u2014 it names neither the\r\n\t\t// throw nor the line. A diagnostic must survive the case it exists to diagnose, and report\r\n\t\t// what it found.\r\n\t\ttry\r\n\t\t{\r\n\t\t\tif ( !ResourceLibrary.TryGet\u003CSoundEvent\u003E( path, out var ev ) )\r\n\t\t\t{\r\n\t\t\t\tLog.Warning( $\u0022[nz-sound] no SoundEvent at \u0027{path}\u0027\u0022 );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tint count;\r\n\t\t\ttry { count = ev.Sounds?.Count ?? 0; }\r\n\t\t\tcatch { count = -1; }\r\n\r\n\t\t\tLog.Info( $\u0022[nz-sound] \u0027{path}\u0027 \u00B7 {(count \u003C 0 ? \u0022UNREADABLE\u0022 : count.ToString())} sample(s)\u0022 );\r\n\r\n\t\t\t// \u26A0\uFE0F PLAYED SEPARATELY, AFTER REPORTING THE COUNT. \u0060Sound.Play\u0060 is what throws on an event\r\n\t\t\t// whose samples are broken, so doing it first loses the count as well.\r\n\t\t\tvar h = Sound.Play( ev );\r\n\r\n\t\t\tLog.Info( $\u0022[nz-sound]   handle {(h.IsValid() ? \u0022VALID\u0022 : \u0022DEAD\u0022)}\u0022 );\r\n\t\t}\r\n\t\tcatch ( System.Exception e )\r\n\t\t{\r\n\t\t\tLog.Warning( $\u0022[nz-sound] \u26D4 threw: {e.GetType().Name}: {e.Message}\u0022 );\r\n\t\t\tLog.Warning( \u0022[nz-sound]   a stale resource is the usual cause \u2014 restart play mode so the\u0022\r\n\t\t\t\t\u002B \u0022 recompiled .sound and .vsnd files reload\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_voice_off\u0060 \u2014 silence the crew.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_voice_off\u0022 )]\r\n\tpublic static void Off() { Enabled = false; Log.Info( \u0022[nz-voice] OFF\u0022 ); }\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_voice_on\u0060 \u2014 restore them.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_voice_on\u0022 )]\r\n\tpublic static void On() { Enabled = true; Log.Info( \u0022[nz-voice] on\u0022 ); }\r\n}\r\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Player/ReviveAugments.cs","FileName":"ReviveAugments.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\nnamespace NZombies;\r\n\r\n/// \u003Csummary\u003E\r\n/// Quick Revive \u2014 the co-op revive, the downed pistol, and the nine augments.\r\n///\r\n/// | | effect |\r\n/// |---|---|\r\n/// | base | self-revive **3 times solo**; downed players hold an **M1911**; reviving others takes **1.5s** with the perk, **3s** without it |\r\n/// | M1 **Phoenix** | **5** self-revives, and reviving others is a further **3\u00D7 faster** \u2014 **0.5s** |\r\n/// | M2 **Grave Keeper** | keep **every perk** when downed |\r\n/// | M3 **Guardian Aura** | revive players just by **standing near** them |\r\n/// | M4 **Last Stand** | keep your **real weapons** when downed, and a **kill self-revives** you |\r\n/// | m1 **Rapid Recovery** | health regen delay **\u221220%** |\r\n/// | m2 **Fast Metabolism** | health regen **20% faster** |\r\n/// | m3 **Field Medic** | reviving someone **heals you fully** and gives **2s** of speed |\r\n/// | m4 **Plate Carrier** | anyone revived comes up with **full armor**, self-revive included |\r\n/// | m5 **Phase Shift** | a fatal hit leaves you at **1 HP** and teleports you to a special spawn (**2 min**) |\r\n///\r\n/// \u26D4 THIS PERK ABSORBED TOMBSTONE SODA. Both were deferred, and between them only two augments\r\n/// needed something that does not exist (friendly AI, and Tombstone\u0027s own tombstone). Merged, the\r\n/// nine are all buildable \u2014 M2 is Tombstone\u0027s Grave Keeper, M4 its Last Stand, m5 its Phase Shift.\r\n///\r\n/// \u26D4 TWO BASE SYSTEMS DID NOT EXIST AND ARE BUILT HERE. Four augments depended on them:\r\n///\r\n/// - **There was no co-op revive at all.** \u0060NZPlayer.Revive()\u0060 was reached only by self-revive and\r\n///   a console command \u2014 no way for one player to pick another up. M1\u0027s speed half, M3, m3 and m4\r\n///   are all modifiers on an interaction that had no implementation.\r\n/// - **Being downed did not change your weapons.** \u0060StripWeapons\u0060 existed and \u0060GoDown\u0060 never\r\n///   called it, so a downed player kept firing their LMG. The pistol swap is the base rule M4\r\n///   then buys you out of.\r\n///\r\n/// \u26A0\uFE0F SELF-REVIVE WAS UNLIMITED. \u0060CanSelfRevive\u0060 already made it solo-only (on nobody else standing then; on being the\r\n/// only player in the game since 2026-10-05, \u0060NZPlayer.IsAlone\u0060), but nothing counted uses \u2014 you could self-revive\r\n/// forever. Now three, or five with M1.\r\n///\r\n/// \u26D4 M3 AND m3 CANNOT BE TESTED SOLO, AND A DUMMY PLAYER IS NOT WORTH BUILDING. They act\r\n/// on another player, so the obvious move is a downed stand-in \u2014 but 122 sites in this project\r\n/// resolve \u0022the player\u0022 as \u0060GetAllComponents\u003CNZPlayer\u003E().FirstOrDefault()\u0060. A second \u0060NZPlayer\u0060 in\r\n/// the scene would silently point an unknowable share of them at the dummy, and enumeration\r\n/// order is not something I can pin down. That trades every other diagnostic in the project\r\n/// for two augments.\r\n///\r\n/// \u26A0\uFE0F m4 USED TO BE ON THAT LIST AND IS NOT ANY MORE. It fired only from \u0060Complete\u0060, the co-op\r\n/// path, so solo it did nothing \u2014 by request it now also runs on self-revive, which makes it the\r\n/// one of the three that a single player can actually verify. Buy an armor tier, spend it, go\r\n/// down, self-revive, and the vest should be full.\r\n///\r\n/// So they stay untested until there is a real second client. What CAN be checked solo is that\r\n/// they are wired and reachable: \u0060nz_aug_revive\u0060 prints the aura reach, the heal, the speed\r\n/// window and the armor grant with the augment\u0027s own numbers resolved.\r\n/// \u003C/summary\u003E\r\npublic static class ReviveAugments\r\n{\r\n\tconst string Perk = \u0022revive\u0022;\r\n\r\n\t// \u2550\u2550 tuning \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n\tstatic int? _selfReviveUses;\r\n\t/// \u003Csummary\u003ESelf-revives allowed per game, solo only. 3.\u003C/summary\u003E\r\n\tpublic static int SelfReviveUses { get =\u003E _selfReviveUses ?? 3; set =\u003E _selfReviveUses = value; }\r\n\r\n\tstatic int? _phoenixUses;\r\n\t/// \u003Csummary\u003EM1 Phoenix \u2014 what the self-revive count BECOMES. 5.\u003C/summary\u003E\r\n\tpublic static int PhoenixUses { get =\u003E _phoenixUses ?? 5; set =\u003E _phoenixUses = value; }\r\n\r\n\tstatic float? _reviveSeconds;\r\n\t/// \u003Csummary\u003E\r\n\t/// How long picking another player up takes with NO Quick Revive. 3s.\r\n\t///\r\n\t/// \u26A0\uFE0F 3, NOT 4. Lowered by request so the perk\u0027s halved figure lands on a round 1.5s.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static float ReviveSeconds { get =\u003E _reviveSeconds ?? 3f; set =\u003E _reviveSeconds = value; }\r\n\r\n\tstatic float? _perkSpeed;\r\n\t/// \u003Csummary\u003E\r\n\t/// Holding Quick Revive \u2014 how much faster you pick others up. \u00D72, so 3s becomes **1.5s**.\r\n\t///\r\n\t/// \u26D4 THE PERK HAD NO CO-OP SPEED BONUS AT ALL, and its own registry line has always said\r\n\t/// *\u0022Revive yourself; revive others faster.\u0022* Only M1 Phoenix made reviving faster, so the\r\n\t/// second half of that sentence was false for anyone who had not rolled that one augment out\r\n\t/// of four majors. This is the base effect the description was already promising.\r\n\t///\r\n\t/// \u26A0\uFE0F IT COMPOSES WITH M1 RATHER THAN BEING REPLACED BY IT \u2014 3 / 2 / 3 = **0.5s** with both.\r\n\t/// \u0060Has( .., \u0022M1\u0022 )\u0060 already requires the perk, so the two dividers never double-count the\r\n\t/// perk itself; they stack because they are separate purchases.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static float PerkSpeed { get =\u003E _perkSpeed ?? 2f; set =\u003E _perkSpeed = value; }\r\n\r\n\tstatic float? _phoenixSpeed;\r\n\t/// \u003Csummary\u003EM1 Phoenix \u2014 how much faster reviving others is, ON TOP of the perk\u0027s \u00D72. \u00D73.\u003C/summary\u003E\r\n\tpublic static float PhoenixSpeed { get =\u003E _phoenixSpeed ?? 3f; set =\u003E _phoenixSpeed = value; }\r\n\r\n\tstatic float? _reviveReach;\r\n\t/// \u003Csummary\u003EHow close you must be to revive someone. 90u.\u003C/summary\u003E\r\n\tpublic static float ReviveReach { get =\u003E _reviveReach ?? 90f; set =\u003E _reviveReach = value; }\r\n\r\n\tstatic float? _auraReach;\r\n\t/// \u003Csummary\u003E\r\n\t/// M3 Guardian Aura \u2014 how close counts as \u0022standing near\u0022. 140u.\r\n\t///\r\n\t/// \u26A0\uFE0F WIDER THAN \u0060ReviveReach\u0060, because the augment removes the button rather than the\r\n\t/// distance. Same reach would make it feel like nothing changed.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static float AuraReach { get =\u003E _auraReach ?? 140f; set =\u003E _auraReach = value; }\r\n\r\n\tstatic float? _regenDelayScale;\r\n\t/// \u003Csummary\u003Em1 Rapid Recovery \u2014 multiplier on the regen delay. 0.8, i.e. \u221220%.\u003C/summary\u003E\r\n\tpublic static float RegenDelayScale { get =\u003E _regenDelayScale ?? 0.8f; set =\u003E _regenDelayScale = value; }\r\n\r\n\tstatic float? _regenRateScale;\r\n\t/// \u003Csummary\u003E\r\n\t/// m2 Fast Metabolism \u2014 multiplier on the gap between regen ticks. 0.8.\r\n\t///\r\n\t/// \u26A0\uFE0F A SMALLER GAP IS FASTER REGEN, so \u002220% faster\u0022 is 0.8 here rather than 1.2. The value\r\n\t/// divides an interval, exactly like Speed Cola\u0027s reload figure \u2014 which \u0060Weapon.Reload\u0060\u0027s own\r\n\t/// note records inverting once and getting a slower reload for its trouble.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static float RegenRateScale { get =\u003E _regenRateScale ?? 0.8f; set =\u003E _regenRateScale = value; }\r\n\r\n\tstatic float? _medicSpeedSeconds;\r\n\t/// \u003Csummary\u003Em3 Field Medic \u2014 how long the speed boost lasts. 2s.\u003C/summary\u003E\r\n\tpublic static float MedicSpeedSeconds { get =\u003E _medicSpeedSeconds ?? 2f; set =\u003E _medicSpeedSeconds = value; }\r\n\r\n\tstatic float? _medicSpeed;\r\n\t/// \u003Csummary\u003Em3 Field Medic \u2014 how much faster you move during it. \u00D71.5.\u003C/summary\u003E\r\n\tpublic static float MedicSpeed { get =\u003E _medicSpeed ?? 1.5f; set =\u003E _medicSpeed = value; }\r\n\r\n\tstatic float? _phaseCooldown;\r\n\t/// \u003Csummary\u003Em5 Phase Shift \u2014 the cooldown. 120s.\u003C/summary\u003E\r\n\tpublic static float PhaseCooldown { get =\u003E _phaseCooldown ?? 120f; set =\u003E _phaseCooldown = value; }\r\n\r\n\t/// \u003Csummary\u003EWhat a downed player is given to hold, unless they have M4.\u003C/summary\u003E\r\n\tpublic static string DownedWeapon { get; set; } = \u0022prefabs/weapons/nz_m1911.prefab\u0022;\r\n\r\n\tstatic bool Has( NZPlayer p, string augId )\r\n\t\t=\u003E p.IsValid() \u0026\u0026 p.HasPerk( Perk ) \u0026\u0026 PerkAugments.Has( p, Perk, augId );\r\n\r\n\t// \u2550\u2550 base \u002B M1 \u2014 self-revive \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n\t/// \u003Csummary\u003EHow many self-revives this player gets in total. 3, or 5 with M1.\u003C/summary\u003E\r\n\tpublic static int UsesFor( NZPlayer player )\r\n\t\t=\u003E Has( player, \u0022M1\u0022 )\r\n\t\t\t? Math.Max( 0, PhoenixUses )\r\n\t\t\t: Math.Max( 0, SelfReviveUses );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Has this player any self-revives left.\r\n\t///\r\n\t/// \u26A0\uFE0F THE COUNT IS CHECKED, NOT THE PERK. \u0060NZPlayer.CanSelfRevive\u0060 already handles \u0022solo and\r\n\t/// holding Quick Revive\u0022; this adds only the budget, so the two questions stay separate and\r\n\t/// the solo rule keeps one author.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static bool HasSelfRevive( NZPlayer player )\r\n\t\t=\u003E player.IsValid() \u0026\u0026 player.SelfRevivesUsed \u003C UsesFor( player );\r\n\r\n\t/// \u003Csummary\u003ESpend one. Called by the self-revive path.\u003C/summary\u003E\r\n\tpublic static void SpendSelfRevive( NZPlayer player )\r\n\t{\r\n\t\tif ( !player.IsValid() ) return;\r\n\r\n\t\tplayer.SelfRevivesUsed\u002B\u002B;\r\n\r\n\t\tLog.Info( $\u0022[nz-aug] revive self-revive {player.SelfRevivesUsed}/{UsesFor( player )} used\u0022 );\r\n\t}\r\n\r\n\t// \u2550\u2550 base \u002B M1 \u002B M3 \u2014 picking others up \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Seconds to revive another player. **3s bare \u00B7 1.5s with Quick Revive \u00B7 0.5s with M1 too.**\r\n\t///\r\n\t/// \u26D4 THE PERK DIVIDER IS SEPARATE FROM M1\u0027S AND BOTH APPLY. Folding them into one factor\r\n\t/// would mean picking a number that is either the perk\u0027s or the augment\u0027s and calling it both;\r\n\t/// they are two purchases and they multiply. \u0060Has\u0060 already gates M1 on owning the perk, so the\r\n\t/// perk half is never counted twice.\r\n\t///\r\n\t/// \u26A0\uFE0F \u0060HasPerk\u0060, NOT \u0060Has\u0060. \u0060Has\u0060 additionally demands an augment id \u2014 using it here would\r\n\t/// have made the base bonus require an augment, which is exactly the gap this closes.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static float SecondsFor( NZPlayer rescuer )\r\n\t{\r\n\t\tvar seconds = ReviveSeconds;\r\n\r\n\t\tif ( rescuer.IsValid() \u0026\u0026 rescuer.HasPerk( Perk ) )\r\n\t\t\tseconds /= MathF.Max( 0.1f, PerkSpeed );\r\n\r\n\t\tif ( Has( rescuer, \u0022M1\u0022 ) )\r\n\t\t\tseconds /= MathF.Max( 0.1f, PhoenixSpeed );\r\n\r\n\t\treturn MathF.Max( 0.05f, seconds );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EDoes this player revive without holding the key \u2014 M3.\u003C/summary\u003E\r\n\tpublic static bool RevivesPassively( NZPlayer player ) =\u003E Has( player, \u0022M3\u0022 );\r\n\r\n\t/// \u003Csummary\u003EHow close this player can start a revive from.\u003C/summary\u003E\r\n\tpublic static float ReachFor( NZPlayer player )\r\n\t\t=\u003E RevivesPassively( player ) ? AuraReach : ReviveReach;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The nearest downed player this one could pick up, or null.\r\n\t///\r\n\t/// \u26A0\uFE0F EXCLUDES THE RESCUER, obviously, but also anyone who has already bled out \u2014 \u0060IsDown\u0060 is\r\n\t/// still true for a frame after \u0060Die\u0060, and reviving a corpse would put a dead player back on\r\n\t/// their feet.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static NZPlayer TargetFor( NZPlayer rescuer )\r\n\t{\r\n\t\tif ( !rescuer.IsValid() || rescuer.IsDown ) return null;\r\n\r\n\t\tvar scene = Game.ActiveScene;\r\n\t\tif ( !scene.IsValid() ) return null;\r\n\r\n\t\tvar reach = ReachFor( rescuer );\r\n\t\tvar at = rescuer.WorldPosition;\r\n\r\n\t\treturn scene.GetAllComponents\u003CNZPlayer\u003E()\r\n\t\t\t// \u26D4 \u0060IsOutOfRound\u0060 EXCLUDED, AND ITS ABSENCE WAS HALF OF \u0022STILL THERE BUT INVISIBLE\u0022.\r\n\t\t\t// A player who has bled out keeps \u0060IsDown\u0060 set \u2014 deliberately, because \u0060ZombieAI\u0060 skips\r\n\t\t\t// targets that are down \u2014 so the revive search went on finding them after their body\r\n\t\t\t// was gone. Teammates got a prompt, held E, and stood up somebody who was not there.\r\n\t\t\t.Where( p =\u003E p.IsValid() \u0026\u0026 p != rescuer \u0026\u0026 p.IsDown \u0026\u0026 !p.IsOutOfRound )\r\n\r\n\t\t\t// \u26D4 NOT THE ONE JUST PICKED UP, FOR A MOMENT (2026-09-27). A teammate\u0027s \u0060IsDown\u0060 here is a\r\n\t\t\t// copy of their own machine\u0027s, so it stays true until their \u0022I am up\u0022 comes back \u2014 and\r\n\t\t\t// until then they were a fresh target: a second revive began at once, and past the revive\r\n\t\t\t// time (0.5s with Phoenix, no key at all with Guardian Aura) a second \u0060Complete\u0060 counted\r\n\t\t\t// the revive and paid Field Medic twice. Their machine refuses the second stand-up anyway.\r\n\t\t\t.Where( p =\u003E p != rescuer.JustRevived || rescuer.SinceJustRevived \u003E JustRevivedGrace )\r\n\t\t\t.Where( p =\u003E p.Hp.IsValid() \u0026\u0026 !p.Hp.IsDead )\r\n\t\t\t.Where( p =\u003E at.Distance( p.WorldPosition ) \u003C= reach )\r\n\t\t\t.OrderBy( p =\u003E at.Distance( p.WorldPosition ) )\r\n\t\t\t.FirstOrDefault();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Point the rescuer at a patient, or at nobody, and tell the patient either way.\r\n\t///\r\n\t/// \u26D4 ONE AUTHOR FOR WHAT WERE THREE IDENTICAL PAIRS OF LINES. \u0060Tick\u0060 cleared \u0060RevivingWho\u0060\r\n\t/// and \u0060ReviveProgress\u0060 together in three places and set them in a fourth; every one of those\r\n\t/// is now also a message to another machine, and four copies of a two-line pattern is how one\r\n\t/// of them ends up not sending.\r\n\t///\r\n\t/// \u26A0\uFE0F IT RETURNS EARLY ON NO CHANGE, so this is safe to call every frame from the two\r\n\t/// branches that run every frame \u2014 which is what makes it usable as the only entry point.\r\n\t/// \u003C/summary\u003E\r\n\tstatic void SetReviving( NZPlayer rescuer, NZPlayer target )\r\n\t{\r\n\t\tvar was = rescuer.RevivingWho;\r\n\t\tif ( was == target ) return;\r\n\r\n\t\trescuer.RevivingWho = target;\r\n\t\trescuer.ReviveProgress = 0f;\r\n\r\n\t\tif ( was.IsValid() ) Announce( was, 0f );\r\n\t\tif ( target.IsValid() ) Announce( target, SecondsFor( rescuer ) );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Tell a patient that a revive on them has started or stopped.\r\n\t///\r\n\t/// \u26A0\uFE0F LOCAL WHEN THE PATIENT IS THIS MACHINE\u0027S, which is every solo self-test and the case\r\n\t/// the RPC cannot serve \u2014 \u0060ReviveBeing\u0060 is addressed by owner and a body with no session has no\r\n\t/// owner. Routing solo through the network would make the bar a multiplayer-only feature and\r\n\t/// leave it untestable here.\r\n\t/// \u003C/summary\u003E\r\n\tstatic void Announce( NZPlayer patient, float seconds )\r\n\t{\r\n\t\tif ( !patient.IsValid() ) return;\r\n\r\n\t\tif ( !Networking.IsActive || PlayerPresence.Mine( patient.GameObject ) )\r\n\t\t{\r\n\t\t\tpatient.BeingRevivedBy( seconds );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar owner = NZPlayers.OwnerOf( patient.GameObject );\r\n\t\tif ( string.IsNullOrEmpty( owner ) ) return;\r\n\r\n\t\tNZNet.ReviveBeing( owner, seconds );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Per-frame: run the co-op revive.\r\n\t///\r\n\t/// \u26D4 THE PROGRESS LIVES ON THE RESCUER, NOT THE PATIENT, so two players reviving one downed\r\n\t/// teammate each build their own progress and the faster one wins. Progress on the patient\r\n\t/// would let two rescuers each contribute half and finish in half the time, which is a\r\n\t/// different mechanic than the one described.\r\n\t///\r\n\t/// \u26A0\uFE0F PROGRESS RESETS WHEN THE TARGET CHANGES OR THE KEY IS RELEASED. A revive you can pause\r\n\t/// and resume is a revive you can do while running away, which is the opposite of the risk the\r\n\t/// interaction exists to create.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void Tick( NZPlayer player )\r\n\t{\r\n\t\tif ( !player.IsValid() ) return;\r\n\r\n\t\t// \u26D4 A RESCUER WHO GOES DOWN MID-REVIVE LETS GO OF IT (2026-09-27). This returned on\r\n\t\t// \u0060IsDown\u0060 before clearing anything, so \u0060RevivingWho\u0060 stayed set for the whole down \u2014 and\r\n\t\t// the weapon tucks the gun while it is set (\u0060Weapon.ShouldTuckVar\u0060), so a rescuer downed\r\n\t\t// mid-revive could not fire at all, Last Stand\u0027s kill-to-stand-up included, while the\r\n\t\t// patient\u0027s bar ran on to nothing. \u0060SetReviving\u0060 does nothing when there is nothing to\r\n\t\t// clear, so this is safe every frame and on every body.\r\n\t\tif ( player.IsDown )\r\n\t\t{\r\n\t\t\tSetReviving( player, null );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// \u26D4 MY BODY ONLY, AND IT IS CALLED FROM ABOVE \u0060OnUpdate\u0060\u0027S GUARD. \u0060NZPlayer.OnUpdate\u0060\r\n\t\t// runs on every body in the scene \u2014 mine and my copy of everyone else\u0027s \u2014 and this method\r\n\t\t// reads \u0060Input.Down( \u0022Use\u0022 )\u0060, of which there is exactly ONE per machine. Without this,\r\n\t\t// holding Use started a revive on every teammate\u0027s proxy at once: their progress bars all\r\n\t\t// advanced off MY key, and whichever proxy happened to be near a downed player would\r\n\t\t// \u0022complete\u0022 a revive nobody performed.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F THE CHECK BELONGS HERE RATHER THAN AT THE CALL SITE. Moving the call below the\r\n\t\t// guard would fix today\u0027s caller and leave the trap set for the next one; this method\r\n\t\t// simply cannot be correct for a body whose keyboard is on another machine.\r\n\t\tif ( Networking.IsActive \u0026\u0026 !PlayerPresence.Mine( player.GameObject ) ) return;\r\n\r\n\t\tvar target = TargetFor( player );\r\n\r\n\t\tif ( !target.IsValid() )\r\n\t\t{\r\n\t\t\tSetReviving( player, null );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// \u26A0\uFE0F M3 NEEDS NO KEY. Everyone else holds Use \u2014 and \u0060Input.Down\u0060, not \u0060Pressed\u0060, because\r\n\t\t// this is a hold.\r\n\t\tif ( !RevivesPassively( player ) \u0026\u0026 !Input.Down( \u0022Use\u0022 ) )\r\n\t\t{\r\n\t\t\tSetReviving( player, null );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( player.RevivingWho != target )\r\n\t\t{\r\n\t\t\tSetReviving( player, target );\r\n\r\n\t\t\t// \u26A0\uFE0F INSIDE THE \u0022JUST STARTED\u0022 BRANCH, NOT BESIDE THE PROGRESS TICK BELOW. That line runs\r\n\t\t\t// every frame of the revive; speaking there would fire the line once per frame and lean\r\n\t\t\t// entirely on the cooldown to hide it.\r\n\t\t\tCharacterVoice.Say( \u0022reviving\u0022, player );\r\n\t\t}\r\n\r\n\t\tplayer.ReviveProgress \u002B= Time.Delta;\r\n\r\n\t\tif ( player.ReviveProgress \u003C SecondsFor( player ) ) return;\r\n\r\n\t\tComplete( player, target );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// m4 Plate Carrier \u2014 does this rescuer\u0027s revive fill the patient\u0027s armor.\r\n\t///\r\n\t/// \u26D4 A QUESTION, ASKED BEFORE THE STAND-UP, AND THAT IS THE FIX (2026-09-27). It was a method\r\n\t/// that asked and filled in one go, called by the self-revive AFTER \u0060Revive\u0060 \u2014 and \u0060Revive\u0060\r\n\t/// runs \u0060LosePerksOnDown\u0060, which takes Quick Revive with the rest, so \u0060Has\u0060 read the augment as\r\n\t/// unowned and a self-revive never plated anybody unless M2 kept the perks. The answer is taken\r\n\t/// while the perk is still owned; \u0060PlateFor\u0060 then fills the vest once the player is up.\r\n\t///\r\n\t/// \u26A0\uFE0F CO-OP ASKS IT THE SAME WAY: \u0060Complete\u0060 sends \u0060Has( rescuer, \u0022m4\u0022 )\u0060 with the revive, and the\r\n\t/// patient\u0027s machine runs \u0060PlateFor\u0060.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static bool PlatesOthers( NZPlayer rescuer ) =\u003E Has( rescuer, \u0022m4\u0022 );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// How long a player just picked up is left out of \u0060TargetFor\u0060: long enough for their own machine\u0027s\r\n\t/// \u0022I am up\u0022 to arrive. The same grace the patient\u0027s own bar allows its messages.\r\n\t/// \u003C/summary\u003E\r\n\tpublic const float JustRevivedGrace = NZPlayer.StaleReviveGrace;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Fill this player\u0027s vest. The rescuer\u0027s side of the question has already been answered.\r\n\t///\r\n\t/// \u26D4 SPLIT OUT BECAUSE THE TWO HALVES NOW LIVE ON DIFFERENT MACHINES. \u0022Does the rescuer own\r\n\t/// m4\u0022 can only be asked where the rescuer is; \u0022is there a vest and how big is it\u0022 can only be\r\n\t/// answered where the patient is. In co-op those are two computers, so one method that did\r\n\t/// both would have to be wrong on one of them \u2014 and it was: it set \u0060Armor\u0060 on the rescuer\u0027s\r\n\t/// PROXY copy of the patient, which is a number nobody would ever read.\r\n\t///\r\n\t/// \u26A0\uFE0F \u0060NZPlayer.Revive( plate )\u0060 IS THE OTHER CALLER, through the RPC. The boolean it\r\n\t/// carries is the rescuer\u0027s answer; this is the patient\u0027s.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void PlateFor( NZPlayer patient, string how )\r\n\t{\r\n\t\tif ( !patient.IsValid() ) return;\r\n\t\tif ( patient.ArmorTier \u003C= 0 ) return;\r\n\r\n\t\tpatient.Armor = NZombies.Armor.CapFor( patient );\r\n\r\n\t\tLog.Info( $\u0022[nz-aug] revive m4 Plate Carrier ({how}) \u2014 armor {patient.Armor:0}\u0022\r\n\t\t\t\u002B $\u0022 (tier {patient.ArmorTier})\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Finish a revive: stand the patient up and pay the rescuer\u0027s augments.\r\n\t///\r\n\t/// \u26A0\uFE0F \u0060NZPlayer.Revive()\u0060 DOES THE STANDING UP, including the perk loss. This only adds what\r\n\t/// the augments promise on top, so the base rule stays in one place.\r\n\t/// \u003C/summary\u003E\r\n\tstatic void Complete( NZPlayer rescuer, NZPlayer patient )\r\n\t{\r\n\t\t// \u26A0\uFE0F CLEARED WITHOUT ANNOUNCING A STOP. Finishing IS the stop, and the patient clears its\r\n\t\t// own bar in \u0060Revive\u0060 a line later \u2014 sending \u0022cancelled\u0022 here would race the revive itself\r\n\t\t// and could blank the bar a frame before the player stands up.\r\n\t\trescuer.RevivingWho = null;\r\n\t\trescuer.ReviveProgress = 0f;\r\n\r\n\t\t// \u26A0\uFE0F REMEMBERED, SO \u0060TargetFor\u0060 LEAVES THEM ALONE until their stand-up reaches this machine.\r\n\t\trescuer.JustRevived = patient;\r\n\t\trescuer.SinceJustRevived = 0f;\r\n\r\n\t\t// \u26A0\uFE0F THE RESCUER, NOT THE PATIENT, and only here. \u0060NZPlayer.Revive()\u0060 is also reached by\r\n\t\t// self-revive, the round manager and a console command \u2014 crediting a revive there would\r\n\t\t// hand everyone one at round start and let a solo player farm them off Quick Revive.\r\n\t\t// This method is the only co-op pickup, which is what the column is counting.\r\n\t\tPlayerStats.For( rescuer )?.RecordRevive();\r\n\r\n\t\t// \u26A0\uFE0F THE RESCUER\u0027S AUGMENT PLATES THE PATIENT \u2014 it is the reviver who paid for it \u2014 so\r\n\t\t// the answer travels WITH the revive rather than as a second action afterwards. Across two\r\n\t\t// machines a separate call would have to find the patient again, and would land on the\r\n\t\t// rescuer\u0027s own proxy copy of them. \u0060Revive\u0060 is already relayed; this rides along.\r\n\t\tpatient.Revive( Has( rescuer, \u0022m4\u0022 ) );\r\n\r\n\t\tLog.Info( $\u0022[nz-aug] revive \u2014 {patient.GameObject.Name} back up\u0022\r\n\t\t\t\u002B $\u0022 in {SecondsFor( rescuer ):0.##}s\u0022 );\r\n\r\n\t\t// \u2500\u2500 m3 Field Medic \u2500\u2500\r\n\t\tif ( !Has( rescuer, \u0022m3\u0022 ) ) return;\r\n\r\n\t\tvar hp = rescuer.Components.Get\u003CHealth\u003E( FindMode.EverythingInSelfAndAncestors );\r\n\r\n\t\tif ( hp.IsValid() \u0026\u0026 hp.Current \u003C hp.Max ) hp.Heal( hp.Max - hp.Current );\r\n\r\n\t\trescuer.MedicSpeedUntil = MedicSpeedSeconds;\r\n\r\n\t\tLog.Info( $\u0022[nz-aug] revive m3 Field Medic \u2014 healed and {MedicSpeedSeconds:0.#}s of\u0022\r\n\t\t\t\u002B $\u0022 x{MedicSpeed:0.##} speed\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003Em3\u0027s speed boost, read by \u0060PerkEffects.SpeedMultiplier\u0060.\u003C/summary\u003E\r\n\tpublic static float SpeedBonus( NZPlayer player )\r\n\t\t=\u003E player.IsValid() \u0026\u0026 player.MedicSpeedUntil \u003E 0f\r\n\t\t\t? MathF.Max( 0.1f, MedicSpeed )\r\n\t\t\t: 1f;\r\n\r\n\t// \u2550\u2550 base \u002B M4 \u2014 the downed weapon \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n\t/// \u003Csummary\u003EDoes this player keep their real guns when downed \u2014 M4.\u003C/summary\u003E\r\n\tpublic static bool KeepsWeapons( NZPlayer player ) =\u003E Has( player, \u0022M4\u0022 );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Swap to the downed pistol, remembering what to give back.\r\n\t///\r\n\t/// \u26D4 THE PREFAB PATHS ARE REMEMBERED, NOT THE OBJECTS. \u0060StripWeapons\u0060 destroys the weapon\r\n\t/// GameObjects, so holding references would leave a list of destroyed objects \u2014 and every\r\n\t/// upgrade is stored against the PREFAB PATH anyway (\u0060ApplyStoredUpgrades\u0060), so re-giving the\r\n\t/// path restores the Pack-a-Punch tier, rarity and tech with it. The same reason Mule Kick\u0027s\r\n\t/// Insurance stores paths.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void OnDowned( NZPlayer player )\r\n\t{\r\n\t\tif ( !player.IsValid() ) return;\r\n\r\n\t\tif ( KeepsWeapons( player ) )\r\n\t\t{\r\n\t\t\tLog.Info( \u0022[nz-aug] revive M4 Last Stand \u2014 keeping your weapons while down\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar inv = player.Inventory;\r\n\t\tif ( !inv.IsValid() ) return;\r\n\r\n\t\tplayer.DownedWeapons.Clear();\r\n\r\n\t\tforeach ( var w in inv.Weapons.ToList() )\r\n\t\t{\r\n\t\t\tif ( !w.IsValid() ) continue;\r\n\r\n\t\t\tvar wep = w.Components.Get\u003CSWB.Base.Weapon\u003E( FindMode.EverythingInSelf );\r\n\t\t\tvar path = wep.IsValid() ? Rarity.PrefabOf( wep ) : null;\r\n\r\n\t\t\tif ( !string.IsNullOrEmpty( path ) ) player.DownedWeapons.Add( path );\r\n\t\t}\r\n\r\n\t\tplayer.StripWeapons();\r\n\t\tplayer.GiveWeapon( DownedWeapon );\r\n\r\n\t\tLog.Info( $\u0022[nz-aug] revive \u2014 downed with a pistol, {player.DownedWeapons.Count}\u0022\r\n\t\t\t\u002B \u0022 weapon(s) held for you\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Give the real weapons back on standing up.\r\n\t///\r\n\t/// \u26A0\uFE0F THE FIRST ONE GOES ACTIVE AND THE REST DO NOT, so the player comes up holding something\r\n\t/// rather than empty-handed with two guns in their pockets.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void OnRevived( NZPlayer player )\r\n\t{\r\n\t\tif ( !player.IsValid() ) return;\r\n\t\tif ( player.DownedWeapons.Count == 0 ) return;\r\n\r\n\t\tplayer.StripWeapons();\r\n\r\n\t\tvar first = true;\r\n\r\n\t\tforeach ( var path in player.DownedWeapons )\r\n\t\t{\r\n\t\t\tplayer.GiveWeapon( path, makeActive: first );\r\n\t\t\tfirst = false;\r\n\t\t}\r\n\r\n\t\tLog.Info( $\u0022[nz-aug] revive \u2014 {player.DownedWeapons.Count} weapon(s) returned\u0022 );\r\n\r\n\t\tplayer.DownedWeapons.Clear();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// M4 Last Stand \u2014 a kill while downed stands you straight back up.\r\n\t///\r\n\t/// \u26A0\uFE0F IT DOES NOT SPEND A SELF-REVIVE. Those are the solo safety net; this is the augment you\r\n\t/// paid for, and charging both would make M4 strictly worse than not having it whenever you\r\n\t/// were out of self-revives.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void OnZombieKilled( NZPlayer killer )\r\n\t{\r\n\t\tif ( !killer.IsValid() || !killer.IsDown ) return;\r\n\r\n\t\t// \u26D4 NOT ONCE THEY HAVE BLED OUT (2026-09-27). An out-of-round player keeps \u0060IsDown\u0060 \u2014 zombies\r\n\t\t// skip targets that are down \u2014 and keeps Last Stand\u0027s real guns, so a kill from there stood them\r\n\t\t// back up mid-round, past \u0022back next round\u0022. Bleeding out is the end of the down.\r\n\t\tif ( killer.IsOutOfRound ) return;\r\n\t\tif ( !Has( killer, \u0022M4\u0022 ) ) return;\r\n\r\n\t\tLog.Info( \u0022[nz-aug] revive M4 Last Stand \u2014 killed while down, back up\u0022 );\r\n\r\n\t\tkiller.Revive();\r\n\t}\r\n\r\n\t// \u2550\u2550 M2 \u2014 keeping perks \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// How many perks survive going down.\r\n\t///\r\n\t/// \u26A0\uFE0F RETURNS A COUNT, not a bool, because that is what \u0060LosePerksOnDown\u0060 already reads\r\n\t/// (\u0060ActiveConfig.Player.PerksKeptOnDown\u0060). M2 returns the perks the player actually holds\r\n\t/// rather than a large constant, so the log line says \u0022kept 4 of 4\u0022 instead of \u0022kept 99\u0022.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static int PerksKeptFor( NZPlayer player )\r\n\t\t=\u003E Has( player, \u0022M2\u0022 )\r\n\t\t\t? Math.Max( 0, player.Perks.Count )\r\n\t\t\t: ActiveConfig.Player.PerksKeptOnDown;\r\n\r\n\t// \u2550\u2550 m1 / m2 \u2014 regen \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n\t/// \u003Csummary\u003Em1 Rapid Recovery \u2014 multiplier on the wait before regen starts.\u003C/summary\u003E\r\n\tpublic static float DelayScale( NZPlayer player )\r\n\t\t=\u003E Has( player, \u0022m1\u0022 ) ? MathF.Max( 0.05f, RegenDelayScale ) : 1f;\r\n\r\n\t/// \u003Csummary\u003Em2 Fast Metabolism \u2014 multiplier on the gap between regen ticks.\u003C/summary\u003E\r\n\tpublic static float RateScale( NZPlayer player )\r\n\t\t=\u003E Has( player, \u0022m2\u0022 ) ? MathF.Max( 0.05f, RegenRateScale ) : 1f;\r\n\r\n\t// \u2550\u2550 m5 \u2014 Phase Shift \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// m5 Phase Shift \u2014 survive a fatal hit at 1 HP and teleport out.\r\n\t///\r\n\t/// \u26D4 RETURNS TRUE WHEN IT SAVED YOU, so the damage path can stop. Called from \u0060Health.Apply\u0060\r\n\t/// before the health reaches zero \u2014 after it, the player is already down and standing them\r\n\t/// back up would be a different and much messier effect.\r\n\t///\r\n\t/// \u26A0\uFE0F A SPECIAL SPAWN, as asked, and \u0060RoundManager.EligibleSpecialSpawns\u0060 already respects\r\n\t/// which are open \u2014 so it cannot drop you behind unbought debris.\r\n\t///\r\n\t/// \u26D4 THE TWO HALVES ARE INDEPENDENT NOW, AND BINDING THEM WAS A BUG. This used to \u0060return\r\n\t/// false\u0060 when there was nowhere to teleport, which meant a map with no special spawns \u2014 the\r\n\t/// \u0060countdown\u0060 test map among them \u2014 got NO PHASE SHIFT AT ALL: the player went down normally\r\n\t/// while the console explained why. Surviving a fatal hit is the augment\u0027s actual promise and it\r\n\t/// does not need a destination. You are saved either way; the teleport is what happens next if\r\n\t/// there is somewhere to go.\r\n\t///\r\n\t/// \u26A0\uFE0F WHICH MEANS IT CAN NOW SAVE YOU AND LEAVE YOU STANDING IN THE CROWD THAT NEARLY KILLED\r\n\t/// YOU, on 1 HP. That is worse than the intended effect and still far better than being downed,\r\n\t/// and the fix is placing special spawns (\u0060nz_special_here\u0060) rather than code.\r\n\t///\r\n\t/// \u26A0 THIS RETURNS A VERDICT; \u0060Health.Apply\u0060 DOES THE 1 HP WRITE. Health only moves down\r\n\t/// through \u0060Apply\u0060 \u2014 \u0060Current\u0060 has a private setter and \u0060Heal\u0060 refuses negatives \u2014 so the\r\n\t/// caller is the only place that can set it, and it does. Returning a bool keeps the decision\r\n\t/// (do I have the augment, is the hit fatal, is there anywhere to go) here with the rest of\r\n\t/// the perk.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static bool TryPhaseShift( NZPlayer player, float incoming )\r\n\t{\r\n\t\tif ( !Has( player, \u0022m5\u0022 ) ) return false;\r\n\r\n\t\t// \u26A0\uFE0F NOT WHILE DOWN (2026-09-27). A fatal hit on a downed player is not the one this saves you\r\n\t\t// from \u2014 the teleport would carry them away from the teammates coming to pick them up, and\r\n\t\t// spend the two-minute cooldown doing it.\r\n\t\tif ( player.IsDown ) return false;\r\n\t\tif ( player.PhaseShiftReady \u003E 0f ) return false;\r\n\r\n\t\tvar hp = player.Components.Get\u003CHealth\u003E( FindMode.EverythingInSelfAndAncestors );\r\n\t\tif ( !hp.IsValid() || incoming \u003C hp.Current ) return false;\r\n\r\n\t\tvar spawns = RoundManager.Instance?.EligibleSpecialSpawns;\r\n\r\n\t\t// \u26D4 THE COOLDOWN STARTS HERE, BEFORE THE TELEPORT IS EVEN ATTEMPTED, because the save is\r\n\t\t// what it pays for. Charging it only on a successful teleport would give a map with no\r\n\t\t// special spawns an unlimited 1 HP save on every fatal hit \u2014 the strongest augment in the\r\n\t\t// game, by accident, on exactly the maps where it looks broken.\r\n\t\tplayer.PhaseShiftReady = PhaseCooldown;\r\n\r\n\t\tif ( spawns is not null \u0026\u0026 spawns.Count \u003E 0 )\r\n\t\t{\r\n\t\t\tvar to = spawns[Game.Random.Int( 0, spawns.Count - 1 )].Position;\r\n\r\n\t\t\tplayer.WorldPosition = to;\r\n\r\n\t\t\tLog.Info( $\u0022[nz-aug] revive m5 Phase Shift \u2014 survived at 1 HP, teleported to {to}\u0022\r\n\t\t\t\t\u002B $\u0022 \u00B7 next in {PhaseCooldown:0.#}s\u0022 );\r\n\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\t// \u26D4 THE TWO CAUSES ARE SEPARATED BECAUSE THE OLD MESSAGE ASSERTED ONE IT COULD NOT KNOW. It\r\n\t\t// said \u0022no special spawns on this map\u0022 while reading \u0060EligibleSpecialSpawns\u0060, which is the\r\n\t\t// PLACED list already filtered by link-open, power and round \u2014 so \u0022all of them are gated\r\n\t\t// right now\u0022 printed as \u0022this map has none\u0022. Those need completely different responses from\r\n\t\t// the player: place some, versus open a door or turn the power on.\r\n\t\tvar placed = ActiveConfig.Current?.SpecialSpawns?.Count ?? 0;\r\n\r\n\t\tLog.Warning( $\u0022[nz-aug] revive m5 Phase Shift \u2014 SAVED you at 1 HP but could not teleport: \u0022\r\n\t\t\t\u002B (placed == 0\r\n\t\t\t\t? \u0022this map has no special spawns placed at all \u2014 use \u0060nz_special_here\u0060\u0022\r\n\t\t\t\t: $\u0022all {placed} special spawn(s) are gated right now (door unbought, power off,\u0022\r\n\t\t\t\t\t\u002B \u0022 or round not reached)\u0022)\r\n\t\t\t\u002B $\u0022 \u00B7 next in {PhaseCooldown:0.#}s\u0022 );\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\t// \u2550\u2550 diagnostics \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n\tpublic static void Report( NZPlayer player )\r\n\t{\r\n\t\tif ( !player.IsValid() ) { Log.Warning( \u0022[nz-aug] no player\u0022 ); return; }\r\n\r\n\t\tvar has = player.HasPerk( Perk );\r\n\t\tvar equipped = PerkAugments.EquippedOn( player, Perk );\r\n\r\n\t\tLog.Info( $\u0022[nz-aug] QUICK REVIVE {(has ? \u0022owned\u0022 : \u0022NOT OWNED \u2014 every line below is inert\u0022)}\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 equipped [{(equipped.Length == 0 ? \u0022none\u0022 : string.Join( \u0022\u002B\u0022, equipped ))}]\u0022 );\r\n\r\n\t\tLog.Info( $\u0022[nz-aug]  base            self-revive {player.SelfRevivesUsed}/{UsesFor( player )}\u0022\r\n\t\t\t\u002B $\u0022 (only when alone \u2014 {(player.IsAlone ? \u0022you are\u0022 : \u0022you are NOT, so none\u0022)})\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 revive others in {SecondsFor( player ):0.##}s within {ReachFor( player ):0}u\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 downed weapon {(KeepsWeapons( player ) ? \u0022YOUR OWN\u0022 : \u0022M1911\u0022)}\u0022 );\r\n\r\n\t\t// \u26A0\uFE0F THE WHOLE CHAIN, NOT JUST THE ANSWER. \u0060SecondsFor\u0060 above prints one number, and the\r\n\t\t// question being asked of this report is which of the two dividers is actually applying \u2014\r\n\t\t// a player holding the perk with no M1 and a player with M1 and no perk are different bugs\r\n\t\t// that produce different numbers, and neither is distinguishable from a single figure.\r\n\t\tLog.Info( $\u0022[nz-aug]  revive speed    {ReviveSeconds:0.##}s base\u0022\r\n\t\t\t\u002B $\u0022 \u00F7{(has ? PerkSpeed : 1f):0.##} perk\u0022\r\n\t\t\t\u002B $\u0022 \u00F7{(Has( player, \u0022M1\u0022 ) ? PhoenixSpeed : 1f):0.##} M1\u0022\r\n\t\t\t\u002B $\u0022 = {SecondsFor( player ):0.##}s\u0022 );\r\n\r\n\t\tLog.Info( $\u0022[nz-aug]  M1 Phoenix      {(Has( player, \u0022M1\u0022 ) ? $\u0022{PhoenixUses} self-revives \u00B7 reviving x{PhoenixSpeed:0.##} faster\u0022 : \u0022-\u0022)}\u0022 );\r\n\t\tLog.Info( $\u0022[nz-aug]  M2 Grave Keeper {(Has( player, \u0022M2\u0022 ) ? $\u0022keep all {player.Perks.Count} perk(s)\u0022 : $\u0022- (config keeps {ActiveConfig.Player.PerksKeptOnDown})\u0022)}\u0022 );\r\n\t\tLog.Info( $\u0022[nz-aug]  M3 Guardian     {(Has( player, \u0022M3\u0022 ) ? $\u0022no key needed, {AuraReach:0}u\u0022 : \u0022-\u0022)}\u0022 );\r\n\t\tLog.Info( $\u0022[nz-aug]  M4 Last Stand   {(Has( player, \u0022M4\u0022 ) ? \u0022keep weapons when down \u00B7 a kill revives you\u0022 : \u0022-\u0022)}\u0022 );\r\n\t\tLog.Info( $\u0022[nz-aug]  m1 Rapid Recov  {(Has( player, \u0022m1\u0022 ) ? $\u0022regen delay x{RegenDelayScale:0.##}\u0022 : \u0022-\u0022)}\u0022\r\n\t\t\t\u002B $\u0022   {ActiveConfig.Player.HealthRegenDelay * PerkEffects.RegenDelayMultiplier( player ) * DelayScale( player ):0.##}s wait\u0022 );\r\n\t\tLog.Info( $\u0022[nz-aug]  m2 Fast Metab   {(Has( player, \u0022m2\u0022 ) ? $\u0022tick gap x{RegenRateScale:0.##}\u0022 : \u0022-\u0022)}\u0022\r\n\t\t\t\u002B $\u0022   {ActiveConfig.Player.HealthRegenRate * RateScale( player ):0.###}s per tick\u0022 );\r\n\t\tLog.Info( $\u0022[nz-aug]  m3 Field Medic  {(Has( player, \u0022m3\u0022 ) ? $\u0022full heal \u002B {MedicSpeedSeconds:0.#}s of x{MedicSpeed:0.##} speed\u0022 : \u0022-\u0022)}\u0022\r\n\t\t\t\u002B $\u0022   boost {(player.MedicSpeedUntil \u003E 0f ? $\u0022{(float)player.MedicSpeedUntil:0.0}s left\u0022 : \u0022off\u0022)}\u0022 );\r\n\t\tLog.Info( $\u0022[nz-aug]  m4 Plate Carrier {(Has( player, \u0022m4\u0022 ) ? \u0022anyone revived, self included, gets full armor\u0022 : \u0022-\u0022)}\u0022 );\r\n\t\tLog.Info( $\u0022[nz-aug]  m5 Phase Shift  {(Has( player, \u0022m5\u0022 ) ? $\u0022survive at 1 HP, {PhaseCooldown:0.#}s cd\u0022 : \u0022-\u0022)}\u0022\r\n\t\t\t\u002B $\u0022   ready in {MathF.Max( 0f, player.PhaseShiftReady ):0.0}s\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 {(RoundManager.Instance?.EligibleSpecialSpawns?.Count ?? 0)} special spawn(s)\u0022 );\r\n\r\n\t\t// \u26A0\uFE0F NAMED EXPLICITLY, because \u0022it did nothing\u0022 on a solo test is the expected answer for\r\n\t\t// three of these and that is not obvious from the list above.\r\n\t\tLog.Info( \u0022[nz-aug]  \u26A0 M3 and m3 act on ANOTHER PLAYER \u2014 solo they cannot fire at all. m4 now also fires on self-revive.\u0022 );\r\n\t\tLog.Info( \u0022[nz-aug]     No dummy command: a second NZPlayer would hijack the 122 call sites\u0022 );\r\n\t\tLog.Info( \u0022[nz-aug]     that resolve the player as FirstOrDefault\u003CNZPlayer\u003E(). The lines above\u0022 );\r\n\t\tLog.Info( \u0022[nz-aug]     at least prove they are wired.\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_aug_revive\u0060 \u2014 the resolved state of the base and all nine.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_aug_revive\u0022 )]\r\n\tpublic static void ReportCmd()\r\n\t\t=\u003E Report( NZPlayer.Local );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Is this player\u0027s body actually drawn right now?\r\n\t///\r\n\t/// \u26A0\uFE0F READ OFF THE OBJECT, NOT INFERRED FROM THE FLAG. \u0022Out of round\u0022 is what SHOULD hide a\r\n\t/// body; whether it DID is a separate fact, and on a proxy the two can disagree \u2014 which is the\r\n\t/// whole reason the despawn needed a synced flag rather than a local one.\r\n\t/// \u003C/summary\u003E\r\n\tstatic bool BodyShown( NZPlayer p )\r\n\t{\r\n\t\tvar ctrl = p.Components.Get\u003CPlayerController\u003E( FindMode.EverythingInSelfAndDescendants );\r\n\r\n\t\treturn ctrl.IsValid() \u0026\u0026 ctrl.Renderer.IsValid() \u0026\u0026 ctrl.Renderer.GameObject.Enabled;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_revive_state\u0060 \u2014 WHAT EVERY MACHINE BELIEVES ABOUT EVERY PLAYER\u0027S DOWN.\r\n\t///\r\n\t/// \u26D4 THE CO-OP REVIVE FAILS IN FOUR PLACES AND ALL FOUR ARE SILENT. The owner never\r\n\t/// published; the flag never arrived; the rescuer is out of reach; or the relay never landed.\r\n\t/// Every one of them looks the same from a keyboard: you hold E over a crawling teammate and\r\n\t/// nothing whatever happens. Only both machines\u0027 answers side by side tell them apart \u2014 and\r\n\t/// the two columns that matter most are the ones that DISAGREE.\r\n\t///\r\n\t/// \u26A0\uFE0F \u0060down\u0060 IS THIS MACHINE\u0027S BELIEF AND \u0060net\u0060 IS WHAT THE OWNER PUBLISHED. On the owner\u0027s\r\n\t/// own row they must match, because the owner writes one from the other. On a proxy row a\r\n\t/// mismatch is the replication itself failing, which is a different bug from the revive not\r\n\t/// working \u2014 and before this command they were indistinguishable.\r\n\t///\r\n\t/// Client output routes to the host, so one capture holds both sides.\r\n\t/// \u003C/summary\u003E\r\n\t/// \u003Csummary\u003EA player\u0027s profile name for a report, falling back to the object\u0027s name.\u003C/summary\u003E\r\n\tstatic string Named( NZPlayer p )\r\n\t{\r\n\t\tvar n = NZPlayers.NameOf( p );\r\n\t\treturn string.IsNullOrWhiteSpace( n ) ? (p.IsValid() ? p.GameObject.Name : \u0022?\u0022) : n;\r\n\t}\r\n\r\n\t[ConCmd( \u0022nz_revive_state\u0022 )]\r\n\tpublic static void StateCmd()\r\n\t{\r\n\t\tvoid Tell( string line )\r\n\t\t{\r\n\t\t\tif ( NZGame.IsHost || !Networking.IsActive ) Log.Info( line );\r\n\t\t\telse NZNet.Say( line );\r\n\t\t}\r\n\r\n\t\tvar who = NZGame.IsHost ? \u0022HOST  \u0022 : \u0022CLIENT\u0022;\r\n\t\tvar me = NZPlayer.Local;\r\n\r\n\t\t// \u26D4 SAID OUT LOUD, BECAUSE OTHERWISE THIS COMMAND REPORTS A BUG THAT IS NOT ONE. Solo\r\n\t\t// there is no session, so \u0060TickDownedMirror\u0060 never runs and \u0060net\u0060 stays at its default \u2014\r\n\t\t// printing \u0060down=True net=False\u0060 beside each other, which is precisely the shape of the\r\n\t\t// replication failure the column exists to catch. A reader would be right to report it.\r\n\t\tif ( !Networking.IsActive )\r\n\t\t\tTell( \u0022[nz-rev] \u26A0\uFE0F SOLO \u2014 no session, so nothing replicates and \u0060net\u0060 means nothing here.\u0022\r\n\t\t\t\t\u002B \u0022 A mismatch below is expected, not a fault.\u0022 );\r\n\r\n\t\tforeach ( var go in PlayerSpawner.AllBodies() )\r\n\t\t{\r\n\t\t\tvar p = go.Components.Get\u003CNZPlayer\u003E( FindMode.EverythingInSelf );\r\n\t\t\tif ( !p.IsValid() ) continue;\r\n\r\n\t\t\tvar mine = PlayerPresence.Mine( go );\r\n\t\t\t// \u26A0\uFE0F THE PROFILE NAME, matching every other place a player is named. A report that\r\n\t\t\t// calls somebody by their costume cannot be compared against what the player says they\r\n\t\t\t// saw on screen.\r\n\t\t\tvar name = NZPlayers.NameOf( p );\r\n\t\t\tif ( string.IsNullOrWhiteSpace( name ) ) name = go.Name;\r\n\r\n\t\t\tTell( $\u0022[nz-rev] {who} \u0027{name}\u0027 mine={mine,-5}\u0022\r\n\t\t\t\t\u002B $\u0022 down={p.IsDown,-5} net={p.DownedNet,-5}\u0022\r\n\t\t\t\t\u002B (p.IsDown != p.DownedNet \u0026\u0026 !mine ? \u0022 \u26D4 DISAGREE\u0022 : \u0022\u0022)\r\n\t\t\t\t\u002B $\u0022 bled={p.HasBledOut,-5}\u0022\r\n\t\t\t\t\u002B $\u0022 out={p.IsOutOfRound,-5}\u0022\r\n\t\t\t\t\u002B $\u0022 body={(BodyShown( p ) ? \u0022shown\u0022 : \u0022\u26D4 despawned\u0022),-11}\u0022\r\n\t\t\t\t\u002B $\u0022 bleedout={(p.IsDown ? $\u0022{MathF.Max( 0f, p.BleedsOutIn ):0.#}s\u0022 : \u0022\u2014\u0022)}\u0022\r\n\t\t\t\t\u002B $\u0022 othersUp={p.OthersStillUp}\u0022\r\n\t\t\t\t\u002B $\u0022 canBeRevived={p.CanBeRevived}\u0022\r\n\t\t\t\t\u002B $\u0022 selfRevive={(mine || !Networking.IsActive ? p.SelfReviveComing : p.SelfReviveNet)}\u0022 );\r\n\t\t}\r\n\r\n\t\t// \u26A0\uFE0F AND THE HOST\u0027S EVERYBODY-DOWN CHECK (\u0060NZPlayer.TickEverybodyDown\u0060), which ends a run with nobody standing.\r\n\t\tTell( $\u0022[nz-rev] {who} everybody down: \u0022\r\n\t\t\t\u002B (NZPlayer.EverybodyDown\r\n\t\t\t\t? $\u0022YES for {NZPlayer.EverybodyDownFor:0.0}s \u2014 game over at {NZPlayer.EverybodyDownGrace:0.0}s\u0022\r\n\t\t\t\t: \u0022no (somebody is up, a self-revive is coming, or this is not the host)\u0022) );\r\n\r\n\t\tif ( !me.IsValid() ) { Tell( $\u0022[nz-rev] {who} \u26D4 no local player\u0022 ); return; }\r\n\r\n\t\t// \u26A0\uFE0F THE RESCUER\u0027S OWN VIEW, SEPARATELY. \u0022Is anybody in reach of ME\u0022 is the question the\r\n\t\t// hold actually asks, and it is not answerable from the rows above \u2014 reach depends on the\r\n\t\t// augments this player owns, so it is a different number for each of them.\r\n\t\tvar target = TargetFor( me );\r\n\r\n\t\tTell( $\u0022[nz-rev] {who}   my reach {ReachFor( me ):0.#}u\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 revive takes {SecondsFor( me ):0.##}s\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 passive(M3)={RevivesPassively( me )}\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 in reach: {(target.IsValid() ? Named( target ) : \u0022\u26A0\uFE0F nobody\u0022)}\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 progress {me.ReviveProgress:0.##}s\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_revive_down\u0060 \u2014 put yourself down, to test the pistol swap and Last Stand.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_revive_down\u0022 )]\r\n\tpublic static void DownCmd()\r\n\t{\r\n\t\tvar p = NZPlayer.Local;\r\n\t\tif ( !p.IsValid() ) { Log.Warning( \u0022[nz-aug] no player\u0022 ); return; }\r\n\r\n\t\tvar hp = p.Components.Get\u003CHealth\u003E( FindMode.EverythingInSelfAndAncestors );\r\n\t\tif ( !hp.IsValid() ) return;\r\n\r\n\t\thp.OnDamage( new DamageInfo\r\n\t\t{\r\n\t\t\tDamage = hp.Current \u002B 1f,\r\n\t\t\tPosition = p.WorldPosition,\r\n\t\t\tTags = new TagSet(),\r\n\t\t} );\r\n\r\n\t\tLog.Info( $\u0022[nz-aug] revive \u2014 down {p.IsDown}\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 holding {(p.Inventory.IsValid() ? p.Inventory.Count : 0)} weapon(s)\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_revive_set\u0060 \u2014 retune live. Negative or omitted leaves a value alone.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_revive_set\u0022 )]\r\n\tpublic static void SetCmd( int uses = -1, int phoenixUses = -1, float seconds = -1f,\r\n\t\tfloat phoenixSpeed = -1f, float reach = -1f, float auraReach = -1f,\r\n\t\tfloat delayScale = -1f, float rateScale = -1f, float medicSeconds = -1f,\r\n\t\tfloat medicSpeed = -1f, float phaseCd = -1f, float perkSpeed = -1f )\r\n\t{\r\n\t\tif ( uses \u003E= 0 ) SelfReviveUses = uses;\r\n\t\tif ( phoenixUses \u003E= 0 ) PhoenixUses = phoenixUses;\r\n\t\tif ( seconds \u003E= 0f ) ReviveSeconds = seconds;\r\n\t\t// \u26A0\uFE0F APPENDED, NOT INSERTED. \u0060nz_revive_set\u0060 takes positional arguments, so adding this\r\n\t\t// anywhere but the end would silently re-map every argument after it \u2014 somebody\u0027s saved\r\n\t\t// \u0022phaseCd 90\u0022 line would start setting something else.\r\n\t\tif ( perkSpeed \u003E= 0f ) PerkSpeed = perkSpeed;\r\n\t\tif ( phoenixSpeed \u003E= 0f ) PhoenixSpeed = phoenixSpeed;\r\n\t\tif ( reach \u003E= 0f ) ReviveReach = reach;\r\n\t\tif ( auraReach \u003E= 0f ) AuraReach = auraReach;\r\n\t\tif ( delayScale \u003E= 0f ) RegenDelayScale = delayScale;\r\n\t\tif ( rateScale \u003E= 0f ) RegenRateScale = rateScale;\r\n\t\tif ( medicSeconds \u003E= 0f ) MedicSpeedSeconds = medicSeconds;\r\n\t\tif ( medicSpeed \u003E= 0f ) MedicSpeed = medicSpeed;\r\n\t\tif ( phaseCd \u003E= 0f ) PhaseCooldown = phaseCd;\r\n\r\n\t\tReportCmd();\r\n\t}\r\n}\r\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Player/SalvageDrop.cs","FileName":"SalvageDrop.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\r\nusing System;\r\n\r\nnamespace NZombies;\r\n\r\n/// \u003Csummary\u003E\r\n/// DROP SALVAGE FOR SOMEONE ELSE TO PICK UP \u2014 \u00606\u0060, or \u0060nz_drop_salvage\u0060 (2026-09-29): *\u0022pressing \u00226\u0022 should allow me to drop\r\n/// 100 salvage any player can pick up, kind of what we did with \u00225\u0022 for money sharing\u0022*.\r\n///\r\n/// The player spends up to \u003Csee cref=\u0022Amount\u0022/\u003E salvage and a pile worth exactly that lands in front of them\r\n/// (\u0060PickupKind.SalvageGift\u0060, outlined in gold). Anyone can take it, the dropper included: the first to walk over it.\r\n///\r\n/// \u26D4 THE SALVAGE IS MOVED, NEVER CREATED OR DESTROYED, the rule \u0060PointsDrop\u0060 keeps. The pile carries the amount actually spent\r\n/// (\u0060Pickup.Amount\u0060, sent to every machine with it) and pays exactly that \u2014 not a kill\u0027s roll with its augments on top.\r\n///\r\n/// \u26D4 NOT A KILL\u0027S SALVAGE, WHICH IS ITS KILLER\u0027S ALONE. That kind lands on one machine and nobody else ever sees it\r\n/// (\u0060Pickup.DropFor\u0060). This one is shared, so it takes the shared path: the host decides who stood on it first and offers it\r\n/// to them, and their machine pays them (\u0060Pickup.Offer\u0060, \u0060Pickup.TryTakeLocal\u0060).\r\n///\r\n/// \u26A0\uFE0F SHORT OF THE FULL AMOUNT, IT DROPS WHAT YOU HAVE, as \u00605\u0060 does: \u0022give a teammate everything I\u0027ve got\u0022 is the case this is for.\r\n///\r\n/// \u26A0\uFE0F IT WAITS FIVE MINUTES (the kind\u0027s \u0060Lifetime\u0060), where a kill\u0027s pile waits two \u2014 then it is gone, like a dropped powerup.\r\n/// \u003C/summary\u003E\r\npublic static class SalvageDrop\r\n{\r\n\t/// \u003Csummary\u003E\r\n\t/// What a full drop costs and pays \u2014 100, as asked. Nullable-backed, so a changed default reaches a running editor\r\n\t/// (INSTRUCTIONS \u00A71).\r\n\t/// \u003C/summary\u003E\r\n\tpublic static int Amount\r\n\t{\r\n\t\tget =\u003E _amount ??= 100;\r\n\t\tset =\u003E _amount = Math.Max( 1, value );\r\n\t}\r\n\r\n\tstatic int? _amount;\r\n\r\n\t/// \u003Csummary\u003EWhy this player cannot drop salvage right now, or null if they can \u2014 \u00606 did nothing\u0060 must always be answerable.\u003C/summary\u003E\r\n\tpublic static string WhyCannot( NZPlayer player )\r\n\t{\r\n\t\tif ( !player.IsValid() ) return \u0022no player\u0022;\r\n\t\tif ( player.IsDown ) return \u0022you are down\u0022;\r\n\t\tif ( player.Salvage \u003C= 0 ) return \u0022you have no salvage\u0022;\r\n\t\treturn null;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ESpend and drop. True when the salvage left this player \u2014 dropped here, or asked of the host.\u003C/summary\u003E\r\n\tpublic static bool Drop( NZPlayer player )\r\n\t{\r\n\t\tvar why = WhyCannot( player );\r\n\t\tif ( why is not null )\r\n\t\t{\r\n\t\t\tLog.Info( $\u0022[nz-drop] cannot drop salvage \u2014 {why}\u0022 );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\t// \u26A0\uFE0F CLAMPED TO WHAT THEY HAVE, so the spend cannot refuse\r\n\t\tvar amount = Math.Min( Amount, player.Salvage );\r\n\r\n\t\tif ( !Salvage.TrySpend( player, amount ) )\r\n\t\t{\r\n\t\t\tLog.Warning( \u0022[nz-drop] salvage spend refused unexpectedly \u2014 nothing dropped\u0022 );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\t// the same spot \u00605\u0060 drops on: in front, on the floor, short of a wall\r\n\t\tvar at = PointsDrop.DropSpot( player );\r\n\r\n\t\t// \u26D4 A CLIENT ASKS THE HOST TO MAKE IT, for \u0060PointsDrop\u0060\u0027s reason: made here, the pile would be in this world only and\r\n\t\t// nobody could take it. The spend above stays local \u2014 salvage lives on the owner\u0027s body.\r\n\t\tif ( NZGame.IsClient )\r\n\t\t{\r\n\t\t\tNZNet.SalvageDropAsk( at, amount );\r\n\t\t\tLog.Info( $\u0022[nz-drop] asked the host to drop {amount} salvage at {at:0}\u0022 );\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tvar pile = Pickup.Spawn( at, PickupKind.SalvageGift, null, player.GameObject, amount: amount );\r\n\r\n\t\tif ( !pile.IsValid() )\r\n\t\t{\r\n\t\t\t// \u26D4 REFUNDED: spent with nothing on the floor is the one outcome that must not happen\r\n\t\t\tSalvage.Refund( player, amount );\r\n\t\t\tLog.Warning( $\u0022[nz-drop] the salvage pile failed to spawn \u2014 {amount} refunded\u0022 );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tLog.Info( $\u0022[nz-drop] dropped {amount} salvage at {at:0}\u0022 \u002B ( amount \u003C Amount ? \u0022 (everything they had)\u0022 : \u0022\u0022 ) );\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_drop_salvage\u0060 \u2014 the same drop the key does (every button gets a command).\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_drop_salvage\u0022 )]\r\n\tpublic static void DropCmd()\r\n\t{\r\n\t\tvar player = NZPlayer.Local;\r\n\t\tif ( !player.IsValid() ) { Log.Warning( \u0022[nz-drop] no player\u0022 ); return; }\r\n\r\n\t\tDrop( player );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_drop_salvage_amount [salvage]\u0060 \u2014 what a full drop costs. No argument reports it.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_drop_salvage_amount\u0022 )]\r\n\tpublic static void AmountCmd( int salvage = -1 )\r\n\t{\r\n\t\tif ( salvage \u003E 0 ) Amount = salvage;\r\n\r\n\t\tLog.Info( $\u0022[nz-drop] a full salvage drop costs and pays {Amount}, landing {PointsDrop.DropDistance:0}u in front\u0022\r\n\t\t\t\u002B ( salvage \u003E 0 ? \u0022\u0022 : \u0022 (nz_drop_salvage_amount \u003Csalvage\u003E to change)\u0022 ) );\r\n\t}\r\n}\r\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Player/Slide.cs","FileName":"Slide.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\r\nusing System;\r\nusing System.Linq;\r\n\r\nnamespace NZombies;\r\n\r\n/// \u003Csummary\u003E\r\n/// Sprint-into-crouch slide.\r\n///\r\n/// \u26D4 THE COMMENT HERE USED TO SAY GMOD nZOMBIES HAD NO SLIDE. That was WRONG \u2014\r\n/// \u0060gamemode/sliding/\u0060 is a full subsystem (4 files, 17KB). The claim came from a\r\n/// truncated grep, not from the source. See Docs/SLIDING.md.\r\n///\r\n/// \u26A0\uFE0F ORIGINAL VALUES: slidespeed **1.3**, duration **0.6s**, cooldown **0.4s**\r\n/// (map settings). Ours are 1.45 / 0.85 / 0.55 \u2014 same shape, slower and longer,\r\n/// and worth retuning toward the original.\r\n///\r\n/// \u26A0\uFE0F STILL MISSING vs the original: slide stamina, per-player slide speed, the\r\n/// BO3/BO4 difficulty toggles, the \u0060wos_CSlide\u0060 animation and surface sounds.\r\n///\r\n/// \u26A0\uFE0F Exists mainly so PERKS can hang off it \u2014 Banana Colada trails a slide and\r\n/// PhD Flopper detonates out of one. \u0060IsSliding\u0060 and \u0060OnSlideStart\u0060 are the hooks\r\n/// those will use.\r\n/// \u003C/summary\u003E\r\npublic sealed class Slide : Component\r\n{\r\n\t// \u2500\u2500 tuning \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t// \u26A0\uFE0F STATIC, so the console commands can tune them live for every player at\r\n\t// once. Statics that start EMPTY or wrong do not survive a hotload \u2014 these\r\n\t// carry their defaults inline, which does.\r\n\r\n\t/// \u003Csummary\u003ESlide at all.\r\n\t///\r\n\t/// \u26A0\uFE0F NOT \u0060Enabled\u0060 \u2014 that is \u0060Component.Enabled\u0060, and shadowing it means\r\n\t/// \u0022turn the slide off\u0022 and \u0022disable the component\u0022 become the same word with\r\n\t/// two different meanings.\u003C/summary\u003E\r\n\tpublic static bool SlideEnabled { get; set; } = true;\r\n\r\n\t// \u26D4 READ FROM THE CONFIG, NOT HELD AS STATICS. They were statics while the\r\n\t// slide was being proven; keeping them there would mean a mapper\u0027s saved\r\n\t// settings and the live values could disagree, with the console silently\r\n\t// winning. Player Settings is the single source now and the commands below\r\n\t// write THROUGH to it.\r\n\r\n\t/// \u003Csummary\u003ELaunch speed as a multiple of run speed.\u003C/summary\u003E\r\n\tpublic static float SpeedMultiplier\r\n\t{\r\n\t\tget =\u003E ActiveConfig.Player.SlideSpeed;\r\n\t\tset =\u003E ActiveConfig.Player.SlideSpeed = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EHow long a slide lasts, seconds.\u003C/summary\u003E\r\n\tpublic static float Duration\r\n\t{\r\n\t\tget =\u003E ActiveConfig.Player.SlideDuration;\r\n\t\tset =\u003E ActiveConfig.Player.SlideDuration = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EWait before another slide, seconds.\u003C/summary\u003E\r\n\tpublic static float Cooldown\r\n\t{\r\n\t\tget =\u003E ActiveConfig.Player.SlideCooldown;\r\n\t\tset =\u003E ActiveConfig.Player.SlideCooldown = value;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EFraction of run speed you must already be doing to start one.\u003C/summary\u003E\r\n\tpublic static float MinSpeedFraction { get; set; } = 0.7f;\r\n\r\n\t// \u2500\u2500 state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\tpublic bool IsSliding { get; private set; }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// How fast this player is going under their OWN power right now, horizontally: the slide\u0027s launch speed while one runs and for\r\n\t/// half a second after (the brakes come back over a few frames), the stashed chain speed while a Banana Colada chain is armed in\r\n\t/// the air, else 0 (nothing beyond running).\r\n\t///\r\n\t/// \u26A0\uFE0F FOR ShoveGuard (2026-10-03), so a boosted slide is never taken for an ejection. Stamin-Up\u0027s Slide Boost with Banana Colada\u0027s\r\n\t/// coasting launches at 1,001 u/s, over that guard\u0027s 1,000 ceiling.\r\n\t/// \u003C/summary\u003E\r\n\tpublic float OwnSpeed\r\n\t\t=\u003E IsSliding || _sinceEnd \u003C OwnSpeedGrace ? _launchSpeed\r\n\t\t: _chainUntil \u003E 0f ? _chainSpeed\r\n\t\t: 0f;\r\n\r\n\t/// \u003Csummary\u003ESeconds after a slide ends that its speed still counts as the player\u0027s own (see \u0060OwnSpeed\u0060).\u003C/summary\u003E\r\n\tconst float OwnSpeedGrace = 0.5f;\r\n\r\n\t/// \u003Csummary\u003EFires when a slide begins \u2014 (player, direction). For the perks.\u003C/summary\u003E\r\n\tpublic static Action\u003CNZPlayer, Vector3\u003E OnSlideStart { get; set; }\r\n\r\n\tTimeSince _sinceStart;\r\n\tTimeSince _sinceEnd;\r\n\tVector3 _dir;\r\n\tfloat _launchSpeed;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The effective duration of the slide in progress. Captured at Start rather than\r\n\t/// read per frame, so Banana Colada\u0027s multiplier cannot change under a slide that\r\n\t/// is already running \u2014 which would make the ease-out jump.\r\n\t/// \u003C/summary\u003E\r\n\tfloat _duration;\r\n\r\n\t/// \u003Csummary\u003ETrue while this slide should coast at launch speed instead of decaying.\u003C/summary\u003E\r\n\tbool _keepMomentum;\r\n\r\n\t// \u2500\u2500 BANANA COLADA\u0027S CHAIN \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t/// \u003Csummary\u003E\r\n\t/// Speed and direction stashed when a slide left the ground, so a crouch in mid-air\r\n\t/// can resume it on landing.\r\n\t///\r\n\t/// \u26A0\uFE0F STASHED AT TAKEOFF, NOT READ ON LANDING. Reading velocity when you touch down\r\n\t/// would hand back whatever the fall did to it \u2014 air drag, a wall clip, a slope \u2014 and\r\n\t/// the chain would lose speed for reasons the player cannot see. Remembering the\r\n\t/// number is what makes \u0022maintains all momentum\u0022 true rather than approximately true.\r\n\t/// \u003C/summary\u003E\r\n\tVector3 _chainDir;\r\n\tfloat _chainSpeed;\r\n\r\n\t/// \u003Csummary\u003ERuns from the moment a chainable slide left the ground.\u003C/summary\u003E\r\n\tTimeUntil _chainUntil;\r\n\r\n\t/// \u003Csummary\u003ESet while crouch is held airborne; consumed on landing.\u003C/summary\u003E\r\n\tbool _chainQueued;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// This slide came from \u0060nz_slide go\u0060 / \u0060nz_slide back\u0060 rather than from input.\r\n\t///\r\n\t/// \u26D4 EXISTS ONLY TO EXEMPT THE CONSOLE FROM THE RELEASE CHECK. A forced slide is\r\n\t/// started by someone who is not touching the keyboard, so \u0022crouch is not held\u0022 is true\r\n\t/// on its very first frame \u2014 without this the two diagnostic commands would stop one\r\n\t/// frame after starting and report zero travel, which is exactly the symptom they exist\r\n\t/// to rule out.\r\n\t/// \u003C/summary\u003E\r\n\tbool _forced;\r\n\t/// \u003Csummary\u003E\r\n\t/// Why the last slide ended, for the Stop log.\r\n\t///\r\n\t/// \u26A0\uFE0F AN INSTRUMENT FOR THE CHAIN SPECIFICALLY. \u0022The chain does not work\u0022 has three\r\n\t/// causes that look identical in play \u2014 the slide ran its timer out (nothing to chain),\r\n\t/// the player never left the ground (the controller refused the jump), or the crouch\r\n\t/// press missed the window. Only the first two are visible from here, and printing\r\n\t/// which one happened is the difference between one test and five.\r\n\t/// \u003C/summary\u003E\r\n\tstring _endReason = \u0022\u0022;\r\n\r\n\tVector3 _startPos;\r\n\tfloat _savedDucked;\r\n\tfloat _savedFriction;\r\n\tfloat _savedBrake;\r\n\r\n\tPlayerController _c;\r\n\tNZPlayer _nz;\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// \u26D4 MY BODY ONLY (2026-10-05). A slide is begun by THIS machine\u0027s crouch key, and this ran on every body in the scene, so\r\n\t\t// a crouch also slid any teammate who happened to be running, on this screen alone. Pu4DinFL7\u0027s log (2026-10-05 00:38)\r\n\t\t// ends two slides on the same frame ten times: one press, two bodies. Everything that reads \u0060IsSliding\u0060 asks about its\r\n\t\t// own body (ShoveGuard, the weapon tech), and each machine slides its own player.\r\n\t\tif ( !PlayerPresence.Mine( GameObject ) ) return;\r\n\r\n\t\t_c ??= Components.Get\u003CPlayerController\u003E();\r\n\t\t_nz ??= Components.Get\u003CNZPlayer\u003E();\r\n\r\n\t\tif ( !_c.IsValid() ) return;\r\n\r\n\t\tif ( IsSliding ) Tick();\r\n\t\telse TryStart();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ECan a slide begin this frame.\u003C/summary\u003E\r\n\tvoid TryStart()\r\n\t{\r\n\t\tif ( !SlideEnabled ) return;\r\n\t\tif ( _nz.IsValid() \u0026\u0026 _nz.IsDown ) return;\r\n\r\n\t\t// \u26D4 THE CHAIN IS CHECKED BEFORE THE COOLDOWN AND BEFORE THE SPEED GATE, and it\r\n\t\t// has to be. A jump out of a slide lands inside the cooldown almost by definition,\r\n\t\t// and the speed gate wants \u0060RunSpeed * 0.7\u0060 on the ground \u2014 neither is true in the\r\n\t\t// frame you touch down. Running the chain through the normal path would mean it\r\n\t\t// worked only on the rare landing that happened to satisfy both.\r\n\t\tif ( TryChain() ) return;\r\n\r\n\t\tif ( _sinceEnd \u003C Cooldown ) return;\r\n\r\n\t\t// \u26D4 ON THE PRESS, NOT WHILE HELD. Crouch is a movement key people hold \u2014\r\n\t\t// starting on Down would re-trigger every frame you stayed crouched, and\r\n\t\t// the slide would never actually end while the key was down.\r\n\t\tif ( !Input.Pressed( \u0022Duck\u0022 ) ) return;\r\n\r\n\t\tif ( !_c.IsOnGround ) return;\r\n\r\n\t\t// \u26A0\uFE0F SPEED, not \u0022is the run key held\u0022. Judging by the key would let you\r\n\t\t// slide from a standstill by tapping both together; judging by actual\r\n\t\t// velocity means a slide always comes out of real momentum.\r\n\t\tvar vel = _c.Velocity.WithZ( 0f );\r\n\t\tvar speed = vel.Length;\r\n\t\tif ( speed \u003C _c.RunSpeed * MinSpeedFraction ) return;\r\n\r\n\t\tStart( vel.Normal, speed );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Banana Colada\u0027s chain: a crouch in mid-air resumes the slide you jumped out of.\r\n\t///\r\n\t/// \u26A0\uFE0F THE CROUCH IS CAPTURED IN THE AIR, THE SLIDE RESUMES ON LANDING. Those are two\r\n\t/// different frames and conflating them is the obvious bug here \u2014 requiring the key to\r\n\t/// be HELD at the moment of touchdown would refuse every input that came a fraction\r\n\t/// early, which is most of them.\r\n\t///\r\n\t/// \u26A0\uFE0F \u0060Input.Down\u0060, NOT \u0060Pressed\u0060. Jumping straight out of a slide means crouch was\r\n\t/// already held when you took off, so demanding a fresh press would force the player to\r\n\t/// release and re-press in mid-air \u2014 an input nobody would guess at and one that fights\r\n\t/// the thing it is chaining out of.\r\n\t///\r\n\t/// \u26A0\uFE0F Reading a held key is only safe because the WINDOW gates it. \u0060_chainUntil\u0060 is\r\n\t/// opened solely by a live slide leaving the ground early, so a player who happens to be\r\n\t/// crouch-holding through an ordinary fall has no window and cannot chain off it.\r\n\t///\r\n\t/// \u26A0\uFE0F NOTHING HERE MAKES THE JUMP HAPPEN, and that is deliberate. Whether\r\n\t/// \u0060PlayerController\u0060 allows a jump while crouch is HELD is engine behaviour this\r\n\t/// project cannot read, and applying our own upward impulse would double the jump on\r\n\t/// the branch where the controller allows it anyway. So the chain only reacts to the\r\n\t/// player having left the ground; if a held crouch turns out to block the jump, the\r\n\t/// Stop log will say \u0022timer\u0022 instead of \u0022airborne\u0022 and that is the fix\u0027s starting\r\n\t/// point \u2014 not a guess baked in now.\r\n\t/// \u003C/summary\u003E\r\n\tbool TryChain()\r\n\t{\r\n\t\tif ( _chainUntil \u003C= 0f ) return false;\r\n\r\n\t\tif ( !_c.IsOnGround )\r\n\t\t{\r\n\t\t\tif ( Input.Down( \u0022Duck\u0022 ) ) _chainQueued = true;\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tif ( !_chainQueued ) return false;\r\n\r\n\t\t// \u26A0\uFE0F Cleared BEFORE starting, not after. Start() can end up back here through\r\n\t\t// Stop() on the same frame in edge cases, and a queue that survives its own\r\n\t\t// consumption is an infinite slide.\r\n\t\t_chainQueued = false;\r\n\t\t_chainUntil = 0f;\r\n\r\n\t\tStart( _chainDir, _chainSpeed );\r\n\t\tLog.Info( $\u0022[nz-slide] chained \u2014 resumed at {_chainSpeed:0.#} u/s\u0022 );\r\n\t\treturn true;\r\n\t}\r\n\r\n\tvoid Start( Vector3 dir, float speed, bool forced = false )\r\n\t{\r\n\t\tIsSliding = true;\r\n\t\t_forced = forced;\r\n\t\t_sinceStart = 0f;\r\n\t\t_dir = dir;\r\n\t\t_startPos = WorldPosition;\r\n\r\n\t\t// \u26A0\uFE0F BOTH PERK EFFECTS ARE RESOLVED HERE, ONCE. Asking per frame would let the\r\n\t\t// perk being lost mid-slide change the curve underneath the player, and a slide\r\n\t\t// that suddenly starts decaying reads as a bug rather than as a consequence.\r\n\t\t// \u26A0\uFE0F Seeded, not left over. Stop() is public and other systems call it \u2014 without\r\n\t\t// this the log would attribute an external stop to whatever ended the last slide.\r\n\t\t_endReason = \u0022external\u0022;\r\n\r\n\t\tvar banana = PerkEffects.HasBanana( _nz );\r\n\t\t_duration = Duration * (banana ? PerkEffects.BananaSlideDuration : 1f);\r\n\t\t_keepMomentum = banana;\r\n\r\n\t\t// \u26D4 INPUT DRIVING STAYS ON. Turning it off does NOT hand us the movement \u2014\r\n\t\t// nothing then consumes WishVelocity at all, and the probe read actual=21\r\n\t\t// against wish=493 for the whole slide while the player stood still.\r\n\t\t//\r\n\t\t// \u26D4 A SLIDE IS ONE IMPULSE WITH THE BRAKES OFF, not a velocity held every\r\n\t\t// frame. The controller decelerates toward its wish using GroundFriction\r\n\t\t// and BrakePower; with no keys held that wish is zero, which is what kept\r\n\t\t// killing the launch. Zero them for the slide and the body coasts.\r\n\t\t_savedFriction = _c.GroundFriction;\r\n\t\t_savedBrake = _c.BrakePower;\r\n\t\t_c.GroundFriction = 0f;\r\n\t\t_c.BrakePower = 0f;\r\n\r\n\t\t// \u26D4 THE DUCKED SPEED CAP IS LIFTED FOR THE SLIDE. The controller clamps\r\n\t\t// horizontal movement to \u0060DuckedSpeed\u0060 whenever IsDucking is set \u2014 and a\r\n\t\t// slide MUST duck, that is its whole pose. With the stock cap the probe\r\n\t\t// read wish=493 / actual=21: the wish was being honoured and then clamped\r\n\t\t// to a crouch-walk. Restored on Stop.\r\n\t\t_savedDucked = _c.DuckedSpeed;\r\n\r\n\t\t// \u26A0\uFE0F Off RUN SPEED rather than current speed, so every slide is the same\r\n\t\t// length. This is the one thing worth keeping from the GMod dive, which\r\n\t\t// launches off \u0060runspeed\u0060 for exactly that reason.\r\n\t\t//\r\n\t\t// \u26D4 COMPUTED BEFORE THE IMPULSE USES IT. It was assigned AFTER, so the\r\n\t\t// launch ran on the PREVIOUS slide\u0027s speed \u2014 zero on the very first one.\r\n\t\t// Tick() re-asserts the right speed a frame later, which is exactly why\r\n\t\t// this hid: every slide looked correct except its first frame.\r\n\t\t// \u26A0\uFE0F A CHAIN KEEPS ITS OWN SPEED. Recomputing from RunSpeed here would quietly\r\n\t\t// reset every link to the same value, so a chain would neither gain nor lose speed\r\n\t\t// but would also ignore whatever the first slide actually achieved.\r\n\t\t// \u26D4 STAMIN-UP\u0027S m2 SCALES THE MULTIPLIER, NOT \u0060Slide.SpeedMultiplier\u0060 ITSELF. That\r\n\t\t// property writes through to \u0060ActiveConfig.Player.SlideSpeed\u0060, which is SHARED \u2014 the\r\n\t\t// same trap Banana Colada\u0027s duration documents. A per-slide factor cannot leak into\r\n\t\t// the map\u0027s saved settings.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F SPEED ONLY. The original\u0027s Slide Boost also held slide stamina full and halved\r\n\t\t// the cooldown; both were dropped, so this perk moves slide SPEED and Banana Colada\r\n\t\t// moves slide DURATION and chaining. Two perks on one mechanic, no overlap.\r\n\t\t// \u26A0\uFE0F AND THE MATCH\u0027S SLIDE SPEED (the lobby\u0027s Difficulty, 2026-10-05), per slide for the perk\u0027s reason above\r\n\t\tvar slideBoost = SpeedMultiplier * StaminUpAugments.SlideSpeedMultiplier( _nz ) * Difficulty.SlideSpeed;\r\n\r\n\t\t_launchSpeed = speed \u003E 0f \u0026\u0026 _keepMomentum\r\n\t\t\t? MathF.Max( speed, _c.RunSpeed * slideBoost )\r\n\t\t\t: _c.RunSpeed * slideBoost;\r\n\r\n\t\tvar b = _c.Body;\r\n\t\tif ( b.IsValid() )\r\n\t\t\tb.Velocity = dir * _launchSpeed \u002B Vector3.Up * b.Velocity.z;\r\n\r\n\t\tOnSlideStart?.Invoke( _nz, dir );\r\n\r\n\t\t// \u26D4 CALLED DIRECTLY, NOT THROUGH \u0060OnSlideStart\u0060, AND THAT IS DELIBERATE. That event is a\r\n\t\t// STATIC delegate, so it survives a hotload with its subscriber list intact \u2014 a \u0060\u002B=\u0060\r\n\t\t// anywhere would re-subscribe on every reload and PhD would detonate twice, then three\r\n\t\t// times, then four. \u00A71 with a delegate instead of a value, and silent: nothing about a\r\n\t\t// doubled blast says \u0022you subscribed twice\u0022.\r\n\t\t//\r\n\t\t// \u26A0 The event stays for anything outside this assembly that wants it; this file already\r\n\t\t// calls into \u0060PerkEffects\u0060 and \u0060StaminUpAugments\u0060 directly, so one more is consistent.\r\n\t\tPhdAugments.OnSlideStarted( _nz );\r\n\r\n\t\t// \u26A0\uFE0F TUMBLEWEED (revolver tier 3, 2026-10-04): a round into the gun in hand, called directly for the reason above.\r\n\t\tClassTech.OnSlideStarted( _nz );\r\n\r\n\t\tPlaySlideSound( start: true );\r\n\t}\r\n\r\n\t// \u2550\u2550 sound \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\r\n\r\n\t/// \u003Csummary\u003EPlay the \u0060end/\u0060 sample when a slide finishes.\r\n\t///\r\n\t/// \u26D4 A DELIBERATE ADDITION, NOT A PORT. GMod declares \u0060end_sounds\u0060 at the top of\r\n\t/// \u0060gamemode/sliding/sh_hooks.lua\u0060 and references it NOWHERE ELSE in the entire\r\n\t/// gamemode \u2014 the four samples ship and never play. They were imported and given\r\n\t/// cues here anyway, so they are wired; set this false for the original\u0027s\r\n\t/// behaviour.\u003C/summary\u003E\r\n\tpublic static bool EndSound { get; set; } = true;\r\n\r\n\t/// \u003Csummary\u003EMap dirt/mud/sand to \u0060slide_mud_b\u0060.\r\n\t///\r\n\t/// \u26D4 ALSO NOT IN THE ORIGINAL. \u0060slide_sounds\u0060 has no \u0060MAT_DIRT\u0060 entry, so\r\n\t/// \u0060slide_mud_b.wav\u0060 is dead content in GMod \u2014 but a matching\r\n\t/// \u0060slide_mud_b_end.wav\u0060 ships too, which reads as an omission rather than a\r\n\t/// decision.\u003C/summary\u003E\r\n\tpublic static bool MudSound { get; set; } = true;\r\n\r\n\t/// \u003Csummary\u003EHow far down to look for the surface being slid on.\u003C/summary\u003E\r\n\tpublic static float SurfaceProbe { get; set; } = 48f;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The slide\u0027s sound, chosen by the surface under the player.\r\n\t///\r\n\t/// Ported from \u0060SlideSurfaceSound\u0060 in \u0060gamemode/sliding/sh_hooks.lua\u0060: a trace\r\n\t/// straight down, the material it lands on picks the sample, and it plays ONCE.\r\n\t/// The original never loops it and never fades it out.\r\n\t///\r\n\t/// \u26D4 EVERY CUE AND EVERY .sound ASSET FOR THIS ALREADY EXISTED AND NOTHING\r\n\t/// CALLED THEM. \u0060NZSound\u0060 carries eleven \u0060Slide*\u0060 constants and the compiled\r\n\t/// assets are all present \u2014 \u0060grep -rn \u0022NZSound.Slide\u0022\u0060 returned nothing. The\r\n\t/// audio was imported and never wired, which is the whole of \u0022sliding has no\r\n\t/// sound\u0022.\r\n\t/// \u003C/summary\u003E\r\n\tvoid PlaySlideSound( bool start )\r\n\t{\r\n\t\tif ( !start \u0026\u0026 !EndSound ) return;\r\n\r\n\t\tvar at = WorldPosition;\r\n\r\n\t\tvar tr = Scene.Trace\r\n\t\t\t.Ray( at \u002B Vector3.Up * 8f, at \u002B Vector3.Down * SurfaceProbe )\r\n\t\t\t.IgnoreGameObjectHierarchy( GameObject )\r\n\t\t\t.Run();\r\n\r\n\t\tvar surface = tr.Hit ? (tr.Surface?.ResourceName ?? \u0022\u0022) : \u0022\u0022;\r\n\r\n\t\tNZSound.Play( start ? CueFor( surface ) : EndCueFor( surface ), at );\r\n\r\n\t\t// \u26A0\uFE0F LAYERED, NOT SUBSTITUTED \u2014 the original plays the ground\u0027s sample and\r\n\t\t// THEN adds water on top whenever \u0060ply:WaterLevel() \u003E 0\u0060, so sliding through\r\n\t\t// a puddle on metal is metal AND water. Replacing it would lose the surface\r\n\t\t// underneath, which is the half that tells you where you are.\r\n\t\tif ( start \u0026\u0026 InWater() \u0026\u0026 CueFor( surface ) != NZSound.SlideWater )\r\n\t\t\tNZSound.Play( NZSound.SlideWater, at );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EWhich start cue a surface name maps to.\r\n\t///\r\n\t/// \u26A0\uFE0F MATCHED ON SUBSTRING, not on a fixed list of exact names. s\u0026box surfaces\r\n\t/// are dotted and open-ended (\u0060metal\u0060, \u0060metal.sheet\u0060, \u0060metal.grate\u0060), so an\r\n\t/// equality test against \u0022metal\u0022 would silently fall through to the default on\r\n\t/// most real level geometry. GMod gets the aliasing free by mapping MAT_VENT and\r\n\t/// MAT_GRATE onto MAT_METAL; this is the same idea against a different scheme.\r\n\t///\r\n\t/// \u26A0\uFE0F IT NEVER RETURNS NOTHING. An unrecognised surface is \u0060slide_00\u0060, exactly\r\n\t/// what \u0060slide_sounds[tr.MatType] or slide_sounds[0]\u0060 does. A silent slide would\r\n\t/// read as the feature being broken rather than as an unmapped material.\u003C/summary\u003E\r\n\tpublic static string CueFor( string surface )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( surface ) ) return NZSound.Slide;\r\n\r\n\t\tvar s = surface.ToLowerInvariant();\r\n\r\n\t\t// \u26A0\uFE0F WATER FIRST. A water surface is often also named for what is under it,\r\n\t\t// and MAT_SLOSH wins in the original too.\r\n\t\tif ( s.Contains( \u0022water\u0022 ) || s.Contains( \u0022slosh\u0022 ) ) return NZSound.SlideWater;\r\n\t\tif ( s.Contains( \u0022snow\u0022 ) || s.Contains( \u0022ice\u0022 ) ) return NZSound.SlideSnow;\r\n\t\tif ( s.Contains( \u0022grass\u0022 ) || s.Contains( \u0022foliage\u0022 ) ) return NZSound.SlideGrass;\r\n\r\n\t\tif ( s.Contains( \u0022metal\u0022 ) || s.Contains( \u0022grate\u0022 ) || s.Contains( \u0022vent\u0022 ) )\r\n\t\t\treturn NZSound.SlideMetal;\r\n\r\n\t\tif ( MudSound \u0026\u0026 (s.Contains( \u0022dirt\u0022 ) || s.Contains( \u0022mud\u0022 )\r\n\t\t\t|| s.Contains( \u0022sand\u0022 ) || s.Contains( \u0022gravel\u0022 )) )\r\n\t\t\treturn NZSound.SlideMud;\r\n\r\n\t\treturn NZSound.Slide;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EThe matching end cue.\r\n\t///\r\n\t/// \u26A0\uFE0F THERE ARE ONLY FOUR. GMod ships end samples for grass, metal, mud and the\r\n\t/// default \u2014 snow and water have none. Those fall back to \u0060slide_00_end\u0060 rather\r\n\t/// than going silent, which is the same rule the start table uses.\u003C/summary\u003E\r\n\tpublic static string EndCueFor( string surface )\r\n\t\t=\u003E CueFor( surface ) switch\r\n\t\t{\r\n\t\t\tNZSound.SlideGrass =\u003E NZSound.SlideEndGrass,\r\n\t\t\tNZSound.SlideMetal =\u003E NZSound.SlideEndMetal,\r\n\t\t\tNZSound.SlideMud =\u003E NZSound.SlideEndMud,\r\n\t\t\t_ =\u003E NZSound.SlideEnd,\r\n\t\t};\r\n\r\n\t/// \u003Csummary\u003EIs the player in water? The original\u0027s \u0060ply:WaterLevel() \u003E 0\u0060.\r\n\t///\r\n\t/// \u26A0\uFE0F \u0022Unknown = no\u0022. There is no WaterLevel on PlayerController, so this asks\r\n\t/// the tag set; if a map never tags water the layered splash simply does not\r\n\t/// play and the ground sample still does.\u003C/summary\u003E\r\n\tbool InWater()\r\n\t\t=\u003E GameObject.Tags.Has( \u0022water\u0022 )\r\n\t\t\t|| (_c.IsValid() \u0026\u0026 _c.Body.IsValid() \u0026\u0026 _c.Body.Tags.Has( \u0022water\u0022 ));\r\n\r\n\tvoid Tick()\r\n\t{\r\n\t\t// \u26D4 LETTING GO OF CROUCH ENDS THE SLIDE, ON EVERY SLIDE INCLUDING A CHAINED ONE.\r\n\t\t// The timer is the MAXIMUM length, not a commitment \u2014 a slide you cannot cancel is a\r\n\t\t// slide that carries you into a horde you have already seen.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F \u0060Input.Down\u0060, and it must be. \u0060Released\u0060 fires once and would be missed on any\r\n\t\t// frame the slide did not tick; asking whether the key is down each frame cannot\r\n\t\t// miss, and is also correct if focus is lost mid-slide.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F DOES NOT ARM A CHAIN. Chaining is paid for by giving up the rest of the slide\r\n\t\t// to a jump; releasing crouch is just stopping, and letting it bank momentum too\r\n\t\t// would make the jump pointless.\r\n\t\tif ( !_forced \u0026\u0026 !Input.Down( \u0022Duck\u0022 ) )\r\n\t\t{\r\n\t\t\t_endReason = \u0022crouch released\u0022;\r\n\t\t\tStop();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// \u26A0\uFE0F FORCED EVERY FRAME. PlayerController recomputes IsDucking from input\r\n\t\t// in its own update, so setting it once at the start is undone the moment\r\n\t\t// the player is not holding crouch \u2014 the same trap the bleedout crawl hit.\r\n\t\t_c.IsDucking = true;\r\n\r\n\t\tvar t = ((float)_sinceStart / MathF.Max( 0.05f, _duration )).Clamp( 0f, 1f );\r\n\r\n\t\t// Ease out: quick launch, long settle. t\u00B2 decays slowly at first and\r\n\t\t// steeply at the end, which reads as friction rather than a ramp.\r\n\t\t// \u26D4 BANANA COLADA COASTS \u2014 NO DECAY AT ALL. The base slide eases from launch\r\n\t\t// speed down to ducked speed; with the perk the launch speed simply holds for the\r\n\t\t// whole (already longer) slide. That is what \u0022maintains all momentum\u0022 means, and\r\n\t\t// it is why a chain does not bleed speed across jumps: each link starts at the\r\n\t\t// speed the last one ended.\r\n\t\tvar floor = _c.DuckedSpeed;\r\n\t\tvar speed = _keepMomentum\r\n\t\t\t? _launchSpeed\r\n\t\t\t: MathX.Lerp( _launchSpeed, floor, t * t );\r\n\r\n\t\t// \u26D4 DRIVEN THROUGH WishVelocity, which is what the controller actually\r\n\t\t// moves on. \u0060PlayerController.Velocity\u0060 is READ ONLY, and writing the\r\n\t\t// rigidbody directly loses the race with the controller\u0027s own move step.\r\n\t\t//\r\n\t\t// \u26A0\uFE0F Horizontal only \u2014 vertical is left to gravity, or a slide would hold\r\n\t\t// you off a slope.\r\n\t\t_c.DuckedSpeed = MathF.Max( _savedDucked, speed );\r\n\r\n\t\t// \u26A0\uFE0F The decay is applied to the BODY, re-asserting the coast each frame\r\n\t\t// so the ease-out curve is what shapes the slide rather than engine drag.\r\n\t\tvar bd = _c.Body;\r\n\t\tif ( bd.IsValid() )\r\n\t\t\tbd.Velocity = _dir * speed \u002B Vector3.Up * bd.Velocity.z;\r\n\r\n\t\t// \u26A0\uFE0F Ends on LEAVING THE GROUND too, not just the timer \u2014 sliding off a\r\n\t\t// ledge should drop you, not carry you through the air at launch speed.\r\n\t\tif ( t \u003E= 1f || !_c.IsOnGround )\r\n\t\t{\r\n\t\t\t_endReason = _c.IsOnGround ? \u0022timer\u0022 : \u0022airborne\u0022;\r\n\r\n\t\t\t// \u26D4 A CHAIN IS ARMED ONLY BY LEAVING THE GROUND EARLY. A slide that ran its\r\n\t\t\t// timer out has spent itself and must not be resumable, or crouch-spamming on\r\n\t\t\t// flat ground would be permanent free speed. Jumping out of a live slide is\r\n\t\t\t// the cost: you give up the rest of it to keep the momentum.\r\n\t\t\tif ( _keepMomentum \u0026\u0026 t \u003C 1f \u0026\u0026 !_c.IsOnGround )\r\n\t\t\t{\r\n\t\t\t\t_chainDir = _dir;\r\n\t\t\t\t_chainSpeed = speed;\r\n\t\t\t\t_chainUntil = PerkEffects.BananaChainWindow;\r\n\t\t\t\t_chainQueued = false;\r\n\t\t\t\t_endReason = \u0022airborne \u2014 chain armed\u0022;\r\n\t\t\t}\r\n\r\n\t\t\tStop();\r\n\t\t}\r\n\t}\r\n\r\n\tpublic void Stop()\r\n\t{\r\n\t\tif ( !IsSliding ) return;\r\n\r\n\t\tIsSliding = false;\r\n\t\t_sinceEnd = 0f;\r\n\r\n\t\t// \u26A0\uFE0F BEFORE the controller values are handed back, so the trace still runs\r\n\t\t// from where the slide actually ended rather than after anything else has\r\n\t\t// had a chance to move the player.\r\n\t\tPlaySlideSound( start: false );\r\n\r\n\t\t// \u26A0\uFE0F Handed straight back, and to the SAVED value rather than a hardcoded\r\n\t\t// true \u2014 a slide that began during noclip must not switch input driving\r\n\t\t// back on underneath it.\r\n\t\tif ( _c.IsValid() )\r\n\t\t{\r\n\t\t\tif ( _savedDucked \u003E 0f ) _c.DuckedSpeed = _savedDucked;\r\n\t\t\t_c.GroundFriction = _savedFriction;\r\n\t\t\t_c.BrakePower = _savedBrake;\r\n\t\t}\r\n\r\n\t\t// \u26A0\uFE0F An INSTRUMENT, not decoration. \u0022Did the slide move the player\u0022 cannot\r\n\t\t// be answered from the console any other way \u2014 the forced-slide command\r\n\t\t// prints that it fired whether or not a single unit of travel happened.\r\n\t\tvar dist = WorldPosition.Distance( _startPos );\r\n\t\tLog.Info( $\u0022[nz-slide] ended ({_endReason}) \u2014 {dist:0.#} units in {(float)_sinceStart:0.##}s\u0022\r\n\t\t\t\u002B $\u0022 of {_duration:0.##}s (launch {_launchSpeed:0.#} u/s{(_keepMomentum ? \u0022, coasting\u0022 : \u0022\u0022)})\u0022 );\r\n\t}\r\n\r\n\t// \u2500\u2500 console \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\tstatic Slide Find()\r\n\t\t=\u003E Game.ActiveScene?.GetAllComponents\u003CSlide\u003E().FirstOrDefault();\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_slide\u0060 \u2014 report, or force one for testing.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_slide\u0022 )]\r\n\tpublic static void SlideCmd( string arg = \u0022\u0022 )\r\n\t{\r\n\t\tvar s = Find();\r\n\t\tif ( !s.IsValid() ) { Log.Warning( \u0022[nz-slide] no player\u0022 ); return; }\r\n\r\n\t\tif ( arg == \u0022back\u0022 )\r\n\t\t{\r\n\t\t\t// \u26A0\uFE0F Exists because a forced slide launches along EYE FORWARD, and the\r\n\t\t\t// creative spawn faces a fence \u2014 every measurement so far may have been\r\n\t\t\t// a slide straight into geometry rather than a slide that does nothing.\r\n\t\t\tvar back = s._c.IsValid() ? -s._c.EyeAngles.Forward.WithZ( 0f ).Normal : Vector3.Backward;\r\n\t\t\ts.Start( back, s._c.RunSpeed, forced: true );\r\n\t\t\tLog.Info( \u0022[nz-slide] forced BACKWARD\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( arg == \u0022go\u0022 )\r\n\t\t{\r\n\t\t\t// \u26A0\uFE0F Forces one regardless of speed so the slide can be SEEN from the\r\n\t\t\t// console without having to be sprinting when the command lands.\r\n\t\t\tvar fwd = s._c.IsValid() ? s._c.EyeAngles.Forward.WithZ( 0f ).Normal : Vector3.Forward;\r\n\t\t\ts.Start( fwd, s._c.RunSpeed, forced: true );\r\n\t\t\tLog.Info( \u0022[nz-slide] forced\u0022 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tLog.Info( $\u0022[nz-slide] enabled {SlideEnabled} \u00B7 sliding {s.IsSliding} \u00B7 x{SpeedMultiplier:0.##} run speed \u00B7 {Duration:0.##}s \u00B7 {Cooldown:0.##}s cooldown \u00B7 needs {MinSpeedFraction:P0} of run speed\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_slide_set [mult] [duration] [cooldown]\u0060.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_slide_set\u0022 )]\r\n\tpublic static void SetCmd( float mult = 0f, float duration = 0f, float cooldown = 0f )\r\n\t{\r\n\t\tif ( mult \u003E 0f ) SpeedMultiplier = mult;\r\n\t\tif ( duration \u003E 0f ) Duration = duration;\r\n\t\tif ( cooldown \u003E 0f ) Cooldown = cooldown;\r\n\r\n\t\tLog.Info( $\u0022[nz-slide] x{SpeedMultiplier:0.##} \u00B7 {Duration:0.##}s \u00B7 {Cooldown:0.##}s cooldown\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\u0060nz_slide_enable [0/1]\u0060.\u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_slide_enable\u0022 )]\r\n\tpublic static void EnableCmd( int on = -1 )\r\n\t{\r\n\t\tif ( on \u003E= 0 ) SlideEnabled = on != 0;\r\n\t\tLog.Info( $\u0022[nz-slide] {(SlideEnabled ? \u0022on\u0022 : \u0022off\u0022)}\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// \u0060nz_slide_sound [play]\u0060 \u2014 the raw surface under you, the cue it maps to, and the\r\n\t/// whole table. \u0060nz_slide_sound 1\u0060 plays it.\r\n\t///\r\n\t/// \u26D4 IT PRINTS THE RAW SURFACE NAME. \u0022The slide sound is wrong here\u0022 is either the\r\n\t/// surface not being what you think or the mapping not catching it, and those are\r\n\t/// indistinguishable by ear. \u0060concrete.polished -\u003E default\u0060 separates them at a glance.\r\n\t/// \u003C/summary\u003E\r\n\t[ConCmd( \u0022nz_slide_sound\u0022 )]\r\n\tpublic static void SoundCmd( int play = 0 )\r\n\t{\r\n\t\tvar scene = Game.ActiveScene;\r\n\t\tvar p = NZPlayer.Local;\r\n\t\tif ( !p.IsValid() ) { Log.Warning( \u0022[nz-slide] no player\u0022 ); return; }\r\n\r\n\t\tvar at = p.WorldPosition;\r\n\r\n\t\tvar tr = scene.Trace\r\n\t\t\t.Ray( at \u002B Vector3.Up * 8f, at \u002B Vector3.Down * SurfaceProbe )\r\n\t\t\t.IgnoreGameObjectHierarchy( p.GameObject )\r\n\t\t\t.Run();\r\n\r\n\t\tvar surface = tr.Hit ? (tr.Surface?.ResourceName ?? \u0022\u0022) : \u0022\u0022;\r\n\r\n\t\tLog.Info( $\u0022[nz-slide] under you: \u0027{(surface == \u0022\u0022 ? \u0022\u003Cnothing hit\u003E\u0022 : surface)}\u0027\u0022\r\n\t\t\t\u002B $\u0022 -\u003E start {CueFor( surface )} \u00B7 end {EndCueFor( surface )}\u0022 );\r\n\t\tLog.Info( $\u0022[nz-slide]   end sound {(EndSound ? \u0022ON (not in GMod)\u0022 : \u0022off\u0022)}\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 mud mapping {(MudSound ? \u0022ON (not in GMod)\u0022 : \u0022off\u0022)}\u0022\r\n\t\t\t\u002B $\u0022 \u00B7 probe {SurfaceProbe:0}u\u0022 );\r\n\r\n\t\t// \u26A0\uFE0F Printed as MATCH SUBSTRINGS, not example names \u2014 the mapping is \u0060Contains\u0060,\r\n\t\t// and listing \u0022metal\u0022 as an exact name would suggest \u0060metal.grate\u0060 is unmapped.\r\n\t\tforeach ( var (match, cue) in new[]\r\n\t\t{\r\n\t\t\t(\u0022water / slosh\u0022,              NZSound.SlideWater),\r\n\t\t\t(\u0022snow / ice\u0022,                 NZSound.SlideSnow),\r\n\t\t\t(\u0022grass / foliage\u0022,            NZSound.SlideGrass),\r\n\t\t\t(\u0022metal / grate / vent\u0022,       NZSound.SlideMetal),\r\n\t\t\t(\u0022dirt / mud / sand / gravel\u0022, NZSound.SlideMud),\r\n\t\t\t(\u0022anything else\u0022,              NZSound.Slide),\r\n\t\t} )\r\n\t\t\tLog.Info( $\u0022[nz-slide]   {match,-28} {cue}\u0022 );\r\n\r\n\t\tif ( play \u003E 0 )\r\n\t\t{\r\n\t\t\tNZSound.Play( CueFor( surface ), at );\r\n\t\t\tLog.Info( $\u0022[nz-slide]   played {CueFor( surface )}\u0022 );\r\n\t\t}\r\n\t}\r\n}\r\n"},{"Ident":"cifosi2500.nzombies_sbox","Path":"Power/Power.cs","FileName":"Power.cs","PackageType":"game","CodeKind":"Game","AssetVersionId":421629,"IsPrivate":false,"Code":"using Sandbox;\r\nusing System.Collections.Generic;\r\n\r\nnamespace NZombies;\r\n\r\n/// \u003Csummary\u003E\r\n/// The map\u0027s electricity. One flag, read by doors, spawns and anything else\r\n/// gated behind it.\r\n///\r\n/// \u26A0\uFE0F THE \u0022ON\u0022 STATE IS DERIVED, NOT STORED.\r\n///\r\n///     no switch placed  -\u003E  ON       (nothing on this map is gated)\r\n///     switch placed     -\u003E  OFF until somebody flips it\r\n///\r\n/// Storing a bool instead would mean adding the first switch to a map leaves\r\n/// the power stuck on until something remembers to clear the flag, and deleting\r\n/// the last switch leaves a map permanently unpowered with no way to fix it.\r\n/// Deriving it makes both cases correct for free.\r\n/// \u003C/summary\u003E\r\npublic static class Power\r\n{\r\n\t/// \u003Csummary\u003EHas somebody flipped a switch this game? Meaningless on a map\r\n\t/// with no switches \u2014 see IsOn.\u003C/summary\u003E\r\n\tpublic static bool Switched =\u003E !HasSwitch || Flipped \u003E= Total;\r\n\r\n\t// \u2500\u2500 every switch, not any switch \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\t// \u26D4 THIS WAS ONE GLOBAL BOOL AND ANY SINGLE SWITCH POWERED THE WHOLE MAP. A map author\r\n\t// placing a second panel got a second way to do the same thing rather than a second thing to\r\n\t// do \u2014 which makes placing more than one actively misleading, since the map looks like it is\r\n\t// asking for both.\r\n\t//\r\n\t// \u26A0\uFE0F THE SET IS OF INDICES INTO \u0060ActiveConfig.Current.PowerSwitches\u0060, which is the same key\r\n\t// \u0060PowerManager\u0060 spawns and aims by \u2014 so \u0022which switch did you flip\u0022 has one answer\r\n\t// everywhere and the visual can show a flipped-but-not-yet-enough panel.\r\n\r\n\tstatic readonly HashSet\u003Cint\u003E _flipped = new();\r\n\r\n\t/// \u003Csummary\u003EHow many switches this map has. 0 means the power is simply on.\u003C/summary\u003E\r\n\tpublic static int Total =\u003E ActiveConfig.Current?.PowerSwitches?.Count ?? 0;\r\n\r\n\t/// \u003Csummary\u003EHow many have been flipped.\u003C/summary\u003E\r\n\tpublic static int Flipped =\u003E _flipped.Count;\r\n\r\n\t/// \u003Csummary\u003EHas this particular switch been flipped \u2014 for the prop\u0027s own look.\u003C/summary\u003E\r\n\tpublic static bool IsFlipped( int index ) =\u003E _flipped.Contains( index );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Require EVERY switch, rather than any one. On by request.\r\n\t///\r\n\t/// \u26A0\uFE0F A KNOB BECAUSE IT CHANGES EXISTING MAPS. Any map already built around \u0022one switch, any\r\n\t/// switch\u0022 that happens to carry two panels becomes unfinishable the moment this is on and\r\n\t/// the second panel is somewhere the author never expected anyone to go. \u0060nz_power_all 0\u0060\r\n\t/// restores the old rule.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static bool RequireAll { get; set; } = true;\r\n\r\n\t/// \u003Csummary\u003EDoes this map have a switch at all?\u003C/summary\u003E\r\n\tpublic static bool HasSwitch\r\n\t\t=\u003E ActiveConfig.Current?.PowerSwitches?.Count \u003E 0;\r\n\r\n\t/// \u003Csummary\u003EIs the power on right now?\u003C/summary\u003E\r\n\t/// \u003Csummary\u003E\r\n\t/// Is the power on. No switches = always on; otherwise every switch, or any one when\r\n\t/// \u0060RequireAll\u0060 is off.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static bool IsOn\r\n\t\t=\u003E !HasSwitch || (RequireAll ? _flipped.Count \u003E= Total : _flipped.Count \u003E 0);\r\n\r\n\t/// \u003Csummary\u003EFired when the power comes on, so systems can react without\r\n\t/// polling. PowerManager uses it to open the free doors.\u003C/summary\u003E\r\n\tpublic static event System.Action OnPowered;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Flip the switch. Returns false if it was already on, so a caller can tell\r\n\t/// \u0022you turned it on\u0022 from \u0022it was already on\u0022 without checking first.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static bool TurnOn()\r\n\t{\r\n\t\tif ( IsOn ) return false;\r\n\r\n\t\t// \u26D4 \u0060Switched\u0060 IS A STATIC BOOL, so the power was on for exactly the machine that flipped\r\n\t\t// the switch. A client turning it on lit its own map, opened its own free doors and left\r\n\t\t// the host in the dark; the host\u0027s zombies, doors and perk machines never heard.\r\n\t\t// User: *\u0022when the client turns electricity on... it opens for the client, not for the host.\u0022*\r\n\t\t//\r\n\t\t// \u26A0\uFE0F A CLIENT ASKS AND RETURNS FALSE. It must NOT flip locally first: the host\u0027s broadcast\r\n\t\t// arrives a moment later and drives every consequence \u2014 the free doors, the flags, the nav \u2014\r\n\t\t// through the same path on every machine. Flipping locally as well would open the client\u0027s\r\n\t\t// free doors twice and its flags from two directions.\r\n\t\tif ( Networking.IsActive \u0026\u0026 NZGame.IsClient )\r\n\t\t{\r\n\t\t\tNZNet.PowerAsk();\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\t// \u26A0\uFE0F FLIPS EVERY SWITCH, because this is the \u0022make the power on\u0022 entry point \u2014 the console\r\n\t\t// command and the host applying a client\u0027s request. \u0060Flip\u0060 is the one that means \u0022a player\r\n\t\t// pulled THIS lever\u0022; conflating them would make \u0060nz_power_on\u0060 unable to express itself on\r\n\t\t// a two-switch map.\r\n\t\tfor ( int i = 0; i \u003C Total; i\u002B\u002B ) _flipped.Add( i );\r\n\t\tApplyPowered();\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Back to unpowered. Only meaningful on a map that has a switch \u2014 on one\r\n\t/// without, IsOn stays true and this is a no-op by design.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static void Reset()\r\n\t{\r\n\t\t// \u26D4 SILENT WHEN IT WAS ALREADY OFF. Reset runs at the start of every\r\n\t\t// game, and the original only plays power_down when the electricity\r\n\t\t// actually goes OUT \u2014 firing it on each round-one countdown would turn a\r\n\t\t// rare, alarming cue into part of the loading noise.\r\n\t\tbool was = Switched;\r\n\t\t_flipped.Clear();\r\n\r\n\t\tif ( was ) NZSound.Play( NZSound.PowerOff );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EOne line for the console and the map editor.\u003C/summary\u003E\r\n\t/// \u003Csummary\u003E\r\n\t/// Flip ONE switch. Returns a line describing what happened, for the use prompt.\r\n\t///\r\n\t/// \u26D4 THE HOST DECIDES, ALWAYS. A client flipping locally would light its own map on the\r\n\t/// last switch while the host stayed dark \u2014 the exact bug \u0060TurnOn\u0060\u0027s own note records from\r\n\t/// before the relay existed. The client asks and waits.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static string Flip( int index )\r\n\t{\r\n\t\tif ( IsOn ) return \u0022power is already on\u0022;\r\n\t\tif ( index \u003C 0 || index \u003E= Total ) return $\u0022no switch #{index}\u0022;\r\n\t\tif ( _flipped.Contains( index ) ) return $\u0022switch #{index \u002B 1} is already on\u0022;\r\n\r\n\t\tif ( Networking.IsActive \u0026\u0026 NZGame.IsClient )\r\n\t\t{\r\n\t\t\tNZNet.PowerAsk( index );\r\n\t\t\treturn \u0022asking the host\u0022;\r\n\t\t}\r\n\r\n\t\t_flipped.Add( index );\r\n\r\n\t\tif ( !IsOn )\r\n\t\t{\r\n\t\t\t// \u26A0\uFE0F TOLD TO EVERYONE EVEN WHEN IT DOES NOT COMPLETE THE SET, so a teammate across the map\r\n\t\t\t// sees that lever move and the count on their own prompt go up. Without it the only\r\n\t\t\t// feedback for switch 1 of 3 is on the machine that pulled it.\r\n\t\t\tif ( Networking.IsActive \u0026\u0026 NZGame.IsHost )\r\n\t\t\t\tNZNet.PowerFlipped( index );\r\n\r\n\t\t\tNZSound.Play( NZSound.PowerOn );\r\n\t\t\tLog.Info( $\u0022[nz] switch {Flipped}/{Total}\u0022 );\r\n\t\t\treturn $\u0022switch {Flipped} of {Total} \u2014 find the rest\u0022;\r\n\t\t}\r\n\r\n\t\t// \u26D4 BUT NOT THE LEVER THAT COMPLETES THE SET: \u0060PowerIsOn\u0060 carries that one (the co-op pass, 2026-09-28). Sent as a flip\r\n\t\t// first, it reached a client before the power did and counted every lever down, so \u0060TurnOnFromHost\u0060 \u2014 which returns when\r\n\t\t// they all are \u2014 returned: no \u0060OnPowered\u0060 on any client, so no tints, no tremor, no motif, and no power-on sound. On basalt,\r\n\t\t// with its one lever, that was every time.\r\n\t\t//\r\n\t\t// \u26D4 THE SET IS COMPLETE, SO RUN THE REAL THING. \u0060_flipped\u0060 already contains this index, so\r\n\t\t// \u0060TurnOn\u0060\u0027s \u0060if ( IsOn ) return false\u0060 would refuse \u2014 the consequences are invoked here\r\n\t\t// through the same helper \u0060TurnOn\u0060 uses rather than by duplicating them.\r\n\t\tApplyPowered();\r\n\t\treturn \u0022power on\u0022;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ERecord a flip the host made. Client side, no consequences \u2014 \u0060PowerIsOn\u0060\r\n\t/// carries those when the set completes.\u003C/summary\u003E\r\n\tpublic static void FlipFromHost( int index )\r\n\t{\r\n\t\tif ( index \u003C 0 ) return;\r\n\t\tif ( !_flipped.Add( index ) ) return;\r\n\r\n\t\tif ( !IsOn ) NZSound.Play( NZSound.PowerOn );\r\n\t}\r\n\r\n\tpublic static string Summary =\u003E !HasSwitch\r\n\t\t? \u0022ON (no switch on this map)\u0022\r\n\t\t: IsOn ? $\u0022ON ({Total} switch(es))\u0022\r\n\t\t\t: $\u0022OFF \u2014 {Flipped} of {Total} switch(es) flipped{(RequireAll ? \u0022\u0022 : \u0022, any one will do\u0022)}\u0022;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The host says the power is on. Clients only, and it must not ask again.\r\n\t///\r\n\t/// \u26A0\uFE0F IT RUNS THE SAME CONSEQUENCES \u0060TurnOn\u0060 DOES \u2014 \u0060OnPowered\u0060 is what opens the free doors,\r\n\t/// tints the machines and unlocks everything that tests \u0060IsOn\u0060 \u2014 so a client reaches the state\r\n\t/// the host reached rather than a subset of it.\r\n\t/// \u003C/summary\u003E\r\n\t/// \u003Cparam name=\u0022live\u0022\u003EIt has just come on. False for a joiner\u0027s catch-up (\u0060NZNet.SendGame\u0060), which is told the power IS on \u2014\r\n\t/// the doors, the tints \u2014 but must not feel the tremor or hear the motif as it walks in (the co-op pass, 2026-09-28).\u003C/param\u003E\r\n\tpublic static void TurnOnFromHost( bool live = true )\r\n\t{\r\n\t\tif ( Switched ) return;\r\n\r\n\t\tfor ( int i = 0; i \u003C Total; i\u002B\u002B ) _flipped.Add( i );\r\n\t\tOnPowered?.Invoke();\r\n\r\n\t\tif ( !live ) return;\r\n\r\n\t\t// \u26A0\uFE0F THE POWER-ON SOUND, WHICH ONLY THE HOST HAD PLAYED (\u0060ApplyPowered\u0060) \u2014 the cue is 2D and map-wide by design (\u0060NZSound.PowerOn\u0060)\r\n\t\tNZSound.Play( NZSound.PowerOn );\r\n\r\n\t\t// \u26A0\uFE0F AND THE GROUND SHAKES HERE TOO, each machine its own view (\u0060PowerTremor\u0060), on a map that asks for it \u2014 and the map\u0027s\r\n\t\t// motif sounds (\u0060PowerMotif\u0060)\r\n\t\tPowerTremor.OnPowerOn();\r\n\t\tPowerMotif.Play();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Everything that happens when the power actually comes on.\r\n\t///\r\n\t/// \u26D4 EXTRACTED SO \u0060Flip\u0060 AND \u0060TurnOn\u0060 CANNOT DIVERGE. There are now two routes to powered \u2014\r\n\t/// the last of several switches, and \u0022turn it on\u0022 from the console or a client request \u2014 and\r\n\t/// a second copy of the voice line, the sound, \u0060OnPowered\u0060 and the broadcast is a second copy\r\n\t/// that will eventually be missing one of them. The ORDER matters and is the original\u0027s:\r\n\t/// cue before \u0060OnPowered\u0060 so the lever is heard before the doors it opens, and the broadcast\r\n\t/// after it so the host\u0027s own free doors are already open when clients apply theirs.\r\n\t/// \u003C/summary\u003E\r\n\tstatic void ApplyPowered()\r\n\t{\r\n\t\tLog.Info( \u0022[nz] POWER ON\u0022 );\r\n\t\tCharacterVoice.Say( \u0022poweron\u0022 );\r\n\t\tNZSound.Play( NZSound.PowerOn );\r\n\r\n\t\t// \u26A0\uFE0F AND THE GROUND SHAKES, THE CEILING SHEDDING (\u0060PowerTremor\u0060, 2026-09-28) on a map that asks for it (\u0060Gameplay.PowerOnTremor\u0060,\r\n\t\t// basalt\u0027s, since 2026-10-01), AND THE MAP\u0027S MOTIF SOUNDS (\u0060PowerMotif\u0060) \u2014 here for the host and solo; a client\u0027s are in\r\n\t\t// \u0060TurnOnFromHost\u0060\r\n\t\tPowerTremor.OnPowerOn();\r\n\t\tPowerMotif.Play();\r\n\r\n\t\tOnPowered?.Invoke();\r\n\r\n\t\tif ( Networking.IsActive \u0026\u0026 NZGame.IsHost )\r\n\t\t\tNZNet.PowerIsOn( live: true );\r\n\t}\r\n}\r\n"}]}