From 040cfba51402413606ab60ebf3dcf416d0296ed8 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 27 May 2026 19:33:57 -0700 Subject: [PATCH] Long expeditions D5-a: per-tier supply pack caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today's global SupplyPackStandardMax=3 / SupplyPackDeluxeMax=1 were a 2-day shape; with D1's longer room budgets and D2-b's event-anchored night burns, a T4/T5 player can't legally buy enough supplies for the intended duration. supplyPackCaps(tier) now returns (std,dlx) per tier — T1/T2: (2,1); T3: (3,1) unchanged; T4: (5,1); T5: (7,2) — sized to clear DailyBurn(raw) × intendedDays × 1.3 even with the harsh×3 multiplier layered on. Validate takes a tier; both call sites (!expedition start, !resume) pass the resolved zone's tier. Holiday +1 standard pack still bypasses the cap on purpose. DailyBurn / phase5BDailyBurnRatePct unchanged; that's a D7 lever once the sim can measure event-anchored rollovers. --- gogobee_long_expedition_plan.md | 15 +++--- internal/plugin/dnd_expedition_cmd.go | 12 +++-- internal/plugin/dnd_expedition_extract.go | 3 +- internal/plugin/dnd_expedition_supplies.go | 40 +++++++++++---- .../plugin/dnd_expedition_supplies_test.go | 49 ++++++++++++++++--- 5 files changed, 89 insertions(+), 30 deletions(-) diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md index 4dec0a1..13fa7d5 100644 --- a/gogobee_long_expedition_plan.md +++ b/gogobee_long_expedition_plan.md @@ -113,12 +113,15 @@ Each successful background walk now logs a `walk` entry (`appendExpeditionLog(.. **Exit criteria:** a 7-day T5 produces ≤ 10 DMs across the whole expedition (1 launch + 6 end-of-day + 1 boss + 1 run-complete + 1 emergency). ### D5 — Supplies economics retune -**Files:** `dnd_expedition.go:38-47` (ExpeditionSupplies), pack-purchase surface (E1b), `applyDailyBurn`. -**Work:** -- New pack tiers sized for 2-day → 7-day. Max packs scale with intended duration. Today's caps (PacksStandard ≤ 3, PacksDeluxe ≤ 1) are a 2-day shape. -- DailyBurn: keep zone-tier scaling, but the autopilot's camp choices now consume meaningful SU per day — so `Max` headroom needs to roughly equal `DailyBurn × intendedDays × 1.3` (slop). -- Surface supply purchase at launch as a single "Pick your loadout" prompt with 3–4 presets ("Lean: 2-day shape", "Balanced", "Heavy: full T5"), with raw pack counts as an advanced override. -- Forage and Ranger forage tuning re-baseline. + +**D5-a (shipped 2026-05-27):** per-tier pack caps. `dnd_expedition_supplies.go` retires the global `SupplyPackStandardMax`/`SupplyPackDeluxeMax` constants in favor of `supplyPackCaps(tier) (std, dlx int)` — T1/T2: (2,1); T3: (3,1) unchanged; T4: (5,1); T5: (7,2). `SupplyPurchase.Validate` is now `Validate(tier ZoneTier)`; both call sites (`!expedition start`, `!resume`) pass the resolved zone's tier. The cap clears `DailyBurn(raw) × intendedDays × 1.3` for every tier under the §2 target durations even with harsh×3 layered on top, so a player who wants to buy for the long shape can. DailyBurn / harsh-multiplier / `phase5BDailyBurnRatePct` are unchanged here — they're a D7 lever, alongside the sim work that should unblock empirical baselining. The help text now describes the cap as "scales by zone tier"; the holiday +1 standard pack still bypasses the cap on purpose (it's a freebie on top). + +> **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. +- Forage and Ranger forage re-baseline against the new caps. +- DailyBurn / `phase5BDailyBurnRatePct` revisit once D7 sim measures real elapsed-day counts. **Exit criteria:** balanced loadout completes intended-duration expedition in ≥ 85% of sim runs without starvation extraction. diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index 0ba28ec..b6aa11f 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -93,8 +93,8 @@ func expeditionHelpText() string { 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(" `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 run` — autopilot: walk rooms until something needs you (alias `!explore`)\n") b.WriteString("`!expedition status` — current expedition snapshot\n") @@ -199,7 +199,8 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter if err != nil { return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error()) } - if err := purchase.Validate(); err != nil { + zoneForCaps, _ := getZone(zoneID) + if err := purchase.Validate(zoneForCaps.Tier); err != nil { return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) } cost := float64(purchase.Cost()) @@ -226,9 +227,10 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter zone.Display)) } - zone, _ := getZone(zoneID) + zone := zoneForCaps // Holiday perk: a complimentary standard pack is added to the supplies - // snapshot without inflating the coin cost. + // snapshot without inflating the coin cost. Bypasses the per-tier cap + // on purpose — it's a freebie on top of whatever the player bought. suppliesPurchase := purchase if isHol, _ := isHolidayToday(); isHol { suppliesPurchase.StandardPacks++ diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 20a59e5..cbcf3d1 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -312,7 +312,8 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error if err != nil { return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error()) } - if err := purchase.Validate(); err != nil { + resumeZone, _ := getZone(exp.ZoneID) + if err := purchase.Validate(resumeZone.Tier); err != nil { return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) } cost := float64(purchase.Cost()) diff --git a/internal/plugin/dnd_expedition_supplies.go b/internal/plugin/dnd_expedition_supplies.go index 353d767..6ef28b7 100644 --- a/internal/plugin/dnd_expedition_supplies.go +++ b/internal/plugin/dnd_expedition_supplies.go @@ -11,15 +11,34 @@ import ( const ( SupplyPackStandardSU = 10 SupplyPackStandardCoins = 50 - SupplyPackStandardMax = 3 // per expedition SupplyPackDeluxeSU = 20 SupplyPackDeluxeCoins = 90 - SupplyPackDeluxeMax = 1 // per expedition SupplyForageMaxSU = 4 // 1d4 cap (Ranger, WIS DC 12) — §4.2 ) +// supplyPackCaps returns the per-tier maximum standard and deluxe pack +// counts a player can buy for an expedition. D5-a: caps now scale by +// zone tier so a T5 loadout actually clears DailyBurn(raw) × intended +// days × harsh-multiplier — see gogobee_long_expedition_plan.md §D5. +// Intended-day anchors come from the §2 target table (T1=2 → T5=7). +func supplyPackCaps(tier ZoneTier) (standard, deluxe int) { + switch tier { + case ZoneTierBeginner: + return 2, 1 + case ZoneTierApprentice: + return 2, 1 + case ZoneTierJourneyman: + return 3, 1 + case ZoneTierVeteran: + return 5, 1 + case ZoneTierLegendary: + return 7, 2 + } + return 3, 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. func supplyDailyBurn(tier ZoneTier) float32 { @@ -122,18 +141,19 @@ func (p SupplyPurchase) Cost() int { return p.StandardPacks*SupplyPackStandardCoins + p.DeluxePacks*SupplyPackDeluxeCoins } -// Validate enforces §4.2 caps (max 3 standard, max 1 deluxe, no negatives, -// at least one pack purchased — an expedition without supplies is not a -// legal start). -func (p SupplyPurchase) Validate() error { +// Validate enforces §4.2 caps (no negatives, at least one pack +// purchased — an expedition without supplies is not a legal start) and +// the per-tier maximums from supplyPackCaps. +func (p SupplyPurchase) Validate(tier ZoneTier) error { if p.StandardPacks < 0 || p.DeluxePacks < 0 { return fmt.Errorf("supply pack counts must be non-negative") } - if p.StandardPacks > SupplyPackStandardMax { - return fmt.Errorf("standard packs capped at %d (got %d)", SupplyPackStandardMax, p.StandardPacks) + stdCap, dlxCap := supplyPackCaps(tier) + if p.StandardPacks > stdCap { + return fmt.Errorf("standard packs capped at %d for T%d (got %d)", stdCap, int(tier), p.StandardPacks) } - if p.DeluxePacks > SupplyPackDeluxeMax { - return fmt.Errorf("deluxe packs capped at %d (got %d)", SupplyPackDeluxeMax, p.DeluxePacks) + if p.DeluxePacks > dlxCap { + return fmt.Errorf("deluxe packs capped at %d for T%d (got %d)", dlxCap, int(tier), p.DeluxePacks) } if p.StandardPacks == 0 && p.DeluxePacks == 0 { return fmt.Errorf("expedition requires at least one supply pack") diff --git a/internal/plugin/dnd_expedition_supplies_test.go b/internal/plugin/dnd_expedition_supplies_test.go index 02d2657..a6d8b03 100644 --- a/internal/plugin/dnd_expedition_supplies_test.go +++ b/internal/plugin/dnd_expedition_supplies_test.go @@ -92,19 +92,52 @@ func TestSupplyAllowsLongRest(t *testing.T) { func TestSupplyPurchase_Validate(t *testing.T) { cases := []struct { p SupplyPurchase + tier ZoneTier wantErr bool }{ - {SupplyPurchase{StandardPacks: 1}, false}, - {SupplyPurchase{StandardPacks: 3, DeluxePacks: 1}, false}, // max - {SupplyPurchase{StandardPacks: 4}, true}, // over standard cap - {SupplyPurchase{DeluxePacks: 2}, true}, // over deluxe cap - {SupplyPurchase{StandardPacks: -1}, true}, - {SupplyPurchase{}, true}, // none purchased + // T3 is the only tier whose caps are unchanged from the pre-D5 + // shape (3 standard / 1 deluxe); use it to anchor the + // happy-path / over-cap parity assertions. + {SupplyPurchase{StandardPacks: 1}, ZoneTierJourneyman, false}, + {SupplyPurchase{StandardPacks: 3, DeluxePacks: 1}, ZoneTierJourneyman, false}, + {SupplyPurchase{StandardPacks: 4}, ZoneTierJourneyman, true}, + {SupplyPurchase{DeluxePacks: 2}, ZoneTierJourneyman, true}, + {SupplyPurchase{StandardPacks: -1}, ZoneTierJourneyman, true}, + {SupplyPurchase{}, ZoneTierJourneyman, true}, // none purchased + + // D5-a per-tier caps. T1/T2 tighten to (2,1); T4 widens to (5,1); + // T5 widens to (7,2). A T3-legal 3-standard loadout fails on T1. + {SupplyPurchase{StandardPacks: 2, DeluxePacks: 1}, ZoneTierBeginner, false}, + {SupplyPurchase{StandardPacks: 3}, ZoneTierBeginner, true}, + {SupplyPurchase{StandardPacks: 5, DeluxePacks: 1}, ZoneTierVeteran, false}, + {SupplyPurchase{StandardPacks: 6}, ZoneTierVeteran, true}, + {SupplyPurchase{StandardPacks: 7, DeluxePacks: 2}, ZoneTierLegendary, false}, + {SupplyPurchase{StandardPacks: 7, DeluxePacks: 3}, ZoneTierLegendary, true}, } for _, c := range cases { - err := c.p.Validate() + err := c.p.Validate(c.tier) if (err != nil) != c.wantErr { - t.Errorf("%+v: err = %v, wantErr=%v", c.p, err, c.wantErr) + t.Errorf("%+v T%d: err = %v, wantErr=%v", c.p, int(c.tier), err, c.wantErr) + } + } +} + +func TestSupplyPackCaps_PerTier(t *testing.T) { + cases := []struct { + tier ZoneTier + wantStd int + wantDlx int + }{ + {ZoneTierBeginner, 2, 1}, + {ZoneTierApprentice, 2, 1}, + {ZoneTierJourneyman, 3, 1}, + {ZoneTierVeteran, 5, 1}, + {ZoneTierLegendary, 7, 2}, + } + for _, c := range cases { + s, d := supplyPackCaps(c.tier) + if s != c.wantStd || d != c.wantDlx { + t.Errorf("T%d caps: got (%d,%d), want (%d,%d)", int(c.tier), s, d, c.wantStd, c.wantDlx) } } }