mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Five bugs found reviewing n1-restoration end to end. beginCombatTurn settles any phase the engine owes before reading whose turn it is. That settle can end the fight — and the old code then answered "you're not in a fight" and returned. The terminal status was already persisted, so nothing ever paid the party out: no XP, no loot, no death recorded, no run teardown. The reaper cannot recover it either, because listExpiredCombatSessions filters on status='active'. Close the fight out there, the way the !fight start path and the reaper already do. A party member was permanently soft-locked when their leader extracted and never resumed. seatedExpeditionFor (the guard) spans 'extracting'; expeditionForMember (what !expedition leave resolved through) saw only 'active'. So the member was refused any new adventure by the guard and told "No active expedition" by the command the guard points them at, with nothing sweeping stale rows and only the leader able to clear one. Resolve the exit through the same lookup as the gate. updateSupplies overwrites supplies_json wholesale, and expeditionCmdAccept folded a member's packs onto a snapshot read before the coin debit, unlocked. Handlers run one goroutine per event, so two invitees accepting genuinely interleave and one member's packs vanish. advUserLock cannot help — it is keyed by sender, so racing members take different mutexes. Add advExpeditionLock and re-read the pool under it. Closes accept-vs-accept; the six other updateSupplies callers still race and are written up separately. runHarvestInterrupt picked an elite enemy and elite narration off a local `elite` flag, then passed a hardcoded false as isElite to closeOutZoneWin. dropZoneLoot gates masterwork on isBoss||isElite, so beating an elite interrupt skipped the masterwork roll and took standard treasure weight — while the same elite fought via !zone paid out correctly. arenaSeasonRollover marked its job complete even when recordArenaSeasonTitle failed, and JobCompleted short-circuits every later run for that quarter, so a transient SQLite BUSY lost the crown forever. Defer completion on failure; the insert is ON CONFLICT DO NOTHING against PRIMARY KEY (season, kind) and a past season's data is frozen, so the retry is safe. Also: drop dead partySurvivors, collapse the zoneCombatRoster alias into fightRoster, route partyCasualtyLine through joinNames, fold four copies of the expedition column projection into expeditionSelectCols, stop replyDM sending a blank DM, and correct two doc comments describing a path that no longer exists. Deliberately not fixed, with reasons, in gogobee_code_review_followups.md — most notably that both turn-based close-outs skip grantCombatAchievements and persistDnDPostCombatSubclass, which the auto-resolve paths run.
233 lines
8.1 KiB
Go
233 lines
8.1 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
|
|
}
|
|
|
|
player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
|
|
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
|
|
} else if hp, _ := dndHPSnapshot(uid); hp <= 0 {
|
|
// A downed member sits it out. They own no close-out claim.
|
|
continue
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
p.grantCombatAchievements(b.uid, seatRes)
|
|
persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP)
|
|
if err := persistDnDPostCombatSubclass(b.dndChar, b.mods.BerserkerRage, seatRes, b.mods); err != nil {
|
|
slog.Error("dnd: post-combat subclass persist (zone)", "user", b.uid, "err", err)
|
|
}
|
|
|
|
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."
|
|
}
|