Adv 2.0 D&D Phase 12 E5: Extraction & Resume

Voluntary !extract burns 1 day, flips status to 'extracting', keeps
all rewards, resumable for 7 real days. Forced extraction (Abyss
collapse, future HP-0/supply-0 hooks) flips to 'abandoned' and applies
the §10.2 20% coin tax via the cycle layer's euro handle. !resume
within the 7-day window repurchases supplies and restores threat &
temporal stacks as-is; expired rows demote to failed.

getActiveExpedition / briefing / recap loaders narrow to status='active'
so 'extracting' rows don't get phantom day rollovers. Reuses
flavor.ExtractionVoluntary, ExtractionForced, ExpeditionResume from
twinbee_expedition_flavor.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 16:36:05 -07:00
parent a199efd773
commit d179ca27a7
8 changed files with 461 additions and 9 deletions

View File

@@ -264,6 +264,12 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "region") {
return p.handleRegionCmd(ctx, p.GetArgs(ctx.Body, "region"))
}
if p.IsCommand(ctx.Body, "extract") {
return p.handleExtractCmd(ctx, p.GetArgs(ctx.Body, "extract"))
}
if p.IsCommand(ctx.Body, "resume") {
return p.handleResumeCmd(ctx, p.GetArgs(ctx.Body, "resume"))
}
// 1. Arena commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "bail") {

View File

@@ -176,6 +176,7 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
}
// getActiveExpedition returns the player's in-flight expedition, or (nil, nil).
// 'extracting' rows are post-extraction (resumable) — see getResumableExpedition.
func getActiveExpedition(userID id.UserID) (*Expedition, error) {
row := db.Get().QueryRow(`
SELECT expedition_id, user_id, zone_id, run_id, status,
@@ -186,7 +187,7 @@ func getActiveExpedition(userID id.UserID) (*Expedition, error) {
last_briefing_at, last_recap_at, last_activity, completed_at
FROM dnd_expedition
WHERE user_id = ?
AND status IN ('active', 'extracting')
AND status = 'active'
ORDER BY start_date DESC
LIMIT 1`, string(userID))
e, err := scanExpedition(row)

View File

@@ -64,6 +64,10 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
return p.expeditionCmdLog(ctx)
case "abandon", "quit":
return p.expeditionCmdAbandon(ctx)
case "extract":
return p.handleExtractCmd(ctx, "")
case "resume":
return p.handleResumeCmd(ctx, rest)
default:
return p.SendDM(ctx.Sender, expeditionHelpText())
}
@@ -79,7 +83,9 @@ func expeditionHelpText() string {
b.WriteString(" default: `1s`\n")
b.WriteString("`!expedition status` — current expedition snapshot\n")
b.WriteString("`!expedition log` — last 5 log entries\n")
b.WriteString("`!expedition abandon` — end the expedition (no rewards)")
b.WriteString("`!expedition abandon` — end the expedition (no rewards)\n")
b.WriteString("`!extract` — voluntary extraction (1 day, resumable for 7 days)\n")
b.WriteString("`!resume [Ns] [Md]` — resume an extracted expedition")
return b.String()
}

View File

@@ -105,7 +105,7 @@ func loadExpeditionsNeedingBriefing(threshold time.Time) ([]*Expedition, error)
xp_earned, coins_earned, gm_mood,
last_briefing_at, last_recap_at, last_activity, completed_at
FROM dnd_expedition
WHERE status IN ('active', 'extracting')
WHERE status = 'active'
AND start_date < ?
AND (last_briefing_at IS NULL OR last_briefing_at < ?)`,
threshold, threshold)
@@ -126,7 +126,7 @@ func loadExpeditionsNeedingRecap(threshold time.Time) ([]*Expedition, error) {
xp_earned, coins_earned, gm_mood,
last_briefing_at, last_recap_at, last_activity, completed_at
FROM dnd_expedition
WHERE status IN ('active', 'extracting')
WHERE status = 'active'
AND (last_recap_at IS NULL OR last_recap_at < ?)`, threshold)
if err != nil {
return nil, err
@@ -202,6 +202,17 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
// has advanced, so e.CurrentDay reflects the new day).
temporalLines := applyZoneTemporalPostRollover(e)
// E5b: if a temporal event forced extraction (abyss collapse, etc.),
// apply the §10.2 coin tax. The temporal layer flips the row to
// 'abandoned'; the cycle holds the euro handle to do the debit.
if e.Status == ExpeditionStatusAbandoned && p.euro != nil {
tax := int(float64(e.CoinsEarned) * forcedExtractCoinTaxFrac)
if tax > 0 {
p.euro.Debit(id.UserID(e.UserID), float64(tax),
"forced extraction tax")
}
}
line := pickMorningBriefing(e.CurrentDay)
body := renderMorningBriefing(e, line, burn)
if restSummary != "" {

View File

@@ -0,0 +1,245 @@
package plugin
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"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
)
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, _ := applyDailyBurn(e.Supplies, 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
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
return e, tax, nil
}
// 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_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
}
// 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, err := getActiveExpedition(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.")
}
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)
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")
}
b.WriteString(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.",
(time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST")))
return p.SendDM(ctx.Sender, b.String())
}
// ── !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, _ := getActiveExpedition(ctx.Sender); existing != nil {
zone, _ := getZone(existing.ZoneID)
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 exp.CompletedAt == nil || time.Since(*exp.CompletedAt) > extractResumeWindow {
// Expire it so it doesn't keep resurfacing.
_ = 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.")
}
purchase, err := parseSupplyArgs(strings.TrimSpace(args))
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
}
if err := purchase.Validate(); 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())
}
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())
}

View File

@@ -0,0 +1,176 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// E5a: voluntary extraction burns one day's supplies, advances the day,
// flips status to 'extracting', and stamps completed_at.
func TestVoluntaryExtract_FlipsToExtracting(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-extract-voluntary:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}
exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies)
if err != nil {
t.Fatal(err)
}
startDay := exp.CurrentDay
updated, err := voluntaryExtractExpedition(uid)
if err != nil {
t.Fatalf("voluntaryExtractExpedition: %v", err)
}
if updated.Status != ExpeditionStatusExtracting {
t.Errorf("status = %q, want extracting", updated.Status)
}
if updated.CurrentDay != startDay+1 {
t.Errorf("current_day = %d, want %d", updated.CurrentDay, startDay+1)
}
got, _ := getExpedition(exp.ID)
if got.Supplies.Current != 9 {
t.Errorf("supplies after extract = %.1f, want 9", got.Supplies.Current)
}
if got.CompletedAt == nil {
t.Error("expected completed_at to be set")
}
// Active query should now return nothing — extracting is post-extract.
if active, _ := getActiveExpedition(uid); active != nil {
t.Errorf("getActiveExpedition still returns row: %s", active.Status)
}
}
func TestVoluntaryExtract_NoActive(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-extract-noactive:example")
defer cleanupExpeditions(uid)
if _, err := voluntaryExtractExpedition(uid); err != ErrNoActiveExpedition {
t.Errorf("err = %v, want ErrNoActiveExpedition", err)
}
}
// E5b: forced extraction flips to 'abandoned' and reports the 20% coin tax.
func TestForcedExtract_AbandonedAndTax(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-extract-forced:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET coins_earned = 100 WHERE expedition_id = ?`,
exp.ID); err != nil {
t.Fatal(err)
}
got, tax, err := forcedExtractExpedition(exp.ID, "supplies depleted")
if err != nil {
t.Fatalf("forcedExtractExpedition: %v", err)
}
if tax != 20 {
t.Errorf("tax = %d, want 20 (20%% of 100)", tax)
}
if got.Status != ExpeditionStatusAbandoned {
t.Errorf("status = %q, want abandoned", got.Status)
}
persisted, _ := getExpedition(exp.ID)
if persisted.Status != ExpeditionStatusAbandoned {
t.Errorf("persisted status = %q", persisted.Status)
}
if persisted.CompletedAt == nil {
t.Error("expected completed_at after forced extract")
}
}
// E5c: resume restores 'active' status, fresh supplies, preserves threat.
func TestResume_FreshSuppliesPreservesThreat(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-resume-ok:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if err := applyThreatDelta(exp.ID, 35, "test"); err != nil {
t.Fatal(err)
}
if err := updateTemporalStack(exp.ID, 12); err != nil {
t.Fatal(err)
}
if _, err := voluntaryExtractExpedition(uid); err != nil {
t.Fatal(err)
}
resumable, err := getResumableExpedition(uid)
if err != nil || resumable == nil {
t.Fatalf("getResumableExpedition: %v / %v", resumable, err)
}
if resumable.ID != exp.ID {
t.Error("wrong row")
}
freshSupplies := ExpeditionSupplies{Current: 20, Max: 20, DailyBurn: 1, HarshMod: 1}
if err := resumeExpedition(resumable.ID, freshSupplies); err != nil {
t.Fatalf("resumeExpedition: %v", err)
}
got, _ := getExpedition(exp.ID)
if got.Status != ExpeditionStatusActive {
t.Errorf("status = %q, want active", got.Status)
}
if got.Supplies.Current != 20 {
t.Errorf("supplies.Current = %.1f, want 20", got.Supplies.Current)
}
if got.ThreatLevel != 35 {
t.Errorf("threat = %d, want 35 (preserved)", got.ThreatLevel)
}
if got.TemporalStack != 12 {
t.Errorf("temporal = %d, want 12 (preserved)", got.TemporalStack)
}
if got.CompletedAt != nil {
t.Error("completed_at should be cleared on resume")
}
}
// E5c: resume window expires after 7 real days; getResumableExpedition still
// returns the row (caller decides), but the command path should reject.
func TestResume_WindowExpired(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-resume-expired:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if _, err := voluntaryExtractExpedition(uid); err != nil {
t.Fatal(err)
}
// Backdate completed_at well past the 7-day window.
stale := time.Now().UTC().Add(-8 * 24 * time.Hour)
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET completed_at = ? WHERE expedition_id = ?`,
stale, exp.ID); err != nil {
t.Fatal(err)
}
got, _ := getResumableExpedition(uid)
if got == nil || got.CompletedAt == nil {
t.Fatal("expected resumable row with completed_at set")
}
if time.Since(*got.CompletedAt) <= extractResumeWindow {
t.Errorf("expected window to be expired (since=%v, window=%v)",
time.Since(*got.CompletedAt), extractResumeWindow)
}
}

View File

@@ -520,11 +520,17 @@ func abyssPortalTemporalPostRollover(e *Expedition) []string {
return []string{line}
case "collapse":
line := flavor.Pick(flavor.AbyssPortalCollapse)
forced := flavor.Pick(flavor.ExtractionForced)
_ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal",
"portal collapsed — expedition forcibly extracted", line)
// Forced extraction: spec §7.6 marks the run failed.
_ = completeExpedition(e.ID, ExpeditionStatusFailed)
return []string{line}
// §10.2: forced extraction → abandoned. Coin tax is applied by
// the cycle layer (which holds the euro handle) once it sees
// the row flipped to 'abandoned'.
_, _, _ = forcedExtractExpedition(e.ID, "abyss portal collapse")
e.Status = ExpeditionStatusAbandoned
_ = appendExpeditionLog(e.ID, e.CurrentDay, "narrative",
"forced extraction", forced)
return []string{line, forced}
}
return nil
}

View File

@@ -771,8 +771,9 @@ func TestAbyss_CollapseFailsExpedition(t *testing.T) {
if got.TemporalStack != 100 {
t.Errorf("instability = %d, want 100", got.TemporalStack)
}
if got.Status != ExpeditionStatusFailed {
t.Errorf("status = %q, want %q after collapse", got.Status, ExpeditionStatusFailed)
// E5b: collapse is a §10.2 forced extraction → 'abandoned' (not 'failed').
if got.Status != ExpeditionStatusAbandoned {
t.Errorf("status = %q, want %q after collapse", got.Status, ExpeditionStatusAbandoned)
}
}