mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ gogobee
|
|||||||
gensolver
|
gensolver
|
||||||
holdem-train
|
holdem-train
|
||||||
/open5e-import
|
/open5e-import
|
||||||
|
/expedition-sim
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,7 +243,37 @@ 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
|
||||||
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
|
// 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.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.
|
||||||
|
|||||||
8423
sim_results/baseline_j0_n100.err
Normal file
8423
sim_results/baseline_j0_n100.err
Normal file
File diff suppressed because it is too large
Load Diff
4500
sim_results/baseline_j0_n100.jsonl
Normal file
4500
sim_results/baseline_j0_n100.jsonl
Normal file
File diff suppressed because it is too large
Load Diff
8422
sim_results/baseline_j1_extra_attack.err
Normal file
8422
sim_results/baseline_j1_extra_attack.err
Normal file
File diff suppressed because it is too large
Load Diff
4500
sim_results/baseline_j1_extra_attack.jsonl
Normal file
4500
sim_results/baseline_j1_extra_attack.jsonl
Normal file
File diff suppressed because it is too large
Load Diff
80
sim_results/run_matrix.sh
Executable file
80
sim_results/run_matrix.sh
Executable 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
|
||||||
@@ -1,38 +1,51 @@
|
|||||||
#!/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,
|
||||||
level: .[0].Level,
|
level: .[0].Level,
|
||||||
zone: .[0].Zone,
|
zone: .[0].Zone,
|
||||||
n: length,
|
n: length,
|
||||||
yields: [.[] | .YieldCount],
|
yields: [.[] | .YieldCount],
|
||||||
rooms: [.[] | .Rooms],
|
yields_cleared: [.[] | select(.Outcome=="cleared") | .YieldCount],
|
||||||
outcomes: [.[] | .Outcome]
|
rooms: [.[] | .Rooms],
|
||||||
|
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),
|
||||||
pct_cleared: (([.outcomes[] | select(. == "cleared")] | length) * 100 / .n),
|
mean_rooms: ((.rooms | add) / .n),
|
||||||
pct_tpk: (([.outcomes[] | select(. == "tpk")] | length) * 100 / .n),
|
pct_cleared: (([.outcomes[] | select(. == "cleared")] | length) * 100 / .n),
|
||||||
pct_fled: (([.outcomes[] | select(. == "fled")] | length) * 100 / .n),
|
pct_boss_reached: (([.stops[] | select(. == "boss" or . == "complete")] | length) * 100 / .n),
|
||||||
pct_extracted: (([.outcomes[] | select(. == "extracted")] | 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)
|
| 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_tpk | round),
|
(.pct_boss_reached | round),
|
||||||
(.pct_fled | round),
|
(.pct_tpk | round),
|
||||||
(.pct_extracted | round)] | @tsv)
|
(.pct_fled | round),
|
||||||
|
(.pct_extracted | round)] | @tsv)
|
||||||
' "$infile" | column -t -s $'\t'
|
' "$infile" | column -t -s $'\t'
|
||||||
|
|||||||
Reference in New Issue
Block a user