Adv 2.0 D&D Phase 12 E1b: supply system (procurement, burn, depletion)

Pure logic per gogobee_expedition_system.md §4. Tier→base SU/day and
harsh-conditions multiplier tables (§4.1). Standard pack 10 SU/50c
(max 3) and deluxe pack 20 SU/90c (max 1) per §4.2. SupplyPurchase
struct with Validate / Total / Cost. Depletion brackets (>25 / 10–25
/ 1–9 / 0) → roll modifier and long-rest gate per §4.3. applyDailyBurn
deducts and clamps; resets the per-day forage flag.

Procurement is exposed as a pure helper here; the !expedition start
flow wires it interactively in E1c.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 15:13:35 -07:00
parent 9608ce554a
commit 2413f892e5
2 changed files with 336 additions and 0 deletions

View File

@@ -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, 1025% rationing, 19% 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
}