mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
176
internal/plugin/dnd_expedition_supplies.go
Normal file
176
internal/plugin/dnd_expedition_supplies.go
Normal 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, 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
|
||||||
|
}
|
||||||
160
internal/plugin/dnd_expedition_supplies_test.go
Normal file
160
internal/plugin/dnd_expedition_supplies_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user