diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md index 13fa7d5..07276e3 100644 --- a/gogobee_long_expedition_plan.md +++ b/gogobee_long_expedition_plan.md @@ -118,8 +118,9 @@ Each successful background walk now logs a `walk` entry (`appendExpeditionLog(.. > **Note:** the original "Empirical (sim-driven)" path was blocked: under D2-b event-anchored expeditions, `SimRunner.TickDay` calls `deliverBriefing` → `deliverBriefingEventAnchored`, which reads `time.Now().UTC()` for its safety-net check, so synthetic ticks never advance `CurrentDay` and `DaysAtEnd` stays at 0. Re-baselining off real day-counts requires teaching the sim to drive the event-anchored rollover (D7). -**Remaining work (D5-b+):** -- "Pick your loadout" preset prompt at launch (Lean / Balanced / Heavy) — raw `Ns Md` syntax becomes the advanced override. +**D5-b (shipped 2026-05-27):** "Pick your loadout" preset prompt at launch. `!expedition start ` with no pack arg now DMs a Lean / Balanced / Heavy menu (sized per zone tier via new `loadoutPurchase(tier, SupplyLoadout)` in `dnd_expedition_supplies.go`); player replies with `!expedition start lean|balanced|heavy` (short forms `l`/`b`/`h` also work). `!resume` got the same treatment. Raw `Ns Md` syntax remains the advanced override — `resolveLoadoutOrParse` in `dnd_expedition_cmd.go` tries a single-token preset first, falls back to `parseSupplyArgs`. Heavy always equals `supplyPackCaps` (the D5-a harsh×3 ceiling); Lean covers intended days at raw burn; Balanced sits between. `parseSupplyArgs("")` still returns 1 standard for any tier — it's no longer reachable from `!expedition start` (the empty path is intercepted to prompt), but the `1s` default is preserved so future callers don't get surprise zero-pack purchases. Help text updated; two existing scenario tests now pass `lean` explicitly, three new tests cover preset resolution + the prompt-vs-start guard. + +**Remaining work (D5-c+):** - Forage and Ranger forage re-baseline against the new caps. - DailyBurn / `phase5BDailyBurnRatePct` revisit once D7 sim measures real elapsed-day counts. diff --git a/internal/plugin/adv2_scenario_test.go b/internal/plugin/adv2_scenario_test.go index b3bc812..b1511e5 100644 --- a/internal/plugin/adv2_scenario_test.go +++ b/internal/plugin/adv2_scenario_test.go @@ -304,7 +304,7 @@ func TestAdv2Scenario_HarvestForestShadows(t *testing.T) { p := &AdventurePlugin{euro: euro} // Forage-friendly expedition. - if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows"); err != nil { + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows lean"); err != nil { t.Fatalf("expedition start: %v", err) } exp, _ := getActiveExpedition(uid) diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index b6aa11f..4aff8cf 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -92,16 +92,16 @@ 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; cap scales by zone tier)\n") - b.WriteString(" `Md` = M deluxe packs (20 SU, 90 coins; cap scales by zone tier)\n") - b.WriteString(" default: `1s`\n") + b.WriteString("`!expedition start [loadout]` — outfit & begin\n") + b.WriteString(" loadout: `lean` / `balanced` / `heavy` (scales by zone tier)\n") + b.WriteString(" advanced: raw pack counts — `Ns Md` (e.g. `2s 1d`)\n") + b.WriteString(" no loadout? you'll be prompted to pick one\n") b.WriteString("`!expedition run` — autopilot: walk rooms until something needs you (alias `!explore`)\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)\n") b.WriteString("`!extract` — voluntary extraction (1 day, resumable for 7 days)\n") - b.WriteString("`!resume [Ns] [Md]` — resume an extracted expedition\n") + b.WriteString("`!resume [loadout]` — resume an extracted expedition (same `lean`/`balanced`/`heavy` choices)\n") b.WriteString("`!map` — region/room ASCII map") return b.String() } @@ -178,6 +178,66 @@ func parseSupplyArgs(rest string) (SupplyPurchase, error) { return p, nil } +// resolveLoadoutOrParse first tries a single-token preset (lean/balanced/ +// heavy and short forms); on miss it falls back to raw `Ns Md` parsing. +// Tier is needed because preset purchase counts scale by zone tier. +func resolveLoadoutOrParse(tok string, tier ZoneTier) (SupplyPurchase, error) { + trimmed := strings.TrimSpace(tok) + if !strings.ContainsAny(trimmed, " \t") { + if l, ok := parseLoadoutToken(trimmed); ok { + return loadoutPurchase(tier, l), nil + } + } + return parseSupplyArgs(tok) +} + +// renderLoadoutPrompt formats the "pick your loadout" DM. The resume +// command and start command share it; cmdHint tells the player which +// command to type back with the chosen preset. +func renderLoadoutPrompt(zone ZoneDefinition, cmdHint string) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("🎒 **Pick a loadout — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier))) + for _, l := range []SupplyLoadout{LoadoutLean, LoadoutBalanced, LoadoutHeavy} { + pp := loadoutPurchase(zone.Tier, l) + b.WriteString(fmt.Sprintf(" `%s` — %s — %.0f SU, %d coins — %s\n", + loadoutName(l), packBreakdown(pp), pp.Total(), pp.Cost(), loadoutBlurb(l))) + } + b.WriteString(fmt.Sprintf("\nType `!%s %s` (or `lean` / `heavy`).\n", cmdHint, loadoutName(LoadoutBalanced))) + b.WriteString("Advanced: raw pack counts like `2s 1d`.") + return b.String() +} + +func loadoutName(l SupplyLoadout) string { + switch l { + case LoadoutLean: + return "lean" + case LoadoutHeavy: + return "heavy" + } + return "balanced" +} + +func loadoutBlurb(l SupplyLoadout) string { + switch l { + case LoadoutLean: + return "covers the intended run at calm burn; thin if things go sideways" + case LoadoutHeavy: + return "max cap; rides out harsh stretches and overruns" + } + return "recommended; absorbs a harsh patch or two" +} + +func packBreakdown(p SupplyPurchase) string { + switch { + case p.StandardPacks > 0 && p.DeluxePacks > 0: + return fmt.Sprintf("%d standard + %d deluxe", p.StandardPacks, p.DeluxePacks) + case p.DeluxePacks > 0: + return fmt.Sprintf("%d deluxe", p.DeluxePacks) + default: + return fmt.Sprintf("%d standard", p.StandardPacks) + } +} + func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter, rest string) error { if rest == "" { return p.SendDM(ctx.Sender, @@ -195,11 +255,16 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter return p.SendDM(ctx.Sender, "Unknown zone for your level. Try `!expedition list`.") } - purchase, err := parseSupplyArgs(packTok) + zoneForCaps, _ := getZone(zoneID) + // D5-b: prompt for a preset loadout on empty args. Raw `Ns Md` syntax + // still works as the advanced override. + if strings.TrimSpace(packTok) == "" { + return p.SendDM(ctx.Sender, renderLoadoutPrompt(zoneForCaps, "expedition start "+string(zoneID))) + } + purchase, err := resolveLoadoutOrParse(packTok, zoneForCaps.Tier) if err != nil { return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error()) } - zoneForCaps, _ := getZone(zoneID) if err := purchase.Validate(zoneForCaps.Tier); err != nil { return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) } diff --git a/internal/plugin/dnd_expedition_cmd_test.go b/internal/plugin/dnd_expedition_cmd_test.go index d704c66..acec0ce 100644 --- a/internal/plugin/dnd_expedition_cmd_test.go +++ b/internal/plugin/dnd_expedition_cmd_test.go @@ -66,6 +66,76 @@ func TestParseSupplyArgs_Combinations(t *testing.T) { } } +func TestResolveLoadout_PresetTokens(t *testing.T) { + cases := []struct { + tok string + tier ZoneTier + std, dlx int + }{ + {"lean", ZoneTierBeginner, 1, 0}, + {"balanced", ZoneTierJourneyman, 3, 0}, + {"heavy", ZoneTierLegendary, 7, 2}, + {"h", ZoneTierVeteran, 5, 1}, + {"L", ZoneTierLegendary, 5, 0}, + } + for _, c := range cases { + got, err := resolveLoadoutOrParse(c.tok, c.tier) + if err != nil { + t.Errorf("%q@T%d: %v", c.tok, c.tier, err) + continue + } + if got.StandardPacks != c.std || got.DeluxePacks != c.dlx { + t.Errorf("%q@T%d: got %+v, want std=%d dlx=%d", c.tok, c.tier, got, c.std, c.dlx) + } + } +} + +func TestResolveLoadout_FallsBackToRawParse(t *testing.T) { + got, err := resolveLoadoutOrParse("2s 1d", ZoneTierJourneyman) + if err != nil { + t.Fatal(err) + } + if got.StandardPacks != 2 || got.DeluxePacks != 1 { + t.Errorf("raw parse: got %+v", got) + } +} + +func TestLoadoutPurchase_HeavyMatchesCaps(t *testing.T) { + for _, tier := range []ZoneTier{ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman, ZoneTierVeteran, ZoneTierLegendary} { + p := loadoutPurchase(tier, LoadoutHeavy) + std, dlx := supplyPackCaps(tier) + if p.StandardPacks != std || p.DeluxePacks != dlx { + t.Errorf("T%d heavy %+v, want std=%d dlx=%d", tier, p, std, dlx) + } + if err := p.Validate(tier); err != nil { + t.Errorf("T%d heavy fails own validation: %v", tier, err) + } + } +} + +func TestExpeditionCmd_StartNoArgsPromptsLoadout(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@exp-cmd-prompt:example") + expeditionCmdTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + euro := &EuroPlugin{} + euro.ensureBalance(uid) + euro.Credit(uid, 500, "test") + p := &AdventurePlugin{euro: euro} + + // Empty pack args: should prompt, NOT start. + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens"); err != nil { + t.Fatal(err) + } + if exp, _ := getActiveExpedition(uid); exp != nil { + t.Error("expedition started on empty-arg prompt; should have waited for preset choice") + } + if bal := euro.GetBalance(uid); bal != 500 { + t.Errorf("coins moved on prompt: %v", bal) + } +} + func TestThreatThresholdLabel_Bands(t *testing.T) { cases := []struct { level int @@ -119,8 +189,8 @@ func TestExpeditionCmd_StartAbandonRoundtrip(t *testing.T) { 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 { + // Lean preset = 1 standard pack at T1 = 50 coins. Should land an active expedition. + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens lean"); err != nil { t.Fatal(err) } exp, err := getActiveExpedition(uid) @@ -219,7 +289,7 @@ func TestExpeditionCmd_RunWalksRooms(t *testing.T) { euro.Credit(uid, 200, "test setup") p := &AdventurePlugin{euro: euro} - if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens"); err != nil { + if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens lean"); err != nil { t.Fatal(err) } exp, _ := getActiveExpedition(uid) diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index cbcf3d1..6296bee 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -308,11 +308,15 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error "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)) + 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()) } - resumeZone, _ := getZone(exp.ZoneID) if err := purchase.Validate(resumeZone.Tier); err != nil { return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) } diff --git a/internal/plugin/dnd_expedition_supplies.go b/internal/plugin/dnd_expedition_supplies.go index 6ef28b7..ee23b77 100644 --- a/internal/plugin/dnd_expedition_supplies.go +++ b/internal/plugin/dnd_expedition_supplies.go @@ -2,6 +2,7 @@ package plugin import ( "fmt" + "strings" ) // Phase 12 E1b — Supply procurement, daily burn, and depletion effects. @@ -39,6 +40,68 @@ func supplyPackCaps(tier ZoneTier) (standard, deluxe int) { return 3, 1 } +// SupplyLoadout names a tier-scaled preset purchase. D5-b: empty-arg +// `!expedition start ` now prompts the player to pick one of these +// rather than silently defaulting to 1 standard pack. Raw `Ns Md` syntax +// remains the advanced override. +type SupplyLoadout int + +const ( + LoadoutLean SupplyLoadout = iota + LoadoutBalanced + LoadoutHeavy +) + +// loadoutPurchase returns the SupplyPurchase for a preset at a tier. +// Lean: covers intended days at raw daily burn (cheap, no harsh buffer). +// Balanced: ~harsh-ready for an intended-length run (recommended). +// Heavy: equals supplyPackCaps — the harsh×3 ceiling D5-a sized for. +func loadoutPurchase(tier ZoneTier, l SupplyLoadout) SupplyPurchase { + switch l { + case LoadoutHeavy: + std, dlx := supplyPackCaps(tier) + return SupplyPurchase{StandardPacks: std, DeluxePacks: dlx} + case LoadoutBalanced: + switch tier { + case ZoneTierBeginner, ZoneTierApprentice: + return SupplyPurchase{StandardPacks: 2} + case ZoneTierJourneyman: + return SupplyPurchase{StandardPacks: 3} + case ZoneTierVeteran: + return SupplyPurchase{StandardPacks: 5} + case ZoneTierLegendary: + return SupplyPurchase{StandardPacks: 5, DeluxePacks: 1} + } + return SupplyPurchase{StandardPacks: 2} + default: // LoadoutLean + switch tier { + case ZoneTierBeginner, ZoneTierApprentice: + return SupplyPurchase{StandardPacks: 1} + case ZoneTierJourneyman: + return SupplyPurchase{StandardPacks: 2} + case ZoneTierVeteran: + return SupplyPurchase{StandardPacks: 3} + case ZoneTierLegendary: + return SupplyPurchase{StandardPacks: 5} + } + return SupplyPurchase{StandardPacks: 1} + } +} + +// parseLoadoutToken returns the preset selected by tok, if any. Accepts +// short forms (l/b/h) and a couple of synonyms. Empty/unknown → false. +func parseLoadoutToken(tok string) (SupplyLoadout, bool) { + switch strings.ToLower(strings.TrimSpace(tok)) { + case "lean", "l", "light": + return LoadoutLean, true + case "balanced", "b", "bal", "standard": + return LoadoutBalanced, true + case "heavy", "h", "max", "full": + return LoadoutHeavy, true + } + return 0, false +} + // supplyDailyBurn returns the base SU/day for a zone tier (§4.1). // Tier 1: 1, Tier 2: 1.5, Tier 3: 2, Tier 4: 3, Tier 5: 4. func supplyDailyBurn(tier ZoneTier) float32 {