mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41:09 +00:00
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:
@@ -7,3 +7,4 @@ gogobee
|
||||
gensolver
|
||||
holdem-train
|
||||
/open5e-import
|
||||
/expedition-sim
|
||||
|
||||
@@ -86,6 +86,7 @@ func (p *AdventurePlugin) runDungeonCombat(
|
||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||
trySimAutoArm(dndChar)
|
||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ func (p *AdventurePlugin) buildZoneCombatants(
|
||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||
trySimAutoArm(dndChar)
|
||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
|
||||
}
|
||||
|
||||
@@ -197,10 +197,18 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
||||
te.stepPlayerActionEffect(action.Effect)
|
||||
return
|
||||
}
|
||||
// Default: weapon attack. resolvePlayerAttack 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 resolvePlayerAttack(te.st, te.player, te.enemy, &turnCombatPhase, te.result) {
|
||||
// Default: weapon attack. resolvePlayerSwings rolls the base swing plus
|
||||
// Mods.ExtraAttacks follow-ups (5e Extra Attack at Fighter L5/L11/L20,
|
||||
// Ranger/Paladin L5, Bard College of Valor L7). One !attack press in the
|
||||
// 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 {
|
||||
te.finish(CombatStatusLost)
|
||||
} else {
|
||||
|
||||
@@ -357,6 +357,59 @@ func displayAbility(id string) string {
|
||||
|
||||
// ── 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
|
||||
// applies its effect to the player's CombatModifiers, then clears the armed
|
||||
// flag. Called from combat_bridge.go before SimulateCombat.
|
||||
|
||||
@@ -12,6 +12,7 @@ package plugin
|
||||
// real-time waits.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -35,6 +36,11 @@ func NewSimRunner(dataDir string) (*SimRunner, error) {
|
||||
if err := db.Init(dataDir); err != nil {
|
||||
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{}
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
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 {
|
||||
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 err := setSpellSlotsForLevel(uid, class, level); err != nil {
|
||||
return nil, fmt.Errorf("setSpellSlotsForLevel: %w", err)
|
||||
@@ -231,7 +243,37 @@ type SimResult struct {
|
||||
// adventure_inventory at end-of-run.
|
||||
YieldCount int
|
||||
YieldsByName map[string]int
|
||||
Log []SimLogEntry
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -379,9 +421,77 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
|
||||
}
|
||||
}
|
||||
res.YieldCount, res.YieldsByName = simMaterialYields(uid)
|
||||
res.Combats = simCombatSummaries(uid)
|
||||
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
|
||||
// banked in adventure_inventory. One row per +1, so the count is the
|
||||
// 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
Executable
+80
@@ -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
|
||||
+36
-23
@@ -1,38 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Aggregate h4_baseline.jsonl into a per-(class,level,zone) table.
|
||||
# Columns: class, level, zone, n, p50 yield, mean yield, mean rooms, %cleared, %tpk, %fled, %extracted.
|
||||
# Aggregate a sim corpus JSONL into a per-(class,level,zone) table.
|
||||
# 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
|
||||
|
||||
infile=${1:-h4_baseline.jsonl}
|
||||
infile=${1:-baseline_j0.jsonl}
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
jq -rs '
|
||||
def p50(xs): if (xs|length)==0 then 0 else (xs|sort|.[(length/2|floor)]) end;
|
||||
|
||||
group_by([.Class, .Level, .Zone])
|
||||
| map({
|
||||
class: .[0].Class,
|
||||
level: .[0].Level,
|
||||
zone: .[0].Zone,
|
||||
n: length,
|
||||
yields: [.[] | .YieldCount],
|
||||
rooms: [.[] | .Rooms],
|
||||
outcomes: [.[] | .Outcome]
|
||||
class: .[0].Class,
|
||||
level: .[0].Level,
|
||||
zone: .[0].Zone,
|
||||
n: length,
|
||||
yields: [.[] | .YieldCount],
|
||||
yields_cleared: [.[] | select(.Outcome=="cleared") | .YieldCount],
|
||||
rooms: [.[] | .Rooms],
|
||||
outcomes: [.[] | .Outcome],
|
||||
stops: [.[] | .StopCode]
|
||||
})
|
||||
| map(. + {
|
||||
p50_yield: (.yields | sort | .[(length/2|floor)]),
|
||||
mean_yield: ((.yields | add) / length),
|
||||
mean_rooms: ((.rooms | add) / length),
|
||||
pct_cleared: (([.outcomes[] | select(. == "cleared")] | length) * 100 / .n),
|
||||
pct_tpk: (([.outcomes[] | select(. == "tpk")] | length) * 100 / .n),
|
||||
pct_fled: (([.outcomes[] | select(. == "fled")] | length) * 100 / .n),
|
||||
pct_extracted: (([.outcomes[] | select(. == "extracted")] | length) * 100 / .n)
|
||||
p50_yield: p50(.yields),
|
||||
p50_yield_cleared: p50(.yields_cleared),
|
||||
mean_yield: ((.yields | add) / .n),
|
||||
mean_rooms: ((.rooms | add) / .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_fled: (([.outcomes[] | select(. == "fled")] | length) * 100 / .n),
|
||||
pct_extracted: (([.outcomes[] | select(. == "extracted")] | length) * 100 / .n)
|
||||
})
|
||||
| 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_yield,
|
||||
| (["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, .p50_yield_cleared,
|
||||
(.mean_yield * 10 | round / 10),
|
||||
(.mean_rooms * 10 | round / 10),
|
||||
(.pct_cleared | round),
|
||||
(.pct_tpk | round),
|
||||
(.pct_fled | round),
|
||||
(.pct_extracted | round)] | @tsv)
|
||||
(.pct_cleared | round),
|
||||
(.pct_boss_reached | round),
|
||||
(.pct_tpk | round),
|
||||
(.pct_fled | round),
|
||||
(.pct_extracted | round)] | @tsv)
|
||||
' "$infile" | column -t -s $'\t'
|
||||
|
||||
Reference in New Issue
Block a user