8 Commits

Author SHA1 Message Date
prosolis
d76c63be0c Review follow-ups A + B: armed abilities survive the fight, supply pool serialized
A. An armed ability lasted one round of a turn-based fight.

buildZoneCombatants called applyArmedAbility, which applies an ability's mods
*and* clears ArmedAbility and saves the sheet. The turn engine calls that
builder again on every !attack / !cast / !consume, so round 1 fired the ability
and disarmed the character, and every later round rebuilt them with none of its
mods. A Berserker paid stamina for a single round of BerserkerRage /
RageMeleeDmg / PhysicalResistRage / FrenzyDmgBonus. Every entry in
dndActiveAbilities had the same shape. mods.BerserkerRage was not merely unread
at close-out — by then it no longer existed.

Split arming into its two halves:

  consumeArmedAbility(c)          mutates: disarms, saves, returns the id. Once,
                                  at fight start.
  applyAbilityByID(c, id, mods)   pure: no DB write, no disarm. Safe on every
                                  rebuild. (No ability's Apply writes to the
                                  character, so this really is pure.)
  armAbilityForFight(c, mods)     consume + apply, for the auto-resolve callers
                                  that build and fight in one breath.

buildZoneCombatants now takes the already-consumed id and re-applies it. The id
rides on ActorStatuses.ArmedAbility, seeded per seat at fight start, so
partyCombatantsForSession reproduces the ability every rebuild and the close-out
can still see that a rage fired.

The close-out itself: postCombatBookkeeping now carries grantCombatAchievements
+ persistDnDPostCombatSubclass, and all four close-outs route through it —
runDungeonCombat, runZoneCombatRoster, finishCombatSession,
finishPartyCombatSession. It fires on every terminal status, not just a win: a
Berserker who rages and loses is still exhausted, which is what auto-resolve
always did.

Also: buildFightSeats and runZoneCombatRoster consumed the ability before the
checks that could sit a seat out, so a downed member was disarmed for a fight
they never joined. The refusals now run first.

B. Six unlocked read-modify-writes against the shared supply pool.

updateSupplies rewrites supplies_json wholesale, so a caller folding its delta
onto an *Expedition it read earlier discards whatever landed in between.
Handlers run one goroutine per event, so those writers genuinely interleave.

All six now go through withExpeditionSupplies, which takes advExpeditionLock,
re-reads the row, hands the closure the fresh copy and persists what it returns:
nightRolloverBurn (forage + burn in one write), grantTwoWeeksCache,
advanceToNextRegion's transit burn, campPitch, pitchAutopilotCamp, and the
ambient pack-rat drain. expeditionCmdAccept's hand-rolled lock folds onto the
same helper. expedition_sim.go is left alone: single-threaded, takes no locks.

Known consequence, for the balance track: trySimAutoArm used to live inside the
rebuild, so a simulated Fighter (second_wind) or Cleric (healing_word) re-armed
and re-spent a resource every round of every elite/boss fight. expedition-sim
drives those through the turn engine, so every prior expedition-sim corpus
overstates those two classes. Re-baseline after this, not before.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:01:51 -07:00
prosolis
3369d7d8fe gofmt: bring internal/ and cmd/ back to gofmt -l clean
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.

Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
2026-07-10 07:18:07 -07:00
prosolis
519964fb01 J1: Extra Attack now fires in turn-based combat
The class-identity audit (98ba416) wired Extra Attack via the new
resolvePlayerSwings helper, but only SimulateCombat (auto-resolve)
called it. The turn-based engine — every !fight/!attack and every
elite/boss gate the sim drives via autoResolveCombat — still called
single-swing resolvePlayerAttack, so Fighter L11+ got 1 swing/turn at
the gates instead of 3. The audit close-out was correct in spirit but
half-applied.

J1 baseline matrix surfaced it: Fighter L12 cleared 100% of T2 forest
but 2% of T3 manor and 7% of T4 underdark, with %boss_reached at 100%
across the board. The wall was the boss-room damage exchange, not
mid-zone attrition. Trace dump on a sample fight: Fighter dealt 79
dmg in 14 rounds (7 hits / 9 swings) — exactly one swing per round —
versus 167 enemy dmg. With multi-swing wired in, the same fight ends
in 7 rounds with the boss dead, Fighter at 87/168 HP, 16 hits in 19
swings.

n=100 matrix after the fix:
  Fighter L12 manor:     2% → 100% clr
  Fighter L12 underdark: 7% → 98%  clr
  Fighter L12 forest:    94% → 100% (no leader regression)
Mage cells unchanged (J2 territory). Rogue cells within noise.

Sim infra changes that landed alongside (needed to read the J1
signal):

* expedition_sim auto-arms class-default defensive abilities
  (Second Wind / Healing Word) via the new simAutoArmEnabled toggle
  + trySimAutoArm helper, hooked before applyArmedAbility in both
  combat builders. Production code paths untouched (toggle stays
  off). Without this the sim simulated a player who never types
  !arm, which under-counts class survival.
* SimResult.Combats captures per-fight turn-log summaries (rounds,
  hits/misses, damage by side, AC values inferred from RollAgainst)
  so future J-phase questions can dig into the engine without
  re-running the matrix.
* sim_results/run_matrix.sh fans the matrix across (class,level,zone)
  cells via xargs -P (one process per cell — each owns its global
  sqlite handle). ~6× wall-clock speedup on a 14-core box; n=100
  matrix runs in ~3min.
* sim_results/summarize.sh gains p50_yld_clr + %boss_reached columns
  so future sweeps don't conflate "reaches boss" with "clears zone".

Baselines:
  sim_results/baseline_j0_n100.jsonl       — pre-fix (1350 rows)
  sim_results/baseline_j1_extra_attack.jsonl — post-fix (4500 rows)

Phase J state: J0 baseline locked, J1 done. T5 dragons_lair still
0% clear universally (J3). Mage T2+ wall still real (J2).
2026-05-17 14:11:39 -07:00
prosolis
64c70dda27 Adv 2.0 D&D Phase 10 SUB3a-ii: Battle Master L10/L15
L10 Improved Combat Superiority — superiority dice grow d8 → d10. The
two scalar maneuvers (Precision Attack +4→+5; Rally heal +4→+5) bump in
their Apply hooks; Trip Attack has no scalar to scale.

L15 Relentless — 5e regenerates one die when initiative is rolled with
an empty pool. Proxied as +1 max pool size (4→5), which gives the same
"always at least one die per encounter" guarantee across the long-rest
cycle without introducing per-fight regen state.

subclassResourceMax now takes level; initSubclassResources upserts the
max (and bumps current by the delta) so existing characters grow on
level-up. Hooked into the dnd_xp level-up loop so Battle Masters
crossing L15 see the new die without waiting for a long rest. 4 new
tests; no regression in subclass/resource/level-up paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
a733d52166 Adv 2.0 D&D Phase 10 SUB2c: Cleric subclasses (Life/War/Trickery)
Channel Divinity resource pool (2/long-rest) shared across all three
Cleric domains. One armed expression per fight, mirroring Battle Master.

Life Domain
  - L5 Disciple of Life: heal spells restore +(2 + slot level) extra HP
    (lifeDomainHealBonus, hooked into resolveHealOutOfCombat).
  - L5 Preserve Life: Channel Divinity heal — sets HealItem to 5×Cleric
    level, capped at HPMax/2; fires when player drops below 50%.
  - L7 Blessed Healer skipped — no allies in 1v1 combat to "heal an ally".

War Domain
  - L5 War Priest passive: +1 attack bonus + 0.15 DamageBonus, proxy for
    bonus-action extra-attack throughput one-shot combat can't model
    discretely.
  - L5 Guided Strike: Channel Divinity → +10 to first attack roll
    (FirstAttackBonus).
  - L7 War God's Blessing skipped — ally reaction, no allies.

Trickery Domain
  - L5 Invoke Duplicity: Channel Divinity → AssassinateAdvantage (reroll
    first miss) + 0.10 DamageBonus, proxy for "advantage on attacks vs.
    creatures adjacent to the duplicate".
  - L7 Cloak of Shadows passive: +2 SporeCloud rounds (15% enemy miss)
    proxy for the brief invisibility window.
  - L5 Blessing of the Trickster skipped — non-combat Stealth flavor.

15 new tests, all green. Same pre-existing
TestSimulateCombat_FirstAttackBonusImprovesEarlyHits flake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
c8fac7ffd9 Adv 2.0 D&D Phase 10 SUB2a-ii: Battle Master + Assassin L5/L7
Battle Master:
  - New "superiority" resource pool (4/long-rest at L5), provisioned via
    initSubclassResources at !subclass selection.
  - Three armed maneuvers fueled by superiority dice:
    * Precision Attack — +d8 (≈+4) to first attack roll
    * Tripping Attack  — enemy skips first attack (reuses Phase 9
      SpellEnemySkipFirst)
    * Rally            — +(d8 + CHA) HealItem at <50% HP
  - L7 Know Your Enemy proxied as +1 AttackBonus passive.

Assassin:
  - L5 Assassinate: AssassinateAdvantage (re-roll first miss, take better
    of two d20s) + AssassinateBonusDmg = level (5 at L5) stacked on top
    of the Rogue's existing Sneak Attack auto-crit.
  - L7 Impostor bumps the bonus damage by +3.
  - L5 Infiltration Expertise → +5 Deception; L7 Impostor → +10.

Engine:
  - CombatModifiers gains FirstAttackBonus, AssassinateAdvantage,
    AssassinateBonusDmg.
  - resolvePlayerAttack consumes each on the first attack only via new
    combatState one-shot flags.

Tests added: 14 covering passive gating (BM L7+, Assassin L5/L7),
maneuver Apply flags, superiority pool init for BM only, !arm gating
across class/subclass for precision_attack, Deception bonus tiers,
statistical lift from FirstAttackBonus and AssassinateBonusDmg in
SimulateCombat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
437460c9b2 Adv 2.0 D&D Phase 10 SUB2a-i: Champion + Berserker + Thief L5/L7
First mechanical slice of the subclass system. Each subclass gets its L5
and L7 abilities wired through the existing combat engine + skill check
substrate; no new resource pools introduced.

Champion (L5/L7):
- Improved Critical: new Mods.CritThreshold lowers crit floor to nat 19+
  in resolvePlayerAttack. Default 20 preserved for non-Champions.
- Remarkable Athlete: +½ proficiency bonus to STR/DEX/CON skill checks
  via subclassSkillBonus, layered on raceSkillBonus.

Berserker (L5/L7):
- Rage: new !arm-able active ability gated to Berserker subclass via
  DnDAbility.Subclass field. Reuses the Fighter stamina pool (3/long-rest
  matches 5e's rage uses). On fire: +2 flat damage per hit, halve incoming
  weapon damage, +50% damage approximation for Frenzy's bonus-attack-per-
  turn (one-shot combat can't model that literally).
- Frenzy exhaustion: new exhaustion column on dnd_character; incremented
  by persistDnDPostCombatSubclass after any rage'd combat. Decremented by
  one per long rest.
- Mindless Rage: documented; charm/frighten conditions don't exist in the
  combat engine yet, so no functional effect until P11 zone bosses land.

Thief (L5/L7):
- Sleight of Hand added to dndSkillTable.
- Fast Hands: +5 to Sleight of Hand checks via subclassSkillBonus.
- Supreme Sneak: advantage on Stealth (best of two d20s) via new
  subclassSkillAdvantage hook in performSkillCheck. 5e's half-speed gate
  dropped — we don't track movement speed.

Plumbing:
- applySubclassPassives layered after applyClassPassives in both arena
  and dungeon combat bridges.
- DnDAbility.Subclass enables per-subclass gating; characterActiveAbilities
  filters !arm and !abilities lists by both class and subclass.
- Existing !respec full-wipe also clears Exhaustion.

Tests: Champion CritThreshold gating across L4/L5/no-subclass; rage Apply
flag-set; rage probabilistic win-rate lift vs tougher enemy; exhaustion
increment only on raged combats; long-rest decrement; Champion bonus on
STR/DEX/CON only and only at L7+; Thief Fast Hands SoH-only; Thief
Supreme Sneak L7+ Stealth-only; statistical advantage roll lifts Thief
stealth average by ≥2 vs plain Rogue; !arm rage subclass gating; schema
roundtrip; characterActiveAbilities visibility.
2026-05-09 14:25:21 -07:00
prosolis
9e1a1f606c Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2
Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00