Review follow-up D: unify solo/party close-out effects

Both the solo (finishCombatSession) and party (finishPartyWin/Loss) close-outs
carried hand-copied lists of the same terminal effects. Item A drifted exactly
there. Hoist the effects into three shared helpers so the lists can't diverge:

- applyOwnerWinEffects: kill record + room threat + boss-defeat threat, once
  through the owner; returns bossOnExpedition.
- grantSeatWinXP: near-death calc + XP grant, per seat.
- endRunOnLoss: mood event (death only) + run/expedition teardown, shared by
  the Lost and Fled paths.

Player-facing text stays divergent (it legitimately differs) and the
roster-size death-on-win rule (item E) is untouched. Pure extract-and-call;
full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 08:50:15 -07:00
parent 91eeee0826
commit d7a5333048
2 changed files with 68 additions and 50 deletions

View File

@@ -354,28 +354,12 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
var b strings.Builder
switch sess.Status {
case CombatStatusWon:
recordZoneKillForUser(userID, sess.EnemyID)
applyRoomCombatThreatForUser(userID, elite)
// zoneCombatXP only reads PlayerWon + NearDeath off the result.
nearDeath := sess.PlayerHPMax > 0 && sess.PlayerHP*5 < sess.PlayerHPMax
tier := 1
if run != nil {
tier = int(zone.Tier)
}
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
if _, err := p.grantDnDXP(userID, xp); err != nil {
slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err)
}
}
bossOnExpedition := false
if !elite {
// §8.1 — zone boss defeat drops expedition threat. Silent no-op
// for standalone zone runs (no active expedition).
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
_ = applyBossDefeatThreat(exp.ID)
bossOnExpedition = true
}
}
bossOnExpedition := p.applyOwnerWinEffects(userID, sess.EnemyID, elite)
p.grantSeatWinXP(userID, sess.PlayerHP, sess.PlayerHPMax, monster, tier)
if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" {
b.WriteString(line + "\n")
}
@@ -399,11 +383,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
}
case CombatStatusLost:
if run != nil {
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
}
_ = abandonZoneRun(userID)
forceExtractExpeditionForRunLoss(userID, "combat death")
endRunOnLoss(userID, sess.RunID, true)
markAdventureDead(userID, "zone", zone.Display)
if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" {
b.WriteString(line + "\n")
@@ -414,8 +394,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
// Flee = run ends, light penalty: wounds persist (HP already saved),
// but no death timer. Chosen candidate from the migration plan's
// open question on flee outcome.
_ = abandonZoneRun(userID)
forceExtractExpeditionForRunLoss(userID, "combat flee")
endRunOnLoss(userID, sess.RunID, false)
b.WriteString(fmt.Sprintf("🏃 You broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
default:

View File

@@ -65,8 +65,7 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
case CombatStatusLost:
return p.finishPartyLoss(ct, zone, cadence)
case CombatStatusFled:
_ = abandonZoneRun(owner)
forceExtractExpeditionForRunLoss(owner, "combat flee")
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:
@@ -84,16 +83,7 @@ func (p *AdventurePlugin) finishPartyWin(
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
}
}
bossOnExpedition := p.applyOwnerWinEffects(owner, sess.EnemyID, elite)
tier := 1
if run != nil {
@@ -110,14 +100,8 @@ func (p *AdventurePlugin) finishPartyWin(
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)
}
}
// 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 {
@@ -156,11 +140,7 @@ func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, c
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")
endRunOnLoss(owner, sess.RunID, true)
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
out := make([]string, sess.RosterSize())
@@ -184,3 +164,62 @@ func (p *AdventurePlugin) eachSeat(ct *combatTurn, block string) []string {
}
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)
}