mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Rewrite !expedition help around the autopilot loop, frame !camp/!fight as overrides, and add Day X / ~Y expected + rooms/total + last-3-events to !expedition status.
353 lines
10 KiB
Go
353 lines
10 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"math/rand/v2"
|
||
"strings"
|
||
)
|
||
|
||
// 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
|
||
|
||
SupplyPackDeluxeSU = 20
|
||
SupplyPackDeluxeCoins = 90
|
||
|
||
SupplyForageMaxSU = 4 // 1d4 cap (Ranger, WIS DC 12) — §4.2
|
||
)
|
||
|
||
// supplyPackCaps returns the per-tier maximum standard and deluxe pack
|
||
// counts a player can buy for an expedition. D5-a: caps now scale by
|
||
// zone tier so a T5 loadout actually clears DailyBurn(raw) × intended
|
||
// days × harsh-multiplier — see gogobee_long_expedition_plan.md §D5.
|
||
// Intended-day anchors come from the §2 target table (T1=2 → T5=7).
|
||
func supplyPackCaps(tier ZoneTier) (standard, deluxe int) {
|
||
switch tier {
|
||
case ZoneTierBeginner:
|
||
return 2, 1
|
||
case ZoneTierApprentice:
|
||
return 2, 1
|
||
case ZoneTierJourneyman:
|
||
return 3, 1
|
||
case ZoneTierVeteran:
|
||
return 5, 1
|
||
case ZoneTierLegendary:
|
||
return 7, 2
|
||
}
|
||
return 3, 1
|
||
}
|
||
|
||
// expeditionTargetDays returns the §2 target-duration anchor for a zone
|
||
// tier — what the supply-cap math, balanced loadout, and the
|
||
// "Day X / ~Y expected" line in !expedition status are all sized against.
|
||
func expeditionTargetDays(tier ZoneTier) int {
|
||
switch tier {
|
||
case ZoneTierBeginner:
|
||
return 2
|
||
case ZoneTierApprentice:
|
||
return 3
|
||
case ZoneTierJourneyman:
|
||
return 4
|
||
case ZoneTierVeteran:
|
||
return 5
|
||
case ZoneTierLegendary:
|
||
return 7
|
||
}
|
||
return 4
|
||
}
|
||
|
||
// SupplyLoadout names a tier-scaled preset purchase. D5-b: empty-arg
|
||
// `!expedition start <zone>` now prompts the player to pick one of these
|
||
// rather than silently defaulting to 1 standard pack. Raw `Ns Md` syntax
|
||
// remains the advanced override.
|
||
type SupplyLoadout int
|
||
|
||
const (
|
||
LoadoutLean SupplyLoadout = iota
|
||
LoadoutBalanced
|
||
LoadoutHeavy
|
||
)
|
||
|
||
// loadoutPurchase returns the SupplyPurchase for a preset at a tier.
|
||
// Lean: covers intended days at raw daily burn (cheap, no harsh buffer).
|
||
// Balanced: ~harsh-ready for an intended-length run (recommended).
|
||
// Heavy: equals supplyPackCaps — the harsh×3 ceiling D5-a sized for.
|
||
func loadoutPurchase(tier ZoneTier, l SupplyLoadout) SupplyPurchase {
|
||
switch l {
|
||
case LoadoutHeavy:
|
||
std, dlx := supplyPackCaps(tier)
|
||
return SupplyPurchase{StandardPacks: std, DeluxePacks: dlx}
|
||
case LoadoutBalanced:
|
||
switch tier {
|
||
case ZoneTierBeginner, ZoneTierApprentice:
|
||
return SupplyPurchase{StandardPacks: 2}
|
||
case ZoneTierJourneyman:
|
||
return SupplyPurchase{StandardPacks: 3}
|
||
case ZoneTierVeteran:
|
||
return SupplyPurchase{StandardPacks: 5}
|
||
case ZoneTierLegendary:
|
||
return SupplyPurchase{StandardPacks: 5, DeluxePacks: 1}
|
||
}
|
||
return SupplyPurchase{StandardPacks: 2}
|
||
default: // LoadoutLean
|
||
switch tier {
|
||
case ZoneTierBeginner, ZoneTierApprentice:
|
||
return SupplyPurchase{StandardPacks: 1}
|
||
case ZoneTierJourneyman:
|
||
return SupplyPurchase{StandardPacks: 2}
|
||
case ZoneTierVeteran:
|
||
return SupplyPurchase{StandardPacks: 3}
|
||
case ZoneTierLegendary:
|
||
return SupplyPurchase{StandardPacks: 5}
|
||
}
|
||
return SupplyPurchase{StandardPacks: 1}
|
||
}
|
||
}
|
||
|
||
// parseLoadoutToken returns the preset selected by tok, if any. Accepts
|
||
// short forms (l/b/h) and a couple of synonyms. Empty/unknown → false.
|
||
func parseLoadoutToken(tok string) (SupplyLoadout, bool) {
|
||
switch strings.ToLower(strings.TrimSpace(tok)) {
|
||
case "lean", "l", "light":
|
||
return LoadoutLean, true
|
||
case "balanced", "b", "bal", "standard":
|
||
return LoadoutBalanced, true
|
||
case "heavy", "h", "max", "full":
|
||
return LoadoutHeavy, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// applyRangerForage grants the Ranger's daily 1d4-SU forage yield (§4.2,
|
||
// wired in D5-c). Returns the SU added — 0 for non-Rangers, when the
|
||
// player has already foraged today, or when supplies are already at Max.
|
||
// The grant is headroom-capped so a Heavy loadout doesn't overflow its
|
||
// purchased ceiling. rng is the test-injectable [0,n) source; nil falls
|
||
// back to math/rand. Caller owns the persistence.
|
||
//
|
||
// Sizing rationale: with D5-a caps so generous (Lean T5 = 50 SU vs ~14 SU
|
||
// burned over a 7-day intended run at phase5B×0.5), this perk operates
|
||
// as a quiet Lean-loadout cushion, not a Heavy multiplier — average +2.5
|
||
// SU/day off a Ranger is roughly one extra day of late-stage T5 burn
|
||
// across a full expedition.
|
||
func applyRangerForage(e *Expedition, c *DnDCharacter, rng func(int) int) float32 {
|
||
if e == nil || c == nil || c.Class != ClassRanger {
|
||
return 0
|
||
}
|
||
if e.Supplies.ForagedToday {
|
||
return 0
|
||
}
|
||
headroom := e.Supplies.Max - e.Supplies.Current
|
||
if headroom <= 0 {
|
||
e.Supplies.ForagedToday = true
|
||
return 0
|
||
}
|
||
var roll int
|
||
if rng != nil {
|
||
roll = rng(SupplyForageMaxSU) + 1
|
||
} else {
|
||
roll = rand.IntN(SupplyForageMaxSU) + 1
|
||
}
|
||
gain := float32(roll)
|
||
if gain > headroom {
|
||
gain = headroom
|
||
}
|
||
e.Supplies.Current += gain
|
||
e.Supplies.ForagedToday = true
|
||
return gain
|
||
}
|
||
|
||
// 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 (no negatives, at least one pack
|
||
// purchased — an expedition without supplies is not a legal start) and
|
||
// the per-tier maximums from supplyPackCaps.
|
||
func (p SupplyPurchase) Validate(tier ZoneTier) error {
|
||
if p.StandardPacks < 0 || p.DeluxePacks < 0 {
|
||
return fmt.Errorf("supply pack counts must be non-negative")
|
||
}
|
||
stdCap, dlxCap := supplyPackCaps(tier)
|
||
if p.StandardPacks > stdCap {
|
||
return fmt.Errorf("standard packs capped at %d for T%d (got %d)", stdCap, int(tier), p.StandardPacks)
|
||
}
|
||
if p.DeluxePacks > dlxCap {
|
||
return fmt.Errorf("deluxe packs capped at %d for T%d (got %d)", dlxCap, int(tier), 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).
|
||
// phase5BDailyBurnRatePct is the shipped daily-burn multiplier from
|
||
// Phase 3-B's sweep + Phase 5-B's post-buff re-validation. 50 means
|
||
// "half live burn" — needed because the Phase 5-B player power floor
|
||
// keeps T4/T5 expeditioners alive long enough that the original 100%
|
||
// burn rate starves them out before extraction. See
|
||
// gogobee_expedition_difficulty.md Phase 3-B (sweep) and Phase 5-B
|
||
// (re-validated under HP buff, shipped).
|
||
const phase5BDailyBurnRatePct = 50
|
||
|
||
func applyDailyBurn(s ExpeditionSupplies, harshActive, siege bool) (ExpeditionSupplies, float32) {
|
||
return applyDailyBurnP(s, harshActive, siege, phase5BDailyBurnRatePct)
|
||
}
|
||
|
||
// applyDailyBurnP is the rate-parameterized form used by the Phase 3-B
|
||
// sim harness lever sweep. burnRatePct == 0 means "use live" (100%);
|
||
// any positive value scales the final per-day burn by that percent
|
||
// (e.g. 50 = half burn). Live callers always go through applyDailyBurn.
|
||
// See gogobee_expedition_difficulty.md Phase 3-B.
|
||
func applyDailyBurnP(s ExpeditionSupplies, harshActive, siege bool, burnRatePct int) (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
|
||
}
|
||
if burnRatePct > 0 {
|
||
burn = burn * float32(burnRatePct) / 100
|
||
}
|
||
s.Current -= burn
|
||
if s.Current < 0 {
|
||
s.Current = 0
|
||
}
|
||
// Reset per-day forage flag.
|
||
s.ForagedToday = false
|
||
return s, burn
|
||
}
|