Files
gogobee/internal/plugin/dnd_expedition_supplies.go
prosolis 2413f892e5 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>
2026-05-09 14:25:22 -07:00

177 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}