Long expeditions D5-b: loadout preset prompt at launch

`!expedition start <zone>` (and `!resume`) with no pack arg now DMs a
Lean / Balanced / Heavy menu sized per zone tier. Player replies with
the preset name (short forms l/b/h also work). Raw `Ns Md` syntax stays
as the advanced override.

Heavy always equals supplyPackCaps (the D5-a harsh×3 ceiling); Lean
covers intended days at raw burn; Balanced sits between.
This commit is contained in:
prosolis
2026-05-27 19:41:49 -07:00
parent 040cfba514
commit 26cda148fb
6 changed files with 218 additions and 15 deletions

View File

@@ -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). > **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+):** **D5-b (shipped 2026-05-27):** "Pick your loadout" preset prompt at launch. `!expedition start <zone>` 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 <zone> 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.
- "Pick your loadout" preset prompt at launch (Lean / Balanced / Heavy) — raw `Ns Md` syntax becomes the advanced override.
**Remaining work (D5-c+):**
- Forage and Ranger forage re-baseline against the new caps. - Forage and Ranger forage re-baseline against the new caps.
- DailyBurn / `phase5BDailyBurnRatePct` revisit once D7 sim measures real elapsed-day counts. - DailyBurn / `phase5BDailyBurnRatePct` revisit once D7 sim measures real elapsed-day counts.

View File

@@ -304,7 +304,7 @@ func TestAdv2Scenario_HarvestForestShadows(t *testing.T) {
p := &AdventurePlugin{euro: euro} p := &AdventurePlugin{euro: euro}
// Forage-friendly expedition. // 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) t.Fatalf("expedition start: %v", err)
} }
exp, _ := getActiveExpedition(uid) exp, _ := getActiveExpedition(uid)

View File

@@ -92,16 +92,16 @@ func expeditionHelpText() string {
var b strings.Builder var b strings.Builder
b.WriteString("**!expedition** — multi-day dungeon expeditions.\n\n") b.WriteString("**!expedition** — multi-day dungeon expeditions.\n\n")
b.WriteString("`!expedition list` — show zones available at your level\n") b.WriteString("`!expedition list` — show zones available at your level\n")
b.WriteString("`!expedition start <zone> [Ns] [Md]` — outfit & begin\n") b.WriteString("`!expedition start <zone> [loadout]` — outfit & begin\n")
b.WriteString(" `Ns` = N standard packs (10 SU, 50 coins; cap scales by zone tier)\n") b.WriteString(" loadout: `lean` / `balanced` / `heavy` (scales by zone tier)\n")
b.WriteString(" `Md` = M deluxe packs (20 SU, 90 coins; cap scales by zone tier)\n") b.WriteString(" advanced: raw pack counts — `Ns Md` (e.g. `2s 1d`)\n")
b.WriteString(" default: `1s`\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 run` — autopilot: walk rooms until something needs you (alias `!explore`)\n")
b.WriteString("`!expedition status` — current expedition snapshot\n") b.WriteString("`!expedition status` — current expedition snapshot\n")
b.WriteString("`!expedition log` — last 5 log entries\n") b.WriteString("`!expedition log` — last 5 log entries\n")
b.WriteString("`!expedition abandon` — end the expedition (no rewards)\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("`!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") b.WriteString("`!map` — region/room ASCII map")
return b.String() return b.String()
} }
@@ -178,6 +178,66 @@ func parseSupplyArgs(rest string) (SupplyPurchase, error) {
return p, nil 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 { func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter, rest string) error {
if rest == "" { if rest == "" {
return p.SendDM(ctx.Sender, return p.SendDM(ctx.Sender,
@@ -195,11 +255,16 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
return p.SendDM(ctx.Sender, return p.SendDM(ctx.Sender,
"Unknown zone for your level. Try `!expedition list`.") "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 { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
} }
zoneForCaps, _ := getZone(zoneID)
if err := purchase.Validate(zoneForCaps.Tier); err != nil { if err := purchase.Validate(zoneForCaps.Tier); err != nil {
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
} }

View File

@@ -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) { func TestThreatThresholdLabel_Bands(t *testing.T) {
cases := []struct { cases := []struct {
level int level int
@@ -119,8 +189,8 @@ func TestExpeditionCmd_StartAbandonRoundtrip(t *testing.T) {
euro.Credit(uid, 200, "test setup") euro.Credit(uid, 200, "test setup")
p := &AdventurePlugin{euro: euro} p := &AdventurePlugin{euro: euro}
// Default 1 standard pack = 50 coins. Should land an active expedition. // Lean preset = 1 standard pack at T1 = 50 coins. Should land an active expedition.
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) t.Fatal(err)
} }
exp, err := getActiveExpedition(uid) exp, err := getActiveExpedition(uid)
@@ -219,7 +289,7 @@ func TestExpeditionCmd_RunWalksRooms(t *testing.T) {
euro.Credit(uid, 200, "test setup") euro.Credit(uid, 200, "test setup")
p := &AdventurePlugin{euro: euro} 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) t.Fatal(err)
} }
exp, _ := getActiveExpedition(uid) exp, _ := getActiveExpedition(uid)

View File

@@ -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.") "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 { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
} }
resumeZone, _ := getZone(exp.ZoneID)
if err := purchase.Validate(resumeZone.Tier); err != nil { if err := purchase.Validate(resumeZone.Tier); err != nil {
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
} }

View File

@@ -2,6 +2,7 @@ package plugin
import ( import (
"fmt" "fmt"
"strings"
) )
// Phase 12 E1b — Supply procurement, daily burn, and depletion effects. // Phase 12 E1b — Supply procurement, daily burn, and depletion effects.
@@ -39,6 +40,68 @@ func supplyPackCaps(tier ZoneTier) (standard, deluxe int) {
return 3, 1 return 3, 1
} }
// SupplyLoadout names a tier-scaled preset purchase. D5-b: empty-arg
// `!expedition start <zone>` 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). // 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. // Tier 1: 1, Tier 2: 1.5, Tier 3: 2, Tier 4: 3, Tier 5: 4.
func supplyDailyBurn(tier ZoneTier) float32 { func supplyDailyBurn(tier ZoneTier) float32 {