Files
Project-M/Docs/Vault/03_Design/Boon_Overhaul_Build_Spec.md
T
kronic 972e0d5b4f 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>
2026-07-12 18:54:06 -07:00

20 KiB
Raw Blame History

title, date, tags, permalink
title date tags permalink
Boon Overhaul (Phase 1.7) — Build Spec 2026-07-12
design
build-spec
boons
phase-1.7
coop-hades
netcode
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):

[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):

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):

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):

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:

// 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:

// 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-killHealthApplyDamageSystem 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); FrenzyTimedModifierUtil.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=scriptsread_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