mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Pete's entire taxonomy was arrival, companion_hire, death, milestone,
rival_result and zone_first — every one of them a win, a death, or an
introduction. An expedition that ended with the player walking out alive emitted
nothing at all, so the feed showed a realm where adventurers only ever triumph
or die.
That is not a rare gap. Before the §6 slot work, a cleric retreated on 167 of
500 simulated expeditions: a third of that class's runs ended in a way the news
was structurally incapable of reporting. Casters did not read as unlucky in the
feed. They read as absent.
So: a `retreat` bulletin, filed from forceExtractExpeditionForRunLoss — the one
chokepoint every bad ending already passes through. It carries who, where, and
the day they got to before it came apart.
Gated on the reason, not on "the expedition ended":
- a death already files its own priority dispatch, and must not ALSO be
reported as a retreat;
- an idle reap is not a retreat. A player who closed their laptop did not flee
anything, and Pete announcing by name that they were driven from the field
would be a lie about a person.
The four reason strings were bare literals at their call sites; they are
constants now, because the gate cannot be allowed to drift from them.
The day count is read BEFORE forcedExtractExpedition, which stamps the row
'abandoned' and takes the live fields with it.
Claude-Session: https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
515 lines
20 KiB
Go
515 lines
20 KiB
Go
package plugin
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
"gogobee/internal/flavor"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// Phase 12 E5 — Extraction & Resume.
|
|
// Spec: gogobee_expedition_system.md §10.
|
|
//
|
|
// - Voluntary (§10.1): !extract — burns 1 in-game day, status='extracting',
|
|
// resumable for 7 real days. All loot/XP/coins kept.
|
|
// - Forced (§10.2): triggered by HP-0 / supplies-0 / Abyss collapse.
|
|
// status='abandoned', 20% coin tax on CoinsEarned, NOT resumable.
|
|
// - Resume (§10.3): !resume within 7 days, repurchase supplies, threat
|
|
// and temporal stacks restored as-is.
|
|
//
|
|
// HP-0 and supplies-0 wiring lives with combat / supply hookups (deferred);
|
|
// this commit ships the helpers and wires the existing Abyss-collapse path.
|
|
|
|
const (
|
|
extractResumeWindow = 7 * 24 * time.Hour
|
|
forcedExtractCoinTaxFrac = 0.20
|
|
extractionSweepInterval = time.Hour
|
|
)
|
|
|
|
var (
|
|
ErrNoResumableExpedition = errors.New("no resumable expedition for player")
|
|
ErrResumeWindowExpired = errors.New("expedition resume window expired (7 days)")
|
|
)
|
|
|
|
// voluntaryExtractExpedition burns one in-game day's supplies, advances
|
|
// the day counter, and flips status to 'extracting'. The expedition stays
|
|
// resumable until completed_at + 7 days.
|
|
func voluntaryExtractExpedition(userID id.UserID) (*Expedition, error) {
|
|
e, err := getActiveExpedition(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if e == nil {
|
|
return nil, ErrNoActiveExpedition
|
|
}
|
|
harsh := e.ThreatLevel > 60
|
|
newSupplies, _ := applyExpeditionDailyBurn(e, harsh, e.SiegeMode)
|
|
e.Supplies = newSupplies
|
|
supJSON, _ := json.Marshal(newSupplies)
|
|
if _, err := db.Get().Exec(`
|
|
UPDATE dnd_expedition
|
|
SET status = 'extracting',
|
|
supplies_json = ?,
|
|
current_day = current_day + 1,
|
|
completed_at = CURRENT_TIMESTAMP,
|
|
last_activity = CURRENT_TIMESTAMP
|
|
WHERE expedition_id = ?`, string(supJSON), e.ID); err != nil {
|
|
return nil, fmt.Errorf("voluntary extract: %w", err)
|
|
}
|
|
e.CurrentDay++
|
|
e.Status = ExpeditionStatusExtracting
|
|
_ = retireAllRegionRuns(e)
|
|
return e, nil
|
|
}
|
|
|
|
// forcedExtractExpedition flips an expedition to 'abandoned'. Returns the
|
|
// 20% coin-tax amount the caller should debit (CoinsEarned * 0.20, floored).
|
|
// Reason is appended to the expedition log.
|
|
func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) {
|
|
e, err := getExpedition(expID)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if e == nil {
|
|
return nil, 0, ErrNoActiveExpedition
|
|
}
|
|
tax := int(float64(e.CoinsEarned) * forcedExtractCoinTaxFrac)
|
|
if _, err := db.Get().Exec(`
|
|
UPDATE dnd_expedition
|
|
SET status = 'abandoned',
|
|
completed_at = CURRENT_TIMESTAMP,
|
|
last_activity = CURRENT_TIMESTAMP
|
|
WHERE expedition_id = ?`, expID); err != nil {
|
|
return nil, 0, fmt.Errorf("forced extract: %w", err)
|
|
}
|
|
e.Status = ExpeditionStatusAbandoned
|
|
_ = retireAllRegionRuns(e)
|
|
releaseParty(e.ID)
|
|
return e, tax, nil
|
|
}
|
|
|
|
// The reasons a run can end badly. They were bare strings at four call sites;
|
|
// they are constants now because the news seam has to tell them apart — a
|
|
// retreat is a story and a death is a different story, and an idle reap is
|
|
// neither.
|
|
const (
|
|
lossCombatDeath = "combat death"
|
|
lossCombatRetreat = "combat retreat" // solo: ran out the phase clock and withdrew
|
|
lossCombatFlee = "combat flee" // party: the turn engine broke off
|
|
lossIdleTimeout = "run idle-timeout (§4.3 stale-run reap)"
|
|
)
|
|
|
|
// forceExtractExpeditionForRunLoss bridges run-loss call sites (turn-based
|
|
// elite/boss death or flee, exploration combat death, patrol-interrupt
|
|
// death) into the forced-extract flow. Those sites already abandon the
|
|
// zone run, but without flipping the wrapping expedition the ambient
|
|
// ticker keeps DMing about a dungeon the player walked away from. No-op
|
|
// when there is no active expedition for this user.
|
|
//
|
|
// It is also the one chokepoint every bad ending passes through, which makes it
|
|
// where the retreat dispatch is filed. Read the expedition BEFORE the extract:
|
|
// forcedExtractExpedition stamps it 'abandoned' and zeroes the live fields, so
|
|
// afterwards there is no day count left to report.
|
|
func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) {
|
|
exp, err := getActiveExpedition(userID)
|
|
if err != nil || exp == nil {
|
|
return
|
|
}
|
|
day, zoneID := exp.CurrentDay, exp.ZoneID
|
|
if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil {
|
|
slog.Warn("expedition: force-extract on run loss",
|
|
"user", userID, "expedition", exp.ID, "reason", reason, "err", err)
|
|
return
|
|
}
|
|
emitRetreatNews(userID, reason, zoneID, day)
|
|
}
|
|
|
|
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
|
|
// no outgoing edges) into expedition completion — the success-path twin of
|
|
// forceExtractExpeditionForRunLoss. When the cleared run is the active
|
|
// expedition's current run AND the clear finishes the whole zone (a
|
|
// single-region zone, or the zone-boss region of a multi-region zone), it
|
|
// flips the expedition to 'complete', records boss_defeated, and awards
|
|
// completion milestones. Returns the rendered milestone lines for the caller
|
|
// to append to the run-complete message.
|
|
//
|
|
// No-op (nil) for standalone runs and for mid-zone region clears, which
|
|
// leave the expedition active so inter-region travel can continue. Without
|
|
// this, a cleared zone leaves the expedition 'active' forever and the
|
|
// ambient ticker keeps DMing about a dungeon the player already finished.
|
|
func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID string) []string {
|
|
exp, err := getActiveExpedition(userID)
|
|
if err != nil || exp == nil {
|
|
return nil
|
|
}
|
|
if exp.RunID != runID {
|
|
return nil // the completed run isn't this expedition's current run
|
|
}
|
|
|
|
if IsMultiRegionZone(exp.ZoneID) {
|
|
region, ok := CurrentRegion(exp)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if _, err := MarkRegionBossDefeated(exp, region.ID); err != nil {
|
|
slog.Warn("expedition: mark region boss defeated",
|
|
"user", userID, "expedition", exp.ID, "region", region.ID, "err", err)
|
|
}
|
|
if !region.IsZoneBoss {
|
|
return nil // region cleared; expedition continues to the next region
|
|
}
|
|
} else {
|
|
// Single-region zone: there's no region registry to flip through
|
|
// MarkRegionBossDefeated, so set the zone-level flag directly.
|
|
exp.BossDefeated = true
|
|
if _, err := db.Get().Exec(`
|
|
UPDATE dnd_expedition
|
|
SET boss_defeated = 1,
|
|
last_activity = CURRENT_TIMESTAMP
|
|
WHERE expedition_id = ?`, exp.ID); err != nil {
|
|
slog.Warn("expedition: set boss defeated on zone clear",
|
|
"user", userID, "expedition", exp.ID, "err", err)
|
|
}
|
|
}
|
|
|
|
// completeExpedition must run before AwardCompletionMilestones — the
|
|
// latter gates on status == 'complete'.
|
|
if err := completeExpedition(exp.ID, ExpeditionStatusComplete); err != nil {
|
|
slog.Warn("expedition: complete on zone clear",
|
|
"user", userID, "expedition", exp.ID, "err", err)
|
|
return nil
|
|
}
|
|
exp.Status = ExpeditionStatusComplete
|
|
_ = retireAllRegionRuns(exp)
|
|
p.rollZoneTreasure(userID, exp.ZoneID, advTreasureWeightZoneClear)
|
|
lines := p.AwardCompletionMilestones(exp, false)
|
|
// N6/D3: the Shadow's payoff for this zone — a crow (player was first) or a
|
|
// waiting journal page (the Shadow cleared it first). Appended after the
|
|
// milestone lines so the campaign beat reads last.
|
|
if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" {
|
|
lines = append(lines, sl)
|
|
}
|
|
// News: boss down means the zone is cleared. Realm-first → PRIORITY,
|
|
// repeat → BULLETIN. No-op unless the Pete seam is enabled.
|
|
emitZoneClearNews(userID, exp)
|
|
return lines
|
|
}
|
|
|
|
// midZoneRegionClear reports whether the just-completed run (runID) is the
|
|
// active expedition's current run AND finishes a non-boss region of a
|
|
// multi-region zone — i.e. a region clear that leaves the expedition active
|
|
// with a next region to cross into. Returns the cleared region, the next
|
|
// region, and true in that case; zero-values + false for a full zone clear,
|
|
// a standalone run, or any read error. Used to word the run-complete message
|
|
// (region-cleared vs zone-cleared) without re-deriving region state inline.
|
|
func midZoneRegionClear(userID id.UserID, runID string) (cur, next ExpeditionRegion, ok bool) {
|
|
exp, err := getActiveExpedition(userID)
|
|
if err != nil || exp == nil || exp.RunID != runID || !IsMultiRegionZone(exp.ZoneID) {
|
|
return ExpeditionRegion{}, ExpeditionRegion{}, false
|
|
}
|
|
region, found := CurrentRegion(exp)
|
|
if !found || region.IsZoneBoss {
|
|
return ExpeditionRegion{}, ExpeditionRegion{}, false
|
|
}
|
|
nxt, found := nextRegion(exp.ZoneID, region.ID)
|
|
if !found {
|
|
return ExpeditionRegion{}, ExpeditionRegion{}, false
|
|
}
|
|
return region, nxt, true
|
|
}
|
|
|
|
// getResumableExpedition returns the most recent 'extracting' row for the
|
|
// user, regardless of age (caller checks the 7-day window).
|
|
func getResumableExpedition(userID id.UserID) (*Expedition, error) {
|
|
row := db.Get().QueryRow(`
|
|
SELECT expedition_id, user_id, zone_id, run_id, status,
|
|
start_date, current_day, current_region, boss_defeated,
|
|
supplies_json, camp_json, threat_level, threat_siege,
|
|
threat_events, temporal_stack, region_state,
|
|
xp_earned, coins_earned, gm_mood,
|
|
last_briefing_at, last_recap_at, last_ambient_kind,
|
|
last_activity, completed_at
|
|
FROM dnd_expedition
|
|
WHERE user_id = ?
|
|
AND status = 'extracting'
|
|
ORDER BY completed_at DESC
|
|
LIMIT 1`, string(userID))
|
|
e, err := scanExpedition(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return e, err
|
|
}
|
|
|
|
// extractionLapsed reports whether an 'extracting' row is past its resume
|
|
// window and should be reaped. A NULL completed_at counts as lapsed: the column
|
|
// is the only clock the window has, and a row without one can never expire.
|
|
func extractionLapsed(e *Expedition, now time.Time) bool {
|
|
return e.CompletedAt == nil || now.Sub(*e.CompletedAt) > extractResumeWindow
|
|
}
|
|
|
|
// loadLapsedExtractions returns every extracted expedition whose resume window
|
|
// has closed, regardless of owner.
|
|
func loadLapsedExtractions(now time.Time) ([]*Expedition, error) {
|
|
rows, err := db.Get().Query(`
|
|
SELECT`+expeditionSelectCols+`
|
|
FROM dnd_expedition e
|
|
WHERE e.status = 'extracting'
|
|
AND (e.completed_at IS NULL OR e.completed_at < ?)`,
|
|
now.Add(-extractResumeWindow))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanExpeditionRows(rows)
|
|
}
|
|
|
|
// sweepLapsedExtractions closes out every extraction whose seven-day window has
|
|
// run out, which releases its roster.
|
|
//
|
|
// Without this the window is a promise the code never keeps. `handleResumeCmd`
|
|
// expires a lapsed row lazily, but only the *leader* can call `!resume` — so a
|
|
// leader who quits, forgets, or simply starts a different expedition leaves the
|
|
// row 'extracting' forever, and releaseParty (which fires only on a terminal
|
|
// status) never runs. Every member stays seated, and assertNotAdventuring keeps
|
|
// refusing them a run of their own. `!expedition leave` is their escape, but a
|
|
// player should not have to find it.
|
|
func (p *AdventurePlugin) sweepLapsedExtractions(now time.Time) {
|
|
exps, err := loadLapsedExtractions(now)
|
|
if err != nil {
|
|
slog.Error("expedition: load lapsed extractions", "err", err)
|
|
return
|
|
}
|
|
for _, e := range exps {
|
|
if err := p.reapLapsedExtraction(e); err != nil {
|
|
slog.Warn("expedition: expire lapsed extraction", "expedition", e.ID, "err", err)
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// reapLapsedExtraction closes one lapsed extraction and tells its roster the
|
|
// way back has shut. It is the single reap-with-notify path: the hourly
|
|
// sweeper loops it, and expeditionCmdStart calls it when a player starts a new
|
|
// expedition on top of one whose window has already closed — so a member is
|
|
// never silently unseated by whichever path happens to reach the row first.
|
|
//
|
|
// The audience is read before the close-out: completeExpedition disbands the
|
|
// roster, and expeditionAudience reads that roster.
|
|
func (p *AdventurePlugin) reapLapsedExtraction(e *Expedition) error {
|
|
audience := expeditionAudience(e)
|
|
if err := completeExpedition(e.ID, ExpeditionStatusFailed); err != nil {
|
|
return err
|
|
}
|
|
zone, _ := getZone(e.ZoneID)
|
|
body := fmt.Sprintf(
|
|
"🕯 **The way back has closed — %s**\n\nYour extracted expedition sat past its seven-day resume window, and the dungeon reshaped without you. Day %d is where it ends.\n\nThe loot, XP, and coins you carried out are still yours. `!expedition list` when you're ready for the next one.",
|
|
zone.Display, e.CurrentDay)
|
|
for _, uid := range audience {
|
|
if err := p.SendDM(uid, body); err != nil {
|
|
slog.Warn("expedition: lapsed-extraction DM failed", "user", uid, "expedition", e.ID, "err", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// expeditionExtractionSweepTicker reaps lapsed extractions hourly. The window is
|
|
// seven days, so the cadence only bounds how long a freed member waits to hear
|
|
// about it.
|
|
func (p *AdventurePlugin) expeditionExtractionSweepTicker() {
|
|
ticker := time.NewTicker(extractionSweepInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-p.stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
p.sweepLapsedExtractions(time.Now().UTC())
|
|
}
|
|
}
|
|
}
|
|
|
|
// resumeExpedition flips an extracting row back to 'active' with fresh
|
|
// supplies. Threat/temporal/region state are preserved as-is.
|
|
func resumeExpedition(expID string, supplies ExpeditionSupplies) error {
|
|
supJSON, _ := json.Marshal(supplies)
|
|
_, err := db.Get().Exec(`
|
|
UPDATE dnd_expedition
|
|
SET status = 'active',
|
|
supplies_json = ?,
|
|
completed_at = NULL,
|
|
last_activity = CURRENT_TIMESTAMP
|
|
WHERE expedition_id = ?`, string(supJSON), expID)
|
|
return err
|
|
}
|
|
|
|
// ── !extract command ────────────────────────────────────────────────────────
|
|
|
|
func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
|
userMu := p.advUserLock(ctx.Sender)
|
|
userMu.Lock()
|
|
defer userMu.Unlock()
|
|
|
|
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 to extract from.")
|
|
}
|
|
if !isLeader {
|
|
// Extraction ends the expedition for the whole roster, so it is the
|
|
// leader's call — the same reasoning that makes `!flee` leader-only.
|
|
return p.SendDM(ctx.Sender, "Only your party leader can call the extraction. Ask them to `!extract`, or `!expedition leave` to walk out alone.")
|
|
}
|
|
zone, _ := getZone(exp.ZoneID)
|
|
updated, err := voluntaryExtractExpedition(ctx.Sender)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't extract: "+err.Error())
|
|
}
|
|
line := flavor.Pick(flavor.ExtractionVoluntary)
|
|
_ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative",
|
|
"voluntary extraction", line)
|
|
markActedToday(ctx.Sender)
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n",
|
|
zone.Display, updated.CurrentDay))
|
|
if line != "" {
|
|
b.WriteString(line)
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
// The extraction ends the day for everyone standing in the dungeon, and the
|
|
// roster outlives a voluntary extract — `extracting` is a resumable limbo, so
|
|
// members are still seated and must hear about it. Only the leader can call
|
|
// `!resume`, so the closing line differs per reader.
|
|
expires := (time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST")
|
|
p.fanOutExpeditionDM(updated, b.String(), func(uid id.UserID, body string) string {
|
|
if uid == id.UserID(updated.UserID) {
|
|
return body + fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.", expires)
|
|
}
|
|
return body + fmt.Sprintf("Loot, XP, and coins are kept. Your leader can `!resume` within 7 days, and you'll walk back in with them. After %s the expedition expires.", expires)
|
|
})
|
|
|
|
// Emergence seam: surfacing from a run is when an animal may have moved
|
|
// into the empty house. Every member surfaced, so every member rolls.
|
|
for _, uid := range expeditionAudience(updated) {
|
|
p.maybeRollPetArrivalOnEmerge(uid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── !resume command ─────────────────────────────────────────────────────────
|
|
|
|
func (p *AdventurePlugin) handleResumeCmd(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.")
|
|
}
|
|
|
|
if existing, isLeader, _ := activeExpeditionFor(ctx.Sender); existing != nil {
|
|
zone, _ := getZone(existing.ZoneID)
|
|
if !isLeader {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"You're riding a party expedition in **%s** (Day %d). Only its leader can `!resume`.",
|
|
zone.Display, existing.CurrentDay))
|
|
}
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
|
zone.Display, existing.CurrentDay))
|
|
}
|
|
|
|
exp, err := getResumableExpedition(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 extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.")
|
|
}
|
|
if extractionLapsed(exp, time.Now().UTC()) {
|
|
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get
|
|
// here on its own; this keeps the refusal and the reap in one breath.
|
|
_ = completeExpedition(exp.ID, ExpeditionStatusFailed)
|
|
return p.SendDM(ctx.Sender,
|
|
"That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.")
|
|
}
|
|
|
|
resumeZone, _ := getZone(exp.ZoneID)
|
|
// D5-b: prompt for a preset loadout on empty args.
|
|
if strings.TrimSpace(args) == "" {
|
|
return p.SendDM(ctx.Sender, renderLoadoutPrompt(resumeZone, "resume"))
|
|
}
|
|
purchase, err := resolveLoadoutOrParse(strings.TrimSpace(args), resumeZone.Tier)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
|
}
|
|
if err := purchase.Validate(resumeZone.Tier); err != nil {
|
|
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
|
}
|
|
cost := float64(purchase.Cost())
|
|
if p.euro == nil {
|
|
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
|
}
|
|
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
|
int(cost), balance))
|
|
}
|
|
if !p.euro.Debit(ctx.Sender, cost, "expedition resume outfitting: "+string(exp.ZoneID)) {
|
|
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).")
|
|
}
|
|
|
|
zone, _ := getZone(exp.ZoneID)
|
|
supplies := makeSupplies(zone.Tier, purchase)
|
|
if err := resumeExpedition(exp.ID, supplies); err != nil {
|
|
p.euro.Credit(ctx.Sender, cost, "expedition resume refund")
|
|
return p.SendDM(ctx.Sender, "Couldn't resume: "+err.Error())
|
|
}
|
|
exp.Status = ExpeditionStatusActive
|
|
exp.Supplies = supplies
|
|
// Spawn a fresh DungeonRun for the resumed region; the prior run was
|
|
// abandoned at extract time. Per-room harvest state in RegionState is
|
|
// preserved as-is so progression isn't reset.
|
|
exp.RunID = ""
|
|
exp.RegionState[regionStateRegionRuns] = map[string]string{}
|
|
_ = persistRegionState(exp)
|
|
if _, err := ensureRegionRun(exp, c.Level); err != nil {
|
|
p.euro.Credit(ctx.Sender, cost, "expedition resume refund (run-spawn failed)")
|
|
return p.SendDM(ctx.Sender, "Couldn't outfit the resumed region: "+err.Error())
|
|
}
|
|
line := flavor.Pick(flavor.ExpeditionResume)
|
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
|
"expedition resumed", line)
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("🚪 **Expedition resumed — %s, Day %d**\n\n",
|
|
zone.Display, exp.CurrentDay))
|
|
if line != "" {
|
|
b.WriteString(line)
|
|
b.WriteString("\n\n")
|
|
}
|
|
b.WriteString(fmt.Sprintf("**Re-outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n",
|
|
supplies.Max, purchase.StandardPacks, purchase.DeluxePacks, purchase.Cost()))
|
|
b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", exp.ThreatLevel))
|
|
if exp.TemporalStack != 0 {
|
|
b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", exp.TemporalStack))
|
|
}
|
|
b.WriteString("\nUse `!expedition status` for the daily briefing.")
|
|
return p.SendDM(ctx.Sender, b.String())
|
|
}
|