Commit Graph

24 Commits

Author SHA1 Message Date
prosolis
aaa45eab14 N7/B3: the Omen — one rotating world modifier per ISO week
activeOmen() is a pure function of the UTC ISO (year, week): omenTable
indexed by (year*53+week)%len, so it advances weekly with no schema, no
ticker state, no persistence. Five non-combat seams read it — harvest yield,
supply freebie, expedition start mood, arena payout (scales gross earnings
before the pot tax), and ingredient drop chance. TwinBee reveals the active
omen in the existing morning DM (no net-new scheduled message).

Launch set is buffs-with-texture on non-combat levers only: Bountiful
Harvest, Quartermaster's Blessing, Golden Purse, Overflowing Satchels, Still
Waters. Nothing touches SimulateCombat or the turn engine — the omen is keyed
on the real clock, so a combat mutator would make the golden and the balance
corpus week-dependent. The plan's "elites +2 ATK" is deliberately dropped for
that reason.

The balance sim drives the real expedition loop and would otherwise traverse
all five seams, making corpus sweeps depend on the wall-clock week. NewSimRunner
sets simOmenDisabled (mirrors simAutoArmEnabled), so activeOmen returns a
no-effect omen under the sim. Still Waters subtracts from the daily threat
*rise* only, floored at hold-steady — it never forces active decay.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:09:30 -07:00
prosolis
32e3148755 N3/P7: a party that only fights together twice
Adds -party N / -party-classes to expedition-sim. Followers are seated
through the real !expedition invite / !expedition accept pair, so the
harness measures the tier gate, the busy guard and the supply pooling
rather than a roster hand-built to succeed. A follower who is refused
halts the run: a party reading taken from a walk that was secretly solo
is worse than no reading.

It immediately found what it was built to find. Only elite and boss
doorways seat the roster; exploration rooms, patrols and harvest
interrupts all resolve through SimulateCombat against ctx.Sender, and
P6d made the walk commands leader-only. So on a 38-room T5 expedition
the party fights 2-3 rooms together and the leader solos the rest -
then dies alone, tearing down the run rows only they own.

Measured at L15/16 over dragons_lair + abyss_portal, party of 3, n=15
per cell: zero TPKs and zero member deaths across 240 seats. Every
failure is the leader falling while two untouched members stand at full
HP. Fighter clears 100% (solo: 47-67%), cleric 33-53% (solo: 13-47%).
The band is unreadable until inline combat seats the party, so the C1
contingency - +35% monster HP per member - stays on the shelf; it would
punish the trash the leader already fights alone.

Hence the outcome vocabulary grows a third word. "tpk" used to mean any
run-ending event, which is how a leader dying beside a healthy party
stayed invisible through P5 and P6. Now: tpk (roster dead),
leader_down, fled (run over, leader alive) - read off the death flag,
not off HP, which the close-out leaves anywhere. A one-seat roster
makes leader-dead and all-dead the same predicate, so solo keeps its
labels for a real death.

Two bugs found in review before this landed:

  - An unknown class was not an error anywhere: -class fightr built a
    1-HP character and reported an ordinary outcome for it. Guarded in
    BuildCharacter, not the flag parser, since the leader had the bug
    long before parties did.
  - The roster short-rest healed the dead - handleDnDShortRest does not
    gate on the death flag, so a member killed in a won boss fight got
    rested back above 0 and stopped counting as a casualty.

Golden byte-identical; go test ./... green.
2026-07-10 00:37:59 -07:00
prosolis
b333d05443 N3/P6c: a fight the whole party sits down for
`!fight` seats the expedition's roster instead of the one player who typed
it. Seat 0 is the leader, always: the session row is theirs, the lock is
theirs, and `!flee`, the fork, and `!extract` stay their call.

A monster that wins initiative now swings before anyone speaks. The session
layer used to park every new fight on a player_turn, which is true of the
hardcoded solo order and a lie about a party's -- the enemy would forfeit
round 1 and nobody would notice. `startPartyCombatSession` rolls the order
and sets the phase from it; `handleFightCmd` settles the round before it
announces, so the opening block narrates the hit rather than quietly
showing its damage.

Members were invisible to two commands that had no business ignoring them:
`!cast` queued a spell for "next combat" while its caster was standing in
one, and `!rest` healed a seated member to full mid boss fight. Both now
resolve through the party.

Nobody leaves without an answer. A downed member's `!fight` opens the
party's fight and tells them why they are not in it. The leader's `!extract`
reaches everyone it drags out of the dungeon, and everyone rolls for what
moved into their house while they were gone.

Supplies burn at 50% x N x 4/5 -- a party eats more than one and less than
N. The ratio is exact: 0.8 as a float truncates a party of three to 119%,
a permanent tax nobody would have found.

Solo is untouched, byte for byte. One seat means one build, one INSERT, no
participant rows, the same RNG draws in the same order -- the combat
characterization golden does not move, and neither does the balance corpus.
2026-07-09 23:23:17 -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
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
4934383a9a J3 D8-f #2: T4 difficulty lift (leaders-define-band) + feywild fork1 soft-lock fix
T4 monster tuning so martial leaders land in the 60-75% band (underdark
59/80, feywild 65/84 at L10/L12, n=50); casters trail (class-side gap that
monster tuning provably can't compress -- Pass 1 showed casters pinned at 0%
while martials moved). T5 deferred (walls everyone at its L12 floor; needs an
L15-16 corpus).

- dnd_bestiary.go: underdark elite/boss HP+AC up + caster-lethal proc cuts
  (mind_flayer/drow_mage/roper); feywild HP+AC up.
- bestiary_srd.go: feywild multiattack profiles (fomorian/night_hag/green_hag)
  + Thornmother 2->3 lashes -- HP/AC alone didn't move feywild's low-damage
  roster; multiattack is what pulls facerolling martials into band.
- zone_graph_feywild_crossing.go: free fork1's marsh edge. fork1 was the only
  fork in the game with every exit skill-locked (CHA+Perception, no LockNone)
  and deterministic no-retry rolls -- a prod SOFT-LOCK stranding ~60% of
  players (low CHA+WIS). Kept grove's CHA bargain as a bonus route.
- expedition_sim.go: firstUnlockedForkChoice -- sim fork policy picks the
  first unlocked option instead of blind 'go 1' (which looped forever on
  locked forks); halts fork_all_locked if none.
- tests: graph-wide TestZoneGraphs_NoSoftLockedFork + fork1 free-path guard.

Writeup: sim_results/d8f_findings.md
2026-05-28 19:02:58 -07:00
prosolis
a46b773750 J3 D8-f #1: route prod autopilot boss/elite through the turn engine
Prod autopilot resolved boss/elite fights inline via SimulateCombat, which
swings the enemy once per round (Combatant has no ID to look up the SRD
multiattack profile). Manual !fight uses the turn engine, which loops the
full profile — so autopilot players faced strictly weaker bosses than
manual. D8-e confirmed this is the gap, not a turn-engine artifact.

- Promote the sim's autoResolveCombat/simPickCombatAction to shared plugin
  methods autoDriveCombat/pickAutoCombatAction (single source of truth; the
  sim now calls the same code prod does).
- Add MessageContext.Silent + a replyDM helper; the turn-engine combat
  handlers route their DMs through it so the background autopilot can drive
  the real !fight/!attack engine without spamming a DM per round (the EoD
  digest summarizes the outcome).
- tryAutoRun now calls runAutopilotWalkDriven (inlineBossCombat flipped
  true->false): walk->fight->walk loop so one tick still covers ~autoRunRoomCap
  rooms, but boss AND elite now face the player's full kit against the
  enemy's full multiattack. Loss surfaces as stopEnded (run already
  force-extracted by finishCombatSession).

Trash mobs stay on the fast inline path. GOGOBEE_SIM_INLINE_BOSS=1 A/B
toggle preserved. Build + plugin tests green; sim smoke-run unchanged.
2026-05-28 15:30:48 -07:00
prosolis
b80de43db1 J3 D8-e: GOGOBEE_SIM_INLINE_BOSS toggle for engine A/B
Diagnostic env toggle (off by default) routing the sim's boss/elite
doorways through the inline SimulateCombat path instead of the turn
engine, for A/B-ing the martial T4/T5 'regression'. D8-e confirmed the
gap is honest multiattack math (inline swings the enemy once/round; the
turn engine loops the full SRD profile) and that prod autopilot is
secretly easier than manual !fight. Toggle left in for the D8-f parity
work.
2026-05-28 15:08:16 -07:00
prosolis
63ad423b79 J3 D8-review: surface sim subprocess errors + SW upcast + doorway msg
- expedition-sim matrix worker now captures child stderr and dumps
  runErr/stderr/stdout-snippet on failure so halted rows have a cause.
- simPickSpiritualWeapon walks slots 2..5 and upcasts when L2 is spent
  instead of silently skipping the spell on high-level clerics.
- advanceOnceWithOpts !inlineBossCombat branch now emits the same
  "Room X/Y — Boss/Elite. Type !fight to engage." line as foreground.
2026-05-28 00:53:25 -07:00
prosolis
631764bbbd J3 D8-prereq: split compact flag so sim drives the picker
Adds inlineBossCombat alongside compact in runAutopilotWalk and
advanceOnceWithOpts. Production background autorun keeps both true
(inline auto-resolve), foreground stays both false (manual !fight), the
sim now uses compact=true + inlineBossCombat=false so the boss/elite
doorway returns stopBoss/stopElite after the safety gate — autoResolveCombat
+ simPickCombatAction / simPickSpell drive the fight via the turn-based
engine. The picker (and D8-b upcasting) has been dead since D3's
compact-inline boss rooms; this re-wires it.

n=50/cell L10 smoke vs d7d (zones T1-T3):
  bard    forest_shadows  61 → 100   (+39)
  bard    manor_blackspire 10 →  34   (+24)
  cleric  forest_shadows  15 →  96   (+81)
  cleric  manor_blackspire 0 →  54   (+54)
  fighter T1-T3                100   (unchanged)

Also parallelizes matrix mode via subprocess workers (each child has its
own SQLite — db package globals preclude in-process parallelism). New
-jobs flag, defaults to runtime.NumCPU(). 8 workers gave ~7x speedup on
the smoke matrix.
2026-05-27 23:16:33 -07:00
prosolis
ad2a8258ba J3 D8-b: sim picker upcasting + discovery picker is dead since D3
simPickSpell now enumerates one candidate per available slot >= native
for every prepared damage spell, scored via spellExpectedDamage with the
existing "+1 die per slot" scaling. Cantrips contribute a single slot-0
candidate. Tie-break: highest slot first, then highest expDmg. Picker
returns "<id> --upcast N" when the winning slot exceeds native so
parseCombatCast upcasts in the engine. Build + plugin tests green.

Then: measured zero impact. d8b_corpus.jsonl (same 10x5x100 matrix as
d7d) lands every class within +/-1.5pp of d7d baseline.

Root cause discovered post-corpus: simPickSpell is dead code in the
current sim path. Commit 68ed8e7 ("Long expeditions D3: compact
autopilot auto-resolves boss rooms") added a !compact gate at
dnd_expedition_cmd.go:810 so compact autopilot inline-resolves boss/
elite rooms via resolveCombatRoom -> runZoneCombat -> SimulateCombat
instead of returning stopBoss/stopElite for autoResolveCombat to handle.
The sim uses compact=true (expedition_sim.go:433), so autoResolveCombat
- the only caller of simPickCombatAction/simPickSpell - never fires.
Verified empirically: a stderr-traced simPickSpell produced zero hits
across full bard/cleric L10 runs.

This rewrites the picker-era retrospective. J2's baseline_j2a_v2_all10
(bard 40%, cleric 39%) was measured before D3 with the picker live.
d7d's L10 baseline (bard 34%, cleric 21%) is post-D3 with the picker
disconnected - that 6-18pp drop is the post-D3 caster regression, not
"long-expedition mechanics didn't help casters" as d7d framed it.
D8-a's "+2.2pp cleric" was also noise (1sigma ~ 1.8pp on n=500).

D8-b code kept in tree (correct, currently inert, turns on as soon as
the wire reconnects). Plan rewritten in section 8: D8-b and D8-c are
deferred behind D8-prereq, which splits compact so the sim opts out of
compact-inline boss/elite combat without affecting prod rendering.

Files:
- internal/plugin/expedition_sim.go: simPickSpell upcast enumeration
- gogobee_long_expedition_plan.md: D8-b implemented-but-inert,
  D8-prereq added as next step
- sim_results/d8b_corpus.jsonl: 5000-row corpus retained for the
  picker-dead baseline
2026-05-27 22:27:04 -07:00
prosolis
2fdb280477 J3 D8-a: caster picker data fixes + Spiritual Weapon pick
Bard L1 += thunderwave, L2 += heat_metal; cleric L1 += inflict_wounds;
shatter overlay Classes broadened (defensive — mergeClassList already
unioned). New simPickSpiritualWeapon runs before simPickSpell so a
cleric with an L2 slot opens fights with the BuffSelf-tagged spell that
the regular picker (damage-effects only) was skipping; existing
spiritWeaponStrike per-round mace path lights up.

Measured L10 n=100/zone: cleric 21.0 → 23.2% (+2.2pp), bard within
noise. T3+ wall unchanged — picker upcasting + concentration-damage
modeling (D8-b/c) are the bigger levers, queued in plan §8.
2026-05-27 22:00:02 -07:00
prosolis
4576c75722 Long expeditions D7-d: corpus re-run + sim heavy-preset fix
Fix sim regression introduced by D5-b: bare `expedition start <zone>`
returns the loadout prompt DM without outfitting, so SimRunner.RunExpedition
was halting before persisting any expedition. Pass `heavy` from the
harness — tier-max packs, no prod paths touched.

D7-d corpus (sim_results/, gitignored): n=100 × 10 classes × 5 zones ×
L10. Leaderboard mirrors J2b — martials 78–82%, casters 21–42%, cluster
gap unchanged by long-expedition mechanics. Cleric worst at 21% (L10).
Bard/cleric trailers not relieved by autopilot camp pacing; remains
J3-territory. T3/T4 cleared runs hit their §2 target durations.

D5-d retune decision: no change. phase5BDailyBurnRatePct=50 + per-tier
DailyBurn stay as-is — heavy-preset cleared runs end with 78–98% SU
surplus; even TPK runs leave packs mostly full. Supply economy is not
a binding constraint at heavy preset.

Closes the long-expedition track.
2026-05-27 21:18:48 -07:00
prosolis
3b29d10461 Long expeditions D7-c: -days flag + per-day snapshots in SimResult
cmd/expedition-sim -days N caps runs by synthetic day rollovers
(Outcome="day_capped"). SimResult.DaySnapshots traces HP/SU/threat/rooms
at start, every Night-camp rollover, and end-of-run — unblocks empirical
D5-d retune of phase5BDailyBurnRatePct against per-day SU draws.
2026-05-27 20:47:28 -07:00
prosolis
29cad7972a Long expeditions D7-b: drive autopilot camp from SimRunner
maybeAutoCamp / pitchAutopilotCamp / pitchBossSafetyCamp now take a
now time.Time so the sim can inject a synthetic clock; tryAutoRun
still passes time.Now().UTC(). SimRunner.RunExpedition advances simNow
by autoRunCooldown per walk and runs the production camp scheduler
after each soft stop (and pitchBossSafetyCamp on stopBossSafety),
dwelling minAutoCampDwell + breakAutoCampIfDue so the next walk can
proceed. Effect: HP-low mid-day rests, base-camp waypoints, Night-camp
rollovers, and boss-safety holds all fire under the sim; D7-a's
tickEventAnchoredRollover shortcut is retained on TickDay for tests
and the pre-cutoff legacy path.
2026-05-27 20:40:04 -07:00
prosolis
a2992ea06c Long expeditions D7-a: teach SimRunner.TickDay event-anchored rollover
deliverBriefingEventAnchored reads time.Now().UTC() for its safety-net
check, so synthetic TickDay calls never advanced CurrentDay on D2-b
expeditions — DaysAtEnd / SUEnd stayed at start values. Short-circuit
in TickDay: when isEventAnchored, fire nightRolloverBurn → optional
applyCampRest (Standard, Rough fallback, skipped on low-SU) →
nightRolloverDrift(briefAt). Mirrors pitchAutopilotCamp Night=true.
Production paths untouched.

Unblocks D5-d retune of phase5BDailyBurnRatePct and the class corpus
re-run.
2026-05-27 20:15:54 -07:00
prosolis
b167882e3e expedition-sim: -pet-level flag to model a base housing pet
Synthetic sim chars are vanilla base-class (no pet, no subclass), so the
per-round pet attack/deflect/whiff path was never exercised in the sim.
Add -pet-level N (1-10) which stamps a base Massive Dog (no armor) onto the
AdventureCharacter via the normal save path before the run, so combat's
DerivePlayerStats sees HasPet()==true. 0 (default) stays petless.

Measured lift (n=40, 10 classes x L3/7/12 x 3 zones): overall clear-rate
38.3% petless -> 47.8% at pet L10 (+9.4pp); biggest gains go to the caster
trailers (bard +13.3, warlock +11.9, cleric +11.4), narrowing class spread.
2026-05-22 08:28:51 -07:00
prosolis
5d7c76fb20 Sim: cross region boundaries; word mid-zone clears as region clears
Mirror runAutopilotWalk's multi-region auto-advance in the headless sim:
on a mid-zone stopComplete (active multi-region exp with a next region),
advanceToNextRegion and keep simulating instead of scoring a premature
"cleared". A transit error there is now recorded as halted, not cleared.

Also fix misleading run-complete wording: a non-boss region clear of a
multi-region zone now reads "Cleared {region}. The way to {next} opens
ahead." instead of "Cleared {zone}. Run complete." New midZoneRegionClear
helper shares finalizeExpeditionOnZoneClear's gating; the path is shared
with manual !region travel, so both autopilot and manual play get it.

(cherry picked from commit 5d2bba70849a0a3fdeac285cc55ea9b8fadea29c)
2026-05-21 23:56:18 -07:00
prosolis
68b2122e13 Sim: short-rest between rooms; H5 alone doesn't close caster gap
autoResolveCombat now calls maybeShortRest after a won fight (HP < 60%
or any slots used, charges > 0), mirroring what a competent prod player
does between rooms. Re-baseline at n=100 across the full 10×3×5 matrix
written to baseline_h5sim_all10.jsonl.

Headline (vs baseline_j2a_v2_all10.jsonl, full delta in h5sim_findings.md):
mage L12 manor +8pp, cleric L12 manor +7pp, but bard L12 manor stays at
1pp and bard/cleric L12 underdark still 0. J2c (defaultKnownSpells
damage option at L3+ for bard, L4+ for cleric) is still the lever for
the trailers. Four borderline -9pp regressions on n=100 cells; flagged
for an n=300 re-run before declaring a sim defect.
2026-05-17 16:06:58 -07:00
prosolis
f2c2d774d4 J2: sim picker casts+consumes; T5 raid-content warning
The post-J1 sweep had the casters clustered at 19–22% L12 clear, vs
martials at 70–80%. A per-round trace across 240 boss-room fights showed
why: autoResolveCombat dispatched !attack only — zero spell_casts, zero
mid-fight consumable uses across every caster class. The entire "caster
cliff" was the sim measuring a strawman where casters couldn't cast.

J2a teaches the sim's autoResolveCombat to mirror a competent prod
player: heal at low HP if an inventory consumable is available, otherwise
cast the highest-EV damage spell (slot or cantrip), otherwise swing.
BuildCharacter now seeds the known-spell list via ensureSpellsForCharacter
so the synthetic spellbook is populated. A -trace flag on the cmd
attaches the raw CombatEvent stream to the last combat of each run for
post-hoc diagnostics.

A first re-baseline (n=100, all 10 classes) showed Ranger regressed
-35.8pp — the picker was burning L3 slots on lightning_arrow when
Ranger's weapon chassis (Hunter's Mark + Extra Attack) was the better
play. Added simMartialFirstClass to gate the picker off for Ranger and
Paladin (whose default kit is also weapon-first / no damage spells).
J2c experimented with widening the picker to control + heal spells;
heal-spell preempt cost druid 10pp (slot heals are 10HP vs 40HP
consumables) and control-spell scoring at 22 cost warlock 6.6pp. Both
reverted. Corpora retained under baseline_j2c*.jsonl for the post-mortem
in sim_results/j2b_findings.md.

Post-J2 L12 leaderboard (baseline_j2a_v2_all10.jsonl, n=100):
  fighter 80.0, ranger 80.0, paladin 78.4, rogue 76.8,
  druid 61.6, mage 53.4, sorcerer 50.6, warlock 48.2,
  bard 40.4, cleric 39.0.

The caster cluster is dissolved; martials are within ±5pp of J1 (sweep
noise). Bard/cleric still trail, but it's no longer a sim artifact —
their defaultKnownSpells damage rosters cap at L2 and the picker can't
pick spells they don't have. That's a prod-level fix, deferred.

J3 trace (sim_results/j3_findings.md): T5 dragons_lair walls every solo
class at 0% (Infernax 546 HP vs solo player HP 110–175; ~25% boss HP
eaten before TPK across all classes). Per the J3 plan menu, this is
party-shaped content the engine doesn't yet have parties for. Surface
a TwinBee-voiced heads-up in handleDnDExpeditionCmd's start path and a
matching tag in !expedition list — players see "raid-shaped — solo
runs not yet survivable" before they spend outfitting coin. No combat
or class balance changes.

Files: cmd/expedition-sim/main.go +trace flag; expedition_sim.go picker
+ SimCombatSummary.Events + spellbook seed; dnd_expedition_cmd.go
raidContentWarning + list tag. All baselines + traces + findings
checked in under sim_results/.
2026-05-17 15:43:41 -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
5eb3cac992 Sim hardening + H4 calibration findings → Phase J plan
Harden expedition-sim with the three layers a real player carries:
race mods (+1 Human across the board), tier-appropriate equipment
(weapon/armor/helmet/boots/tool promoted from tier 0 to a level-mapped
tier), and a lean consumables bundle (2 heals + 1 buff at T2-T4,
3 heals + 2 buffs at T5). Capture material yields in SimResult so
calibration sweeps actually read out the H4 signal.

Three sweeps in sim_results/ (n=10): bare baseline, +gear/race,
+lean consumables. Headline: yield is gated by combat survival, not
charge counts. Fighter and Mage hit a sharp wall at T3+ even kitted;
no class extracts the T5 dragons_lair boss. Tuning charges before
fixing class survival would over-tune against a strawman, so H4
is now flagged blocked on Phase J.

Phase J appended to gogobee_harvest_charges_plan.md:
J0 baseline at n=30, J1 Fighter T3+ wall, J2 Mage T2+ wall,
J3 T5 boss (sim artifact vs over-tuned vs raid content),
J4 validation matrix.
2026-05-17 13:37:21 -07:00
prosolis
a8f8be6f40 Sim runner: day-cycle fast-forward, auto-combat, log capture
RunExpedition now drives expeditions end-to-end through the production
plugin paths:
- TickDay calls deliverRecap + deliverBriefing with a synthetic clock
  anchored on exp.StartDate, advancing one day per call without
  real-time waits.
- Autopilot bursts use compact mode so elite gates auto-resolve inline.
- Boss / surviving elite gates are auto-resolved via handleFightCmd +
  looped handleAttackCmd until the session flips Won/Lost/Fled.
- Forks resolve to path 1 deterministically.
- SimResult exposes StopCode, Walks, DayTicks, Threat, plus the full
  dnd_expedition_log projected as SimLogEntry rows.
2026-05-17 13:12:34 -07:00
prosolis
ebea2b430a Sim scaffold: cmd/expedition-sim drives synthetic expeditions
SimRunner re-uses production plugin code paths against a fresh sqlite DB —
SendDM no-ops without a Matrix client, so ground truth comes from the DB.
BuildCharacter persists adv+dnd character rows with class-appropriate
ability scores and spell slots; RunExpedition loops !expedition run with
stall detection until the autopilot halts or the expedition closes.

The cmd binary is a thin orchestrator: one flag-driven run, JSON output.
Per-room structured logging, day-cycle fast-forward, and matrix batching
are next session.
2026-05-17 12:55:23 -07:00