mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Three defects, all the same mistake, all found by sweep and not by tests: the companion has no database row for a thing to persist onto, so the thing "arrives fresh next time" — which for a resource means infinite. 1. His spell slots refilled every fight. The ledger went on his combat SEAT, and a seat is per-session. A human rations one pool across a 30-room run and gets it back at camp; rationing it IS the caster's game. Now on expedition_party.companion_slots_used, refreshed at camp. (Worth ~0pp alone — a run holds only ~2 real fights, so the pool never binds. I predicted this was the whole answer. It was not.) 2. His BODY refilled every fight. buildFightSeats seated him at Stats.MaxHP and the close-out skipped him — "he arrives fresh next time", said the comment. That is an infinite body: he soaked a share of every fight's incoming and then reset, while the humans beside him bled all the way to camp. THIS was the carry. Now expedition_party.companion_hp; healed at camp; a dropped companion returns on 1 HP rather than as a corpse, because there is no companion-death rule and inventing one inside a bug fix would be a second feature. 3. No autopiloted caster had ever healed ITSELF. simPickAllyHeal skipped `i == seat` and bailed on !IsParty(), so a solo cleric carried cure_wounds for a whole run and never once cast it. Now simPickHeal: heal whoever is worst off, which is sometimes you. Measured, 640 runs/arm, like-for-like (the leaders whose role-fill gives Pete a Cleric, against a human Cleric follower of the leader's own level): solo 69.0% + a human cleric 77.6% (+8.6pp) + Pete 66.1% (-2.9pp) The reference arm is the point. Against SOLO even a mace-only Pete looked like a carry — but parties are designed to be safer, so solo is the wrong yardstick. Against a human peer the real bug appeared: a gearless, level-penalized hireling was out-clearing a fully-geared human cleric of the leader's own level by 15pp, because he was the only combatant in the game who healed to full between fights. With the free lunches gone he is honest, and honestly a net negative — which is exactly the plan's §2 diagnosis, unmasked: a below-median seat cannot pay for its own enemy scaling (+15% boss HP and 2.4 enemy actions a round instead of 1). §2(a) is next, and the sweep now argues FOR it; before this commit it would have made things worse. Self-heal moved solo 66.1% -> 66.2%, so the balance corpus is undisturbed and no re-baseline is owed. It is also NOT the answer to §6 — casters reach for a healing consumable first and the sim stocks them, so a human rarely falls through to the spell. Pete carries no consumables, so it is his only heal. Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
261 lines
10 KiB
Go
261 lines
10 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() {
|
|
// The companion has no sheet, so none of the sheet-keyed bookkeeping below
|
|
// applies to him — and postCombatBookkeepingForSeat logs at ERROR for a
|
|
// seat with no sheet, which would file one for every party fight he is ever
|
|
// hired for. But his HP is not bookkeeping: it is the fight's result. It
|
|
// lands on his roster row, because that is the only row he has.
|
|
//
|
|
// He used to be skipped outright, and "he arrives fresh next time" was the
|
|
// stated intent. It is a free lunch — see companionSeatHP.
|
|
if isCompanionUser(sess.seatUserID(seat)) {
|
|
_ = setCompanionHPForRun(sess.RunID, sess.seatHP(seat))
|
|
continue
|
|
}
|
|
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:
|
|
endRunOnLoss(owner, sess.RunID, false)
|
|
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)
|
|
|
|
bossOnExpedition := p.applyOwnerWinEffects(owner, sess.EnemyID, elite)
|
|
|
|
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)
|
|
|
|
// The hired companion takes no cut. He earns no XP (there is no sheet to
|
|
// put it on), rolls no loot (dropZoneLoot writes real inventory rows for
|
|
// whatever id it is handed, and a bot with a magic sword is nobody's
|
|
// intent), and takes no death row. His seat renders nothing: he is not
|
|
// reading this. He was paid up front.
|
|
if isCompanionSeat(uid) {
|
|
out[seat] = ""
|
|
continue
|
|
}
|
|
|
|
// A member who went down before the killing blow still earns the kill.
|
|
p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
|
|
|
|
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")
|
|
}
|
|
}
|
|
if !elite {
|
|
writeBossEpilogue(&b, zone.ID)
|
|
}
|
|
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)
|
|
|
|
endRunOnLoss(owner, sess.RunID, true)
|
|
|
|
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
|
|
out := make([]string, sess.RosterSize())
|
|
for seat := range sess.RosterSize() {
|
|
uid := id.UserID(sess.seatUserID(seat))
|
|
// The companion does not die. markAdventureDead is a silent no-op for him
|
|
// today (no player_meta row to mark), but relying on that accident is how
|
|
// he ends up in the graveyard the first time someone gives him a row —
|
|
// and emitDeathNews would have the news bot file a death notice about
|
|
// itself. Say it out loud instead.
|
|
if isCompanionSeat(uid) {
|
|
out[seat] = ""
|
|
continue
|
|
}
|
|
markAdventureDead(uid, "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
|
|
}
|
|
|
|
// ── Shared terminal effects (item D) ─────────────────────────────────────────
|
|
//
|
|
// finishCombatSession (solo, combat_cmd.go) and finishPartyWin/finishPartyLoss
|
|
// (party, above) still write their own player-facing text — the solo line says
|
|
// "You finished", the party line says "The party stands", and the death-on-win
|
|
// rule is deliberately roster-size-dependent (see item E). But the *effects*
|
|
// they fire were hand-copied lists, and item A drifted exactly there. These
|
|
// helpers are the single copy of each effect; both close-outs route through
|
|
// them so the effect lists cannot diverge again.
|
|
|
|
// applyOwnerWinEffects fires the terminal effects a win owes the run and
|
|
// expedition — the zone-kill record, room threat, and the boss-defeat threat
|
|
// drop — once, through the row's owner (a party member owns neither row).
|
|
// Returns whether the kill was a zone boss on an active expedition; both
|
|
// close-outs use it to reframe the final line as the expedition's climax.
|
|
func (p *AdventurePlugin) applyOwnerWinEffects(owner id.UserID, enemyID string, elite bool) (bossOnExpedition bool) {
|
|
recordZoneKillForUser(owner, enemyID)
|
|
applyRoomCombatThreatForUser(owner, elite)
|
|
if !elite {
|
|
// §8.1 — a zone boss defeat drops expedition threat. Silent no-op for a
|
|
// standalone zone run with no active expedition.
|
|
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
|
|
_ = applyBossDefeatThreat(exp.ID)
|
|
bossOnExpedition = true
|
|
}
|
|
}
|
|
return bossOnExpedition
|
|
}
|
|
|
|
// grantSeatWinXP pays one combatant their kill XP. A seat that finished at a
|
|
// fifth of its pool or less takes the near-death path. Full XP per person,
|
|
// solo or party, per the accessibility-over-crunch stance.
|
|
func (p *AdventurePlugin) grantSeatWinXP(uid id.UserID, hp, hpMax int, monster DnDMonsterTemplate, tier int) {
|
|
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: grantDnDXP", "user", uid, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// endRunOnLoss tears down the owner's run and wrapping expedition when a fight
|
|
// ends in death or flight. On a death (not a flee) it also stamps the run's
|
|
// mood event. Owner-scoped, fires once; the per-seat death mark stays with the
|
|
// caller, since it is decided per HP and gated on roster size.
|
|
func endRunOnLoss(owner id.UserID, runID string, death bool) {
|
|
if death {
|
|
if run, _ := getZoneRun(runID); run != nil {
|
|
_, _ = applyMoodEvent(runID, MoodEventPlayerDeath)
|
|
}
|
|
}
|
|
_ = abandonZoneRun(owner)
|
|
reason := "combat flee"
|
|
if death {
|
|
reason = "combat death"
|
|
}
|
|
forceExtractExpeditionForRunLoss(owner, reason)
|
|
}
|