diff --git a/internal/plugin/dnd_expedition_supplies.go b/internal/plugin/dnd_expedition_supplies.go new file mode 100644 index 0000000..2d3ac7f --- /dev/null +++ b/internal/plugin/dnd_expedition_supplies.go @@ -0,0 +1,176 @@ +package plugin + +import ( + "fmt" +) + +// Phase 12 E1b — Supply procurement, daily burn, and depletion effects. +// Spec: gogobee_expedition_system.md §4. + +// Pack pricing/yield. §4.2. +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 +) + +// 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 { + switch tier { + case ZoneTierBeginner: + return 1.0 + case ZoneTierApprentice: + return 1.5 + case ZoneTierJourneyman: + return 2.0 + case ZoneTierVeteran: + return 3.0 + case ZoneTierLegendary: + return 4.0 + } + return 1.0 +} + +// supplyHarshMultiplier returns the harsh-conditions multiplier (§4.1). +// Tier 1×1, Tier 2×1.5, Tier 3×2, Tier 4×2.5, Tier 5×3. +func supplyHarshMultiplier(tier ZoneTier) float32 { + switch tier { + case ZoneTierBeginner: + return 1.0 + case ZoneTierApprentice: + return 1.5 + case ZoneTierJourneyman: + return 2.0 + case ZoneTierVeteran: + return 2.5 + case ZoneTierLegendary: + return 3.0 + } + return 1.0 +} + +// SupplyDepletionState classifies remaining supply ratio (§4.3). +type SupplyDepletionState int + +const ( + SupplyNormal SupplyDepletionState = iota + SupplyRationing + SupplySevereRationing + SupplyStarvation +) + +// supplyDepletion returns the depletion state for a supplies snapshot. +// Brackets: >25% normal, 10–25% rationing, 1–9% severe, 0 starvation. +func supplyDepletion(s ExpeditionSupplies) SupplyDepletionState { + if s.Max <= 0 { + return SupplyNormal + } + if s.Current <= 0 { + return SupplyStarvation + } + pct := (s.Current / s.Max) * 100 + switch { + case pct >= 25: + return SupplyNormal + case pct >= 10: + return SupplyRationing + default: + return SupplySevereRationing + } +} + +// supplyRollModifier maps depletion state to the universal roll penalty +// applied to attack/skill rolls (§4.3). Starvation has its own CON bleed +// handled elsewhere; the roll mod for starvation matches severe rationing +// floor so combat math doesn't divide by zero. +func supplyRollModifier(state SupplyDepletionState) int { + switch state { + case SupplyRationing: + return -1 + case SupplySevereRationing, SupplyStarvation: + return -2 + } + return 0 +} + +// supplyAllowsLongRest returns false when severe rationing (§4.3) blocks +// long rests. Starvation also blocks (forced extraction is the next step). +func supplyAllowsLongRest(state SupplyDepletionState) bool { + return state == SupplyNormal || state == SupplyRationing +} + +// SupplyPurchase represents an expedition outfitting choice. +type SupplyPurchase struct { + StandardPacks int + DeluxePacks int +} + +// Total SU yield from a purchase. +func (p SupplyPurchase) Total() float32 { + return float32(p.StandardPacks*SupplyPackStandardSU + p.DeluxePacks*SupplyPackDeluxeSU) +} + +// Total cost in coins. +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 { + 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) + } + if p.DeluxePacks > SupplyPackDeluxeMax { + return fmt.Errorf("deluxe packs capped at %d (got %d)", SupplyPackDeluxeMax, p.DeluxePacks) + } + if p.StandardPacks == 0 && p.DeluxePacks == 0 { + return fmt.Errorf("expedition requires at least one supply pack") + } + return nil +} + +// makeSupplies builds an ExpeditionSupplies from a purchase, sized for the +// given zone tier. Harsh-conditions multiplier defaults to 1 (off); +// upgraded by E2/E3 events. +func makeSupplies(tier ZoneTier, p SupplyPurchase) ExpeditionSupplies { + total := p.Total() + return ExpeditionSupplies{ + Current: total, + Max: total, + DailyBurn: supplyDailyBurn(tier), + HarshMod: 1.0, + PacksStandard: p.StandardPacks, + PacksDeluxe: p.DeluxePacks, + } +} + +// applyDailyBurn deducts one day's supplies from the snapshot (caller +// persists). Returns the new snapshot and the SU consumed. +func applyDailyBurn(s ExpeditionSupplies, harshActive bool) (ExpeditionSupplies, float32) { + burn := s.DailyBurn + if harshActive { + burn *= s.HarshMod + if burn == s.DailyBurn { // HarshMod was 0 / unset + burn = s.DailyBurn + } + } + s.Current -= burn + if s.Current < 0 { + s.Current = 0 + } + // Reset per-day forage flag. + s.ForagedToday = false + return s, burn +} diff --git a/internal/plugin/dnd_expedition_supplies_test.go b/internal/plugin/dnd_expedition_supplies_test.go new file mode 100644 index 0000000..55d3afa --- /dev/null +++ b/internal/plugin/dnd_expedition_supplies_test.go @@ -0,0 +1,160 @@ +package plugin + +import "testing" + +func TestSupplyDailyBurn_AllTiers(t *testing.T) { + cases := []struct { + tier ZoneTier + want float32 + }{ + {ZoneTierBeginner, 1.0}, + {ZoneTierApprentice, 1.5}, + {ZoneTierJourneyman, 2.0}, + {ZoneTierVeteran, 3.0}, + {ZoneTierLegendary, 4.0}, + } + for _, c := range cases { + if got := supplyDailyBurn(c.tier); got != c.want { + t.Errorf("tier %d: got %v, want %v", c.tier, got, c.want) + } + } +} + +func TestSupplyHarshMultiplier_AllTiers(t *testing.T) { + cases := []struct { + tier ZoneTier + want float32 + }{ + {ZoneTierBeginner, 1.0}, + {ZoneTierApprentice, 1.5}, + {ZoneTierJourneyman, 2.0}, + {ZoneTierVeteran, 2.5}, + {ZoneTierLegendary, 3.0}, + } + for _, c := range cases { + if got := supplyHarshMultiplier(c.tier); got != c.want { + t.Errorf("tier %d: got %v, want %v", c.tier, got, c.want) + } + } +} + +func TestSupplyDepletion_Brackets(t *testing.T) { + cases := []struct { + current float32 + max float32 + want SupplyDepletionState + }{ + {10, 10, SupplyNormal}, // 100% + {3, 10, SupplyNormal}, // 30% + {2.5, 10, SupplyNormal}, // 25% — boundary stays normal + {2, 10, SupplyRationing}, // 20% + {1, 10, SupplyRationing}, // 10% — boundary stays rationing + {0.9, 10, SupplySevereRationing}, + {0.1, 10, SupplySevereRationing}, + {0, 10, SupplyStarvation}, + } + for _, c := range cases { + s := ExpeditionSupplies{Current: c.current, Max: c.max} + if got := supplyDepletion(s); got != c.want { + t.Errorf("%v/%v: got %d, want %d", c.current, c.max, got, c.want) + } + } +} + +func TestSupplyRollModifier(t *testing.T) { + cases := map[SupplyDepletionState]int{ + SupplyNormal: 0, + SupplyRationing: -1, + SupplySevereRationing: -2, + SupplyStarvation: -2, + } + for state, want := range cases { + if got := supplyRollModifier(state); got != want { + t.Errorf("state %d: got %d, want %d", state, got, want) + } + } +} + +func TestSupplyAllowsLongRest(t *testing.T) { + cases := map[SupplyDepletionState]bool{ + SupplyNormal: true, + SupplyRationing: true, + SupplySevereRationing: false, + SupplyStarvation: false, + } + for state, want := range cases { + if got := supplyAllowsLongRest(state); got != want { + t.Errorf("state %d: got %v, want %v", state, got, want) + } + } +} + +func TestSupplyPurchase_Validate(t *testing.T) { + cases := []struct { + p SupplyPurchase + 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 + } + for _, c := range cases { + err := c.p.Validate() + if (err != nil) != c.wantErr { + t.Errorf("%+v: err = %v, wantErr=%v", c.p, err, c.wantErr) + } + } +} + +func TestSupplyPurchase_TotalAndCost(t *testing.T) { + p := SupplyPurchase{StandardPacks: 3, DeluxePacks: 1} + if p.Total() != 50 { + t.Errorf("total = %v, want 50", p.Total()) + } + if p.Cost() != 240 { + t.Errorf("cost = %d, want 240", p.Cost()) + } +} + +func TestMakeSupplies_FillsFromTier(t *testing.T) { + p := SupplyPurchase{StandardPacks: 2} + s := makeSupplies(ZoneTierJourneyman, p) + if s.Max != 20 || s.Current != 20 { + t.Errorf("supplies: %+v", s) + } + if s.DailyBurn != 2.0 { + t.Errorf("daily burn = %v, want 2", s.DailyBurn) + } + if s.PacksStandard != 2 { + t.Errorf("packs std = %d", s.PacksStandard) + } +} + +func TestApplyDailyBurn_DrainsAndClamps(t *testing.T) { + s := ExpeditionSupplies{Current: 5, Max: 10, DailyBurn: 2, HarshMod: 1.5, ForagedToday: true} + s, burn := applyDailyBurn(s, false) + if burn != 2 { + t.Errorf("burn = %v, want 2", burn) + } + if s.Current != 3 { + t.Errorf("current = %v, want 3", s.Current) + } + if s.ForagedToday { + t.Error("forage flag should reset on day rollover") + } + // Harsh active doubles via mult. + s, burn = applyDailyBurn(s, true) + if burn != 3 { // 2 × 1.5 + t.Errorf("harsh burn = %v, want 3", burn) + } + // Drain to floor. + for i := 0; i < 5; i++ { + s, _ = applyDailyBurn(s, false) + } + if s.Current != 0 { + t.Errorf("expected current clamped to 0, got %v", s.Current) + } +}