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
187 lines
6.6 KiB
Go
187 lines
6.6 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// N3/P5 — closing out a party fight.
|
|
//
|
|
// finishCombatSession (combat_cmd.go) is the solo close-out and stays exactly
|
|
// what it was. This is its sibling for a seated roster, and the split it makes
|
|
// is the one the data model already forced:
|
|
//
|
|
// - Run- and expedition-scoped effects fire ONCE, for the owner. Threat, the
|
|
// zone-kill record, the boss-defeat threat drop, the run teardown on a loss
|
|
// — every one of them resolves through getActiveExpedition(userID) or
|
|
// getActiveZoneRun(userID), and a party member owns neither row. Fanning
|
|
// them out would be a no-op for members and would triple the threat the
|
|
// owner pays for a single kill.
|
|
// - Character-scoped effects fan out. HP, XP, loot, and death belong to the
|
|
// person, not the expedition, and every seat gets their own.
|
|
//
|
|
// A member can be dead in a fight the party won: they dropped, the others
|
|
// finished the job. So death is decided per seat off HP, not off the session's
|
|
// terminal status.
|
|
|
|
// finishPartyCombatSession runs the terminal side effects for a seated roster
|
|
// and returns each seat's own player-facing close-out, indexed by seat. A solo
|
|
// session delegates to finishCombatSession untouched.
|
|
func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
|
|
sess, enemy := ct.sess, *ct.enemy
|
|
owner := id.UserID(sess.UserID)
|
|
if !ct.isParty() {
|
|
return []string{p.finishCombatSession(owner, sess, enemy)}
|
|
}
|
|
|
|
run, _ := getZoneRun(sess.RunID)
|
|
var zone ZoneDefinition
|
|
elite := true
|
|
cadence := sess.Round
|
|
if run != nil {
|
|
zone = zoneOrFallback(run.ZoneID)
|
|
elite = run.CurrentRoomType() != RoomBoss
|
|
cadence = narrationCadence(run)
|
|
}
|
|
monster := dndBestiary[sess.EnemyID]
|
|
|
|
// nat20/nat1 mood deltas from the whole fight's event log — the run's, so once.
|
|
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
|
|
|
|
// Every seat's HP lands on their sheet regardless of how the fight ended, and
|
|
// so does the bookkeeping that outlives the fight — a Berserker who raged and
|
|
// lost is still exhausted. Both fan out; neither is the owner's alone.
|
|
for seat := range sess.RosterSize() {
|
|
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
|
p.postCombatBookkeepingForSeat(sess, seat)
|
|
}
|
|
|
|
switch sess.Status {
|
|
case CombatStatusWon:
|
|
return p.finishPartyWin(ct, run, zone, monster, elite, cadence)
|
|
case CombatStatusLost:
|
|
return p.finishPartyLoss(ct, zone, cadence)
|
|
case CombatStatusFled:
|
|
_ = abandonZoneRun(owner)
|
|
forceExtractExpeditionForRunLoss(owner, "combat flee")
|
|
return p.eachSeat(ct, fmt.Sprintf(
|
|
"🏃 The party broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
|
|
default:
|
|
return p.eachSeat(ct, "The fight is over.")
|
|
}
|
|
}
|
|
|
|
// finishPartyWin pays the party out. The kill is the expedition's, so threat and
|
|
// the zone-kill record resolve once through the owner; XP and loot are the
|
|
// character's, so every member rolls their own — loot independently, per the C1
|
|
// no-split rule, and XP in full, per accessibility over crunch.
|
|
func (p *AdventurePlugin) finishPartyWin(
|
|
ct *combatTurn, run *DungeonRun, zone ZoneDefinition, monster DnDMonsterTemplate, elite bool, cadence int,
|
|
) []string {
|
|
sess := ct.sess
|
|
owner := id.UserID(sess.UserID)
|
|
|
|
recordZoneKillForUser(owner, sess.EnemyID)
|
|
applyRoomCombatThreatForUser(owner, elite)
|
|
|
|
bossOnExpedition := false
|
|
if !elite {
|
|
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
|
|
_ = applyBossDefeatThreat(exp.ID)
|
|
bossOnExpedition = true
|
|
}
|
|
}
|
|
|
|
tier := 1
|
|
if run != nil {
|
|
tier = int(zone.Tier)
|
|
}
|
|
shared := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence)
|
|
emoji := "✅"
|
|
if !elite {
|
|
emoji = "🏆"
|
|
}
|
|
|
|
out := make([]string, sess.RosterSize())
|
|
for seat := range sess.RosterSize() {
|
|
uid := id.UserID(sess.seatUserID(seat))
|
|
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
|
|
|
// A member who went down before the killing blow still earns the kill —
|
|
// but at a fifth of their pool or less, the XP path calls it near-death.
|
|
nearDeath := hpMax > 0 && hp*5 < hpMax
|
|
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
|
|
if _, err := p.grantDnDXP(uid, xp); err != nil {
|
|
slog.Error("combat: party grantDnDXP", "user", uid, "err", err)
|
|
}
|
|
}
|
|
|
|
var b strings.Builder
|
|
if shared != "" && seat == 0 {
|
|
b.WriteString(shared + "\n")
|
|
}
|
|
b.WriteString(fmt.Sprintf("%s **%s** down. The party stands.\n", emoji, ct.enemy.Name))
|
|
if hp <= 0 {
|
|
// They won it from the floor. Down is down: the hospital takes them.
|
|
markAdventureDead(uid, "zone", zone.Display)
|
|
b.WriteString("💀 You didn't see it fall — you were already down.\n")
|
|
} else {
|
|
b.WriteString(fmt.Sprintf("You finished at **%d/%d HP**.\n", hp, hpMax))
|
|
if drop := p.dropZoneLoot(uid, zone.ID, monster, !elite, elite); drop != "" {
|
|
b.WriteString(drop + "\n")
|
|
}
|
|
}
|
|
switch {
|
|
case bossOnExpedition && seat == 0:
|
|
b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.")
|
|
case bossOnExpedition:
|
|
b.WriteString("🎉 **Zone cleared — the expedition is won.** Your leader marches the party out.")
|
|
case seat == 0:
|
|
b.WriteString(continueHint(owner))
|
|
default:
|
|
b.WriteString("Waiting on your leader to move the party on.")
|
|
}
|
|
out[seat] = b.String()
|
|
}
|
|
return out
|
|
}
|
|
|
|
// finishPartyLoss ends the run for everyone. The whole roster is down — the turn
|
|
// engine only reports Lost once anyAlive() goes false — so every member takes
|
|
// the death, and the owner's run and expedition are torn down once.
|
|
func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, cadence int) []string {
|
|
sess := ct.sess
|
|
owner := id.UserID(sess.UserID)
|
|
|
|
if run, _ := getZoneRun(sess.RunID); run != nil {
|
|
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
|
|
}
|
|
_ = abandonZoneRun(owner)
|
|
forceExtractExpeditionForRunLoss(owner, "combat death")
|
|
|
|
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
|
|
out := make([]string, sess.RosterSize())
|
|
for seat := range sess.RosterSize() {
|
|
markAdventureDead(id.UserID(sess.seatUserID(seat)), "zone", zone.Display)
|
|
var b strings.Builder
|
|
if line != "" && seat == 0 {
|
|
b.WriteString(line + "\n")
|
|
}
|
|
b.WriteString(fmt.Sprintf("💀 The party fell to **%s**. Run ended.", ct.enemy.Name))
|
|
out[seat] = b.String()
|
|
}
|
|
return out
|
|
}
|
|
|
|
// eachSeat gives every seat the same close-out block.
|
|
func (p *AdventurePlugin) eachSeat(ct *combatTurn, block string) []string {
|
|
out := make([]string, ct.sess.RosterSize())
|
|
for i := range out {
|
|
out[i] = block
|
|
}
|
|
return out
|
|
}
|