namespace ProjectM.Simulation { /// /// Central home for gameplay-balance constants that were previously buried as private consts /// inside individual systems, so designers have one searchable place to tune them. Burst-safe (compile-time /// consts only — they inline into the consuming systems with no runtime cost or managed reference). /// /// Systems reference these via Tuning.* (wired in the 2026-06-04 polish pass, Stage C). When adding a /// new tunable value, prefer adding it here over a local private const UNLESS it already has an obvious, /// well-named public home (see the cross-references below) — duplicating a literal creates two sources of truth. /// /// /// Values that already live in a clear, public, semantically-named home (NOT duplicated here): /// /// — base→expedition world-space offset. /// Per-ability/character stats — authored in ScriptableObjects, baked to the AbilityDatabase blob (M3). /// /// /// public static class Tuning { // ---- Ability damage upgrade (AbilityUpgradeSystem) ---- /// Distinct sentinel SourceId so the upgrade StatModifier is found + grown in place /// (replace-by-SourceId keeps the bounded modifier buffer from growing a row per upgrade). public const uint AbilityUpgradeSourceId = 0x00A0E711u; /// Damage bonus added per upgrade tier (PercentAdd op): +25% per tier. public const float AbilityUpgradeTierStep = 0.25f; /// Aether cost charged to the shared ledger per upgrade tier. public const int AbilityUpgradeCostAmount = 20; // ---- Resource harvest (ResourceHarvestSystem) ---- /// Effective projectile radius used by the swept-segment node-hit test (added to the node's /// HitRadius). Tunnel-safe because the segment is reconstructed from Projectile.LastStep. public const float HarvestProjectileRadius = 0.2f; // ---- Enemy knockback (ProjectileDamageSystem stamps on hit; EnemyAISystem applies + suppresses seek/strike) ---- /// Knockback speed (world units/sec) a Husk recoils at when shot; 0 disables knockback globally. public const float KnockbackSpeed = 8f; /// Server ticks the knockback lasts (~60 ticks/sec). public const int KnockbackDurationTicks = 8; // ---- Husk attack telegraph (EnemyAISystem 2-phase strike; client cue in CombatFeedbackSystem) ---- /// Wind-up ticks before a Husk strike lands (~0.37s @ 60 ticks/sec) — sized for a fair tell /// under interp lag (>= ~250ms reaction + interp buffer; Slice 1 readability). 0/1 = near-instant (legacy). public const int AttackWindupTicks = 22; /// Charger lunge telegraph windup (ticks). Single source shared by EnemyBaker (the baked client /// danger-ramp denominator) and TuningConfig.Defaults, mirroring the Grunt AttackWindupTicks pattern so the /// baked telegraph can't drift from the server windup. public const int ChargerWindupTicks = 30; // ---- Production / automation (M7: Harvester/Conveyor/Fabricator) ---- /// Max production cycles a single machine awards in one process (bounds within-session /// catch-up after any skipped ticks; restore re-seats the baseline so this never reflects wall-clock). public const int MaxProductionCatchup = 600; /// EB-2: Charge (turret munition) consumed per turret shot, withdrawn from the global ledger. A /// turret with 0 Charge SOFT-FAILS (no shot, no cooldown advance). A ledger-fed Fabricator mints Charge from /// Ore. Operator feel-fork: keep generous so turrets stay fed while you keep mining. public const int TurretChargeCostPerShot = 1; /// Max turrets buildable per base (server-enforced in BuildPlaceSystem; the client preview goes red at /// the cap). Turrets are a deliberate fortress investment, not spammable — paired with the 40-Ore build cost. public const int TurretCap = 6; // ---- Cold start (CycleDirectorSpawnSystem seeds the shared ledger on a NEW game) ---- /// DR-042 C6c: Ore deposited into the shared ledger at spawn on a NEW game ONLY (a restored save keeps /// its persisted ledger). Bootstraps the Fabricator(30)->Charge->Turret(10) chain so a turret placed before any /// mining isn't a silent cold deadlock. Ore-only so the 'build a Fabricator to arm turrets' lesson survives. public const int StartingOre = 90; // ---- Expedition run economy (RoomFieldSystem / RunDirectorSystem) ---- /// Run-wide resource-node allotment: RunDirectorSystem stages it into RunRuntime.NodeBudgetRemaining /// at the launch edge; RoomFieldSystem floors each room's scatter to what remains and spends it down. THE /// scarcity knob — ~1.5 nodes/room over an 8-room run (dense Reward rooms drain it fastest; late rooms can /// run dry, which is the intended tension). public const int ExpeditionNodeBudget = 12; /// Boss-room health multiplier over the base enemy prefab (RoomEnemyDirectorSystem spawns ONE /// boss per Boss room; v1 = a beefed Charger — a real boss kit is a later rung). public const float BossHealthMultiplier = 8f; /// Boss-room visual scale multiplier (LocalTransform.Scale is a replicated [GhostField] — set once /// at spawn on the baked value, legible at a glance). public const float BossScaleMultiplier = 1.6f; /// Room-landing grace before the FIRST enemy slot spawns (~1.7 s @ 60): the party just teleported /// in and needs a beat to orient — without it the wave engages while the camera is still settling (demo /// polish; an idle player died in ~2 s of landing). Subsequent slots keep the normal drip cadence. public const uint RoomEntryGraceTicks = 100; /// Phase 1 (B3) corpse window (~0.9 s @60): a dead enemy lingers as a non-acting, non-counting, /// non-targetable corpse so the client death animation has time to read before the despawn burst. public const uint EnemyDeathWindowTicks = 54; /// Phase 1 (B5): boss HP grows by this fraction of its base per LIVING expedition player beyond the /// first (counted at spawn; Health.Max replicates so the bar stays truthful). public const float BossHealthPerExtraPlayer = 0.75f; // ---- Exploding barrels (Phase 1.5 hazard; server-only detonation consts — see Exploding_Barrels_Build_Spec) ---- /// Fuse ticks between the pop (Remaining hits 0) and detonation (~1.5 s @60; operator feel call /// 07-10 — the 0.6 s fuse popped before the danger could be read): long enough to READ the blast-radius /// disc + walk out, and for the replicated Remaining=0 to straddle snapshots (the client's fuse cue). public const uint BarrelFuseTicks = 90; /// Detonation radius (world units, XZ). Above melee reach so point-blank pops are punished, but a /// fuse-length walk clears it comfortably. public const float BarrelExplodeRadius = 3.25f; /// Detonation damage — hits LIVING enemies AND players alike (friendly fire = the bait mechanic). public const float BarrelExplodeDamage = 26f; // ---- Blight geyser (Phase 1.5b bundle-3 hazard; periodic BOTH-SIDES AoE — see Geyser_Build_Spec) ---- /// Ticks between eruptions (~5 s @60). Server reschedules NextEruptTick = now + this each erupt /// (never +=, per the review — anchored += catch-up-storms after a skip). public const uint GeyserPeriodTicks = 300; /// Telegraph window (ticks, ~1.3 s @60): the client grows the warning disc over the last this-many /// ticks before an eruption (int so the client lead comparison stays signed). public const int GeyserTelegraphTicks = 78; /// Eruption AoE radius (world units, XZ) — hits LIVING players AND enemies (friendly fire = lure bait). public const float GeyserEruptRadius = 3.0f; /// Eruption damage per hit. public const float GeyserEruptDamage = 22f; // ---- Expedition BOSS (a scaled Charger given a real kit by BossAISystem; server-only feel consts) ---- /// Radial SLAM AoE radius (world units): a player inside this ring at wind-up elapse eats the hit /// unless dashing (i-frames). Sized well above melee reach so the tell reads as a boss-scale ring. public const float BossSlamRadius = 5.5f; /// Radial SLAM damage. Heavy but survivable given generous i-frames + respawn (readable-but-fair). public const float BossSlamDamage = 34f; /// SLAM telegraph lead (~0.75 s @60): a fair dodge window under interp lag before the ring lands. public const uint BossSlamWindupTicks = 45; /// Ticks between SLAMs in phase one (~4 s). Phase two multiplies this by BossPhase2SlamCooldownMult. public const uint BossSlamCooldownTicks = 240; // Phase 1 (B4): the boss's SECOND attack - a telegraphed gap-closing LUNGE (repositioning; the slam stays // the damage beat). Windup is readable (~0.6 s); travel reuses the Charger contract (committed, wall-stopped). public const uint BossLungeWindupTicks = 35; public const float BossLungeSpeed = 20f; public const uint BossLungeDurationTicks = 22; public const uint BossLungeCooldownTicks = 300; /// Lunge only when the target is beyond this (else slam) and within LungeMaxRange. public const float BossLungeMinRange = 7f; public const float BossLungeMaxRange = 20f; /// Health fraction at/below which the boss enters phase two (faster + summons adds). public const float BossPhase2HealthFraction = 0.5f; /// Phase-two move-speed multiplier over the boss's base EnemyStats.MoveSpeed (a visible gear shift). public const float BossPhase2SpeedMult = 1.45f; /// Phase-two SLAM cooldown multiplier (<1 = slams more often when enraged). public const float BossPhase2SlamCooldownMult = 0.6f; /// Swarmers summoned per phase-two summon (spawned around the boss, tagged as room zone enemies). public const int BossSummonCount = 3; /// Ticks between phase-two summons (~7 s). public const uint BossSummonCooldownTicks = 420; /// Cap on live zone enemies the boss will summon toward — it holds fire above this so a boss room /// never floods past readable (also bounded by the director's MaxAlive count). public const int BossSummonLiveCap = 8; // ---- DR-046 room-exit PORTAL / loot window ---- /// Client-side portal position offset from the room origin (where the party landed): a short step /// back toward the entrance, so 'leave the way you came'. Client VFX + proximity prompt only. public const float PortalOffsetZ = -5f; /// How close the local player must be to the portal to show 'E to LEAVE' + send the interact. public const float PortalInteractRange = 3.5f; /// RoomExplore soft-timeout (~30 s @60): auto-advance if nobody interacts the portal (no softlock). public const uint ExploreGraceTicks = 1800; // ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ---- /// Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger. public const int InventoryMaxSlots = 24; /// Default per-slot stack cap when an item has no ItemDatabase entry (the catalog is optional at runtime). public const int DefaultStackMax = 999; // ---- Equipment stat-mod SourceIds (EquipSystem) ---- // One DISTINCT sentinel per slot: slot i tags its mods with EquipSourceIdBase + i. All of a slot's // inline mods share that one id and are stripped target-agnostically via // TimedModifierUtil.RemoveBySourceId on unequip/swap. Full StatModifier SourceId map (keep DISJOINT): // 0u = pickups + debug-injection; 0x00A0E711 = ability-damage upgrade; 0x00DEB061 = debug stat command; // 0x00B00000..0x00B10000 = run-scoped BOONS (stripped on return; top slot 0x00B0FFFF = Frenzy timed buff); 0x00C1A550.. = class traits (permanent); // 0x00E7A000..0x00E7A100 = permanent META upgrades (Step 12a); 0x00E91000.. = equipment (4 slots). /// Base of the run-scoped BOON SourceId band: each applied pick draws BoonSourceIdBase + /// (BoonPickCounter++ % BoonSourceIdSpan), so boons stack as distinct rows and ONE range-strip on the /// Returning edge clears the whole run's boons (the two-channel model — boons never persist). public const uint BoonSourceIdBase = 0x00B00000u; /// Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count. public const uint BoonSourceIdSpan = 0x10000u; /// Phase 1.7: the single Frenzy on-kill timed buff's SourceId, pinned to the TOP of the boon band /// (Base + Span - 1 = 0x00B0FFFF). The per-pick counter allocates from the BOTTOM (Base + counter % Span) and /// cannot reach Span-1 within a run, so it never aliases a pick; the Returning whole-band RemoveBySourceIdRange /// still clears it for free. Refreshed (never stacked) via TimedModifierUtil.Upsert. Do NOT copy the sibling-band /// Base+smallIndex idiom into the boon band — its low offsets are counter-consumed. public const uint FrenzySourceId = BoonSourceIdBase + BoonSourceIdSpan - 1u; // 0x00B0FFFF /// Phase 1.7: Frenzy surge duration (ticks, ~60/s) re-stamped on each kill. public const int FrenzyDurationTicks = 240; /// Phase 1.7: Frenzy cooldown modifier (PercentMult on CooldownTicks; -0.30 = 30% faster abilities). public const float FrenzyCooldownMult = -0.30f; /// DR-046: base PREP-LOADOUT run-scoped SourceId band [Base, Base+Span). DISJOINT from boon /// (0x00B00000), class (0x00C1A550), meta (0x00E7A000), equip (0x00E91000); one prep option's live /// StatModifier is keyed PrepSourceIdBase + optionId. Stripped on the Returning edge like boons. public const uint PrepSourceIdBase = 0x00D00000u; /// Width of the prep band (far above the tiny option count). public const uint PrepSourceIdSpan = 0x10000u; /// Base of the PERMANENT meta-upgrade SourceId band: a purchased tier's live StatModifier is /// keyed MetaSourceIdBase + UpgradeId (absolute-value UPSERT — one row per owned upgrade, set to /// ValuePerTier*tier). Persisted via MetaTierState (SaveData v6) and re-applied born-correct at spawn. /// DISJOINT from every other band; NEVER stripped (the permanent channel of the two-channel model). public const uint MetaSourceIdBase = 0x00E7A000u; /// Width of the meta band [Base, Base+Span) — bounds UpgradeId to a byte-sized catalog. public const uint MetaSourceIdSpan = 0x100u; /// Base for per-slot equipment SourceIds; slot i tags its mods with EquipSourceIdBase + i. public const uint EquipSourceIdBase = 0x00E91000u; /// Slice 2: base SourceId for a class's permanent trait StatModifiers (Warrior/Ranger seeds). /// DISJOINT from equipment/upgrade/debug ranges; class traits are NEVER stripped (permanent per session). public const uint ClassSourceId = 0x00C1A550u; } }