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
490 lines
17 KiB
Go
490 lines
17 KiB
Go
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 4–5 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
|
||
}
|