From 972e0d5b4fca2653c22f594a6a7a4cc1d07f4ba9 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Sun, 12 Jul 2026 18:54:06 -0700 Subject: [PATCH] Docs: Phase 1.7 boon-overhaul build spec + DR-047 + session log + roadmap tick + CLAUDE.md 7-type-query gotcha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boon overhaul CODE-COMPLETE (mechanic-changers): design forks locked, pre-code review (11 findings folded), post-impl review (incomplete on a session limit — raised findings self-verified, 2 real bugs fixed). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- .../03_Design/Boon_Overhaul_Build_Spec.md | 173 ++++++++++++++++++ .../06_Roadmap/Iteration_2026-07_CoopHades.md | 15 +- .../2026/2026-07-12_Phase17_Boon_Overhaul.md | 75 ++++++++ ...Phase17_Boon_Overhaul_Mechanic_Changers.md | 55 ++++++ 5 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 Docs/Vault/03_Design/Boon_Overhaul_Build_Spec.md create mode 100644 Docs/Vault/07_Sessions/2026/2026-07-12_Phase17_Boon_Overhaul.md create mode 100644 Docs/Vault/07_Sessions/_Decisions/DR-047_Phase17_Boon_Overhaul_Mechanic_Changers.md diff --git a/CLAUDE.md b/CLAUDE.md index 69d8be465..4cb62d8be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,7 +145,7 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A Full rules: `.claude/skills/dots-dev/references/dots-conventions.md` (in-repo; travels with the repo). These **replace** classic MonoBehaviour/GameObject patterns. - **`struct : IComponentData`** is the default (unmanaged, Burst/job-friendly). `class : IComponentData` only for genuine managed refs (main-thread, no Burst). `IBufferElementData` for per-entity arrays. `IEnableableComponent` to toggle state without a structural change. -- **Systems:** `ISystem` (struct) + `[BurstCompile]` is the **default**; `SystemBase` only when touching managed objects. `SystemAPI.Query<…>()` to iterate. **Aspects (`IAspect`) are DEPRECATED (Entities 1.4+) — do not author new ones.** `Entities.ForEach` is legacy. +- **Systems:** `ISystem` (struct) + `[BurstCompile]` is the **default**; `SystemBase` only when touching managed objects. `SystemAPI.Query<…>()` to iterate — **max 7 type args**; read an 8th component via a `ComponentLookup` keyed by the entity (hit twice in Phase 1.7 boons). **Aspects (`IAspect`) are DEPRECATED (Entities 1.4+) — do not author new ones.** - **Jobs:** `IJobEntity` / `IJobChunk`; thread `JobHandle` through `state.Dependency`; mark inputs `[ReadOnly]`. Allocators: `Temp` (frame), `TempJob` (one job), `Persistent` (must dispose). Burst breaks on managed types/exceptions/reflection/strings. - **Structural changes** (add/remove component, create/destroy entity) invalidate handles + cause sync points → batch via **`EntityCommandBuffer`** (Begin/End`Simulation`EntityCommandBufferSystem; `.AsParallelWriter()` in parallel jobs). - **Baking:** `…Authoring` MonoBehaviour + `class FooBaker : Baker` → `GetEntity(authoring, TransformUsageFlags.…)` then `AddComponent`. Subscenes stream async — entities aren't present the instant a reference exists. diff --git a/Docs/Vault/03_Design/Boon_Overhaul_Build_Spec.md b/Docs/Vault/03_Design/Boon_Overhaul_Build_Spec.md new file mode 100644 index 000000000..8ab777b1c --- /dev/null +++ b/Docs/Vault/03_Design/Boon_Overhaul_Build_Spec.md @@ -0,0 +1,173 @@ +--- +title: Boon Overhaul (Phase 1.7) — Build Spec +date: 2026-07-12 +tags: [design, build-spec, boons, phase-1.7, coop-hades, netcode] +permalink: gamevault/03-design/boon-overhaul-build-spec +--- + +# Phase 1.7 — Boon Overhaul: BUILD SPEC (verified, implementation-ready) + +Unity 6.5 DOTS + Netcode for Entities. Server-authoritative, input-only clients, client prediction. Folds in every confirmed adversarial fix from the pre-code design review (`wf_c4bdd60d-7b2`, 27 agents / 0 errors, 23 findings raised → 11 confirmed). Operator-locked scope: ability-swap boons DEFERRED to Phase 3; ~8 mechanic + ~4 stat; synergy = tags for dedup + dominated-offer protection + light build-bias (NO synergy bonuses). Line refs are to files as read at HEAD `6dcd8a243`. + +--- + +## 0. Corrections folded in (design deltas vs the draft) + +| # | Draft claim | Verified correction | +|---|---|---| +| C1 | Put `PierceRemaining/ChainRemaining/EffectFlags` on `Projectile`; "NO ghost-hash change". | **FALSE.** Adding any field changes `StableTypeHash → SerializerHash → ghost TypeHash` (Netcode 6.5). **Put them on a new server-only `ProjectileEffectState` component** (mirrors `KnockbackState`). `Projectile`'s ghost hash stays **frozen** — truly zero projectile re-bake. | +| C2 | `BoonEffects` uses `OwnerSendType.All` "so the owner rolls it back". | Rollback comes from the **`[GhostField]`s**, not `All`. Reads are owner-prediction (`AbilityFireSystem` `.WithAll()` excludes interpolated remotes) or server-only. **Use `SendToOwnerType.SendToOwner`**, matching `BoonOffer.cs:16`. | +| C3 | Frenzy uses a "dedicated `FrenzySourceId` in the boon band". | The pick counter walks the band bottom-up (`Base + counter%Span`). A low fixed id **collides** and `RemoveBySourceId` strips ALL matches. **Pin `FrenzySourceId = BoonSourceIdBase + BoonSourceIdSpan - 1` (0x00B0FFFF)** — unreachable by the counter, still cleared free by the Returning range-strip. | +| C4 | "Refresh a single timed StatModifier" (Frenzy). | Must be an **UPSERT** across BOTH the `StatModifier` and server-only `TimedModifier` buffers; no upsert helper exists. **Add `TimedModifierUtil.Upsert`.** A blind `Add` stacks rows → compounding `-CooldownTicks`. | +| C5 | Returning strip clears boons. | Strips **only `StatModifier`** rows (`RunDirectorSystem.cs:392-401`); the server-only `TimedModifier` buffer is untouched → a stale Frenzy timed row survives a run boundary. **Add a `TimedModifier` strip on the Returning edge.** | +| C6 | Pierce/chain re-hit guard = single `LastHitEntity`. | Insufficient: selection picks earliest-by-distance with no last-hit exclusion (`ProjectileDamageSystem.cs:108-134`). **Use a per-projectile `FixedList64Bytes` history-set, excluded DURING selection** (beside the self-skip `:116-118`). Overflow → destroy (natural cap). | +| C7 | Dash-trail dedup "store on DashState? or a transient set". | `DashState` has no reliable clear edge on a release server. **Key dedup to `DashState.StartTick`** (= `TickUtil.NonZero(now)` every dash) in a **per-player** server-only `DashTrailState`. | +| C8 | Finisher detonation. | Implement **inline** in `MeleeComboSystem` over the already-gathered enemy snapshot (`:173-184`); no new system/helper. `IsFinisher` is NOT on `PendingCleave` and the resolution block has no `BoonEffects` access → **read `BoonEffects.FinisherDetonate` in the player loop (`:98-102`) and stash BOTH `IsFinisher` + the flag on `PendingCleave`.** `SourceNetworkId = OwnerId` (not −1), `SourceTick = c.Stamp`. | +| C9 | Killer capture. | Must read `SourceNetworkId>=0` **inside the drain loop, before `dmg.Clear()` (`:117`)** — the `Dying` stamp is after the clear (`:149`). Capture a per-victim `int killerNetId=-1`, updated after the negation `continue` (`:98`); "last player-sourced event" is the only rule the summed-damage structure supports. | +| C10 | `PickBoons` owned-state breaks determinism. | **Non-issue** — server-only, single draw per `RoomEpoch` (`BoonOfferSystem.cs:53` latch), client never re-runs it. Proceed; just **update stale determinism doc-comments** (`BoonOfferSystem.cs:68`, `BoonCatalog.cs:48`) to `(seed,room,player,ownedState-at-draw)`. | + +--- + +## 1. Final design + +### 1.1 New / extended data + +**`Simulation/Combat/BoonEffects.cs`** (new, replicated, on the player): +```csharp +[GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)] // C2 — matches BoonOffer.cs:16 +public struct BoonEffects : IComponentData { + [GhostField] public byte Pierce; // extra projectile hits survived (stacks) + [GhostField] public byte Fork; // extra spread projectiles per shot (stacks) + [GhostField] public byte Chain; // chain-to-next targets on hit (stacks) + [GhostField] public byte Flags; // BoonFlag bits +} +public static class BoonFlag { public const byte DashTrail=1, FinisherDetonate=2, KnockToPull=4, Siphon=8, Frenzy=16; } +public static class BoonEffectKind { public const byte None=0,Pierce=1,Fork=2,Chain=3,DashTrail=4,FinisherDetonate=5,KnockToPull=6,Siphon=7,Frenzy=8; } // bytes — Burst-safe, never an enum compared in Burst +``` +Baked INERT (all 0) on the player prefab. Rollback-correct via the `[GhostField]`s (owner is the sole predicting client). Zeroed on the Returning edge. + +**`Simulation/Combat/ProjectileEffectState.cs`** (new, **server-only, NOT a `[GhostField]`**, on the projectile — mirrors `KnockbackState`): +```csharp +public struct ProjectileEffectState : IComponentData { + public byte PierceRemaining, ChainRemaining, Flags; // Flags bit0 = Pull + public FixedList64Bytes Hit; // ~7 entries — re-hit history (C6) +} +``` +Baked inert on the projectile prefab (KnockbackState precedent → **no projectile ghost re-bake**). Seeded server-side at spawn from the owner's `BoonEffects`. + +**`Simulation/Player/DashTrailState.cs`** (new, server-only, plain, on the player): +```csharp +public struct DashTrailState : IComponentData { + public uint LastStartTick; // clear the set when DashState.StartTick differs (C7) + public FixedList64Bytes Hit; +} +``` +Baked inert on the player prefab. Non-replicated → no ghost-hash impact; piggybacks the BoonEffects re-bake. + +**`Simulation/Combat/Dying.cs`** — extend (server-only, not replicated → no wire): +```csharp +public struct Dying : IComponentData { public uint UntilTick; public int KillerNetId; public byte Rewarded; } +``` + +**`Simulation/Combat/BoonCatalog.cs`** — extend `BoonDefBlob` (blob = config, not wire) with `byte Kind` (0=Stat,1=Effect), `byte EffectKind` (`BoonEffectKind` byte), `byte Family` (0=none,1=projectile,2=melee,3=mobility,4=onkill,5=stat-dmg,6=stat-hp,7=stat-speed,8=stat-cd). Update `Make(...)` and `BuildDefault()` to the 12-entry table (§1.6). `Value` on `Kind=1` = the stack delta (usually 1) for Pierce/Fork/Chain, ignored for flag effects. + +**`Simulation/Tuning.cs`** — after `BoonSourceIdSpan`, add + extend the band-map comment reserving the top slot: +```csharp +// TOP-of-band, disjoint-by-construction from the bottom-up per-pick counter (Base+counter%Span); +// cleared free by the Returning RemoveBySourceIdRange. Reserved for the single Frenzy timed row. +public const uint FrenzySourceId = BoonSourceIdBase + BoonSourceIdSpan - 1u; // 0x00B0FFFF +public const int FrenzyDurationTicks = 240; // ~4s @60hz (tune) +public const float FrenzyCooldownMult = -0.30f; // PercentMult on CooldownTicks (tune) +``` + +**`Simulation/Combat/TimedModifier.cs`** — add to `TimedModifierUtil`: +```csharp +// Exactly ONE row per SourceId in BOTH buffers (remove-then-add). Refresh, never stack (C4). +public static void Upsert(DynamicBuffer mods, DynamicBuffer timed, + uint sourceId, byte target, byte op, float value, uint untilTick) { + RemoveBySourceId(mods, sourceId); + for (int j = timed.Length-1; j>=0; j--) if (timed[j].SourceId==sourceId) timed.RemoveAtSwapBack(j); + mods.Add(new StatModifier { Target=target, Op=op, Value=value, SourceId=sourceId }); + timed.Add(new TimedModifier { SourceId=sourceId, UntilTick=untilTick }); +} +``` + +### 1.2 Offer improvements — `BoonMath.PickBoons` (`BoonCatalog.cs:61`) +New param: the picking player's `BoonEffects` (owned state). (a) **Dedup:** exclude non-stacking owned FLAG effects (DashTrail/FinisherDetonate/KnockToPull/Siphon/Frenzy already owned); still re-offer stacking Pierce/Fork/Chain. (b) **Dominated-offer protection:** reject a candidate whose `Family` collides with an already-picked option in THIS deal. (c) **Light build-bias:** ×~1.5 draw weight (integer math) when a candidate's `Family` matches an owned effect's family. Integer-hash only, no RNG state. `BoonOfferSystem.cs:70` passes the player's `BoonEffects`. Update the determinism comments (`BoonOfferSystem.cs:68`, `BoonCatalog.cs:48`) to `(seed,room,player,ownedState-at-draw)`; keep the single-`OfferedRoomEpoch`-latch as the sole draw site. + +### 1.3 Apply branch — `BoonApplySystem.Apply` (`BoonApplySystem.cs:113`) +`Kind==0` → append `StatModifier` (today). `Kind==1` → mutate the player's `BoonEffects` (SetComponent — non-structural, baked present): `Pierce/Fork/Chain += (byte)Value` for stack kinds, else `Flags |= bit`. Bytes only (Burst-safe; system is `[BurstCompile]`). + +### 1.4 Combat hooks +- **Pierce/Chain** (`ProjectileDamageSystem`, server-only, `:108-161`): add `RefRW` and change `Projectile` to `RefRW` (chain rewrites `Direction`). In the selection scan, `continue` on any target in `fx.Hit` (beside the self-skip `:116-118`). On hit: append damage, add target to `fx.Hit`, then — `Pierce>0` → decrement+survive; else `Chain>0` → rescan next-nearest living target **not in `fx.Hit`**, rewrite `Direction`, decrement+survive; else / `fx.Hit` full → destroy. **Duplicate the `DistanceTravelled>=Range` check on the survive branch** (destroy if exceeded). Keep **exactly one `ecb.DestroyEntity` per projectile per tick**. Seed `fx` server-side in `AbilityFireSystem` (`:211-219`) from the owner's `BoonEffects`. +- **Fork** (`AbilityFireSystem`, both worlds, `:198-219`): spawn `Fork` extra projectiles at ± spread; `spawnId = (ownerNetId<<16) | (absoluteFireCount<<4) | forkIndex` — deterministic + unique so `ProjectileClassificationSystem` predicts each. Fork count from replicated `BoonEffects` (owner has it via SendToOwner). +- **Knock→Pull:** projectile path — `ProjectileDamageSystem.cs:148` writes `Dir = (fx.Flags&1)!=0 ? -proj.Direction : proj.Direction`. Melee/cone — add `bool pull` to `KnockbackUtil.Stamp` (negate the away-dir); `MeleeComboSystem` cleave + `AbilityFireSystem` cone (`:146`) pass the attacker's KnockToPull. Boss-immune + `HasComponent` guards preserved. +- **Dash trail** — new server-only `DashTrailDamageSystem` `[WorldSystemFilter(ServerSimulation)][UpdateInGroup(PredictedSimulationSystemGroup)][UpdateAfter(DashSystem)][UpdateBefore(HealthApplyDamageSystem)]`. Per active-window player with `BoonEffects.DashTrail`: if `DashState.StartTick != DashTrailState.LastStartTick` → clear `Hit`, set `LastStartTick`. Append `DamageEvent{SourceNetworkId=NetId, SourceTick=NonZero(now)}` to living enemies within a radius of the swept dash segment (prevPos→pos), skipping any already in `Hit`. No enemy Position/Health writes. Enemies carry no `DashState` → i-frame negation skipped (harmless). +- **Finisher detonation** — inline in `MeleeComboSystem` (C8): stash `IsFinisher` + `Detonate` on `PendingCleave` (read `BoonEffects` in the player loop `:98-102`); after the cleave loop, if `c.IsFinisher && c.Detonate`, second radius loop over the existing `enemyEntities` snapshot (`:173-184`) appending `DamageEvent{SourceNetworkId=OwnerId, SourceTick=c.Stamp}`. +- **On-kill** — `HealthApplyDamageSystem` captures `killerNetId` (C9) into the `Dying` stamp (`:149`). New server-only `KillRewardSystem` `[UpdateInGroup(PredictedSimulationSystemGroup)][UpdateAfter(HealthApplyDamageSystem)]`: query `RefRW` `.WithAll()`; for `Rewarded==0 && KillerNetId>=0`, resolve killer by NetId→player map; **Siphon** → heal killer `Health.Current` clamped to `EffectiveCharacterStats.MaxHealth` via `ComponentLookup` RW (no aliasing — no `Health` in the query); **Frenzy** → `TimedModifierUtil.Upsert(mods, timed, FrenzySourceId, (byte)CooldownTicks, (byte)PercentMult, FrenzyCooldownMult, NonZero(now+FrenzyDurationTicks))`. Set `Rewarded=1` (idempotent value write). + +### 1.5 Returning edge — `RunDirectorSystem.cs:392-401` +Add `BoonEffects` + `TimedModifier` to the query; zero `BoonEffects`; `TimedModifierUtil.RemoveBySourceId(timed, Tuning.FrenzySourceId)` (C5) alongside the existing `StatModifier` range-strips. Idempotent every Returning tick. + +### 1.6 Catalog (12): 8 mechanic + 4 stat +1 Piercing Shots (Ranger·projectile·pierce+1) · 2 Split Shot (Ranger·projectile·fork+1) · 3 Ricochet (Ranger·projectile·chain+1) · 4 Detonating Finisher (Warrior·melee·FinisherDetonate) · 5 Blade Dash (both·mobility·DashTrail) · 6 Gravity Pull (both·melee/proj·KnockToPull) · 7 Siphon (both·onkill·Siphon) · 8 Frenzy (both·onkill·Frenzy) · 9 Executioner (both·stat-dmg·+50% Damage) · 10 Titan's Vigor (both·stat-hp·+60 MaxHealth) · 11 Fleet Foot (both·stat-speed·+18% MoveSpeed) · 12 Berserker's Pace (both·stat-cd·−25% CooldownTicks). `ClassMask` bit0=Warrior, bit1=Ranger — projectile boons Ranger-only (mask 2). + +--- + +## 2. Build order (each step: MCP edit → `refresh_unity scope=scripts` → `read_console`) + +> **Component before any system that references it.** Edit `Assets/*.cs` via MCP only. One edit per `apply_text_edits` call. + +- **Step A — data & utils:** `BoonEffects.cs`, `ProjectileEffectState.cs`, `DashTrailState.cs`; extend `Dying.cs`; extend `BoonDefBlob`+`Make`+`BuildDefault` (12 rows); add `Tuning.FrenzySourceId`/durations + band-map comment; add `TimedModifierUtil.Upsert`. **Checkpoint:** compile clean; `StatModifier` layout UNTOUCHED; all bytes (no enum-in-Burst). +- **Step B — `BoonMath.PickBoons` + `BoonOfferSystem`:** owned-state param, dedup/dominated/bias; update determinism comments. **Checkpoint:** compile; `BoonOfferTests`. +- **Step C — `BoonApplySystem.Apply` Kind branch. Checkpoint:** compile; `BoonApplySystemTests`. +- **Step D — `RunDirectorSystem` Returning edge** (zero BoonEffects; add TimedModifier strip). **Checkpoint:** compile. +- **Step E — combat hooks (one system per edit, compile + `read_console` after each):** E1 `ProjectileDamageSystem`; E2 `AbilityFireSystem`; E3 `KnockbackUtil.Stamp` (+`bool pull`, do FIRST if E1/E2 call it); E4 `DashTrailDamageSystem` (new); E5 `MeleeComboSystem`; E6 `HealthApplyDamageSystem` (killer capture); E7 `KillRewardSystem` (new). After E4/E7 the predicted group has new ordering edges (`Dash→DashTrail→Health`, `Health→KillReward`) — acyclic; **cycle is invisible to EditMode → MUST Play-validate at world creation.** +- **Step F — authoring/bake (no asset edits in Play):** `PlayerAuthoring` +`BoonEffects`+`DashTrailState` (default); projectile prefab +`ProjectileEffectState` (default); re-bake the 12-row blob; re-bake `Gameplay.unity`. Both worlds recompile+rebake together → identical new player-ghost hash. +- **Step G — verification (§4).** + +--- + +## 3. Wire / bake churn classification + +**Ghost-hash CHANGE → player ghost re-bakes ONCE (front-loaded, acceptable):** `BoonEffects` (new `GhostComponent` on the player). A stale un-rebaked Gameplay subscene or a mixed-version peer will be **REFUSED at handshake / throw the runtime hash error** — inherent to adding any GhostComponent. + +**Local re-bake, NO ghost-hash / NO wire change:** `DashTrailState` on the player (plain); `ProjectileEffectState` on the projectile prefab (plain, server-only) — **`Projectile`'s ghost hash stays FROZEN** (C1 fix); `BoonDefBlob` extension + 12-row table (config blob). + +**No bake, no wire:** `Dying` +`KillerNetId`/`Rewarded` (server-only, runtime-added); `StatModifier` UNCHANGED (Frenzy reuses the byte-identical row via Upsert); `TimedModifier` layout unchanged; **RPC collection UNCHANGED** (`BoonPickRequest`/`BoonOffer` untouched). + +--- + +## 4. Verification + +**EditMode (plain-Entities, public API):** +- `TimedModifierUtilTests`: Upsert N× → exactly ONE `StatModifier` + ONE `TimedModifier` at extended `UntilTick`; Upsert→RemoveBySourceId → zero in both. +- `BoonMathTests`/`BoonOfferTests` (extend): family-dedup; owned-flag exclude; stacking effect re-offer; build-bias weight boost; determinism `(seed,room,player,ownedState)`; single draw per `RoomEpoch`. +- `BoonApplySystemTests`: Kind=0 → StatModifier appended (unchanged); Kind=1 → BoonEffects mutated, StatModifier buffer untouched. +- `ProjectileDamageSystemTests`: (1) fast projectile + large target + pierce=1 → exactly ONE DamageEvent per target, survives once, expires at Range; (2) chain=2, A nearest to B → A not re-hit after B; (3) never double-destroyed per tick. +- `KillRewardSystemTests`: Siphon heals (clamped, once); Frenzy Upsert = one+one row, re-kill re-stamps `UntilTick`; `KillerNetId=-1` → no reward. +- `DashTrailDamageSystemTests`: enemy in path → exactly ONE DamageEvent across the multi-tick window (StartTick-keyed dedup); next dash re-hits. +- `RunDirector` Returning: strips Frenzy `StatModifier` + `TimedModifier` + zeroes `BoonEffects`. + +**Play-smoke (headless — expedition-run recipe: server `PlayerReady=1` from clean Staging, guardian delegate + screenshots in the SAME `execute_code`):** +- World creation: no "different hash on the client" error; no `ComponentSystemSorter` circular-dependency exception; no Burst ICE. +- RoomReward → pick a Kind=1 boon (Piercing Shots) → `BoonEffects.Pierce` incremented on BOTH server and owner client. +- Fire: pierce survives ≥1 hit + damages ≥2 enemies; fork spawns N predicted projectiles (no predicted-spawn desync warning); chain retargets; pull drags an enemy toward the player. +- Kill streak: Siphon heals the killer; Frenzy drops cooldown; `StatModifier` length stable (no stacking). +- Cross-run: Returning zeroes BoonEffects + strips the Frenzy timed row; a second run's Frenzy behaves fresh. + +--- + +## 5. Open risks + +1. **Ghost-hash change is real (§3).** Force a full Gameplay re-bake at Step F; never run against a stale build. One-time/front-loaded. +2. **Fork spawnId bit budget:** `(absoluteFireCount<<4)|forkIndex` caps absoluteFireCount at 12 bits (~4096 shots/run) and forkIndex at 4 bits (≤15 forks). Cap Fork stacks ≤15; verify no classification collision in Play. +3. **Chain rewrites `Projectile.Direction`** — relies on the existing auto-target precedent (server already rewrites Direction). Owner's predicted projectile may visibly snap on a chain tick (cosmetic). +4. **Hit-set capacity (~7):** `ProjectileEffectState.Hit` overflow → self-destruct (natural cap); `DashTrailState.Hit` overflow → stop-adding (a re-hit) vs drop — pick a policy. +5. **DashTrail dedup is per-player** — verify two simultaneous dashers don't clobber one shared set. Dash-trail `SourceNetworkId≥0` can trigger Charger whiff-punish scoring (legitimate; note it). +6. **Frenzy tuning:** Upsert prevents stacking, but large mult × long duration refreshed every kill = near-permanent low cooldown. Design-tune. +7. **`BoonApplySystem` is `[BurstCompile]`** — the new Kind branch reads blob bytes + `SetComponent` (Burst-safe). Watch `read_console` for a Burst ICE; editor-restart cure if it de-Bursts. +8. **Warrior vs Ranger gating:** pierce/fork/chain benefit only Ranger. Class-mask them Ranger-only (mask=2). +9. **Knock→Pull on melee/cone** can pull an enemy INTO the player. Verify boss-immune + HasComponent guards survive the `pull` param; design-tune. + +Related: [[Iteration_2026-07_CoopHades]] · [[Geyser_Build_Spec]] · [[Destructible_Cover_Build_Spec]] · [[DR-044_Expedition_Redesign_Shipped_Demo_Polish]] diff --git a/Docs/Vault/06_Roadmap/Iteration_2026-07_CoopHades.md b/Docs/Vault/06_Roadmap/Iteration_2026-07_CoopHades.md index 24bfb8b0b..ce1bdeadb 100644 --- a/Docs/Vault/06_Roadmap/Iteration_2026-07_CoopHades.md +++ b/Docs/Vault/06_Roadmap/Iteration_2026-07_CoopHades.md @@ -194,13 +194,18 @@ a real Blight room). **Gate:** operator playtest says "no longer static"; node harvest feedback visibly works; art direction LOCKED. -## Phase 1.7 — Boon overhaul (PULLED FORWARD from Phase 3, operator call 2026-07-07) +## Phase 1.7 — Boon overhaul (PULLED FORWARD from Phase 3) — ✅ CODE-COMPLETE 2026-07-12 -- **Boons → ~12 mechanic-changers** on existing hooks: dash damage trail, finisher AoE detonation, projectile - pierce/fork/chain, ability-SWAP boons (replicated AbilityRef byte via the ClassSwapUtil pattern), knockback→pull… - plus synergy tags, duplicate/dominated-offer protection. Design-review before coding (replicated ability swaps). +**SHIPPED (2026-07-12, review-first `wf_c4bdd60d-7b2` 11-finding + post-impl `wf_d2a0a673-af5`):** **8 mechanic-changers + +4 stat boons** on a NEW per-player replicated `BoonEffects` (SendToOwner): projectile **pierce / split / ricochet** +(Ranger), **detonating finisher** (Warrior), **blade dash** (trail damage), **gravity pull** (knockback→pull), **siphon** +(on-kill heal) + **frenzy** (on-kill cooldown surge, both). Offers gained **dedup + dominated-offer protection + light +build-bias** (`Family` tags). **488 EditMode green + Play smoke server==owner-client.** Operator forks (this session): +ability-swap boons DEFERRED to Phase 3 (content-thin + AbilityRef→class coupling); synergy = tags only (no bonuses). +See [[DR-047_Phase17_Boon_Overhaul_Mechanic_Changers]] · [[Boon_Overhaul_Build_Spec]] · [[2026-07-12_Phase17_Boon_Overhaul]]. -**Gate:** two consecutive runs feel different from boons alone. +**Gate:** operator feel/balance playtest — two consecutive runs feel different from boons alone (knobs to tune: fork cap 8, +dash-trail radius/dmg 1.6/12, frenzy 240t/-0.30, siphon 8, finisher radius 3.5). ## Phase 2 — Hub-ification (structural; adversarial design-review REQUIRED before coding) diff --git a/Docs/Vault/07_Sessions/2026/2026-07-12_Phase17_Boon_Overhaul.md b/Docs/Vault/07_Sessions/2026/2026-07-12_Phase17_Boon_Overhaul.md new file mode 100644 index 000000000..980192e92 --- /dev/null +++ b/Docs/Vault/07_Sessions/2026/2026-07-12_Phase17_Boon_Overhaul.md @@ -0,0 +1,75 @@ +--- +title: 2026-07-12 — Phase 1.7 Boon Overhaul (mechanic-changers) +date: 2026-07-12 +tags: [session, coop-hades, phase-1.7, boons, netcode, dots] +permalink: gamevault/07-sessions/2026-07-12-phase17-boon-overhaul +--- + +# Phase 1.7 — Boon Overhaul + +**Goal:** turn boons from flat-stat rows (the operator's "boring" verdict) into **mechanic-changers** so two runs feel +different. The full `/dots-dev` spine ran (ground → forks → ctx7 → pre-code review → plan → serial impl → verify → +post-impl review → docs). Build Spec: [[Boon_Overhaul_Build_Spec]]. Decision record: [[DR-047_Phase17_Boon_Overhaul_Mechanic_Changers]]. + +## Forks locked (operator, this session) +1. **Ability-swap boons DEFERRED to Phase 3** (content-thin — only Ranger has alt abilities; the Warrior cone has none; + couples to the client's `AbilityRef→class` derivation). Phase 1.7 = behavior-boons only. +2. **~8 mechanic-changers + ~4 stat boons** (keep a few "safe" econ/pacing picks). +3. **Synergy = tags for dedup + dominated-offer protection + light build-bias** (NO synergy bonuses in v1). + +## What shipped +**Architecture (netcode-correct, review-hardened):** +- **`BoonEffects`** — new per-player `[GhostComponent(SendToOwnerType.SendToOwner)]` with `[GhostField] byte Pierce,Fork,Chain,Flags`. + Baked INERT on the player (the `BoonOffer` idiom → **one deliberate player-ghost re-bake**); mutated on pick (non-structural); + zeroed on the Returning edge. Rollback-correct via the GhostFields (owner is the sole predictor). +- **`ProjectileEffectState`** — SERVER-ONLY (NOT a GhostField), baked inert on the projectile prefab → keeps the `Projectile` + ghost hash FROZEN (the C1 correction; a field on `Projectile` itself WOULD change its StableTypeHash→serializer hash). + Seeded server-side at spawn; a `FixedList64Bytes` re-hit set excluded DURING target selection. +- **`DashTrailState`** — server-only per-player Blade-Dash dedup keyed to `DashState.StartTick` (no reliable clear edge). +- **`BoonDefBlob` +`Kind`/`EffectKind`/`Family`** discriminators (config blob, not wire); the 12-row table redefined. +- **`TimedModifierUtil.Upsert`** (refresh-never-stack) + a `TimedModifier`-buffer `RemoveBySourceId` overload. +- **`Tuning.FrenzySourceId = 0x00B0FFFF`** — pinned to the TOP of the boon band (the bottom-up pick counter can't reach it; + the Returning range-strip still clears it free). + +**Combat hooks:** pierce/chain in `ProjectileDamageSystem` (survive-and-continue, one destroy/tick preserved); fork = extra +predicted projectiles at a spread in `AbilityFireSystem` (unique packed spawnId); knockback→pull negates `Dir` at the stamp +(new `KnockbackUtil.Stamp(..., bool pull)`); Blade-Dash = new server `DashTrailDamageSystem`; Detonating Finisher inline in +`MeleeComboSystem` (reads BoonEffects in the player loop, stashes IsFinisher/Detonate/Pull on `PendingCleave`); Siphon+Frenzy += new server `KillRewardSystem` off a `Dying.KillerNetId`+`Rewarded` latch stamped in `HealthApplyDamageSystem`. + +**Offers:** `BoonMath.PickBoons` gains the owner's `BoonEffects` — dedup (skip an owned non-stacking flag), dominated-offer +protection (no two same-`Family` in a deal → kills the "-15% vs -25% cd" bug), light ×1.5 build-bias. Integer-hash only. + +**Table (12):** 1 Piercing·2 Split·3 Ricochet (Ranger, projectile) · 4 Detonating Finisher (Warrior) · 5 Blade Dash · 6 Gravity +Pull · 7 Siphon · 8 Frenzy (both) · 9 Executioner · 10 Titan's Vigor · 11 Fleet Foot · 12 Berserker's Pace (both, stat). + +## Reviews +- **Pre-code (`wf_c4bdd60d-7b2`, 27 agents / 0 errors):** 23 raised → **11 confirmed**, all folded (C1 separate projectile + component; C2 SendToOwner not All; C3–5 Frenzy top-of-band + Upsert + TimedModifier Returning-strip; C6 hit-set-excluded-in- + selection; C7 dash dedup on StartTick; C8 inline finisher; C9 killer-capture-before-clear; C10 owned-state determinism doc). +- **Post-impl (`wf_d2a0a673-af5`) — INCOMPLETE:** **4 verify agents died on a session limit** (the masquerade hazard — checked + the failures list). Self-verified the raised findings against the code and fixed **2 real bugs the Build Spec §5 open-risks + predicted** + 1 nit: (a) **Fork uncapped** → `shots = 1 + min(Fork,8)` (forkIndex is 4 spawnId bits, wrapped at 16); (b) + **Blade-Dash hit-set overflow re-damaged** an unrecordable enemy every tick → break once `Hit` full; (c) the Slice-2 comment + was displaced onto the BoonEffects lookup line (the `apply_text_edits` swallow gotcha) → relabelled. + +## Verification +- **L1** console clean vs baseline (only benign Server-Tick-Batching warnings), no Burst ICE. +- **L2** **488/488 EditMode** (from 474; +13 new: pierce/chain, TimedModifierUtil Upsert, KillReward ×4, DashTrail ×2, offer + dedup/owned-flag, a predicted-chain cycle guard) + **live Play smoke: `BoonEffects` server==owner-client** (set server + Pierce3/Fork2/Chain1/Flags16 → replicated to the owning client), client connected (new GhostField didn't break the handshake + → ghost hash consistent), world-creation exception-free (no ordering cycle). +- **L3** N/A — behavioral slice, no new visual assets; the boon effects in-combat are the operator's playtest. + +## Wire/bake churn +`BoonEffects` = ONE player-ghost re-bake (front-loaded; both worlds bake identically → handshake OK). `ProjectileEffectState`/ +`DashTrailState`/`Dying`/`BoonDefBlob` = no ghost-hash change. `StatModifier` + RPC collection UNCHANGED. + +## Next-session intent +**Phase 1.7 boon overhaul is CODE-COMPLETE + tests green + Play-smoke-verified.** NEXT = the operator's **feel/balance +playtest** (the gate: "two consecutive runs feel different from boons alone") — tune knobs: Fork cap (8), DashTrail +radius/damage (1.6/12), Frenzy duration/mult (240t/-0.30), Siphon heal (8), finisher detonation radius (3.5). Then **Phase 2 — +Hub-ification** (mothball sieges, win moves fully expedition-side, base=hub; adversarial design-review first). Order stays +1.7 → 2 → 3 → 4/5 parallel. Also OPEN (deferred): ability-swap boons (Phase 3), a client build-display HUD for active boons. + +Related: [[Iteration_2026-07_CoopHades]] · [[DR-047_Phase17_Boon_Overhaul_Mechanic_Changers]] · [[Boon_Overhaul_Build_Spec]] · [[DR-044_Expedition_Redesign_Shipped_Demo_Polish]] diff --git a/Docs/Vault/07_Sessions/_Decisions/DR-047_Phase17_Boon_Overhaul_Mechanic_Changers.md b/Docs/Vault/07_Sessions/_Decisions/DR-047_Phase17_Boon_Overhaul_Mechanic_Changers.md new file mode 100644 index 000000000..568ad5b43 --- /dev/null +++ b/Docs/Vault/07_Sessions/_Decisions/DR-047_Phase17_Boon_Overhaul_Mechanic_Changers.md @@ -0,0 +1,55 @@ +--- +title: DR-047 — Phase 1.7 Boon Overhaul (mechanic-changers) +date: 2026-07-12 +tags: [decision, coop-hades, phase-1.7, boons, netcode] +permalink: gamevault/decisions/dr-047-phase17-boon-overhaul +--- + +# DR-047 — Phase 1.7 Boon Overhaul: mechanic-changers + +**Status:** shipped (code-complete, 488 EditMode green + Play smoke server==client). Supersedes the flat-stat-only boon +table from [[DR-044_Expedition_Redesign_Shipped_Demo_Polish]]. Full build detail: [[Boon_Overhaul_Build_Spec]]; session: +[[2026-07-12_Phase17_Boon_Overhaul]]. + +## Decision +Boons become **8 mechanic-changers + 4 flat-stat** (operator forks: ability-swap DEFERRED to Phase 3; synergy = tags for +dedup/dominated-protection/light-bias, NO synergy bonuses). Mechanic-changers ride a NEW per-player replicated +`BoonEffects` component — NOT the flat `StatModifier` pipeline, which cannot express behavior changes. + +## Invariants (these outlive the current table/tuning) +1. **`BoonEffects` = `[GhostComponent(SendToOwnerType.SendToOwner)]` + `[GhostField] byte Pierce,Fork,Chain,Flags`, + baked INERT on the player, zeroed on the Returning edge.** Rollback-correctness comes from the GhostFields, NOT the + send type (owner is the sole predictor; needed so the owner predict-spawns the right Fork/Pierce projectiles). + A pick MUTATES it (non-structural, baked-present) — never AddComponent. +2. **Per-projectile pierce/chain state lives on a SEPARATE server-only `ProjectileEffectState`, never on `Projectile`.** + Adding ANY field to the ghost `Projectile` changes its StableTypeHash→serializer hash = a projectile ghost re-bake; + a separate non-ghost component keeps it frozen (the `KnockbackState` precedent). +3. **Pierce/chain re-hit guard = a per-projectile hit-set excluded DURING target selection** (not post-filtered), and + **exactly ONE `ecb.DestroyEntity` per projectile per tick** across the hit / survive+range / no-hit branches. +4. **Fork = extra predicted projectiles at a spread**, each with a UNIQUE deterministic spawnId + `(netId<<16)|((fireCount&0xFFF)<<4)|(forkIndex&0xF)`. **forkIndex is 4 bits → Fork MUST be capped ≤15** (shipped cap 8); + an uncapped fork wraps forkIndex → spawnId collision → classification mis-match. (Open risk: fireCount is 12 bits → + wraps at 4096 shots/run.) +5. **On-kill boons (Siphon/Frenzy) live in a SEPARATE `KillRewardSystem`, not in `HealthApplyDamageSystem`** — healing the + killer needs RW `Health` which would alias that system's `RefRW` victim query. The kill edge stamps + `Dying.KillerNetId` (last player-sourced hit, captured BEFORE `dmg.Clear()`) + a `Rewarded` idempotency latch. +6. **A run-scoped timed buff (Frenzy) is `Upsert`-ed (refresh, never stack) on a FIXED SourceId pinned to the TOP of the + boon band** (`BoonSourceIdBase+Span-1`, unreachable by the bottom-up pick counter) and stripped from BOTH the + `StatModifier` AND `TimedModifier` buffers on Returning (the range-strip only covers StatModifier). +7. **`BoonMath.PickBoons` takes the owner's `BoonEffects`** for dedup (skip owned non-stacking flags) + dominated-offer + protection (no two same-`Family` in a deal) + light ×1.5 build-bias — integer-hash only, drawn ONCE per RoomEpoch (the + latch), so feeding owned-state stays deterministic per (seed,room,player,owned-at-draw). +8. **Projectile boons (pierce/fork/chain) are Ranger-only (ClassMask=2)** — the Warrior's Fire is a cone, not a projectile. +9. **`SystemAPI.Query` caps at 7 type args** → reading `BoonEffects` in the already-7-wide `AbilityFireSystem`/`MeleeComboSystem` + player loops goes through a `ComponentLookup`, not an 8th query type. + +## Deferred (open) +Ability-swap boons (→ Phase 3, with a `PlayerClass` GhostField if the client class-derivation coupling matters); a +client build-display HUD for active boons; DashTrail as a swept segment (v1 uses a per-tick radius — tunnel-safe at the +current dash step); fireCount 12-bit spawnId budget. + +## Process note +Post-impl review (`wf_d2a0a673-af5`) had **4 verify agents die on a session limit** — an INCOMPLETE review masquerading as +near-clean. The raised-but-unverified findings were self-verified against the code; two were real bugs (fork cap, dash +overflow) the Build Spec §5 open-risks had predicted. Reinforces [[the failures-list check]] + the value of writing +open-risks into the spec so a truncated review still has a checklist.