Docs: Phase 1.7 boon-overhaul build spec + DR-047 + session log + roadmap tick + CLAUDE.md 7-type-query gotcha

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 18:54:06 -07:00
parent 6dcd8a243d
commit 972e0d5b4f
5 changed files with 314 additions and 6 deletions
+1 -1
View File
@@ -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. 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. - **`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. - **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). - **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<FooAuthoring>``GetEntity(authoring, TransformUsageFlags.…)` then `AddComponent`. Subscenes stream async — entities aren't present the instant a reference exists. - **Baking:** `…Authoring` MonoBehaviour + `class FooBaker : Baker<FooAuthoring>``GetEntity(authoring, TransformUsageFlags.…)` then `AddComponent`. Subscenes stream async — entities aren't present the instant a reference exists.
@@ -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<Simulate>()` 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<Entity>` 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<Entity> 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<Entity> 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<StatModifier> mods, DynamicBuffer<TimedModifier> 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<ProjectileEffectState>` 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<KnockbackState>` 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<Dying>` `.WithAll<EnemyTag>()`; for `Rewarded==0 && KillerNetId>=0`, resolve killer by NetId→player map; **Siphon** → heal killer `Health.Current` clamped to `EffectiveCharacterStats.MaxHealth` via `ComponentLookup<Health>` 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<BoonEffects>` (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]]
@@ -194,13 +194,18 @@ a real Blight room).
**Gate:** operator playtest says "no longer static"; node harvest feedback visibly works; art direction LOCKED. **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 **SHIPPED (2026-07-12, review-first `wf_c4bdd60d-7b2` 11-finding + post-impl `wf_d2a0a673-af5`):** **8 mechanic-changers +
pierce/fork/chain, ability-SWAP boons (replicated AbilityRef byte via the ClassSwapUtil pattern), knockback→pull… 4 stat boons** on a NEW per-player replicated `BoonEffects` (SendToOwner): projectile **pierce / split / ricochet**
plus synergy tags, duplicate/dominated-offer protection. Design-review before coding (replicated ability swaps). (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) ## Phase 2 — Hub-ification (structural; adversarial design-review REQUIRED before coding)
@@ -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<Entity>` 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; C35 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]]
@@ -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<Health>` 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.