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).
This commit is contained in:
prosolis
2026-05-17 14:11:39 -07:00
parent 5eb3cac992
commit 519964fb01
12 changed files with 26140 additions and 28 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ gogobee
gensolver gensolver
holdem-train holdem-train
/open5e-import /open5e-import
/expedition-sim

View File

@@ -86,6 +86,7 @@ func (p *AdventurePlugin) runDungeonCombat(
applyRacePassives(&playerStats, &playerMods, dndChar) applyRacePassives(&playerStats, &playerMods, dndChar)
applySubclassPassives(&playerStats, &playerMods, dndChar) applySubclassPassives(&playerStats, &playerMods, dndChar)
applyMagicItemEffects(&playerStats, &playerMods, userID) applyMagicItemEffects(&playerStats, &playerMods, userID)
trySimAutoArm(dndChar)
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired { if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName) slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
} }

View File

@@ -64,6 +64,7 @@ func (p *AdventurePlugin) buildZoneCombatants(
applyRacePassives(&playerStats, &playerMods, dndChar) applyRacePassives(&playerStats, &playerMods, dndChar)
applySubclassPassives(&playerStats, &playerMods, dndChar) applySubclassPassives(&playerStats, &playerMods, dndChar)
applyMagicItemEffects(&playerStats, &playerMods, userID) applyMagicItemEffects(&playerStats, &playerMods, userID)
trySimAutoArm(dndChar)
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired { if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName) slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
} }

View File

@@ -197,10 +197,18 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
te.stepPlayerActionEffect(action.Effect) te.stepPlayerActionEffect(action.Effect)
return return
} }
// Default: weapon attack. resolvePlayerAttack returns true once the fight // Default: weapon attack. resolvePlayerSwings rolls the base swing plus
// is decided — usually the enemy is down, but a retaliate aura can drop the // Mods.ExtraAttacks follow-ups (5e Extra Attack at Fighter L5/L11/L20,
// player on their own swing, so disambiguate the outcome by HP. // Ranger/Paladin L5, Bard College of Valor L7). One !attack press in the
if resolvePlayerAttack(te.st, te.player, te.enemy, &turnCombatPhase, te.result) { // turn-based engine == one 5e Attack action == all swings in sequence.
// Before this, the turn path called single-swing resolvePlayerAttack and
// Extra Attack only fired in the auto-resolve SimulateCombat path; that
// left every elite/boss !fight at L11+ short two swings/turn, which the
// J1 boss-trace surfaced as Fighter's T3+ wall.
// Returns true once the fight is decided — usually the enemy is down,
// but a retaliate aura can drop the player on their own swing, so
// disambiguate the outcome by HP.
if resolvePlayerSwings(te.st, te.player, te.enemy, &turnCombatPhase, te.result) {
if te.st.playerHP <= 0 { if te.st.playerHP <= 0 {
te.finish(CombatStatusLost) te.finish(CombatStatusLost)
} else { } else {

View File

@@ -357,6 +357,59 @@ func displayAbility(id string) string {
// ── Combat hook ────────────────────────────────────────────────────────────── // ── Combat hook ──────────────────────────────────────────────────────────────
// simAutoArmEnabled, when true, lets the combat builders pre-arm a
// class-default ability for any character entering combat with an empty
// ArmedAbility slot. The expedition-sim flips this on so synthetic
// players model a competent real player (who would `!arm` Second Wind
// before each fight) instead of a player who never touches the
// !arm command. Untouched in production code paths.
var simAutoArmEnabled = false
// simAutoArmDefaultFor returns the class-default ability id the sim
// should pre-arm. Defensive heals (Second Wind, Healing Word) only —
// burning a Mage's spell slot every fight would mask J2, not help it.
func simAutoArmDefaultFor(class DnDClass) string {
switch class {
case ClassFighter:
return "second_wind"
case ClassCleric:
return "healing_word"
}
return ""
}
// trySimAutoArm pre-arms the class-default ability if the global toggle
// is on, no ability is currently armed, and the resource pool has at
// least one charge. Mirrors handleDnDArmCmd's spend-and-save flow.
// Returns the ability name (or "" when nothing was armed).
func trySimAutoArm(c *DnDCharacter) string {
if !simAutoArmEnabled || c == nil || c.ArmedAbility != "" {
return ""
}
id := simAutoArmDefaultFor(c.Class)
if id == "" {
return ""
}
ab, ok := dndActiveAbilities[id]
if !ok {
return ""
}
cur, _, err := getResource(c.UserID, ab.Resource)
if err != nil || cur < 1 {
return ""
}
c.ArmedAbility = ab.ID
if err := SaveDnDCharacter(c); err != nil {
return ""
}
if ok, _ := spendResource(c.UserID, ab.Resource, 1); !ok {
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
return ""
}
return ab.Name
}
// applyArmedAbility checks for a pre-armed ability on the character and // applyArmedAbility checks for a pre-armed ability on the character and
// applies its effect to the player's CombatModifiers, then clears the armed // applies its effect to the player's CombatModifiers, then clears the armed
// flag. Called from combat_bridge.go before SimulateCombat. // flag. Called from combat_bridge.go before SimulateCombat.

View File

@@ -12,6 +12,7 @@ package plugin
// real-time waits. // real-time waits.
import ( import (
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -35,6 +36,11 @@ func NewSimRunner(dataDir string) (*SimRunner, error) {
if err := db.Init(dataDir); err != nil { if err := db.Init(dataDir); err != nil {
return nil, fmt.Errorf("db init: %w", err) return nil, fmt.Errorf("db init: %w", err)
} }
// Synthetic players don't type !arm between fights; flip on the
// in-combat auto-arm so Fighter Second Wind, Cleric Healing Word,
// etc. fire the way a competent prod player would set them up.
// Without this the sim under-counts class survival.
simAutoArmEnabled = true
euro := &EuroPlugin{} euro := &EuroPlugin{}
p := &AdventurePlugin{euro: euro} p := &AdventurePlugin{euro: euro}
return &SimRunner{P: p, Euro: euro}, nil return &SimRunner{P: p, Euro: euro}, nil
@@ -74,6 +80,12 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
if err := SaveDnDCharacter(c); err != nil { if err := SaveDnDCharacter(c); err != nil {
return nil, fmt.Errorf("SaveDnDCharacter: %w", err) return nil, fmt.Errorf("SaveDnDCharacter: %w", err)
} }
// Class resource pool (stamina/favor/focus/spell_slot). !setup writes
// this for prod players; the sim has to do it explicitly so Second
// Wind / Healing Word / etc. have a pool to draw from.
if err := initResources(uid, class); err != nil {
return nil, fmt.Errorf("initResources: %w", err)
}
if pool := slotsForClassLevel(class, level); len(pool) > 0 { if pool := slotsForClassLevel(class, level); len(pool) > 0 {
if err := setSpellSlotsForLevel(uid, class, level); err != nil { if err := setSpellSlotsForLevel(uid, class, level); err != nil {
return nil, fmt.Errorf("setSpellSlotsForLevel: %w", err) return nil, fmt.Errorf("setSpellSlotsForLevel: %w", err)
@@ -231,9 +243,39 @@ type SimResult struct {
// adventure_inventory at end-of-run. // adventure_inventory at end-of-run.
YieldCount int YieldCount int
YieldsByName map[string]int YieldsByName map[string]int
// Combats holds a per-combat trace for every fight the synthetic
// player entered during the expedition (boss + elites + patrols).
// Used by post-hoc analysis to dig into class-survival walls
// without re-running the matrix. Populated from combat_sessions
// rows + their TurnLog at end-of-run.
Combats []SimCombatSummary
Log []SimLogEntry Log []SimLogEntry
} }
// SimCombatSummary is a compact per-fight trace: the entry stats, the
// per-round damage dealt by each side, and the outcome. Lets J-phase
// analysis ask "did Fighter L12 hit the manor boss often enough?"
// without re-running the matrix.
type SimCombatSummary struct {
SessionID string
EncounterID string
EnemyID string
Status string // active/won/lost/fled/expired
Rounds int
PlayerHPMax int
PlayerHPEnd int
EnemyHPMax int
EnemyHPEnd int
PlayerDamage int // total damage dealt by player across the fight
EnemyDamage int // total damage dealt by enemy across the fight
PlayerHits int // d20 rolls by player that landed (>= enemy AC)
PlayerMisses int
EnemyHits int
EnemyMisses int
PlayerAC int // inferred from RollAgainst on enemy attack events
EnemyAC int // inferred from RollAgainst on player attack events
}
// SimLogEntry is a JSONL-friendly projection of one dnd_expedition_log // SimLogEntry is a JSONL-friendly projection of one dnd_expedition_log
// row. We expose just the fields a post-hoc analyzer needs without // row. We expose just the fields a post-hoc analyzer needs without
// pulling the full ExpeditionEntry type. // pulling the full ExpeditionEntry type.
@@ -379,9 +421,77 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
} }
} }
res.YieldCount, res.YieldsByName = simMaterialYields(uid) res.YieldCount, res.YieldsByName = simMaterialYields(uid)
res.Combats = simCombatSummaries(uid)
return res, nil return res, nil
} }
// simCombatSummaries pulls every combat_sessions row for uid and folds
// its TurnLog into a SimCombatSummary. AC values are inferred from the
// RollAgainst column on attack events (the engine writes the defender's
// AC there). Rows are ordered by started_at so the boss fight is last.
func simCombatSummaries(uid id.UserID) []SimCombatSummary {
rows, err := db.Get().Query(`
SELECT session_id, encounter_id, enemy_id, status, round,
player_hp, player_hp_max, enemy_hp, enemy_hp_max,
turn_log_json
FROM combat_session
WHERE user_id = ?
ORDER BY started_at ASC`, string(uid))
if err != nil {
return nil
}
defer rows.Close()
var out []SimCombatSummary
for rows.Next() {
var s SimCombatSummary
var turnLogJSON string
if err := rows.Scan(
&s.SessionID, &s.EncounterID, &s.EnemyID, &s.Status, &s.Rounds,
&s.PlayerHPEnd, &s.PlayerHPMax, &s.EnemyHPEnd, &s.EnemyHPMax,
&turnLogJSON); err != nil {
continue
}
var events []CombatEvent
if turnLogJSON != "" {
_ = json.Unmarshal([]byte(turnLogJSON), &events)
}
for _, ev := range events {
switch ev.Actor {
case "player":
if ev.Roll > 0 {
if ev.Damage > 0 {
s.PlayerHits++
} else {
s.PlayerMisses++
}
if ev.RollAgainst > s.EnemyAC {
s.EnemyAC = ev.RollAgainst
}
}
if ev.Damage > 0 {
s.PlayerDamage += ev.Damage
}
case "enemy":
if ev.Roll > 0 {
if ev.Damage > 0 {
s.EnemyHits++
} else {
s.EnemyMisses++
}
if ev.RollAgainst > s.PlayerAC {
s.PlayerAC = ev.RollAgainst
}
}
if ev.Damage > 0 {
s.EnemyDamage += ev.Damage
}
}
}
out = append(out, s)
}
return out
}
// simMaterialYields tallies the material rows the synthetic player // simMaterialYields tallies the material rows the synthetic player
// banked in adventure_inventory. One row per +1, so the count is the // banked in adventure_inventory. One row per +1, so the count is the
// raw resource-unit total. Map breakdown is keyed by resource name. // raw resource-unit total. Map breakdown is keyed by resource name.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

80
sim_results/run_matrix.sh Executable file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Parallel driver for cmd/expedition-sim matrix sweeps.
#
# Spawns one expedition-sim process per (class, level, zone) cell, so each
# worker owns its own global sqlite handle (NewSimRunner closes+reinits the
# package-level db.Get() — workers in the same process would clobber each
# other). All worker stdouts are concatenated into a single JSONL.
#
# Usage:
# run_matrix.sh OUTFILE RUNS CLASSES LEVELS ZONES [PARALLEL]
#
# Example (1350 rows, 14-way parallel):
# run_matrix.sh baseline_j0.jsonl 30 \
# fighter,mage,rogue 3,7,12 \
# goblin_warrens,forest_shadows,manor_blackspire,underdark,dragons_lair
set -euo pipefail
outfile=${1:?outfile required}
runs=${2:?runs required}
classes=${3:?classes required}
levels=${4:?levels required}
zones=${5:?zones required}
parallel=${6:-$(nproc)}
repo=$(cd "$(dirname "$0")/.." && pwd)
bin=$repo/expedition-sim
[[ -x "$bin" ]] || { echo "missing $bin — run 'go build ./cmd/expedition-sim' first" >&2; exit 1; }
cd "$repo/sim_results"
tmpdir=$(mktemp -d -t simrun-XXXXXX)
trap 'rm -rf "$tmpdir"' EXIT
errfile=${outfile%.jsonl}.err
: > "$outfile"
: > "$errfile"
# Enumerate cells one per line: "class level zone".
cells=$tmpdir/cells.txt
IFS=, read -ra cls <<< "$classes"
IFS=, read -ra lvs <<< "$levels"
IFS=, read -ra zns <<< "$zones"
for c in "${cls[@]}"; do
for l in "${lvs[@]}"; do
for z in "${zns[@]}"; do
printf "%s\t%s\t%s\n" "$c" "$l" "$z"
done
done
done > "$cells"
ncells=$(wc -l < "$cells")
echo "matrix: $ncells cells × $runs runs = $((ncells * runs)) rows, $parallel workers" >&2
# Fan out: one process per cell. Per-cell stdout goes to its own shard,
# stderr is collected to the shared errfile.
worker() {
local class=$1 level=$2 zone=$3
local shard=$tmpdir/$class-$level-$zone.jsonl
"$bin" -matrix \
-classes "$class" -levels "$level" -zones "$zone" \
-runs "$runs" \
> "$shard" 2>> "$errfile"
}
export -f worker
export bin tmpdir runs errfile
xargs -P "$parallel" -L 1 -a "$cells" bash -c 'worker "$@"' _
# Stitch shards in deterministic order (zone, class, level — matches
# summarize.sh's sort_by) so diff-friendliness survives parallel arrival.
for c in "${cls[@]}"; do
for l in "${lvs[@]}"; do
for z in "${zns[@]}"; do
shard=$tmpdir/$c-$l-$z.jsonl
[[ -f "$shard" ]] && cat "$shard" >> "$outfile"
done
done
done
rows=$(wc -l < "$outfile")
echo "wrote $rows rows → $outfile (errors in $errfile)" >&2

View File

@@ -1,12 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Aggregate h4_baseline.jsonl into a per-(class,level,zone) table. # Aggregate a sim corpus JSONL into a per-(class,level,zone) table.
# Columns: class, level, zone, n, p50 yield, mean yield, mean rooms, %cleared, %tpk, %fled, %extracted. # Columns: class, level, zone, n, p50 yield (all), p50 yield among cleared,
# mean yield, mean rooms, %cleared, %boss-reached, %tpk, %fled, %extracted.
#
# boss-reached counts runs whose StopCode is "boss" or "complete" — i.e.,
# the autopilot entered the boss room, whether the run died there or
# finished the zone.
set -euo pipefail set -euo pipefail
infile=${1:-h4_baseline.jsonl} infile=${1:-baseline_j0.jsonl}
cd "$(dirname "$0")" cd "$(dirname "$0")"
jq -rs ' jq -rs '
def p50(xs): if (xs|length)==0 then 0 else (xs|sort|.[(length/2|floor)]) end;
group_by([.Class, .Level, .Zone]) group_by([.Class, .Level, .Zone])
| map({ | map({
class: .[0].Class, class: .[0].Class,
@@ -14,24 +21,30 @@ jq -rs '
zone: .[0].Zone, zone: .[0].Zone,
n: length, n: length,
yields: [.[] | .YieldCount], yields: [.[] | .YieldCount],
yields_cleared: [.[] | select(.Outcome=="cleared") | .YieldCount],
rooms: [.[] | .Rooms], rooms: [.[] | .Rooms],
outcomes: [.[] | .Outcome] outcomes: [.[] | .Outcome],
stops: [.[] | .StopCode]
}) })
| map(. + { | map(. + {
p50_yield: (.yields | sort | .[(length/2|floor)]), p50_yield: p50(.yields),
mean_yield: ((.yields | add) / length), p50_yield_cleared: p50(.yields_cleared),
mean_rooms: ((.rooms | add) / length), mean_yield: ((.yields | add) / .n),
mean_rooms: ((.rooms | add) / .n),
pct_cleared: (([.outcomes[] | select(. == "cleared")] | length) * 100 / .n), pct_cleared: (([.outcomes[] | select(. == "cleared")] | length) * 100 / .n),
pct_boss_reached: (([.stops[] | select(. == "boss" or . == "complete")] | length) * 100 / .n),
pct_tpk: (([.outcomes[] | select(. == "tpk")] | length) * 100 / .n), pct_tpk: (([.outcomes[] | select(. == "tpk")] | length) * 100 / .n),
pct_fled: (([.outcomes[] | select(. == "fled")] | length) * 100 / .n), pct_fled: (([.outcomes[] | select(. == "fled")] | length) * 100 / .n),
pct_extracted: (([.outcomes[] | select(. == "extracted")] | length) * 100 / .n) pct_extracted: (([.outcomes[] | select(. == "extracted")] | length) * 100 / .n)
}) })
| sort_by(.zone, .class, .level) | sort_by(.zone, .class, .level)
| (["class","level","zone","n","p50_yld","mean_yld","mean_rms","%clr","%tpk","%fled","%ext"] | @tsv), | (["class","level","zone","n","p50_yld","p50_yld_clr","mean_yld","mean_rms","%clr","%boss","%tpk","%fled","%ext"] | @tsv),
(.[] | [.class, .level, .zone, .n, .p50_yield, (.[] | [.class, .level, .zone, .n,
.p50_yield, .p50_yield_cleared,
(.mean_yield * 10 | round / 10), (.mean_yield * 10 | round / 10),
(.mean_rooms * 10 | round / 10), (.mean_rooms * 10 | round / 10),
(.pct_cleared | round), (.pct_cleared | round),
(.pct_boss_reached | round),
(.pct_tpk | round), (.pct_tpk | round),
(.pct_fled | round), (.pct_fled | round),
(.pct_extracted | round)] | @tsv) (.pct_extracted | round)] | @tsv)