33 Commits

Author SHA1 Message Date
prosolis
055f07d3c0 Combat engine §2(a): the enemy charges for what a seat brings
Both scaling levers counted seats. partyEnemyHPScale gave +15% boss HP for any
roster >= 2, and partyActionExpectation lifted the enemy from 1 to 2.4 attacks a
round. A seat COUNT charges the same for an under-levelled friend, a hired NPC,
and a true peer — so a below-median body cost a full seat's worth of boss and did
not give a full seat's worth back.

Measured, once the companion's free full-heal was taken away and he became honest:
hiring him was WORSE than going alone (66.1% against solo's 69.0%). That is this
bug, and it has been live for every under-levelled friend anyone has ever invited.

Seats now carry a SeatWeight, and both levers scale on the summed weight of the
LIVING seats rather than on a head count. The weight is level-based, priced against
the leader, times a discount for a hireling (no subclass, no magic items, gear that
is never Masterwork — the layers a player accrues and a hireling never will).

Level, and deliberately not a power score: an HP-x-damage proxy would rank a cleric
below a fighter and quietly make every mixed HUMAN party easier, which is a
difficulty regression smuggled in under a bug fix.

The safety argument is one property: **a peer weighs exactly 1.0**. So the curves
interpolate between the integer knots the P8 sweep tuned — (1, 1.0), (2, 2.4),
2n-1 from 3 up — and every integer input returns exactly what it always returned.
Solo is byte-identical, a party of same-level humans is byte-identical, both
goldens hold unmoved, and only an UNEQUAL roster lands between the knots. That is
the entire point of the change.

It also finishes §2(b): a seat that is down now buys the enemy nothing. §2(b) fixed
the head-count half; a corpse still carried its full weight until this.

Measured, 640 runs/arm, same grid:

  solo                    69.0%   (unchanged — corpus intact)
  + Pete                  76.8%   (+7.8pp)
  + a human cleric peer   77.6%   (+8.6pp)

  band                 solo    +Pete     lift
  trailing (<40%)     10.0%    31.0%   +21.0pp
  middle              58.9%    76.8%   +17.9pp
  leading (>=70%)     93.5%    99.2%    +5.7pp

Help, never a carry: he rescues the players who were drowning and barely moves the
ones who were already fine — and he stays below a real human of the leader's level,
which is the invariant a hireling must never break.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 15:20:49 -07:00
prosolis
d538f91cf7 Combat engine: pay off the N-body debt, and hire Pete
N3 widened the combat *roster* from 1 to N but never widened the action model,
the scaling model, or the test net to match. Building the hireable companion
walked into all three. Only one of the four defects found was the companion's;
the rest have been live in prod for every human party since N3.

The party golden did not exist (§5)
  Solo combat is pinned exhaustively (7468 lines); the entire N-body layer had
  nothing. That is why a healer class that cannot heal shipped without a test
  going red. Adds party_characterization.golden (9 scenarios x 5 seeds, incl.
  weak and dying seats) and TestPartyCharacterization_OneSeatIsStillSolo, so the
  N-body path can never quietly stop being a superset of the balance corpus.
  Regenerate only on purpose: -update-party.

No action could target another seat (§1)
  Every heal in the engine was self-scoped. A party cleric could not put one hit
  point on a friend. Adds turnActionEffect.AllyHeal/AllySeat, `!cast <spell>
  @user` and `--target @user` -- the latter has been advertised in !help and
  silently swallowed by parseCombatCast since SP2 ("reserved for SP3"). The auto
  picker uses it too (simPickAllyHeal), so away-players and engine-driven healers
  behave like competent ones. It will not raise the dead.

Corpses kept buffing the boss (§2b)
  enemyActionsThisRound counted len(st.actors), dead included -- so a party that
  lost a member kept paying for them, and the survivor faced a boss still swinging
  at two-player cadence, alone. A death spiral with the arrow pointing the wrong
  way. Now counts livingActors(). Party golden moved deliberately for this.

An engine-driven seat was a bool any command could clear (§3)
  autoDriveCombat drives a party by dispatching each seat's turn AS that seat, so
  a companion's own auto-played move arrived at beginCombatTurn looking like a
  player returning to the keyboard and cleared the latch that was moving him. He
  then stood in the fight doing nothing while the boss he had inflated killed
  everyone. ActorStatuses.EngineDriven is now a persisted seat property that no
  command clears, and the driver calls driveEngineSeat instead of impersonating.

"The party" could be empty (§4)
  A solo expedition has no expedition_party rows, so asking the roster who was in
  the party answered "nobody" -- and every caller fell back to something plausible.
  That is how the companion got hired at level 1 for exactly the player the feature
  exists for. expeditionParty()/partyHumans() always include the owner.

The companion himself (!expedition hire [class] / !expedition dismiss)
  Day 1, leader only, costs coins, role-fills the gap, globally exclusive. He is an
  NPC seat and must never become a player: no player_meta, no dnd_character, no
  inventory, no DM room -- mint him a player_meta row and
  ensureDnDCharacterForCombat will auto-build the news bot a real character, and
  he starts appearing in the graveyard and filing death notices about himself.
  Mail and seats are different sets: he fights, he does not get written to.

Measured on millenia, n=750/arm. Before these fixes he was -28pp -- worse than no
companion at all. After: solo 48.5% -> 63.9% clear (+15.3pp), with +28.0pp for
trailing players and +2.0pp for leaders. Help, never a carry.

The solo golden is byte-identical throughout: solo combat provably did not move,
and the balance corpus is intact.

Known gap: the companion cannot cast (castActionForSeat loads a sheet from the DB
and he has none by design), so a hired Cleric is still just a bad fighter.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 12:39:01 -07:00
prosolis
c34e740008 Review follow-up M: Grim Harvest fires in turn-based combat
The doc's item M claimed all three mage subclass spell hooks were dead on the
turn path because applyMageSubclassSpellHooks had one caller. It has three.
resolveTurnSpell has called it since 5cd343a, so Empowered Evocation and
Overchannel always worked -- they only move mods.SpellPreDamage, which
resolveTurnSpell returns as EnemyDamage.

Grim Harvest was the real defect, with a narrower cause: the hook wrote
mods.GrimHarvestSlot into a local CombatModifiers that resolveTurnSpell
discarded, because turnSpellOutcome had no field to carry it out. A Necromancy
Mage who killed with a spell in a manual fight never healed.

The stash can't ride on fight-start mods the way auto-resolve's does -- the
spell is cast mid-fight and the turn engine rebuilds combatants every round --
so it rides on the casting seat's ActorStatuses, like ArmedAbility. Each
damaging cast overwrites it; snapshotActor carries it across commit().

grimHarvestHeal also scanned for the *first* spell_cast event to ask whether
the spell landed the killing blow. Auto-resolve casts once, pre-combat, so
first == last there. A turn-based mage casts every round, so a non-lethal
opening cantrip vetoed the heal the killing spell had earned. Now scans for the
last spell_cast -- provably identical on the auto-resolve path, so the golden
corpus does not move.

Balance: a caster buff on the manual surface only, and the one the subclass was
written to have. Auto-resolve already paid it out.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:25:56 -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
08d3053368 N3/P6e: a party that fights every room
Only elite and boss doorways seated a roster. Everything else -- exploration
rooms, patrol encounters, harvest interrupts -- resolved through SimulateCombat
against ctx.Sender, and P6d made the walk commands leader-only. So on a 38-room
T5 expedition a party of three fought together twice and the leader soloed the
other ~35, then died alone while two untouched members stood at full HP.

The plan said the N-body core was already there and only the callers passed one
player. It wasn't: SimulateCombat built a one-seat roster internally. But the
resolution primitives already read st.c -- the cursor's Combatant -- because the
turn engine has called them that way since P3. Only the round loop needed
widening.

combat_engine_party.go carries it: simulateParty, simulatePartyRound,
roundInitiative, enemyTargetSeat. Every roster short-circuit collapses for one
seat, copying P3's solo exemptions, so the RNG draw order is unchanged and
SimulateCombat is now simulateParty([]Combatant{p}, ...).Seats[0].
TestCombatCharacterization is byte-identical; TestSimulateCombat_IsTheOneSeatPartyCase
pins the delegation event-for-event across 40 seeds.

zone_combat_party.go carries the callers' half: runZoneCombatRoster fans out the
character-scoped close-out (HP, XP, achievements, subclass, heal items burned,
Misty's repair) per seat, while loot, threat, kill records and death stay with
whoever knows the room. runZoneCombat remains the explicit solo entry point --
the arena calls it, and an arena bout must never drag in a party.

Death is read per seat off HP, never off the fight's terminal status: a timed-out
party can still have lost somebody, and a solo player at 0 HP has already ended
the fight, so PlayerEndHP <= 0 is exactly the old !TimedOut rule.

Preserved deliberately: a solo player can win at 0 HP (a retaliate aura kills the
swinger on the killing blow, and resolvePlayerAttack returns before enemyDown is
consumed) and is not marked dead. A party marks its downed seats dead on a win,
which is what finishPartyWin always did.

Solo T5 re-sweep is unregressed (fighter 47-73%, cleric 20-33%). Party of 3 now
clears 100% of every T5 cell, which is P8's problem: the enemy takes one turn per
round and swings at one seat, so a party of N deals xN damage and each member
takes ~1/N^2 of the solo incoming. An HP scalar cannot close that -- it restores
the fight's duration, not the enemy's action economy.
2026-07-10 06:32:29 -07:00
prosolis
e8d06195ac N3/P5: a fight that knows whose turn it is
A solo fight is a conversation: the player types, the engine answers, and
nothing happens in between. A party fight is a queue, and three things follow.

Turn ownership. Only the seat on the clock may act, so beginCombatTurn resolves
the sender's seat and refuses the rest before anyone spends a slot or burns an
item. A fight lock, because three members typing !attack at once took three
different user locks and the check would have passed for all three: it takes the
fight's lock (keyed on seat 0) then the member's own, always in that order, and
a solo fight -- whose owner is the sender, and sync.Mutex is not reentrant --
takes exactly the one lock it always took. And a turn deadline, because one
member who wanders off must not freeze the other two for the hour it takes the
session reaper to wake up.

The deadline is three minutes, not the plan's sixty seconds. The sweep rides the
existing one-minute ticker, so any deadline really fires in [d, d+1m); and
expeditions here run for days, so the asymmetry favours patience over robbing
someone of their boss turn while they read the room on their phone. A lapse
latches that seat onto the auto-picker for the rest of the fight, so an absent
member costs the party one wait rather than one per round. Typing anything hands
the wheel back. Solo is never swept.

Three seat-0 leaks fixed on the way past, all of which would have surfaced as
the leader quietly doing everyone's business:

  - mid-fight buffs folded into the session's embedded ActorStatuses, so a
    member casting Shield on themselves would have armoured the leader;
  - pickAutoCombatAction read sess.PlayerHP and Statuses.ConcentrationDmg, so
    playing an away member's turn would have healed the wrong person and
    re-armed the wrong aura;
  - runCombatRound rested on any player_turn, and a downed seat still holds one
    -- the round would have come to rest on a corpse and waited for a dead
    member to type !attack. settleCombatSession drains it. beginCombatTurn
    settles before reading the clock, which also fixes a latent solo bug: a
    fight interrupted mid enemy_turn resumed parked there and silently ate the
    player's next !attack.

The narration turned out to be written in the second person -- "You score 9
damage", "A hit gets through your guard" -- so swapping a name per seat would
have told three people they each landed the same blow. A round is rendered once
per reader instead: your own events go through the untouched flavor pool, your
allies' through a terse third-person summary. CombatEvent carries the seat to
make that possible, stamped once per phase step rather than at the twenty-odd
append sites in the primitives, which emit against the cursor and know nothing
of seats.

Closing out fans along the seam the data model already cut. Threat, the
zone-kill record, the boss-defeat drop and the run teardown all resolve through
getActiveExpedition or getActiveZoneRun, and a member owns neither row -- so
they fire once, for the owner. Fanning them out would have tripled the threat a
single kill costs. HP, XP, loot and death are the character's, and every seat
gets their own. A member can be dead in a fight the party won, so death is read
per seat off HP, not off the session's status.

The reaper stays attack-only. Finishing an abandoned fight should not quietly
burn the player's spell slots and potions; the deadline latch does use the
picker, because that member is mid-fight with a party waiting on them.

startPartyCombatSession has no production caller yet -- handleFightCmd still
opens a solo session. P6 seats the party.

TestCombatCharacterization is byte-identical: solo balance did not move.
2026-07-09 22:07:20 -07:00
prosolis
ec614e84f1 N3/P3: initiative, and a turn engine that seats a party
The turn engine ran a fixed player -> enemy -> round_end phase machine over
one player and one monster. It now runs a round as a sequence of seats.

turnOrder derives that sequence per round. A solo roster short-circuits to
the historical [player, enemy] and rolls nothing -- the duel has never had
initiative, and handing the monster a coin flip on who swings first would be
a live balance change. A party rolls it with the auto-resolve engine's own
formula (speed + d10 + InitiativeBias).

Every seat in a round shares a (round, phase) pair, so the acting seat is
mixed into the RNG *seed* rather than the stream. Seat 0 and the enemy
sentinel mix to nothing, which is what keeps a solo fight drawing exactly
the pre-roster stream across a suspend/resume.

The round cursor persists as Statuses.TurnIdx, omitempty so no solo row
carries it. A fight that was in flight when the field landed decodes it as
0; turnIdxForPhase reconciles that against Phase, which is the older and
load-bearing field. Without it, a suspended enemy_turn would resume, step,
and land back on enemy_turn forever.

The enemy now picks a target uniformly among the standing roster (solo draws
nothing), a downed seat forfeits its turn silently, and the fight is lost
only when anyAlive() goes false -- not when the acting seat drops. That last
one fixes a latent solo bug on the way past: resolvePlayerSwings returns
false when a retaliate aura kills the swinger between extra attacks, and the
old code walked that corpse into the enemy's turn.

commit() reads seat 0 explicitly instead of the cursor, which the enemy turn
parks on its target and round_end walks across the roster.

TestCombatCharacterization is byte-identical: solo balance did not move.
2026-07-09 20:43:37 -07:00
prosolis
41f98b721a N3/P2: give the combat engine an N-player roster
Splits combatState into a fight-scoped half and a per-character half.
Everything that belongs to one PC -- HP, ward/spore/reflect charges,
heal charges, poison ticks, the death save, Lucky/Rage, the
first-attack one-shots, the arcane ward, concentration, and the
debuffs an enemy stacks onto a specific character -- moves to a new
`actor`. What belongs to the fight stays: the enemy pool, the enemy's
stance (evade/block/advantage/retaliate/regen/survive), the round
counter, the event log, and the RNG stream.

combatState embeds *actor, so the promoted fields keep their names and
all ~230 existing reads (st.playerHP, st.wardCharges, ...) compile
untouched. The embedded pointer is a cursor: seat(i) points it at a
roster member. Solo seats one actor and never moves the cursor, so the
draw order off the single RNG stream is unchanged.

That is the whole point. TestCombatCharacterization -- 57 scenarios x
5 seeds, 7468 pinned golden lines -- is byte-identical before and
after. Solo combat provably did not move, so the d8prereq balance
corpus survives the parties work and only party bands need new
baselines in P7.

Hold-person is fight-scoped (holding the enemy holds it for everyone)
while stat_drain/debuff/max_hp_drain are per-character, which is why
they landed on opposite sides of the split.

No multi-actor *semantics* here: nothing yet decides who the enemy
swings at or how initiative interleaves N players. That is P3. This
commit only lands the data model, and the roster tests cover what the
solo golden structurally cannot see -- cursor isolation, shared-state
visibility across seats, and the pointer embed (a value embed would
silently copy on seat() and fail the round-trip assertion).
2026-07-09 20:29:05 -07:00
prosolis
cbfca525f5 Lift caster trailers: concentration re-tick + Josie caster-aid bootstraps
Diagnosed a cleric "death loop" (L14 dying at T2/T3 bosses while
over-levelled): the boss isn't overtuned — caster sustained DPS is
under-delivered, compounded by a fragile healer build.

Engine fix — concentration AOE re-tick:
- Concentration damage spells (spirit_guardians, heat_metal, spike_growth,
  call_lightning, flaming_sphere) now tick the enemy every round at
  round_end instead of resolving as a one-shot, via a new
  CombatStatuses.ConcentrationDmg armed on cast and round-tripped through
  the turn engine. Closes the long-tracked turn-engine concentration gap;
  the burst still lands the casting round, then the aura lingers.
- Sim picker skips re-casting an already-active aura (models competent play
  and prevents a burst+aura double-dip). Re-baseline (n=30 sweep + n=100
  confirm): bard +47pp T3 (heat_metal), druid +3-7, cleric/mage flat,
  fighter unchanged — no regressions.

Player-data bootstraps (idempotent, run once on Init):
- bootstrapCasterSpellBackfill: ensureSpellsForCharacter only seeds an empty
  book, so defaults added after a character's roll never reach it. Backfill
  missing defaults into known+prepared for existing casters (gives the
  affected cleric inflict_wounds + a working healing_word, since her
  healing_word_spell is a dead alias).
- bootstrapGrantStarterPet: one-off L10 pet for an endgame player who never
  got the morning arrival roll; adds per-round proc damage + deflect.
- TestScenario_JosieCasterAid verifies both against a copy of the live DB,
  incl. idempotency.

Also fix a pre-existing wall-clock flake in
TestFireBriefings_EventAnchoredActivePlayerDelivers (start_date defaulted to
real now, filtering the row out when the suite runs after 06:00 UTC).
2026-06-18 06:34:16 -07:00
prosolis
2e6274c1b7 Spiritual Weapon: own channel + narration; rest blocked mid-zone
Spiritual Weapon used to ride the pet-attack channel, so a petless
cleric saw "🐾 Your faithful companion" each round and couldn't tell
the spell was firing. Split it to SpiritWeaponProc/Dmg with its own
 flavor; damage now scales with spell mod + upcast.

Rest also fired mid-dungeon — only the autorun honored RestingUntil,
the !rest commands themselves had no gate. Block both short and long
rest when an expedition or combat session is active.
2026-05-23 10:14:51 -07:00
prosolis
20b0d027b2 Pets: per-round attack + wire deflect/whiff into the turn engine
The live turn engine only struck once per fight and never rolled pet
deflect or whiff, so pet armor (deflect-only) bought nothing in real
runs. Roll pet attack each player turn and roll deflect/whiff per enemy
turn, mirroring the auto-resolve engine; retire the one-shot pet-proc
machinery (rollCombatSessionPetProc / PetProcReady).

(cherry picked from commit a0e41c97801e500efad13c7e9a06be4c345e464e)
2026-05-21 23:56:18 -07:00
prosolis
98ba416359 Class identity audit close-out: Fighter/Ranger/Paladin/Druid + Bard-Valor
Phase 2 Monte Carlo tuning had papered over four classes whose 5e-defining
mechanics never actually existed in the engine — only flat DamageBonus or
FlatDmgStart compensation riders that hit the right aggregate win rate.
This pass replaces the proxies with the real primitives.

New CombatModifiers fields: ExtraAttacks (additional swings/round, looped
in a new resolvePlayerSwings helper); HuntersMarkDie (per-hit Nd6 bonus,
same path as Sneak Attack); ThornLashDmg (flat counter on landed enemy
hits, fires in resolveEnemyAttack after ward/block).

Fighter L5/11/20 Extra Attack, Ranger L5 Extra Attack + Hunter's Mark
(1→4 d6 by level), Paladin L5 Extra Attack + per-hit Divine Smite
(replaces one-shot opener), Druid thorn lash. Bard College of Valor L7
"Extra Attack" subclass tier converted from +1 attack/+10% damage proxy
to the real primitive.

Post-fix T5 class-balance: martials cluster at top (Fighter/Rogue 0.90,
Ranger 0.89, Paladin 0.88), casters keep their relative ordering, spread
14pp top-to-bottom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:53:00 -07:00
prosolis
41adfce9f1 Expedition autopilot Phase 4 + rogue sneak attack + TwinBee voice sweep
Tedium-removal pass driven by live play feedback. Three big threads:

Expedition autopilot Phase 4 — background auto-run + harvest-until-dry:
- New expeditionAutoRunTicker walks active expeditions every 15min
  (5min tick, per-expedition CAS on new last_autorun_at column). Skips
  combat sessions, briefing/recap quiet windows, expeditions <30min old.
- Walks up to 3 rooms/tick with compact narration; suppresses DMs when
  0 rooms walked or just hitting the per-tick cap (no "stretch complete"
  filler). Player only hears from the bot when a real decision is needed.
- Compact mode: one-line combat narration for trash/elite, auto-resolves
  elite doorways via the forward-sim engine (boss still pauses). Threaded
  via new advanceOnceWithOpts → resolveRoom → resolveCombatRoom.
- Auto-harvest now grinds each Common/Uncommon node until dry (cap at 8
  attempts/visit), mirroring manual !scavenge retries. Rare+ still pauses.
- Ambient ticker: anti-repeat by Kind via new last_ambient_kind column
  (avoids two pack_rat DMs in a row when the pool only has 6 lines).
- !expedition go <n> now routes to the fork-choice handler when active +
  numeric; fork footer rewritten to suggest !expedition go instead of
  !zone go. Boss/Elite doorway: formatNextRoomMessage routes the action
  hint by next room type and autopilot loop breaks via new nextRoomType
  field so "Room X/Y — Boss" doesn't double-print with contradictory
  hints (!zone advance vs !fight).
- runAutopilotWalk extracted from expeditionCmdRun so foreground and
  background share the loop body.

Rogue Sneak Attack actually exists now:
- Audit found the rogue's "Sneak Attack" passive was AutoCritFirst +
  5% damage rider. No Nd6, ever. Phase 2 Monte Carlo masked this
  with a small flat buff; the class's defining mechanic never matched
  its tooltip or 5e identity.
- Added SneakAttackDie int to CombatModifiers, per-hit Nd6 in
  combat_primitives.go (same lane as DivineStrikePerHit). Scales with
  level: 1d6 at L1-2 ... 4d6 at L7-8 ... capped at 10d6 at L19-20.
- AutoCritFirst + 5% rider retained as bonuses on top of working sneak
  attack — preserves the opener-burst feel.
- L7 rogue expected per-hit ~8 → ~22 damage. Valdris fight math goes
  from "16 rounds to kill, die in 8" to winnable.

TwinBee voice sweep (Phase B3):
- ~530 line changes across 24 files replacing third-person "TwinBee
  notes/files/tracks/respects/..." constructions with first-person
  inside flavor string literals. Code identifiers (TwinBeeLine,
  twinBeeLine, etc.), chat-prefix labels (🎭 **TwinBee:**), item names
  (TwinBee's Bell), achievements, and game-title references in
  fun.go (Konami TwinBee ship) left intact.
- Catches a real bug: Valdris fight rendered "TwinBee marks the
  Legendary Resistance" mid-combat in third person, contradicting
  the Phase B2 convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:40:17 -07:00
prosolis
632e5ee315 Combat: fix Orc Rage missing fire when enemy two-shots across rounds
The rage threshold check sat only at the top of resolvePlayerAttack,
so a player who took a threshold-crossing hit and was then killed by
the very next enemy swing (before getting back to their own attack)
would never see "rage" emitted — even though HP visibly crossed 50%
while alive.

Extracted maybeTriggerOrcRage shared by both sites:
- Top of resolvePlayerAttack (unchanged UX: rage applies same-round
  when the player swings after the enemy hit).
- End of runRound as a backstop (catches cross-round two-shots).

st.raged guards against double-emit. Fixes the flaky
TestOrcRageFiresOnLowHP; 10/10 repeats green.
2026-05-14 23:17:12 -07:00
prosolis
6386161402 UX S6: race truth-up — wire Tiefling fire resist + best-fit hints
R22: replace race copy that promised mechanics the engine doesn't deliver.
- Tiefling: wire FireResist as a CombatModifier. Enemy main attack is
  halved when monster is FireAttacker-tagged; aoe_fire abilities are
  halved unconditionally; fire-tagged traps deal half damage to Tieflings.
  DnDMonsterTemplate carries FireAttacker; toCombatStats propagates it.
  Hand-authored fire entries tagged in dnd_bestiary.go (flameskull,
  magmin, azer, salamander, fire_elemental, emberlord_thyrak,
  young_red_dragon, infernax, belaxath).
- Open5e tuned generator derives FireAttacker from the highest-AvgDamage
  attack's DamageType (threshold AvgDamage>=5). 19 tuned monsters tag.
  Regenerated bestiary_tuned_data.go.
- Elf: drop "immune to sleep" (no sleep mechanic); reframe as keen
  senses + trance flavor.
- Half-Elf: drop "two bonus skill proficiencies" (no skill system);
  reframe as adaptable cross-cultural know-how.
- Tiefling copy: drop "bonus on CHA checks" (no checks); keep fire
  resistance with flavor framing.

R23: DnDRaceInfo grows a BestFit field; renderRaceMenu emits an
"_best with: …_" hint per race so spiky stat spreads (Orc -1/-1/-1)
read as specialist picks rather than a brick of penalties.

R24: dnd.go header comment for the caster classes now reflects the
shipped state (Playable=true, spell lists populated) instead of the
pre-Open5e scaffold language.

Tests: TestApplyRacePassives gains a FireResist column; new
TestTieflingFireResistance asserts ~0.5x ratio over a 300-trial sweep
against a FireAttacker enemy. Full suite green.
2026-05-14 22:06:31 -07:00
prosolis
0cd8fd3337 Combat: back the flavor-only monster abilities with real effects
Turn the four placeholder ability effects into working mechanics:
spell_resist halves player spell damage, reveal_action rolls the
player's next swing at disadvantage, fear_immune fizzles control
spells, and ally_buff grants an accumulating enemy attack bonus.
All four are armed by applyAbility, read by the shared resolution
primitives, and round-tripped through CombatStatuses for turn-based
suspend/resume. New branches are guarded by zero-valued state so the
auto-resolve characterization golden is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:59:51 -07:00
prosolis
e629f8fd4d Combat: implement stateful monster ability effects
Slice 3 of the bestiary SRD upgrade: the monster abilities that need
per-fight state (evade, block, advantage, retaliate, regenerate,
survive_at_1, stat_drain, debuff, max_hp_drain). applyAbility arms
combatState flags that the shared resolution primitives read, so both
the auto-resolve and turn-based engines honor them; the turn-based
engine round-trips them through CombatStatuses so a suspended fight
resumes from exact mid-state. New branches are guarded by zero-valued
state so the auto-resolve characterization golden is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:32:13 -07:00
prosolis
f1aa9981f8 Combat: implement immediate-resolution monster ability effects
Wires up the ability effects that resolve fully within applyAbility with
no new persistent state: damage riders (bonus_damage, aoe/aoe_fire/
death_aoe, execute) via the shared calcDamage formula, self_heal, and
flavor-only placeholders for effects still pending per-fight state.
Works in both auto-resolve and the turn engine since both call
applyAbility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:17:11 -07:00
prosolis
c5a2634657 Combat: wire pet procs into the turn-based engine
Pet attacks were never resolved in turn-based fights. Roll the proc once
at fight start (a per-round roll would make a proc near-certain over a
long manual fight), persist it on the session so suspend/resume and
reaper auto-play honor the same outcome, and land a single pet hit on
the player's first acting turn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:08:26 -07:00
prosolis
1e1fbf9056 Combat: extract shared attack-resolution primitives
Pull effectiveAttackBonus, attackCritFloor, attackConnects, and the
post-hit player damage stack out of combat_engine.go into
combat_primitives.go so the upcoming turn-based engine resolves an
attack identically to auto-resolve. Behavior-preserving — the combat
characterization golden file is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:05:33 -07:00
prosolis
6141964c58 Combat: characterization test pinning the auto-resolve event stream
Splits SimulateCombat into a thin wrapper over simulateCombatWithRNG, a
deterministic core that accepts an optional *rand.Rand. Production passes
nil (package-global rand, behaviorally identical); the new test seeds it
per scenario.

Adds TestCombatCharacterization: 23 curated scenarios x 5 seeds, with the
full event stream + result summary serialized to a stable text form and
diffed against a golden file. This locks current auto-resolve behavior
before the shared-primitives extraction for the turn-based engine — any
perturbation fails loudly and forces a deliberate -update + diff review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:00:35 -07:00
prosolis
4af110cc20 Combat: inject per-session RNG into the engine
Adds an optional *rand.Rand to combatState so a fight can be driven by a
deterministic source. Auto-resolve leaves it nil and falls through to the
package global in the same call order — behaviorally identical. Threads
the source through calcDamage and rollWeaponDamage.

Groundwork for the turn-based elite/boss engine and its timeout reaper,
which seed the rng per combat_session to make fights resumable and
replayable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:53:06 -07:00
prosolis
4e412219f3 WIP: in-flight combat/expedition/flavor changes
Bundle of uncommitted working-tree edits across combat engine, expedition
cycle, flavor pools, and TwinBee/zone narration. Includes new files:
combat_debug.go, dnd_boss_consumables.go, dnd_dex_floor.go, plus
CHANGES_24H.md and REBALANCE_NOTES.md scratch notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:59:19 -07:00
prosolis
c5217e9ecf Combat: fix wounded-carry HP display + auto-crit narration
Two bugs reported back-to-back from a Rogue's run:

1. HP display reset between battles. End of fight 1: 101/123 → start of
   fight 2: 100/100. applyDnDHPScaling was overwriting Stats.MaxHP with
   the scaled wound value, so the display denominator dropped along with
   the numerator. Wounded carry-over became invisible.

   Fix: introduce CombatStats.StartHP. Combat engine reads it as the
   entry-HP when set; MaxHP stays put. Display now reads "100/123" as
   intended.

2. Auto-crit fired on a roll of 11 with no narrative tell. Rogue passive
   AutoCritFirst was triggering correctly, but the renderer used the
   generic crit pool, so the player saw "🎲 11 vs AC 10 → CRIT" with no
   indication their *class* caused it.

   Fix: tag the crit event with Desc="auto_crit" when the passive (not
   the dice) caused it; new narrativePlayerAutoCrit pool calls out the
   training/instinct/exploit theme.

Test bound for T5 dungeon death rate loosened from 0.02 to 0.01 — the
new Sudden Death phase from the previous commit shifted geared-T5
fights slightly toward player wins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:17:14 -07:00
prosolis
de02907e69 Combat: add Sudden Death phase + fix tiebreak to use absolute HP
Players reported the prior 6-round exhaustion timeout felt arbitrary
when both sides still had plenty of HP. Two fixes:

- Add a "Sudden Death" 4th phase (3-4 extra rounds) so most fights
  resolve naturally before any timeout. Total combat length now caps
  at ~10 rounds.
- When the timeout does fire, tiebreak by absolute HP instead of HP%.
  The %-based logic was unintuitive — a player at 88/123 (71%) would
  lose to a boss at 83/97 (86%) despite having more HP remaining.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:58:26 -07:00
prosolis
83654eadc7 Adv 2.0 D&D Phase 10 SUB3c: Cleric L10/L15
Life/War/Trickery Domain L10/L15 capstones.

L10 Divine Strike (all 3 subclasses): +4 flat per weapon hit, scaling
to +9 at L14. New CombatModifiers.DivineStrikePerHit channel, gated on
Weapon != nil since 5e specs "weapon hit". Damage type (radiant/weapon/
poison) varies by domain but isn't tracked by the engine.

Life L15 Supreme Healing: heal-spell dice resolve at max instead of
rolled. Wired through resolveHealOutOfCombat via lifeDomainSupremeHealing
helper.

War L15 Avatar of Battle: 5e physical resistance vs. non-magical
weapons → flat 0.80 DamageReduct (softer than full 50% to account for
elemental hits).

Trickery L15 Improved Duplicity: 5e duplicates grant ally-flank
advantage; no allies in 1v1, so proxied passively as +1 SporeCloud
round and +5% damage (duplicates flicker around the foe).

10 new tests; cleric-suite green. Pre-existing rng flakes
(TestOrcRageFiresOnLowHP, TestSimulateCombat_FirstAttackBonus...) are
unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
596e17dc73 Adv 2.0 D&D Phase 10 SUB2b: Mage subclasses (Evocation/Abjuration/Necromancy)
Evocation L7 Empowered Evocation adds INT mod (min +1) to Mage evocation
spell damage. Abjuration L5 Arcane Ward grants a 2×level HP buffer
absorbed before player HP, +prof at L7. Necromancy L5 Grim Harvest heals
2× spell level on spell-kill (3× necrotic) via post-combat hook.

Sculpt Spells / Potent Cantrip / Inured to Undeath skipped — no allies,
save-half is already uniform, and no necrotic enemy damage to resist.

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
01c70f5297 Adv 2.0 D&D Phase 9 SP3: wire pending spells into combat
applyPendingCast resolves c.PendingCast against the upcoming fight before
SimulateCombat. Damage spells (Fire Bolt, Burning Hands, Magic Missile,
Fireball, etc.) emit a pre-combat spell_cast event via new
CombatModifiers.SpellPreDamage{,Desc}. Control spells (Hold Person, Sleep,
Command) set SpellEnemySkipFirst so the engine skips the enemy's round-1
attack with a spell_held event; Hold-family also primes AutoCritFirst.
Buffs (Mage Armor, Bless, Hunter's Mark, Shield of Faith, Aid, Spiritual
Weapon, Mirror Image, Greater Invisibility) fold into stats/mods directly.

Concentration-on-damage break is left for Phase 11 (turn-based bosses);
ConcentrationSpell persists across fights until manually dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
prosolis
8e0fe0230c Death source tracking + combat threat/jitter fixes
- Adventure characters now record DeathSource ("adventure"|"arena") and
  DeathLocation when killed. Threaded through Kill() and transitionDeath.
  Daily report uses the recorded death location instead of misattributing
  arena deaths to the day's adventure log: when DeathSource != "adventure",
  the adventure block shows the alive icon and a separate "Later fell in
  {location}." line. Standout-loss flavor uses DeathLocation. Empty
  DeathSource (legacy rows) falls back to current behavior.
- calcDamage applies a ±15% per-hit jitter, so successive hits in a phase
  no longer produce identical numbers. Previously every hit in a phase was
  flat (e.g. 17, 17, 17, 17) and mirror-symmetric between player/enemy,
  reading as scripted.
- assessThreat rewritten to use the engine's actual penetration formula:
  damage-per-round each direction, then rounds-to-kill ratio. The old
  additive HP+Attack*3 model ignored Defense, so even fights could be
  rated trivial — players died after consumables were skipped with the
  "threat assessed as manageable" message.
- SelectConsumables never skips on trivial when arenaRound > 0. Arena
  losses cost real money and equipment, so the cost of a wrong assessment
  is too high to gamble on; adventure-side fights still skip trivial
  threats to avoid wasting items on chump enemies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00
prosolis
7c450aaefb Add forward-simulating combat engine with consumables, monster abilities, and narrative rendering
Replaces the single-roll probability system for dungeon and arena combat with
a multi-phase simulation engine where gear, buffs, pets, and NPCs are meaningful
mechanical inputs. Adds consumable items (auto-crafted from forage/mine/fish drops),
monster abilities for T2+ enemies, and Dragon Quest-style combat narrative with
phased message delivery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 01:05:22 -07:00