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
244 lines
8.4 KiB
Go
244 lines
8.4 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"time"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// runZoneCombatRoster auto-resolves one zone encounter for a whole roster.
|
|
//
|
|
// It is the seam P6e opens: before it, only elite and boss doorways seated a
|
|
// party (through the turn engine), and every other fight in a zone — exploration
|
|
// rooms, patrol encounters, harvest interrupts — resolved as the leader alone.
|
|
// On a 38-room expedition that meant a party of three fought together twice and
|
|
// the leader soloed the other ~35, which is why P7 measured zero member deaths
|
|
// and zero TPKs across 240 seats: the members were never in the fight.
|
|
//
|
|
// Roster order is the seating order, and roster[0] is the leader. Callers get
|
|
// back the seats that actually sat down, aligned index-for-index with
|
|
// res.Seats, so the character-scoped close-out (loot, death) can fan out and the
|
|
// run-scoped one (threat, kill record) can stay with the leader.
|
|
//
|
|
// A member who is down sits the encounter out. A leader who is down is a
|
|
// caller-side error — the run is theirs and the walk should not have reached
|
|
// here.
|
|
//
|
|
// Effects applied here are exactly the character-scoped ones runZoneCombat
|
|
// always applied, now per seat: HP, XP, achievements, the subclass persist, the
|
|
// heal items actually burned, and Misty's condition repair. Loot, threat, kill
|
|
// records and death remain the caller's, because only the caller knows the room.
|
|
func (p *AdventurePlugin) runZoneCombatRoster(
|
|
roster []id.UserID,
|
|
monster DnDMonsterTemplate,
|
|
tier int,
|
|
phases []CombatPhase,
|
|
dmMood int,
|
|
) (PartyCombatResult, []id.UserID, error) {
|
|
if len(roster) == 0 {
|
|
return PartyCombatResult{}, nil, fmt.Errorf("runZoneCombatRoster: empty roster")
|
|
}
|
|
if phases == nil {
|
|
phases = dungeonCombatPhases
|
|
}
|
|
|
|
type seatBuild struct {
|
|
uid id.UserID
|
|
dndChar *DnDCharacter
|
|
advChar *AdventureCharacter
|
|
equip map[EquipmentSlot]*AdvEquipment
|
|
mods CombatModifiers
|
|
}
|
|
|
|
var (
|
|
players []Combatant
|
|
builds []seatBuild
|
|
seated []id.UserID
|
|
enemy Combatant
|
|
)
|
|
|
|
for i, uid := range roster {
|
|
leader := i == 0
|
|
|
|
// The leader's failures are the encounter's failures; a member's only
|
|
// cost them their seat.
|
|
bail := func(err error) (PartyCombatResult, []id.UserID, error) {
|
|
return PartyCombatResult{}, nil, err
|
|
}
|
|
|
|
advChar, err := loadAdvCharacter(uid)
|
|
if err != nil || advChar == nil {
|
|
if leader {
|
|
return bail(fmt.Errorf("load adv character: %w", err))
|
|
}
|
|
slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err)
|
|
continue
|
|
}
|
|
equip, err := loadAdvEquipment(uid)
|
|
if err != nil {
|
|
if leader {
|
|
return bail(fmt.Errorf("load equipment: %w", err))
|
|
}
|
|
slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err)
|
|
continue
|
|
}
|
|
|
|
// A downed member sits it out. They own no close-out claim — and the
|
|
// check runs before the arm, so sitting out doesn't cost them the
|
|
// ability they had readied.
|
|
if !leader {
|
|
if hp, _ := dndHPSnapshot(uid); hp <= 0 {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Auto-resolve builds and fights in one breath, so this seat can arm and
|
|
// apply together. The turn-based path has to split the two — see
|
|
// consumeArmedAbility.
|
|
armed := ""
|
|
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
|
|
trySimAutoArm(c)
|
|
armed = consumeArmedAbility(c)
|
|
}
|
|
player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
|
if err != nil {
|
|
if leader {
|
|
return bail(err)
|
|
}
|
|
slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err)
|
|
continue
|
|
}
|
|
if leader {
|
|
enemy = e
|
|
}
|
|
|
|
// Pre-combat one-shots that the turn-based path does NOT share: a queued
|
|
// spell and the panic-heal consumable trigger. Both mutate this seat's
|
|
// Combatant once, before the fight runs. The queued spell can also debuff
|
|
// the shared enemy, so every seat's cast lands on the one stat block.
|
|
applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
|
|
setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods)
|
|
|
|
players = append(players, player)
|
|
builds = append(builds, seatBuild{
|
|
uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods,
|
|
})
|
|
seated = append(seated, uid)
|
|
}
|
|
|
|
res := simulateParty(players, enemy, phases)
|
|
dumpCombatEventsIfDebug(
|
|
fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)),
|
|
res.Seats[0])
|
|
|
|
for i, b := range builds {
|
|
seatRes := res.Seats[i]
|
|
|
|
// Remove the actual heal items consumed during combat (one inventory item
|
|
// per heal_item event this seat fired). Cheapest-tier first.
|
|
consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes))
|
|
|
|
// Misty condition repair (post-combat, same 20% chance as the arena and
|
|
// encounter paths in combat_bridge.go). Gourmet food keeps her gear in
|
|
// shape on long expeditions, not just in the arena.
|
|
if b.advChar.MistyBuffExpires != nil && time.Now().UTC().Before(*b.advChar.MistyBuffExpires) {
|
|
if rand.Float64() < 0.20 {
|
|
npcRepairMostDamaged(b.uid, b.equip, 5)
|
|
}
|
|
}
|
|
|
|
persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP)
|
|
p.postCombatBookkeeping(b.uid, b.dndChar, b.mods.BerserkerRage, seatRes, b.mods)
|
|
|
|
if xp := zoneCombatXP(seatRes, monster.CR, tier); xp > 0 {
|
|
if _, err := p.grantDnDXP(b.uid, xp); err != nil {
|
|
slog.Error("dnd: grantDnDXP zone", "user", b.uid, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return res, seated, nil
|
|
}
|
|
|
|
// zoneCombatRoster is the roster a zone encounter seats for this walker: their
|
|
// expedition's party if they lead one, otherwise just them. Downed members are
|
|
// filtered by runZoneCombatRoster, not here — this answers "who is on the
|
|
// expedition", not "who can stand up".
|
|
//
|
|
// It must not touch getActiveZoneRun: that lookup carries the §4.3 idle reap,
|
|
// and the callers are mid-walk on the run it would reap. fightRoster observes
|
|
// the same rule, for the same reason.
|
|
func zoneCombatRoster(walker id.UserID) []id.UserID { return fightRoster(walker) }
|
|
|
|
// closeOutZoneWin hands loot to every seat still standing and the hospital to
|
|
// every seat that isn't. It returns the leader's own loot line — the only one
|
|
// that belongs in the leader's room narration — and who went down.
|
|
//
|
|
// The one asymmetry is deliberate. A *solo* player can win at 0 HP: a retaliate
|
|
// aura kills the swinger on the same blow that drops the enemy, and
|
|
// resolvePlayerAttack returns before enemyDown is consumed. Today that player is
|
|
// not marked dead, and P6e is not the place to change it — it would move the
|
|
// sim's outcome labels. A *party* marks its downed seats dead on a win, which is
|
|
// exactly what the turn-engine close-out has always done (finishPartyWin).
|
|
func (p *AdventurePlugin) closeOutZoneWin(
|
|
res PartyCombatResult,
|
|
seated []id.UserID,
|
|
zone ZoneDefinition,
|
|
monster DnDMonsterTemplate,
|
|
isBoss, elite bool,
|
|
deathSource string,
|
|
) (leaderDrop string, downed []id.UserID) {
|
|
party := len(seated) > 1
|
|
for i, uid := range seated {
|
|
if res.Seats[i].PlayerEndHP > 0 {
|
|
drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite)
|
|
if i == 0 {
|
|
leaderDrop = drop
|
|
}
|
|
continue
|
|
}
|
|
// Down when the enemy fell. No loot — they never saw it drop.
|
|
downed = append(downed, uid)
|
|
if party {
|
|
markAdventureDead(uid, deathSource, zone.Display)
|
|
}
|
|
}
|
|
return leaderDrop, downed
|
|
}
|
|
|
|
// closeOutZoneLoss marks every seat the fight actually killed. A loss is not the
|
|
// same as a death: the engine's timeout is a retreat ("ran out the clock"), and
|
|
// it leaves HP where it was. So death is read per seat off HP, never off the
|
|
// fight's terminal status — which for one seat is exactly the old
|
|
// `if !result.TimedOut` rule, since a solo player at 0 HP has already ended the
|
|
// fight.
|
|
func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) {
|
|
for i, uid := range seated {
|
|
if res.Seats[i].PlayerEndHP <= 0 {
|
|
markAdventureDead(uid, deathSource, zone.Display)
|
|
killed = append(killed, uid)
|
|
}
|
|
}
|
|
return killed
|
|
}
|
|
|
|
// partyCasualtyLine names the members a fight put on the floor, for the leader's
|
|
// narration. Empty for a solo walker, and empty when everyone walked away.
|
|
func partyCasualtyLine(downed []id.UserID) string {
|
|
if len(downed) == 0 {
|
|
return ""
|
|
}
|
|
names := make([]string, 0, len(downed))
|
|
for _, uid := range downed {
|
|
name, _ := loadDisplayName(uid)
|
|
if name == "" {
|
|
name = uid.Localpart()
|
|
}
|
|
names = append(names, "**"+name+"**")
|
|
}
|
|
return "💀 " + joinNames(names) + " went down."
|
|
}
|