mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42: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.
582 lines
20 KiB
Go
582 lines
20 KiB
Go
package plugin
|
||
|
||
// Phase R3 — Combat-link integration for expeditions.
|
||
// Spec: gogobee_resource_combat_integration.md §4.
|
||
//
|
||
// Surface:
|
||
// • resolveCombatInterrupt — §4.2 d20+tier brackets for harvest interrupts.
|
||
// • runHarvestInterrupt — picks an enemy, runs combat, returns a
|
||
// narration block + endedRun flag for the caller to fold in.
|
||
// • monsterKillTags — bestiary→tag map used by the kill-log writer
|
||
// so RequiresKill resources unlock once their gating monster falls.
|
||
// • recordZoneKill — appends tags into RegionState["kills"][region].
|
||
// • tryPatrolEncounter — Threat-Clock Alert+ pre-room patrol roll.
|
||
//
|
||
// Notes / scope discipline:
|
||
// • The §4.1 "surprise round" mechanic is approximated as one free enemy
|
||
// swing before normal combat. The dnd combat engine doesn't expose a
|
||
// real surprise primitive yet, and a one-shot HP nick captures the
|
||
// intent without forking SimulateCombat. Polish lives in R6.
|
||
// • §4.1 Night Ambush integration is left to the existing wandering-
|
||
// monster system — that path is independent of !advance.
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"math/rand/v2"
|
||
"strings"
|
||
|
||
"gogobee/internal/flavor"
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// retreatThreatBump is the threat penalty applied when the player times
|
||
// out of a combat (PlayerEndHP > 0 but PlayerWon == false). Retreats
|
||
// represent the player breaking off wounded — the run continues, but the
|
||
// zone's awareness ratchets up. Tuned alongside the expedition-difficulty
|
||
// pass (see gogobee_expedition_difficulty.md): low enough not to compound
|
||
// brutally with chained retreats, high enough that 3–4 retreats walks the
|
||
// threat clock toward Stirring.
|
||
//
|
||
// History: pre-Phase-2 the engine's timeout-loss path called
|
||
// abandonZoneRun + retireAllRegionRuns, ending the expedition outright
|
||
// despite the engine's contract ("Timeout = retreat, not lethal blow").
|
||
// That made any single fight loss across a 14-day expedition an
|
||
// auto-fail, which the sim harness exposed as uniform-0% completion
|
||
// across every tier. Splitting the retreat path here was Phase 2's
|
||
// first lever.
|
||
const retreatThreatBump = 5
|
||
|
||
// ── Combat Interrupt (§4.2) ─────────────────────────────────────────────────
|
||
|
||
// CombatInterruptKind is the bucket the d20+tier roll lands in.
|
||
type CombatInterruptKind int
|
||
|
||
const (
|
||
InterruptNone CombatInterruptKind = iota // 1–8
|
||
InterruptNoise // 9–14, threat +2
|
||
InterruptStandard // 15–18, 1 enemy, surprise
|
||
InterruptElite // 19–21, elite enemy, node not depleted
|
||
InterruptPatrol // 22+, 1d3 enemies, harvest fails
|
||
)
|
||
|
||
// resolveCombatInterrupt rolls 1d20 + zone tier + threat-mod, applies
|
||
// class adjustments, and returns the bracket. rollFn is injectable for
|
||
// tests; pass nil for the live d20.
|
||
//
|
||
// threatLevel: 0–100 expedition threat
|
||
// tier: zone tier 1..5
|
||
// class: active character class
|
||
// zoneID: for Ranger wilderness check (§4.1)
|
||
func resolveCombatInterrupt(
|
||
threatLevel, tier int,
|
||
class DnDClass,
|
||
zoneID ZoneID,
|
||
rollFn func() int,
|
||
) (CombatInterruptKind, int) {
|
||
if rollFn == nil {
|
||
rollFn = func() int { return rand.IntN(20) + 1 }
|
||
}
|
||
r := rollFn()
|
||
mod := tier
|
||
if threatLevel > 40 {
|
||
// §4.2: +1 per 20 threat above 40.
|
||
mod += (threatLevel - 40) / 20
|
||
}
|
||
// Ranger: -3 to interrupt roll in wilderness zones (§4.1).
|
||
if class == ClassRanger && wildernessZones[zoneID] {
|
||
mod -= 3
|
||
}
|
||
total := r + mod
|
||
// Phase 5-B: elite bracket raised from 19 to 23 (Phase 3-A finding).
|
||
// At low threat, base d20 + tier+class mod can reach ~22 at most,
|
||
// so elite-from-interrupt is now effectively a *high-threat* event
|
||
// (the +1-per-20-threat-above-40 mod is what pushes totals into
|
||
// the 23+ band). Elite monsters still appear via the Patrol pool,
|
||
// just less often from the d20 interrupt roll itself. Combined
|
||
// with the player power floor + supply-burn cut + threat-drift
|
||
// cut, this lands the live completion curve in the "fairly breezy
|
||
// with some death" band. Elite case sits *above* Patrol in the
|
||
// switch so a 23+ total prefers Elite (single dangerous fight)
|
||
// over Patrol (multi-enemy harvest fail). See
|
||
// gogobee_expedition_difficulty.md Phase 5-B.
|
||
switch {
|
||
case total >= 23:
|
||
return InterruptElite, total
|
||
case total >= 22:
|
||
return InterruptPatrol, total
|
||
case total >= 15:
|
||
return InterruptStandard, total
|
||
case total >= 9:
|
||
return InterruptNoise, total
|
||
default:
|
||
return InterruptNone, total
|
||
}
|
||
}
|
||
|
||
// runHarvestInterrupt resolves a Standard/Elite/Patrol interrupt: picks
|
||
// an enemy from the zone roster, applies the surprise-round HP nick, runs
|
||
// SimulateCombat, persists the kill (R3 kill-log writer), and returns
|
||
// the narration block + endedRun (true if the player went down).
|
||
func (p *AdventurePlugin) runHarvestInterrupt(
|
||
userID id.UserID,
|
||
exp *Expedition,
|
||
run *DungeonRun,
|
||
zone ZoneDefinition,
|
||
kind CombatInterruptKind,
|
||
) (string, bool) {
|
||
elite := kind == InterruptElite
|
||
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, elite)
|
||
if !ok {
|
||
return fmt.Sprintf("_(No %s roster entry — interrupt skipped.)_",
|
||
map[bool]string{true: "elite", false: "standard"}[elite]), false
|
||
}
|
||
|
||
// Surprise round (§4.1): a single free enemy swing nicks HP. We cap at
|
||
// HP-1 so the nick alone can't KO; real combat resolves below. The
|
||
// wounded-entrant clamp (clampSurpriseNick) softens the nick further
|
||
// when the fighter is already below full HP — see that helper for
|
||
// the death-spiral motivation.
|
||
preDmg := surpriseRoundNick(monster, int(zone.Tier))
|
||
dndChar, _ := LoadDnDCharacter(userID)
|
||
if dndChar != nil && preDmg > 0 {
|
||
preDmg = clampSurpriseNick(preDmg, dndChar.HPCurrent, dndChar.HPMax)
|
||
dndChar.HPCurrent -= preDmg
|
||
_ = SaveDnDCharacter(dndChar)
|
||
}
|
||
|
||
preCombatHP, _ := dndHPSnapshot(userID)
|
||
// P6e: the party fights the interrupt. The surprise nick above stays on the
|
||
// leader — it is one free swing at whoever is walking point.
|
||
pres, seated, err := p.runZoneCombatRoster(
|
||
fightRoster(userID), monster, int(zone.Tier), nil, run.DMMood)
|
||
if err != nil {
|
||
return fmt.Sprintf("_(Interrupt combat error: %v.)_", err), false
|
||
}
|
||
result := pres.Seats[0]
|
||
postHP, maxHP := dndHPSnapshot(userID)
|
||
scanMoodEventsFromEvents(run.RunID, pres.Events)
|
||
|
||
var b strings.Builder
|
||
if kind == InterruptPatrol {
|
||
b.WriteString(flavor.Pick(flavor.PatrolEncounter))
|
||
} else {
|
||
b.WriteString(flavor.Pick(flavor.HarvestInterrupt))
|
||
}
|
||
b.WriteString("\n")
|
||
if elite {
|
||
b.WriteString(fmt.Sprintf("⚔️ **Elite interrupt — %s** (HP %d, AC %d)\n",
|
||
monster.Name, monster.HP, monster.AC))
|
||
} else {
|
||
b.WriteString(fmt.Sprintf("⚔️ **%s** (HP %d, AC %d)\n",
|
||
monster.Name, monster.HP, monster.AC))
|
||
}
|
||
if preDmg > 0 {
|
||
b.WriteString(fmt.Sprintf("_Surprise round — %s strikes first for %d HP._\n",
|
||
monster.Name, preDmg))
|
||
}
|
||
|
||
if !result.PlayerWon {
|
||
if result.TimedOut {
|
||
// Retreat: fighter broke off wounded but alive. The engine's
|
||
// contract (combat_engine.go: "Timeout = retreat, not lethal
|
||
// blow") means HP stays where the engine left it and we keep
|
||
// the run going. The harvest slot is forfeit (no kill, no
|
||
// loot) and threat ticks up.
|
||
_ = applyThreatDelta(exp.ID, retreatThreatBump, "combat retreat")
|
||
b.WriteString(fmt.Sprintf("⏳ **%s** outlasts you. You break off, wounded but alive. (Threat +%d.)",
|
||
monster.Name, retreatThreatBump))
|
||
// A party can run out the clock having lost somebody. The retreat is
|
||
// still a retreat — the run goes on — but the fallen are still fallen.
|
||
if line := partyCasualtyLine(closeOutZoneLoss(pres, seated, zone, "expedition")); line != "" {
|
||
b.WriteString("\n")
|
||
b.WriteString(line)
|
||
}
|
||
return b.String(), false
|
||
}
|
||
// True death. The engine only ends a fight the players lost once nobody is
|
||
// standing, so this branch is the whole roster.
|
||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||
_ = abandonZoneRun(userID)
|
||
_ = retireAllRegionRuns(exp)
|
||
_, _, _ = forcedExtractExpedition(exp.ID, "interrupt death")
|
||
closeOutZoneLoss(pres, seated, zone, "expedition")
|
||
if line := flavor.Pick(flavor.PlayerDeath); line != "" {
|
||
b.WriteString(line)
|
||
b.WriteString("\n")
|
||
}
|
||
if len(seated) > 1 {
|
||
b.WriteString(fmt.Sprintf("💀 The party fell to **%s**. Run ended.", monster.Name))
|
||
} else {
|
||
b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name))
|
||
}
|
||
return b.String(), true
|
||
}
|
||
|
||
// Win: kill-log writer. Run-scoped once; loot and death fan out per seat.
|
||
_ = recordZoneKill(exp, monster.ID)
|
||
if line := flavor.Pick(flavor.CombatVictory); line != "" {
|
||
b.WriteString(line)
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d / %d).",
|
||
monster.Name, preCombatHP, postHP, maxHP))
|
||
drop, downed := p.closeOutZoneWin(pres, seated, zone, monster, false, elite, "expedition")
|
||
if drop != "" {
|
||
b.WriteString("\n")
|
||
b.WriteString(drop)
|
||
}
|
||
if line := partyCasualtyLine(downed); line != "" {
|
||
b.WriteString("\n")
|
||
b.WriteString(line)
|
||
}
|
||
return b.String(), false
|
||
}
|
||
|
||
// surpriseRoundNick computes a small HP nick representing the enemy's
|
||
// free first swing. Roughly attack-bonus + 1d4, with a tier-based floor.
|
||
func surpriseRoundNick(m DnDMonsterTemplate, tier int) int {
|
||
return surpriseRoundNickF(m, tier, -1)
|
||
}
|
||
|
||
// surpriseRoundNickF is the floor-parameterized form used by the Phase
|
||
// 3-B sim harness lever sweep. floorOverride < 0 means "use live"
|
||
// (floor = tier); floorOverride >= 0 substitutes that absolute value
|
||
// as the floor (0 disables the floor entirely). Live callers always go
|
||
// through surpriseRoundNick. See gogobee_expedition_difficulty.md
|
||
// Phase 3-B.
|
||
func surpriseRoundNickF(m DnDMonsterTemplate, tier, floorOverride int) int {
|
||
if tier < 1 {
|
||
tier = 1
|
||
}
|
||
dmg := 1 + rand.IntN(4) + m.AttackBonus/2
|
||
floor := tier
|
||
if floorOverride >= 0 {
|
||
floor = floorOverride
|
||
}
|
||
if dmg < floor {
|
||
dmg = floor
|
||
}
|
||
return dmg
|
||
}
|
||
|
||
// clampSurpriseNick scales the surprise-round nick down for fighters who
|
||
// enter combat already wounded. The raw nick is fine on a fresh entry
|
||
// (full HP), but chained interrupts create a death-spiral: an over-tier
|
||
// elite drops the fighter to ~25% HP and a retreat, then the next
|
||
// standard fight's surprise nick — landing on already-low HP — pre-empts
|
||
// the combat engine entirely. The Phase 2 tier-lethality trace
|
||
// (TestExpeditionBalance_Phase2_TierLethality) showed this cascade was
|
||
// the cause of death in 4 of 5 tier traces post-2a, not the elites
|
||
// themselves.
|
||
//
|
||
// Policy: at full HP, raw nick stands. When wounded (HPCurrent < HPMax),
|
||
// cap the nick at max(1, HPCurrent/5) so a wounded fighter loses at most
|
||
// ~20% of remaining HP to the free swing — they enter the fight bruised
|
||
// but with margin to fight back. The existing KO-guard (nick < HP) is
|
||
// preserved as a hard backstop.
|
||
//
|
||
// Tuning surface: the /5 divisor is the wounded-fighter lethality knob.
|
||
// Tighter (e.g. /10) is gentler; looser (/3) re-opens the cascade. See
|
||
// gogobee_expedition_difficulty.md Phase 2b. liveSurpriseNickDivisor
|
||
// names the shipped value; the harness Phase 2 lever sweep
|
||
// (TestExpeditionBalance_Phase2_LeverSweep) calls clampSurpriseNickD
|
||
// directly with alternate divisors. Live callers always go through
|
||
// clampSurpriseNick.
|
||
const liveSurpriseNickDivisor = 5
|
||
|
||
func clampSurpriseNick(rawNick, hpCurrent, hpMax int) int {
|
||
return clampSurpriseNickD(rawNick, hpCurrent, hpMax, liveSurpriseNickDivisor)
|
||
}
|
||
|
||
// clampSurpriseNickD is the divisor-parameterized form used by the
|
||
// sim harness lever sweep. divisor <= 0 falls back to the shipped
|
||
// value so a zero-valued harness override behaves as "use live."
|
||
func clampSurpriseNickD(rawNick, hpCurrent, hpMax, divisor int) int {
|
||
if rawNick <= 0 || hpCurrent <= 0 {
|
||
return 0
|
||
}
|
||
if divisor <= 0 {
|
||
divisor = liveSurpriseNickDivisor
|
||
}
|
||
nick := rawNick
|
||
if hpCurrent < hpMax {
|
||
cap := hpCurrent / divisor
|
||
if cap < 1 {
|
||
cap = 1
|
||
}
|
||
if nick > cap {
|
||
nick = cap
|
||
}
|
||
}
|
||
// KO-guard: surprise alone never finishes the fighter.
|
||
if nick >= hpCurrent {
|
||
nick = hpCurrent - 1
|
||
}
|
||
if nick < 0 {
|
||
nick = 0
|
||
}
|
||
return nick
|
||
}
|
||
|
||
// ── Kill-log writer ─────────────────────────────────────────────────────────
|
||
|
||
// monsterKillTags maps a bestiary ID to the kill-log tags it satisfies.
|
||
// Tags align with ZoneResource.RequiresKill values in dnd_resource_registry.go;
|
||
// some monsters cover multiple gates (an Owlbear is both "owlbear" and
|
||
// generic "beast" for Forest of Shadows pelts).
|
||
func monsterKillTags(bestiaryID string) []string {
|
||
switch bestiaryID {
|
||
case "worg":
|
||
return []string{"worg", "beast"}
|
||
case "owlbear":
|
||
return []string{"owlbear", "beast"}
|
||
case "dire_wolf", "displacer_beast", "dryad_corrupted":
|
||
return []string{"beast"}
|
||
case "kuo_toa", "kuo_toa_whip":
|
||
return []string{"kuo-toa"}
|
||
case "vampire_spawn":
|
||
return []string{"vampire_spawn"}
|
||
case "wraith":
|
||
return []string{"wraith"}
|
||
case "azer":
|
||
return []string{"azer"}
|
||
case "salamander":
|
||
return []string{"salamander"}
|
||
case "helmed_horror", "fire_elemental", "water_elemental":
|
||
return []string{"construct"}
|
||
case "drow", "drow_elite_warrior", "drow_mage":
|
||
return []string{"drow"}
|
||
case "mind_flayer":
|
||
return []string{"illithid"}
|
||
case "will_o_wisp":
|
||
return []string{"will_o_wisp"}
|
||
case "green_hag", "night_hag":
|
||
return []string{"hag"}
|
||
case "boss_thornmother":
|
||
return []string{"thornmother"}
|
||
case "guard_drake":
|
||
return []string{"drake"}
|
||
case "young_red_dragon":
|
||
return []string{"drake"}
|
||
case "boss_infernax":
|
||
return []string{"infernax"}
|
||
case "quasit", "vrock", "hezrou", "nalfeshnee", "marilith":
|
||
return []string{"demon"}
|
||
case "boss_belaxath":
|
||
return []string{"balor", "demon", "portal_boss"}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// recordZoneKill appends every kill-tag for `bestiaryID` to
|
||
// RegionState["kills"][regionKey] and persists. No-op for monsters that
|
||
// gate no resources.
|
||
func recordZoneKill(exp *Expedition, bestiaryID string) error {
|
||
if exp == nil {
|
||
return nil
|
||
}
|
||
tags := monsterKillTags(bestiaryID)
|
||
if len(tags) == 0 {
|
||
return nil
|
||
}
|
||
if exp.RegionState == nil {
|
||
exp.RegionState = map[string]any{}
|
||
}
|
||
table := loadKillsTable(exp)
|
||
regionKey := regionHarvestKey(exp)
|
||
existing := table[regionKey]
|
||
for _, t := range tags {
|
||
if !stringSliceContains(existing, t) {
|
||
existing = append(existing, t)
|
||
}
|
||
}
|
||
table[regionKey] = existing
|
||
exp.RegionState["kills"] = table
|
||
return persistRegionState(exp)
|
||
}
|
||
|
||
// loadKillsTable normalises RegionState["kills"] to map[region][]string
|
||
// regardless of whether it round-tripped through JSON.
|
||
func loadKillsTable(e *Expedition) map[string][]string {
|
||
out := map[string][]string{}
|
||
if e == nil || e.RegionState == nil {
|
||
return out
|
||
}
|
||
raw, ok := e.RegionState["kills"]
|
||
if !ok {
|
||
return out
|
||
}
|
||
switch v := raw.(type) {
|
||
case map[string][]string:
|
||
return v
|
||
case map[string]any:
|
||
// JSON path: re-marshal then re-parse.
|
||
if b, err := json.Marshal(v); err == nil {
|
||
_ = json.Unmarshal(b, &out)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func stringSliceContains(haystack []string, needle string) bool {
|
||
for _, s := range haystack {
|
||
if s == needle {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// recordZoneKillForUser is the zone-combat-side entry point: looks up the
|
||
// player's active expedition (if any) and records the kill against it.
|
||
// Safe when the user is doing a standalone (non-expedition) zone run —
|
||
// recordZoneKill returns nil with no exp.
|
||
func recordZoneKillForUser(userID id.UserID, bestiaryID string) {
|
||
exp, err := getActiveExpedition(userID)
|
||
if err != nil || exp == nil {
|
||
return
|
||
}
|
||
_ = recordZoneKill(exp, bestiaryID)
|
||
}
|
||
|
||
// ── Patrol Encounters (§4.1) ────────────────────────────────────────────────
|
||
|
||
// patrolThreshold is the threat-band floor at which patrols start moving
|
||
// between cleared rooms. Below Alert (level < 41), no patrols.
|
||
const patrolThreatFloor = 41
|
||
|
||
// patrolBaseChance is the per-advance roll for a patrol when the threat
|
||
// clock is at the floor; scales linearly toward Siege.
|
||
const patrolBaseChance = 0.10
|
||
|
||
// rollPatrolChance returns the per-advance probability that a patrol
|
||
// shows up between rooms. 0 below Alert; ~0.10 at 41, ~0.30 at 100.
|
||
func rollPatrolChance(threatLevel int) float64 {
|
||
if threatLevel < patrolThreatFloor {
|
||
return 0
|
||
}
|
||
over := float64(threatLevel - patrolThreatFloor)
|
||
span := float64(100 - patrolThreatFloor)
|
||
if span <= 0 {
|
||
return patrolBaseChance
|
||
}
|
||
return patrolBaseChance + (0.20 * over / span)
|
||
}
|
||
|
||
// tryPatrolEncounter is called from zoneCmdAdvance *before* the next room
|
||
// resolves. If the player's active expedition is at Alert+, we roll for a
|
||
// patrol; on hit, we run a single combat against a non-elite roster entry.
|
||
//
|
||
// Returns staged messages so the patrol fight gets the same 2–3s pacing
|
||
// as room/boss combat — see resolveCombatRoom for the contract. When no
|
||
// patrol fires, intro/phases/outcome are all empty and ended is false.
|
||
func (p *AdventurePlugin) tryPatrolEncounter(
|
||
userID id.UserID,
|
||
run *DungeonRun,
|
||
zone ZoneDefinition,
|
||
) (intro string, phases []string, outcome string, ended bool, err error) {
|
||
exp, gerr := getActiveExpedition(userID)
|
||
if gerr != nil || exp == nil {
|
||
return
|
||
}
|
||
if exp.RunID != run.RunID {
|
||
return
|
||
}
|
||
chance := rollPatrolChance(exp.ThreatLevel)
|
||
if chance <= 0 || rand.Float64() > chance {
|
||
return
|
||
}
|
||
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false)
|
||
if !ok {
|
||
return
|
||
}
|
||
pres, seated, rerr := p.runZoneCombatRoster(
|
||
fightRoster(userID), monster, int(zone.Tier), nil, run.DMMood)
|
||
if rerr != nil {
|
||
err = rerr
|
||
return
|
||
}
|
||
result := pres.Seats[0]
|
||
postHP, maxHP := dndHPSnapshot(userID)
|
||
scanMoodEventsFromEvents(run.RunID, pres.Events)
|
||
|
||
// Intro: patrol-encounter flavor + creature stat block.
|
||
var ib strings.Builder
|
||
ib.WriteString(flavor.Pick(flavor.PatrolEncounter))
|
||
ib.WriteString("\n")
|
||
ib.WriteString(fmt.Sprintf("🛡 **Patrol — %s** (HP %d, AC %d)",
|
||
monster.Name, monster.HP, monster.AC))
|
||
intro = ib.String()
|
||
|
||
// Phases: forward-simulating engine play-by-play.
|
||
playerName := "You"
|
||
if name, _ := loadDisplayName(userID); name != "" {
|
||
playerName = name
|
||
}
|
||
phases = RenderCombatLog(result, playerName, monster.Name)
|
||
|
||
var ob strings.Builder
|
||
if !result.PlayerWon {
|
||
if result.TimedOut {
|
||
// Retreat — see retreatThreatBump doc. Run continues; threat
|
||
// ticks; the patrol's awareness lingers as a soft penalty
|
||
// instead of an auto-fail.
|
||
_ = applyThreatDelta(exp.ID, retreatThreatBump, "patrol retreat")
|
||
ob.WriteString(fmt.Sprintf("⏳ The patrol drags on. You break off, wounded but alive. (Threat +%d.)",
|
||
retreatThreatBump))
|
||
// The run continues, but a party may have left somebody behind.
|
||
if line := partyCasualtyLine(closeOutZoneLoss(pres, seated, zone, "patrol")); line != "" {
|
||
ob.WriteString("\n")
|
||
ob.WriteString(line)
|
||
}
|
||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||
ob.WriteString("\n")
|
||
ob.WriteString(rollLine)
|
||
}
|
||
outcome = ob.String()
|
||
ended = false
|
||
return
|
||
}
|
||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||
_ = abandonZoneRun(userID)
|
||
_ = retireAllRegionRuns(exp)
|
||
_, _, _ = forcedExtractExpedition(exp.ID, "patrol death")
|
||
closeOutZoneLoss(pres, seated, zone, "patrol")
|
||
if line := flavor.Pick(flavor.PlayerDeath); line != "" {
|
||
ob.WriteString(line)
|
||
ob.WriteString("\n")
|
||
}
|
||
if len(seated) > 1 {
|
||
ob.WriteString("💀 The patrol takes the party down. Run ended.")
|
||
} else {
|
||
ob.WriteString("💀 The patrol takes you down. Run ended.")
|
||
}
|
||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||
ob.WriteString("\n")
|
||
ob.WriteString(rollLine)
|
||
}
|
||
outcome = ob.String()
|
||
ended = true
|
||
return
|
||
}
|
||
_ = recordZoneKill(exp, monster.ID)
|
||
ob.WriteString(fmt.Sprintf("✅ Patrol dispatched. You finished at **%d/%d HP**.",
|
||
postHP, maxHP))
|
||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||
ob.WriteString("\n")
|
||
ob.WriteString(rollLine)
|
||
}
|
||
drop, downed := p.closeOutZoneWin(pres, seated, zone, monster, false, false, "patrol")
|
||
if drop != "" {
|
||
ob.WriteString("\n")
|
||
ob.WriteString(drop)
|
||
}
|
||
if line := partyCasualtyLine(downed); line != "" {
|
||
ob.WriteString("\n")
|
||
ob.WriteString(line)
|
||
}
|
||
outcome = ob.String()
|
||
return
|
||
}
|