Commit Graph

16 Commits

Author SHA1 Message Date
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
d7d0230223 N3/P4: give every seat a row of its own
The turn engine seats a party since P3, but only seat 0 survived a
suspend: seats 1+ reopened from their Mods on every step, rearming their
once-per-fight one-shots -- a party Halfling rerolled a nat 1 every round.

Split CombatStatuses into the fight-scoped half (the enemy's stance, the
round cursor) and the per-character half, ActorStatuses. The embed is
anonymous and untagged, so statuses_json stays the same flat object it
always was and every in-flight prod row decodes unchanged.

Seat 0 keeps living on combat_session. Seats 1+ get combat_participant
rows. That asymmetry is the point: a solo fight -- which is every fight
that has ever run, and the whole balance corpus -- writes exactly the
bytes it wrote before, and no participant rows at all. roster_size
guards the read, so the solo loader never issues the second query.

Parties commit their seats in the same transaction as the session row;
solo keeps its single unwrapped UPDATE.

expedition_party is the co-op roster. No party_id on dnd_expedition:
expedition_id already identifies the party, and a second key would be a
second answer to "who is in this party". Absent means solo, in both new
tables, so neither needs a bootstrap backfill.

The combat characterization golden is byte-identical.

Also seeds and re-powers the two statistical subclass tests. They drew
from the package-global RNG, so their verdict depended on how much
randomness every test declared before them happened to consume -- which
is why they flaked on a clean tree. Sweeping 40 seeds: Precision Attack
had a mean margin of +127 wins against a +50 threshold but a worst case
of -42, and Assassinate averaged +12.8 with two seeds outright negative.
Both effects are real; the trial counts were too low to see them. Seeded,
and raised to 24000/6000 trials, where all 40 seeds clear.
2026-07-09 21:23:35 -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
ef8fbe5496 Phase-H review fixes: camp, fork streak, pet proc, BG transit, legacy streak
- Camp: campLocationCheck rejects only on live combat; no-encounter and
  post-kill rooms are campable (kills the misleading "clear it first").
- Fork: markActedToday moved after the pending-fork early-return so spamming
  !zone advance at a fork no longer keeps the daily streak alive.
- Pet whiff/deflect: single proc spent on the first multiattack swing only
  (was applied to every swing); also dedups the per-swing event spam.
- Autopilot: background region crossing now runs an HP/SU preflight and
  pauses if low, instead of burning a transit day while the player is idle.
- Legacy streak: acted-branch restamps LastActionDate=today so a purely-legacy
  actor's streak no longer resets to 1 every night.
2026-05-22 01:15:23 -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
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
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
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
96b291d831 Combat: SRD multiattack profiles + monster abilities in turn engine
Elites/bosses in turn-based fights now swing a full SRD multiattack
profile and fire their special ability — abilities never fired in the
turn-based path before, and every enemy made a single attack.

bestiary_srd.go adds SRDAttack/SRDProfile and a hand-authored registry
for the named bosses and multiattack elites; auto-resolve keeps its
tuned single-Attack blocks untouched. turnAbilityFires remaps the
auto-resolve phase clock onto fight progress for the phase-less duel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:37:29 -07:00
prosolis
befb44ef03 Combat: persist fight-scoped one-shots + turn-based buffs
CombatStatuses now mirrors every persistent combatState one-shot —
depleting resources (ward/spore/reflect/autocrit/arcane-ward/heal-
charges), once-per-fight class/race/subclass flags, and accumulated
buff stat deltas. resumeTurnEngine restores them; commit writes them
back in place. Fixes turn-based bugs where Orc rage, Halfling Lucky
reroll, and the Assassin first-attack bonus re-fired every round and
Abjuration Arcane Ward did nothing.

Buff spells and buff-type consumables (ward/atk/def/crit/spore/reflect/
auto-crit) are now usable mid-fight: a flattened-delta model diffs the
reused applySpellBuff/ApplyConsumableMods math against a throwaway
combatant, folds the marginal effect into the session, and re-applies
the persistent stat deltas onto the rebuilt player each round. Pure-
utility spells diff to nothing and are refused before a slot is spent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:12:37 -07:00
prosolis
5cd343af0c Combat: per-round !cast / !consume in turn-based fights
Turn-based Elite/Boss fights gain !cast and !consume as player-turn
actions, so casters and item-users make decisions per round instead of
pre-queuing a single effect. The command handler validates and resolves
the spell/item into a pre-rolled turnActionEffect; the engine just
applies the HP deltas and flows on into the enemy turn.

Scoped to effects that resolve within the casting round: damage, heal,
and control spells, plus heal/flat-damage consumables. Buff and utility
spells and buff-type consumables are refused without spending the
resource — they need cross-round stat persistence, a later sub-phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:49:14 -07:00
prosolis
269d13ffe8 Combat: add turn-based session accessors and round-loop state machine
combat_session.go: CombatSession persistence layer mirroring the
dnd_expedition pattern — CRUD accessors, one-active-per-user enforcement,
and the timeout reaper (sweeps stale sessions to 'expired').

combat_turn_engine.go: the player_turn -> enemy_turn -> round_end -> over
state machine. advanceCombatSession seeds a deterministic per-(round,phase)
RNG, resolves one phase via the shared attack primitives, commits, and
persists. The deferred poison/status tick lands in round_end now that the
round-loop shape exists.

CombatStatuses persists only between-round monster-ability effects; the
reaper marks sessions 'expired' rather than auto-playing them — both gaps
depend on Combatant reconstruction, which lands with the command-wiring PR.

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