mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32: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
497 lines
17 KiB
Go
497 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)
|
||
// The companion sleeps at the same fire. His slots and his wounds are not
|
||
// dnd_spell_slots / dnd_character rows — he has none — so nothing above
|
||
// reaches him, and without these two his pool and his body would only ever
|
||
// go down. Camp is where a caster gets their slots back and a body gets
|
||
// patched up, and he is at the camp.
|
||
_ = refreshCompanionSlots(e.ID)
|
||
_ = refreshCompanionHP(e.ID)
|
||
_ = 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
|
||
}
|