Long expeditions D5-a: per-tier supply pack caps

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.
This commit is contained in:
prosolis
2026-05-27 19:33:57 -07:00
parent aaec0ba225
commit 040cfba514
5 changed files with 89 additions and 30 deletions

View File

@@ -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 <zone> [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++

View File

@@ -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())

View File

@@ -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")

View File

@@ -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)
}
}
}