diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 9aecef1..be21da6 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -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") { diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index 534bf27..c4ab5f8 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -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) diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index 2e71232..f766434 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -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() } diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index a9b2bfb..73405e6 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -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 != "" { diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go new file mode 100644 index 0000000..3cadcf1 --- /dev/null +++ b/internal/plugin/dnd_expedition_extract.go @@ -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 ` 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()) +} diff --git a/internal/plugin/dnd_expedition_extract_test.go b/internal/plugin/dnd_expedition_extract_test.go new file mode 100644 index 0000000..786239b --- /dev/null +++ b/internal/plugin/dnd_expedition_extract_test.go @@ -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) + } +} diff --git a/internal/plugin/dnd_expedition_temporal.go b/internal/plugin/dnd_expedition_temporal.go index 15ae2f1..c4ae75f 100644 --- a/internal/plugin/dnd_expedition_temporal.go +++ b/internal/plugin/dnd_expedition_temporal.go @@ -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 } diff --git a/internal/plugin/dnd_expedition_temporal_test.go b/internal/plugin/dnd_expedition_temporal_test.go index 8778e03..bb46f73 100644 --- a/internal/plugin/dnd_expedition_temporal_test.go +++ b/internal/plugin/dnd_expedition_temporal_test.go @@ -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) } }