Files
gogobee/internal/plugin/dnd_expedition_camp.go
prosolis d76c63be0c Review follow-ups A + B: armed abilities survive the fight, supply pool serialized
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
2026-07-10 08:01:51 -07:00

490 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
)
// Phase 12 E1e — basic camp system (rough + standard).
// Spec: gogobee_expedition_system.md §5. Fortified and Base camps are
// deliverables of E2 (Threat Clock & Night Phase) and E4 (Multi-Region)
// respectively — !camp explicitly rejects those types here so the
// surface is forward-compatible.
const (
CampTypeRough = "rough"
CampTypeStandard = "standard"
CampTypeFortified = "fortified"
CampTypeBase = "base"
)
// campSupplyCost — extra SU consumed at pitch time (§5.1).
var campSupplyCost = map[string]float32{
CampTypeRough: 0.5,
CampTypeStandard: 1.0,
CampTypeFortified: 2.0,
CampTypeBase: 3.0,
}
// !camp <type> — establish a camp on the current expedition.
//
// Validation (§5.2):
// - cannot camp in an active-enemy room
// - cannot camp in a trap room
// - cannot camp in a boss room
// - non-cleared room ⇒ forced rough camp regardless of intent
//
// Until !advance is wired into the expedition layer (a later phase),
// the player is treated as standing at the entry room (cleared by
// definition), so all rooms read as cleared and camp validation only
// rejects the never-applicable boss/trap/active cases.
func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
c, err := LoadDnDCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
}
if c == nil || c.PendingSetup {
return p.SendDM(ctx.Sender,
"No Adv 2.0 character yet — run `!setup` first.")
}
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
if exp == nil {
return p.SendDM(ctx.Sender,
"No active expedition. `!camp` only works during an expedition. Use `!expedition start` first.")
}
args = strings.TrimSpace(strings.ToLower(args))
requested, rest := splitFirstWord(args)
if requested == "" {
return p.SendDM(ctx.Sender, campHelpText(exp))
}
// Bare `!camp` reads the party's tent; pitching and striking it are the
// leader's, since the camp is a property of the expedition everyone rides.
if !isLeader {
return p.SendDM(ctx.Sender,
"Your party leader pitches the camp. `!camp` on its own shows what's already up.")
}
if requested == "break" || requested == "down" || requested == "leave" {
return p.campBreak(ctx, exp)
}
switch requested {
case "rough", "standard":
// allowed in E1e
case "fortified":
if !exp.BossDefeated {
return p.SendDM(ctx.Sender,
"Fortified camps require a defeated zone boss. Clear the zone first.")
}
case "base":
// E4d: §11.1 — base camps unlock per region after the region
// boss is defeated, and only at base-camp-eligible sites.
if !IsMultiRegionZone(exp.ZoneID) {
return p.SendDM(ctx.Sender,
"Base camps are a multi-region waypoint feature — only available in Tier 45 zones (Underdark / Dragon's Lair / Abyss Portal).")
}
region, ok := CurrentRegion(exp)
if !ok {
return p.SendDM(ctx.Sender,
"Couldn't resolve your current region — try `!region` first.")
}
if !region.BaseCampSite {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"**%s** isn't a base-camp-eligible site. Look for a region whose boss has fallen and the geography is defensible.",
region.Name))
}
if !IsRegionCleared(exp, region.ID) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You can pitch a base camp in **%s** only after defeating its region boss (%s).",
region.Name, region.RegionBoss))
}
default:
return p.SendDM(ctx.Sender,
"Unknown camp type. Try `rough` (any location) or `standard` (cleared rooms).")
}
_ = rest
return p.campPitch(ctx, exp, requested)
}
func campHelpText(exp *Expedition) string {
var b strings.Builder
b.WriteString("**!camp <type>** — _override._ Autopilot pitches camp at night automatically; reach for this only to force a rest right now.\n\n")
b.WriteString("`!camp rough` — partial rest (any location, +0.5 SU, high night risk)\n")
b.WriteString("`!camp standard` — full long rest (cleared room, +1 SU, medium risk)\n")
b.WriteString("`!camp fortified` — long rest + bonus (zone boss defeated, +2 SU, low risk)\n")
b.WriteString("`!camp base` — persistent waypoint (region-boss-cleared base-camp site, +3 SU, very low risk)\n")
b.WriteString("`!camp break` — break camp\n\n")
if exp.Camp != nil && exp.Camp.Active {
b.WriteString(fmt.Sprintf("_Currently camped: **%s** (room %d)._",
exp.Camp.Type, exp.Camp.RoomIndex+1))
}
return b.String()
}
func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind string) error {
if exp.Camp != nil && exp.Camp.Active {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You're already camped (**%s**). `!camp break` first.", exp.Camp.Type))
}
// Room-context validation. Once !advance is wired into expeditions
// this resolves to the actual current room from the linked DungeonRun.
// For now, the entry room (always cleared) is the assumed location.
cleared, problem := campLocationCheck(exp)
if problem != "" {
return p.SendDM(ctx.Sender, problem)
}
if !cleared && kind == CampTypeStandard {
// §5.2: standard camp requires a cleared room. Reject explicitly
// rather than silently downgrading — the player should make the call.
return p.SendDM(ctx.Sender,
"Standard camp needs a cleared room. This room isn't cleared yet — clear it first, or `!camp rough` for a partial rest here.")
}
cost, ok := campSupplyCost[kind]
if !ok {
cost = 0.5
}
if exp.Supplies.Current < cost {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Not enough supplies for that camp (need **%.1f SU**, have **%.1f SU**). Try `!camp rough` or break camp and conserve.",
cost, exp.Supplies.Current))
}
// D2-b: event-anchored manual !camp counts as the night camp if it's
// the first camp since the last rollover. Burn supplies *before* the
// camp cost so the burn lands against pre-pitch supplies (matches the
// legacy morning-burn ordering), then pitch, then drift.
nightCamp := false
var nightBurn float32
var nightRoll nightRolloverResult
if isEventAnchored(exp) {
var since time.Duration
if exp.LastBriefingAt != nil {
since = time.Now().UTC().Sub(*exp.LastBriefingAt)
} else {
since = time.Now().UTC().Sub(exp.StartDate)
}
if since >= nightCampWindow {
nightCamp = true
burn, err := p.nightRolloverBurn(exp)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't burn night supplies: "+err.Error())
}
nightBurn = burn
}
}
// The affordability check above ran against `exp` as it was read, and
// nightRolloverBurn may have moved the pool since. Deduct against the pool
// as it stands — the same arithmetic, just not onto a stale snapshot.
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
next := fresh.Supplies
next.Current -= cost
return next, nil
})
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
}
exp.Supplies = pooled
camp := &CampState{
Active: true,
Type: kind,
RoomIndex: campCurrentRoomIndex(exp),
EstablishedAt: time.Now().UTC(),
NightEvents: []string{},
}
if err := updateCamp(exp.ID, camp); err != nil {
return p.SendDM(ctx.Sender, "Couldn't pitch camp: "+err.Error())
}
exp.Camp = camp
// Apply the long-rest effects immediately so the player isn't waiting
// until the next 06:00 UTC briefing for HP and spell slots to come
// back. The flag tells processOvernightCamp not to re-apply at briefing.
restSummary := applyCampRest(exp, kind)
if nightCamp {
nightRoll = p.nightRolloverDrift(exp, time.Now().UTC())
nightRoll.Burn = nightBurn
}
camp.RestApplied = true
if err := updateCamp(exp.ID, camp); err != nil {
slog.Warn("camp: mark rest applied", "expedition", exp.ID, "err", err)
}
// E4d: pick the BaseCampEstablished pool for base camps; otherwise
// the generic camp pool. Both already handle [N] day interpolation
// for the base-camp first-pitch message.
var line string
if kind == CampTypeBase {
line = flavor.Pick(flavor.BaseCampEstablished)
line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", exp.CurrentDay))
} else {
line = flavor.Pick(flavor.CampEstablished)
}
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "rest",
fmt.Sprintf("camp pitched (%s) — %.1f SU consumed", kind, cost), line)
// Mark the region as a persistent base-camp waypoint on first pitch.
if kind == CampTypeBase {
if region, ok := CurrentRegion(exp); ok {
if _, err := addRegionListEntry(exp, regionStateBaseCampKey, region.ID); err != nil {
return p.SendDM(ctx.Sender, "Couldn't record base camp waypoint: "+err.Error())
}
}
}
var b strings.Builder
b.WriteString(fmt.Sprintf("⛺ **Camp established — %s.**\n", kind))
b.WriteString(fmt.Sprintf("Supplies: %.1f / %.1f SU (%.1f).\n",
exp.Supplies.Current, exp.Supplies.Max, cost))
if line != "" {
b.WriteString("\n" + line + "\n")
}
if restSummary != "" {
b.WriteString("\n" + restSummary + "\n")
}
switch kind {
case CampTypeBase:
b.WriteString("\n_Base camp — **waypoint persisted**. Camp here again at no eligibility cost on later returns._")
}
if nightCamp {
b.WriteString(fmt.Sprintf("\n\n🌙 **Day %d.** _Overnight burn: %.1f SU._\n", exp.CurrentDay, nightRoll.Burn))
for _, tl := range nightRoll.TemporalLines {
b.WriteString("\n🌀 " + tl + "\n")
}
for _, ml := range nightRoll.MilestoneLines {
b.WriteString("\n" + ml)
}
}
return p.SendDM(ctx.Sender, b.String())
}
// applyCampRest runs the long-rest effects (HP/spells/threat/heat) for the
// given camp kind and returns a player-facing summary. Shared by camp pitch
// (immediate rest) and overnight rollover (legacy deferred path). Does NOT
// break the camp — callers decide that.
func applyCampRest(e *Expedition, kind string) string {
uid := id.UserID(e.UserID)
c, _ := LoadDnDCharacter(uid)
if c == nil {
return ""
}
// Babysit safe-rest: an active subscription promotes a Standard camp
// to Fortified for rest purposes (no need for boss-cleared arrangements).
// Rough/Base are unchanged — Rough still implies no shelter, and Base
// already exceeds Fortified.
babysitUpgraded := false
if kind == CampTypeStandard && BabysitSafeRest(uid) {
kind = CampTypeFortified
babysitUpgraded = true
}
prevHP := c.HPCurrent
bonusHP := 0
switch kind {
case CampTypeRough:
half := c.HPMax / 2
if c.HPCurrent < half {
c.HPCurrent = half
}
case CampTypeStandard:
c.HPCurrent = c.HPMax
c.TempHP = 0
if c.Exhaustion > 0 {
c.Exhaustion--
}
case CampTypeFortified, CampTypeBase:
c.HPCurrent = c.HPMax
c.TempHP = 0
if c.Exhaustion > 0 {
c.Exhaustion--
}
bonusHP = 1 + rand.IntN(6)
c.HPCurrent += bonusHP
if c.HPCurrent > c.HPMax {
c.HPCurrent = c.HPMax
}
}
_ = SaveDnDCharacter(c)
if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase {
_ = refreshAllResources(uid)
_ = refreshSpellSlots(uid)
_ = ReplenishHarvestNodes(e)
}
// Threat reduction (§8.1: -5 for fortified long rest).
if kind == CampTypeFortified || kind == CampTypeBase {
_ = applyThreatDelta(e.ID, -5, "long rest in fortified camp")
}
// §7.3 Underforge: fortified rest drops heat stacks by 2.
heatReduced := 0
if (kind == CampTypeFortified || kind == CampTypeBase) && e.ZoneID == ZoneUnderforge {
before := e.TemporalStack
after := reduceUnderforgeHeat(e)
heatReduced = before - after
}
switch kind {
case CampTypeRough:
if c.HPCurrent > prevHP {
return fmt.Sprintf("Rough rest: HP %d → %d.", prevHP, c.HPCurrent)
}
return "Rough rest: no HP gain (already above the half-HP floor)."
case CampTypeStandard:
return fmt.Sprintf("Long rest: HP %d → %d, spell slots & resources refreshed.", prevHP, c.HPCurrent)
case CampTypeFortified, CampTypeBase:
label := "Fortified rest"
if babysitUpgraded {
label = "Fortified rest (babysitter watching the camp)"
}
summary := fmt.Sprintf("%s: HP %d → %d (+1d6 = %d bonus); threat clock 5; resources refreshed.",
label, prevHP, c.HPCurrent, bonusHP)
if heatReduced > 0 {
summary += fmt.Sprintf(" Heat stacks %d.", heatReduced)
}
return summary
}
return ""
}
// processOvernightCamp handles the briefing-time half of the camp lifecycle:
// auto-breaks the camp, and applies the long rest only if it wasn't already
// applied at pitch time. Returns a one-line summary for the briefing body, or
// "" if there was nothing to do.
func processOvernightCamp(e *Expedition) string {
if e.Camp == nil || !e.Camp.Active {
return ""
}
kind := e.Camp.Type
alreadyApplied := e.Camp.RestApplied
_ = updateCamp(e.ID, nil)
e.Camp = nil
if alreadyApplied {
return ""
}
return applyCampRest(e, kind)
}
func (p *AdventurePlugin) campBreak(ctx MessageContext, exp *Expedition) error {
if exp.Camp == nil || !exp.Camp.Active {
return p.SendDM(ctx.Sender, "No camp to break.")
}
if err := updateCamp(exp.ID, nil); err != nil {
return p.SendDM(ctx.Sender, "Couldn't break camp: "+err.Error())
}
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "camp broken", "")
return p.SendDM(ctx.Sender, "Camp struck. The dungeon awaits.")
}
// campLocationCheck enforces §5.2 placement rules. Returns (clearedRoom, problem).
// Until !advance wires into expeditions, the player is at the entry room
// (always cleared, never trap/boss/active-enemy) so this currently always
// returns (true, "").
func campLocationCheck(exp *Expedition) (cleared bool, problem string) {
if exp.RunID == "" {
// No room state — treat as the entry room. Cleared by definition.
return true, ""
}
run, err := getZoneRun(exp.RunID)
if err != nil || run == nil {
// Run was wiped/abandoned out from under us; behave like entry.
return true, ""
}
rt := run.CurrentRoomType()
switch rt {
case RoomBoss:
return false, "You can't camp in a boss room. The room knows it's a boss room."
case RoomTrap:
return false, "You can't camp in a trap room — even a disarmed one."
}
for _, idx := range run.RoomsCleared {
if idx == run.CurrentRoom {
return true, ""
}
}
// Not yet advanced-past, but the only thing that bars a rest is a live
// fight. Forward-only navigation means players pause right after a kill
// before advancing, and peaceful/exploration/loot rooms never spawn an
// encounter at all — both are safe to rest in. The "cleared" flag would
// otherwise reject standard camp here with a misleading "clear it first".
encID := encounterIDForRoom(run.CurrentRoom)
sess, err := getCombatSessionForEncounter(run.RunID, encID)
if err != nil {
// Can't read the encounter's combat state. Fail open like the
// run-lookup path above rather than blocking standard camp with an
// empty (misleading "not cleared") rejection — a DB hiccup shouldn't
// strand a player who only wants to rest.
slog.Warn("camp: combat session lookup failed; allowing camp",
"run", run.RunID, "encounter", encID, "err", err)
return true, ""
}
if sess != nil && sess.Status == CombatStatusActive {
return false, "You can't camp mid-fight — finish the encounter first."
}
return true, ""
}
// autoBreakCampOnMove strikes an active camp when the player has moved
// to a different room than the one camp was pitched in. Camp is a
// stationary intent (long-rest at this spot until the next briefing);
// once the party walks on — whether by autopilot, manual !advance, or
// fork pick — the tent stops mattering. Overnight rest effects are not
// applied here (those only land at briefing time via
// processOvernightCamp). Returns the camp type that was struck, or ""
// if no break happened. Safe to call when there's no expedition.
func autoBreakCampOnMove(userID id.UserID) string {
exp, err := getActiveExpedition(userID)
if err != nil || exp == nil {
return ""
}
if exp.Camp == nil || !exp.Camp.Active {
return ""
}
if exp.RunID == "" {
return ""
}
run, err := getZoneRun(exp.RunID)
if err != nil || run == nil {
return ""
}
if run.CurrentRoom == exp.Camp.RoomIndex {
return ""
}
kind := exp.Camp.Type
if err := updateCamp(exp.ID, nil); err != nil {
slog.Warn("camp: auto-break on move failed", "expedition", exp.ID, "err", err)
return ""
}
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "camp struck (party moved on)", "")
return kind
}
// campCurrentRoomIndex returns 0 (entry) when no room context exists.
func campCurrentRoomIndex(exp *Expedition) int {
if exp.RunID == "" {
return 0
}
run, err := getZoneRun(exp.RunID)
if err != nil || run == nil {
return 0
}
return run.CurrentRoom
}