mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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).
156 lines
6.2 KiB
Go
156 lines
6.2 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// Phase 13 — Combatant reconstruction for turn-based fights.
|
|
//
|
|
// The auto-resolve path (runZoneCombat) builds a player/enemy Combatant pair,
|
|
// runs SimulateCombat, and discards them. The turn-based path needs the same
|
|
// pair, but rebuilt fresh on every player command from persisted session
|
|
// state — so the build is extracted here as buildZoneCombatants, and
|
|
// combatantsForSession wraps it for the resume case.
|
|
//
|
|
// HP is deliberately NOT carried by the Combatant pair: the turn engine reads
|
|
// live HP from the CombatSession row (sess.PlayerHP / sess.EnemyHP), so the
|
|
// rebuilt Combatants only need correct Stats/Mods/Ability. Re-deriving from
|
|
// current character state each round is safe because zone equipment, streaks,
|
|
// and bonuses don't change mid-fight.
|
|
//
|
|
// applyPendingCast and the auto-heal consumable setup are intentionally left
|
|
// out of the shared builder — those are one-shot mutations that runZoneCombat
|
|
// applies once before SimulateCombat. Re-applying them on every turn-based
|
|
// round would double-consume. Mid-fight !cast / !consume buffs are instead
|
|
// persisted on the CombatSession and folded back in by combatantsForSession
|
|
// via applySessionBuffs.
|
|
|
|
// buildZoneCombatants derives the player/enemy Combatant pair for a zone
|
|
// encounter: the player's full D&D layer (stats + class/race/subclass passives
|
|
// + armed ability) and the monster's bestiary stat block with tier scaling and
|
|
// the DM-mood combat tilt folded in. The returned dndChar is handed back so
|
|
// callers can run post-combat subclass persistence without reloading it.
|
|
func (p *AdventurePlugin) buildZoneCombatants(
|
|
userID id.UserID,
|
|
monster DnDMonsterTemplate,
|
|
tier int,
|
|
dmMood int,
|
|
) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) {
|
|
tilt := dmMoodCombatTilt(dmMood)
|
|
char, err := loadAdvCharacter(userID)
|
|
if err != nil || char == nil {
|
|
return Combatant{}, Combatant{}, nil, fmt.Errorf("load adv character: %w", err)
|
|
}
|
|
equip, err := loadAdvEquipment(userID)
|
|
if err != nil {
|
|
return Combatant{}, Combatant{}, nil, fmt.Errorf("load equipment: %w", err)
|
|
}
|
|
bonuses := p.loadCombatBonuses(userID, char)
|
|
chatLvl := p.chatLevel(userID)
|
|
|
|
playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, false)
|
|
|
|
dndChar, _, err = ensureDnDCharacterForCombat(userID, char)
|
|
if err != nil {
|
|
return Combatant{}, Combatant{}, nil, fmt.Errorf("ensure dnd character: %w", err)
|
|
}
|
|
applyDnDPlayerLayer(&playerStats, dndChar)
|
|
applyDnDEquipmentLayer(&playerStats, dndChar, equip)
|
|
applyDnDHPScaling(&playerStats, dndChar)
|
|
applyClassPassives(&playerStats, &playerMods, dndChar)
|
|
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)
|
|
}
|
|
|
|
enemyStats, enemyMods := monster.toCombatStats()
|
|
// Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a
|
|
// tier floor — boss/elite stat blocks already encode their challenge, so
|
|
// we never double-scale a block that's already above the floor.
|
|
if tier > 1 {
|
|
floorAC := dndDungeonACBase + tier
|
|
if enemyStats.AC < floorAC {
|
|
enemyStats.AC = floorAC
|
|
}
|
|
floorAB := dndDungeonAtkBase + tier
|
|
if enemyStats.AttackBonus < floorAB {
|
|
enemyStats.AttackBonus = floorAB
|
|
}
|
|
}
|
|
|
|
// DM mood tilts: monster Attack delta + player initiative bias.
|
|
enemyStats.Attack += tilt.EnemyAttackDelta
|
|
if enemyStats.Attack < 1 {
|
|
enemyStats.Attack = 1
|
|
}
|
|
playerMods.InitiativeBias += tilt.InitiativeBias
|
|
|
|
displayName, _ := loadDisplayName(userID)
|
|
player = Combatant{
|
|
Name: displayName,
|
|
Stats: playerStats,
|
|
Mods: playerMods,
|
|
IsPlayer: true,
|
|
}
|
|
enemy = Combatant{
|
|
Name: monster.Name,
|
|
Stats: enemyStats,
|
|
Mods: enemyMods,
|
|
Ability: monster.Ability,
|
|
}
|
|
return player, enemy, dndChar, nil
|
|
}
|
|
|
|
// combatantsForSession reconstructs the Combatant pair for an in-flight
|
|
// CombatSession. It reloads the zone run for the DM mood + tier and looks the
|
|
// enemy up in the bestiary by the session's EnemyID. The pair carries no HP —
|
|
// the turn engine reads that from the session row.
|
|
func (p *AdventurePlugin) combatantsForSession(sess *CombatSession) (player Combatant, enemy Combatant, err error) {
|
|
run, rerr := getZoneRun(sess.RunID)
|
|
if rerr != nil {
|
|
return Combatant{}, Combatant{}, fmt.Errorf("load zone run: %w", rerr)
|
|
}
|
|
if run == nil {
|
|
return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: zone run %s not found", sess.SessionID, sess.RunID)
|
|
}
|
|
monster, ok := dndBestiary[sess.EnemyID]
|
|
if !ok {
|
|
return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: enemy %q not in bestiary", sess.SessionID, sess.EnemyID)
|
|
}
|
|
zone := zoneOrFallback(run.ZoneID)
|
|
player, enemy, _, err = p.buildZoneCombatants(id.UserID(sess.UserID), monster, int(zone.Tier), run.DMMood)
|
|
if err != nil {
|
|
return player, enemy, err
|
|
}
|
|
// Fold any fight-scoped buffs a mid-fight !cast / !consume layered on back
|
|
// onto the freshly-rebuilt player. The depleting one-shots (ward/spore/…)
|
|
// live on the session's Statuses and flow through the turn engine's
|
|
// resume/commit cycle, so only the persistent stat deltas are applied here.
|
|
applySessionBuffs(&player, sess.Statuses)
|
|
return player, enemy, err
|
|
}
|
|
|
|
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
|
|
// buff onto the rebuilt player. The buffs are stored as accumulated deltas
|
|
// (diffTurnBuff produced them against the player's state at cast time), so
|
|
// re-applying them to a deterministic rebuild reproduces the same totals every
|
|
// round without double-counting.
|
|
func applySessionBuffs(player *Combatant, s CombatStatuses) {
|
|
player.Stats.AC += s.BuffACBonus
|
|
player.Stats.AttackBonus += s.BuffAtkBonus
|
|
player.Stats.Speed += s.BuffSpeedBonus
|
|
player.Stats.CritRate += s.BuffCritRate
|
|
player.Mods.DamageBonus += s.BuffDamageBonus
|
|
player.Mods.PetAttackProc += s.BuffPetProc
|
|
player.Mods.PetAttackDmg += s.BuffPetDmg
|
|
if s.BuffDamageReductMul > 0 {
|
|
player.Mods.DamageReduct *= s.BuffDamageReductMul
|
|
}
|
|
}
|