mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
A. An armed ability lasted one round of a turn-based fight.
buildZoneCombatants called applyArmedAbility, which applies an ability's mods
*and* clears ArmedAbility and saves the sheet. The turn engine calls that
builder again on every !attack / !cast / !consume, so round 1 fired the ability
and disarmed the character, and every later round rebuilt them with none of its
mods. A Berserker paid stamina for a single round of BerserkerRage /
RageMeleeDmg / PhysicalResistRage / FrenzyDmgBonus. Every entry in
dndActiveAbilities had the same shape. mods.BerserkerRage was not merely unread
at close-out — by then it no longer existed.
Split arming into its two halves:
consumeArmedAbility(c) mutates: disarms, saves, returns the id. Once,
at fight start.
applyAbilityByID(c, id, mods) pure: no DB write, no disarm. Safe on every
rebuild. (No ability's Apply writes to the
character, so this really is pure.)
armAbilityForFight(c, mods) consume + apply, for the auto-resolve callers
that build and fight in one breath.
buildZoneCombatants now takes the already-consumed id and re-applies it. The id
rides on ActorStatuses.ArmedAbility, seeded per seat at fight start, so
partyCombatantsForSession reproduces the ability every rebuild and the close-out
can still see that a rage fired.
The close-out itself: postCombatBookkeeping now carries grantCombatAchievements
+ persistDnDPostCombatSubclass, and all four close-outs route through it —
runDungeonCombat, runZoneCombatRoster, finishCombatSession,
finishPartyCombatSession. It fires on every terminal status, not just a win: a
Berserker who rages and loses is still exhausted, which is what auto-resolve
always did.
Also: buildFightSeats and runZoneCombatRoster consumed the ability before the
checks that could sit a seat out, so a downed member was disarmed for a fight
they never joined. The refusals now run first.
B. Six unlocked read-modify-writes against the shared supply pool.
updateSupplies rewrites supplies_json wholesale, so a caller folding its delta
onto an *Expedition it read earlier discards whatever landed in between.
Handlers run one goroutine per event, so those writers genuinely interleave.
All six now go through withExpeditionSupplies, which takes advExpeditionLock,
re-reads the row, hands the closure the fresh copy and persists what it returns:
nightRolloverBurn (forage + burn in one write), grantTwoWeeksCache,
advanceToNextRegion's transit burn, campPitch, pitchAutopilotCamp, and the
ambient pack-rat drain. expeditionCmdAccept's hand-rolled lock folds onto the
same helper. expedition_sim.go is left alone: single-threaded, takes no locks.
Known consequence, for the balance track: trySimAutoArm used to live inside the
rebuild, so a simulated Fighter (second_wind) or Cleric (healing_word) re-armed
and re-spent a resource every round of every elite/boss fight. expedition-sim
drives those through the turn engine, so every prior expedition-sim corpus
overstates those two classes. Re-baseline after this, not before.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
262 lines
11 KiB
Go
262 lines
11 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.
|
|
//
|
|
// armed is the ability id already consumed for this fight (consumeArmedAbility,
|
|
// or armAbilityForFight's return on the auto-resolve paths); "" means none. The
|
|
// build re-applies it rather than consuming, because the turn-based path calls
|
|
// this once per player command and a consuming build would spend the ability on
|
|
// round 1 and drop it for the rest of the fight.
|
|
func (p *AdventurePlugin) buildZoneCombatants(
|
|
userID id.UserID,
|
|
monster DnDMonsterTemplate,
|
|
tier int,
|
|
dmMood int,
|
|
armed string,
|
|
) (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)
|
|
applyAbilityByID(dndChar, armed, &playerMods)
|
|
|
|
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 solo
|
|
// CombatSession — the shape every caller wanted before parties existed. It is
|
|
// partyCombatantsForSession reduced to seat 0, and it errors rather than
|
|
// silently dropping the rest of a party's roster on the floor.
|
|
func (p *AdventurePlugin) combatantsForSession(sess *CombatSession) (player Combatant, enemy Combatant, err error) {
|
|
if sess.IsParty() {
|
|
return Combatant{}, Combatant{}, fmt.Errorf(
|
|
"combat session %s seats %d players; use partyCombatantsForSession", sess.SessionID, sess.RosterSize())
|
|
}
|
|
players, e, err := p.partyCombatantsForSession(sess)
|
|
if err != nil {
|
|
return Combatant{}, Combatant{}, err
|
|
}
|
|
return *players[0], *e, nil
|
|
}
|
|
|
|
// partyCombatantsForSession reconstructs the seated roster and the enemy 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 combatants
|
|
// carry no HP — the turn engine reads that from the session row and the
|
|
// participant rows.
|
|
//
|
|
// Every seat is rebuilt from *its own* player: their sheet, their equipment,
|
|
// their passives, their magic items. That is N times the per-round DB chatter
|
|
// combatantsForSession already had (project_combat_session_cache_deferred), and
|
|
// a party of 3 pays it three times over. Solo — every fight that has ever run,
|
|
// and the whole balance corpus — still issues exactly one build.
|
|
func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Combatant, *Combatant, error) {
|
|
run, rerr := getZoneRun(sess.RunID)
|
|
if rerr != nil {
|
|
return nil, nil, fmt.Errorf("load zone run: %w", rerr)
|
|
}
|
|
if run == nil {
|
|
return nil, nil, fmt.Errorf("combat session %s: zone run %s not found", sess.SessionID, sess.RunID)
|
|
}
|
|
monster, ok := dndBestiary[sess.EnemyID]
|
|
if !ok {
|
|
return nil, nil, fmt.Errorf("combat session %s: enemy %q not in bestiary", sess.SessionID, sess.EnemyID)
|
|
}
|
|
zone := zoneOrFallback(run.ZoneID)
|
|
|
|
seats := sess.SeatUserIDs()
|
|
players := make([]*Combatant, len(seats))
|
|
var enemy Combatant
|
|
for seat, uid := range seats {
|
|
// The ability this seat armed was consumed once, at fight start, and its
|
|
// id parked on their statuses. Re-applying it here — not re-consuming —
|
|
// is what makes a rage last the whole fight instead of one round.
|
|
st := sess.actorStatusesForSeat(seat)
|
|
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
|
}
|
|
// Fold any fight-scoped buffs this seat's mid-fight !cast / !consume
|
|
// layered on back onto their freshly-rebuilt combatant. The depleting
|
|
// one-shots (ward/spore/…) live on their persisted statuses and flow
|
|
// through the turn engine's resume/commit cycle, so only the persistent
|
|
// stat deltas are applied here — and only that seat's own.
|
|
applySessionBuffs(&player, st)
|
|
players[seat] = &player
|
|
if seat == 0 {
|
|
// The enemy build reads only (monster, tier, dmMood): every seat
|
|
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
|
// Only the *player* half of the build varies by seat.
|
|
enemy = e
|
|
}
|
|
}
|
|
return players, &enemy, nil
|
|
}
|
|
|
|
// seatFightStartMods re-derives the fight-start modifiers a finished fight's
|
|
// close-out still needs — today only the Berserker's rage flag, which decides
|
|
// whether the character owes a point of exhaustion.
|
|
//
|
|
// It re-applies the seat's armed ability to an empty mod set rather than
|
|
// rebuilding the whole combatant: no Apply writes to the character (they read
|
|
// level and HP and write only mods), so this is pure, and the passive/equipment
|
|
// layers a full build would add are not read by any post-combat hook.
|
|
//
|
|
// GrimHarvestSlot stays zero here, and that is not an oversight: the turn-based
|
|
// path never runs applyPendingCast, so a Necromancy Mage's spell is never
|
|
// stashed and Grim Harvest cannot fire on this surface at all. Wiring the mage
|
|
// spell hooks into the turn-based `!cast` is a separate change.
|
|
func seatFightStartMods(sess *CombatSession, seat int, c *DnDCharacter) CombatModifiers {
|
|
var mods CombatModifiers
|
|
applyAbilityByID(c, sess.actorStatusesForSeat(seat).ArmedAbility, &mods)
|
|
return mods
|
|
}
|
|
|
|
// seatCombatResult reconstructs, for one seat of a finished turn-based fight,
|
|
// the slice of CombatResult that the post-combat hooks actually read. The turn
|
|
// engine never builds a CombatResult — it persists a session and a shared event
|
|
// log — so the close-out has to assemble one.
|
|
//
|
|
// SniperKilled and MistyHealed stay false because the turn engine has no Arina
|
|
// or Misty proc to set them: those two live only in the auto-resolve engine.
|
|
// NearDeath mirrors the auto-resolve engine's win threshold (below 15% of max);
|
|
// its loss-side meaning is unused here, since the only hook that reads it gates
|
|
// on PlayerWon.
|
|
func seatCombatResult(sess *CombatSession, seat int) CombatResult {
|
|
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
|
won := sess.Status == CombatStatusWon
|
|
return CombatResult{
|
|
PlayerWon: won,
|
|
Events: eventsForSeat(sess.TurnLog, seat),
|
|
PlayerEndHP: hp,
|
|
EnemyEndHP: sess.EnemyHP,
|
|
TotalRounds: sess.Round,
|
|
NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15,
|
|
}
|
|
}
|
|
|
|
// postCombatBookkeepingForSeat is postCombatBookkeeping for one seat of a
|
|
// terminal turn-based session — the bridge between what the turn engine
|
|
// persisted and what the shared close-out expects.
|
|
func (p *AdventurePlugin) postCombatBookkeepingForSeat(sess *CombatSession, seat int) {
|
|
uid := id.UserID(sess.seatUserID(seat))
|
|
// Loaded after the caller's persistDnDHPAfterCombat, so a Grim-Harvest-style
|
|
// hook that heals off HPCurrent reads the fight's real ending HP.
|
|
c, err := LoadDnDCharacter(uid)
|
|
if err != nil || c == nil {
|
|
slog.Error("combat: post-combat bookkeeping skipped, no sheet", "user", uid, "err", err)
|
|
return
|
|
}
|
|
mods := seatFightStartMods(sess, seat, c)
|
|
p.postCombatBookkeeping(uid, c, mods.BerserkerRage, seatCombatResult(sess, seat), mods)
|
|
}
|
|
|
|
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
|
|
// buff onto one rebuilt character. The buffs are stored as accumulated deltas
|
|
// (diffTurnBuff produced them against that character's state at cast time), so
|
|
// re-applying them to a deterministic rebuild reproduces the same totals every
|
|
// round without double-counting. It takes ActorStatuses rather than the whole
|
|
// session because buffs are per-caster: each seat folds in only its own.
|
|
func applySessionBuffs(player *Combatant, s ActorStatuses) {
|
|
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
|
|
player.Mods.SpiritWeaponProc += s.BuffSpiritProc
|
|
player.Mods.SpiritWeaponDmg += s.BuffSpiritDmg
|
|
if s.BuffDamageReductMul > 0 {
|
|
player.Mods.DamageReduct *= s.BuffDamageReductMul
|
|
}
|
|
}
|