diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 79654ee..1e5a71a 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -250,6 +250,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "zone") { return p.handleDnDZoneCmd(ctx, p.GetArgs(ctx.Body, "zone")) } + if p.IsCommand(ctx.Body, "expedition") { + return p.handleDnDExpeditionCmd(ctx, p.GetArgs(ctx.Body, "expedition")) + } // 1. Arena commands (work in rooms and DMs) if p.IsCommand(ctx.Body, "bail") { diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go new file mode 100644 index 0000000..c0c95a8 --- /dev/null +++ b/internal/plugin/dnd_expedition_cmd.go @@ -0,0 +1,358 @@ +package plugin + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + + "gogobee/internal/flavor" + "maunium.net/go/mautrix/id" +) + +// !expedition — Phase 12 E1c. The command surface for the multi-day +// expedition system landed in E1a/E1b. This file is presentation + +// dispatch only; data plumbing lives in dnd_expedition.go and supply +// math in dnd_expedition_supplies.go. +// +// Subcommands: +// +// !expedition → help (or status if active) +// !expedition list → zones available at player level +// !expedition start [Ns] [Md] → outfit & start; default 1 standard pack +// !expedition status → daily briefing-style block (§12.1) +// !expedition log → last 5 expedition log entries +// !expedition abandon → end the expedition (no rewards) +// !expedition help → help text +// +// Day cycle (06:00/21:00 cron) lands in E1d. Camp commands (!camp) land +// in E1e. !advance / !search / !rest / !extract are out-of-scope for E1. + +func (p *AdventurePlugin) handleDnDExpeditionCmd(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` (or just enter combat and we'll auto-build one).") + } + + args = strings.TrimSpace(args) + sub, rest := splitFirstWord(args) + switch strings.ToLower(sub) { + case "": + // If active, show status; otherwise help. + if exp, _ := getActiveExpedition(ctx.Sender); exp != nil { + return p.expeditionCmdStatus(ctx) + } + return p.SendDM(ctx.Sender, expeditionHelpText()) + case "help", "?": + return p.SendDM(ctx.Sender, expeditionHelpText()) + case "list", "ls": + return p.zoneCmdList(ctx, c) + case "start", "begin", "go": + return p.expeditionCmdStart(ctx, c, rest) + case "status", "info": + return p.expeditionCmdStatus(ctx) + case "log", "history": + return p.expeditionCmdLog(ctx) + case "abandon", "quit": + return p.expeditionCmdAbandon(ctx) + default: + return p.SendDM(ctx.Sender, expeditionHelpText()) + } +} + +func expeditionHelpText() string { + var b strings.Builder + b.WriteString("**!expedition** — multi-day dungeon expeditions.\n\n") + b.WriteString("`!expedition list` — show zones available at your level\n") + b.WriteString("`!expedition start [Ns] [Md]` — outfit & begin\n") + b.WriteString(" `Ns` = N standard packs (10 SU, 50 coins, max 3)\n") + b.WriteString(" `Md` = M deluxe packs (20 SU, 90 coins, max 1)\n") + 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)") + return b.String() +} + +// ── start ─────────────────────────────────────────────────────────────────── + +// parseSupplyArgs reads a token stream like "2s 1d" into a SupplyPurchase. +// Tokens are case-insensitive; suffix s|standard or d|deluxe. Bare integer +// is interpreted as standard packs. Default (empty) is one standard pack. +func parseSupplyArgs(rest string) (SupplyPurchase, error) { + p := SupplyPurchase{} + if strings.TrimSpace(rest) == "" { + p.StandardPacks = 1 + return p, nil + } + for _, tok := range strings.Fields(rest) { + t := strings.ToLower(strings.TrimSpace(tok)) + if t == "" { + continue + } + var ( + numPart string + suf string + ) + // Find first non-digit position. + i := 0 + for i < len(t) && t[i] >= '0' && t[i] <= '9' { + i++ + } + numPart = t[:i] + suf = t[i:] + if numPart == "" { + return p, fmt.Errorf("can't parse pack token %q", tok) + } + n, err := strconv.Atoi(numPart) + if err != nil { + return p, fmt.Errorf("can't parse pack count in %q", tok) + } + switch suf { + case "", "s", "std", "standard": + p.StandardPacks += n + case "d", "dlx", "deluxe": + p.DeluxePacks += n + default: + return p, fmt.Errorf("unknown pack suffix in %q (use s or d)", tok) + } + } + return p, nil +} + +func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter, rest string) error { + if rest == "" { + return p.SendDM(ctx.Sender, + "`!expedition start [Ns] [Md]` — pick from `!expedition list`. Example: `!expedition start goblin_warrens 2s` (2 standard packs).") + } + zoneTok, packTok := splitFirstWord(rest) + available := zonesForLevel(c.Level) + zoneID, ok := resolveZoneInput(zoneTok, available) + if !ok { + return p.SendDM(ctx.Sender, + "Unknown zone for your level. Try `!expedition list`.") + } + purchase, err := parseSupplyArgs(packTok) + 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)) + } + + // Reject if any expedition or zone run already active. + if existing, _ := getActiveExpedition(ctx.Sender); existing != nil { + zone, _ := getZone(existing.ZoneID) + return p.SendDM(ctx.Sender, fmt.Sprintf( + "You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.", + zone.Display, existing.CurrentDay)) + } + if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil { + zone, _ := getZone(existing.ZoneID) + return p.SendDM(ctx.Sender, fmt.Sprintf( + "You have an active single-session zone run in **%s**. Finish or `!zone abandon` before starting an expedition.", + zone.Display)) + } + + zone, _ := getZone(zoneID) + supplies := makeSupplies(zone.Tier, purchase) + + // Debit coins; bail on debit failure (race / cap). + if !p.euro.Debit(ctx.Sender, cost, "expedition outfitting: "+string(zoneID)) { + return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).") + } + + exp, err := startExpedition(ctx.Sender, zoneID, "", supplies) + if err != nil { + // Refund on persistence failure. + p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund") + return p.SendDM(ctx.Sender, "Couldn't start expedition: "+err.Error()) + } + + // Log the start with prewritten flavor. + startLine := flavor.Pick(flavor.ExpeditionStart) + _ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine) + + var b strings.Builder + b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier))) + b.WriteString("_" + zone.Hook + "_\n\n") + b.WriteString(fmt.Sprintf("**Outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n", + supplies.Max, purchase.StandardPacks, purchase.DeluxePacks, purchase.Cost())) + b.WriteString(fmt.Sprintf("**Daily burn:** %.1f SU/day — that's roughly **%d days** of provisions.\n\n", + supplies.DailyBurn, estimateDays(supplies.Max, supplies.DailyBurn))) + if startLine != "" { + b.WriteString(startLine) + b.WriteString("\n\n") + } + b.WriteString("Use `!expedition status` for the daily briefing format. Day 1 begins now.") + return p.SendDM(ctx.Sender, b.String()) +} + +func estimateDays(maxSU, dailyBurn float32) int { + if dailyBurn <= 0 { + return 0 + } + return int(math.Floor(float64(maxSU / dailyBurn))) +} + +// ── status ────────────────────────────────────────────────────────────────── + +func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext) error { + 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. Use `!expedition list` then `!expedition start `.") + } + zone, _ := getZone(exp.ZoneID) + c, _ := LoadDnDCharacter(ctx.Sender) + + var b strings.Builder + b.WriteString(fmt.Sprintf("🎭 **TwinBee — Status, Day %d**\n\n", exp.CurrentDay)) + b.WriteString(fmt.Sprintf("📍 **Zone:** %s _(T%d)_\n", zone.Display, int(zone.Tier))) + if c != nil { + b.WriteString(fmt.Sprintf("❤️ **HP:** %d / %d\n", c.HPCurrent, c.HPMax)) + } + b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU _(burn %.1f/day → ~%d days left)_\n", + exp.Supplies.Current, exp.Supplies.Max, currentBurn(exp), + estimateDays(exp.Supplies.Current, currentBurn(exp)))) + b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n", + exp.ThreatLevel, threatThresholdLabel(exp.ThreatLevel, exp.SiegeMode))) + if exp.TemporalStack != 0 { + b.WriteString(fmt.Sprintf("🌡 **Zone stack:** %d\n", exp.TemporalStack)) + } + if exp.Camp != nil && exp.Camp.Active { + b.WriteString(fmt.Sprintf("⛺ **Camp:** %s (room %d)\n", exp.Camp.Type, exp.Camp.RoomIndex+1)) + } + state := supplyDepletion(exp.Supplies) + if state != SupplyNormal { + b.WriteString(fmt.Sprintf("⚠ **%s** — roll modifier %d\n", + depletionLabel(state), supplyRollModifier(state))) + } + b.WriteString(fmt.Sprintf("\nStarted: %s Last activity: %s", + exp.StartDate.Format("2006-01-02 15:04"), + exp.LastActivity.Format("2006-01-02 15:04"))) + return p.SendDM(ctx.Sender, b.String()) +} + +func currentBurn(exp *Expedition) float32 { + burn := exp.Supplies.DailyBurn + if exp.ThreatLevel > 60 || exp.SiegeMode { + // Harsh conditions: §4.1 / §8.3. + mult := exp.Supplies.HarshMod + if mult <= 0 { + mult = 1 + } + burn *= mult + } + return burn +} + +func threatThresholdLabel(level int, siege bool) string { + if siege { + return "Siege" + } + switch { + case level >= 81: + return "Siege" + case level >= 61: + return "Hostile" + case level >= 41: + return "Alert" + case level >= 21: + return "Stirring" + default: + return "Quiet" + } +} + +func depletionLabel(s SupplyDepletionState) string { + switch s { + case SupplyRationing: + return "Rationing" + case SupplySevereRationing: + return "Severe rationing" + case SupplyStarvation: + return "Starvation" + } + return "Normal" +} + +// ── log ───────────────────────────────────────────────────────────────────── + +func (p *AdventurePlugin) expeditionCmdLog(ctx MessageContext) error { + 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.") + } + entries, err := recentExpeditionLog(exp.ID, 5) + if err != nil { + return p.SendDM(ctx.Sender, "Couldn't read log: "+err.Error()) + } + if len(entries) == 0 { + return p.SendDM(ctx.Sender, "No log entries yet.") + } + var b strings.Builder + b.WriteString(fmt.Sprintf("📜 **Expedition log — last %d entries**\n\n", len(entries))) + for _, e := range entries { + b.WriteString(fmt.Sprintf("**Day %d** — _%s_ (%s)\n", + e.Day, e.Type, formatLogTimestamp(e.Timestamp))) + if e.Summary != "" { + b.WriteString(" " + e.Summary + "\n") + } + if e.Flavor != "" { + b.WriteString(" _" + e.Flavor + "_\n") + } + } + return p.SendDM(ctx.Sender, b.String()) +} + +func formatLogTimestamp(t time.Time) string { + return t.Format("2006-01-02 15:04") +} + +// ── abandon ───────────────────────────────────────────────────────────────── + +func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error { + 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 abandon.") + } + zone, _ := getZone(exp.ZoneID) + if err := abandonExpedition(ctx.Sender); err != nil { + return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error()) + } + _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "") + return p.SendDM(ctx.Sender, fmt.Sprintf( + "Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.", + zone.Display, exp.CurrentDay)) +} + +// helper: ensure we don't shadow id.UserID import in test harness. +var _ id.UserID diff --git a/internal/plugin/dnd_expedition_cmd_test.go b/internal/plugin/dnd_expedition_cmd_test.go new file mode 100644 index 0000000..101cad8 --- /dev/null +++ b/internal/plugin/dnd_expedition_cmd_test.go @@ -0,0 +1,203 @@ +package plugin + +import ( + "testing" + + "maunium.net/go/mautrix/id" +) + +// Phase 12 E1c — !expedition command tests. Mirrors the !zone test +// pattern: side-effect coverage (persisted state) only, since SendDM is +// a no-op without a Matrix client. + +func expeditionCmdTestCharacter(t *testing.T, uid id.UserID, level int) { + t.Helper() + if err := createAdvCharacter(uid, "expcmd"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level, + STR: 14, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10, + HPMax: 20, HPCurrent: 20, ArmorClass: 14, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } +} + +func TestParseSupplyArgs_Defaults(t *testing.T) { + p, err := parseSupplyArgs("") + if err != nil { + t.Fatal(err) + } + if p.StandardPacks != 1 || p.DeluxePacks != 0 { + t.Errorf("default = %+v, want 1 standard / 0 deluxe", p) + } +} + +func TestParseSupplyArgs_Combinations(t *testing.T) { + cases := []struct { + in string + std, dlx int + wantErr bool + }{ + {"2s", 2, 0, false}, + {"2s 1d", 2, 1, false}, + {"3 standard 1 deluxe", 0, 0, true}, // unbound number tokens fail + {"3", 3, 0, false}, + {"3std 1dlx", 3, 1, false}, + {"abc", 0, 0, true}, + {"2x", 0, 0, true}, + {"-1s", 0, 0, true}, + } + for _, c := range cases { + got, err := parseSupplyArgs(c.in) + if (err != nil) != c.wantErr { + t.Errorf("%q: err = %v, wantErr=%v", c.in, err, c.wantErr) + continue + } + if c.wantErr { + continue + } + if got.StandardPacks != c.std || got.DeluxePacks != c.dlx { + t.Errorf("%q: got %+v, want std=%d dlx=%d", c.in, got, c.std, c.dlx) + } + } +} + +func TestThreatThresholdLabel_Bands(t *testing.T) { + cases := []struct { + level int + siege bool + want string + }{ + {0, false, "Quiet"}, + {20, false, "Quiet"}, + {21, false, "Stirring"}, + {40, false, "Stirring"}, + {41, false, "Alert"}, + {60, false, "Alert"}, + {61, false, "Hostile"}, + {80, false, "Hostile"}, + {81, false, "Siege"}, + {100, false, "Siege"}, + {50, true, "Siege"}, // siege flag forces Siege regardless + } + for _, c := range cases { + if got := threatThresholdLabel(c.level, c.siege); got != c.want { + t.Errorf("level=%d siege=%v: got %q, want %q", c.level, c.siege, got, c.want) + } + } +} + +func TestEstimateDays(t *testing.T) { + cases := []struct { + max, burn float32 + want int + }{ + {20, 1, 20}, + {20, 1.5, 13}, // floor(13.33) + {10, 0, 0}, // div-by-zero guard + {0, 1, 0}, + } + for _, c := range cases { + if got := estimateDays(c.max, c.burn); got != c.want { + t.Errorf("max=%v burn=%v: got %d, want %d", c.max, c.burn, got, c.want) + } + } +} + +func TestExpeditionCmd_StartAbandonRoundtrip(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@exp-cmd-start:example") + expeditionCmdTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + euro := &EuroPlugin{} + euro.ensureBalance(uid) + euro.Credit(uid, 200, "test setup") + p := &AdventurePlugin{euro: euro} + + // Default 1 standard pack = 50 coins. Should land an active expedition. + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens"); err != nil { + t.Fatal(err) + } + exp, err := getActiveExpedition(uid) + if err != nil { + t.Fatal(err) + } + if exp == nil { + t.Fatal("expected active expedition after start") + } + if exp.ZoneID != ZoneGoblinWarrens { + t.Errorf("zone = %s", exp.ZoneID) + } + if exp.Supplies.Max != 10 { + t.Errorf("max SU = %v, want 10", exp.Supplies.Max) + } + // Coin balance should have dropped by 50. + if got := euro.GetBalance(uid); got > 200-50+0.001 || got < 200-50-0.001 { + t.Errorf("balance = %v, want 150", got) + } + // Log should have at least the start entry. + entries, _ := recentExpeditionLog(exp.ID, 5) + if len(entries) == 0 { + t.Error("expected start entry in log") + } + + // Concurrent start should fail. + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start crypt_valdris"); err != nil { + t.Fatal(err) + } + // Status path: + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "status"); err != nil { + t.Fatal(err) + } + // Log path: + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "log"); err != nil { + t.Fatal(err) + } + // Abandon. + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "abandon"); err != nil { + t.Fatal(err) + } + if got, _ := getActiveExpedition(uid); got != nil { + t.Error("expected no active expedition after abandon") + } +} + +func TestExpeditionCmd_StartInsufficientCoins(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@exp-cmd-broke:example") + expeditionCmdTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + euro := &EuroPlugin{} + euro.ensureBalance(uid) + // Don't credit — default seed is < 50. + bal := euro.GetBalance(uid) + p := &AdventurePlugin{euro: euro} + + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens 3s 1d"); err != nil { + t.Fatal(err) + } + if got, _ := getActiveExpedition(uid); got != nil { + t.Error("expedition should not start when underfunded") + } + if got := euro.GetBalance(uid); got != bal { + t.Errorf("balance moved despite failed start: %v → %v", bal, got) + } +} + +func TestExpeditionCmd_ListWithoutCharBlocked(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@exp-cmd-nochar:example") + defer cleanupExpeditions(uid) + p := &AdventurePlugin{} + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, ""); err != nil { + t.Fatal(err) + } + if got, _ := getActiveExpedition(uid); got != nil { + t.Error("no expedition should exist") + } +}