Files
gogobee/internal/plugin/dnd_expedition_supplies.go
prosolis 5b8ef740f8 Adv 2.0 D&D Phase 12 E2c: Siege Mode effects + threat-70 warning
§8.3 siege economics:
- applyDailyBurn now takes an explicit siege flag and enforces a 2× floor
  on supply burn even when HarshMod is below 2 (tier-1 zones still get
  starved out per spec).
- currentBurn mirrors the same precedence so status/briefing readouts
  stay consistent.
- Briefing rollover passes e.SiegeMode through, decoupling siege from
  the harsh-conditions composite.

§8.3 threat-70 warning: applyDailyThreatDrift emits a one-time
appendApproachingSiegeLog when the level crosses 70 (prevLevel<70 and
new level≥70). Pulls from a new flavor.ThreatClockApproachingSiege pool
seeded with the spec's verbatim warning beat plus two voice-matched
alternates.

Other siege effects (boss +20 HP / Legendary Resistance, cleared-room
respawn, no-short-rest enforcement) stay deferred to the combat-link
phase — ThreatBandInfo already exposes the flags that engine will read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00

190 lines
5.1 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.
//
// Multiplier precedence (§4.1, §8.3):
// - siege overrides everything with a hard 2× floor (even for tier 1
// where HarshMod is 1×) — the dungeon is actively starving you out.
// - otherwise, harshActive applies HarshMod (zone-tier scaled).
func applyDailyBurn(s ExpeditionSupplies, harshActive, siege bool) (ExpeditionSupplies, float32) {
burn := s.DailyBurn
switch {
case siege:
mult := s.HarshMod
if mult < 2 {
mult = 2
}
burn *= mult
case harshActive:
mult := s.HarshMod
if mult <= 0 {
mult = 1
}
burn *= mult
}
s.Current -= burn
if s.Current < 0 {
s.Current = 0
}
// Reset per-day forage flag.
s.ForagedToday = false
return s, burn
}