mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Five bugs found reviewing n1-restoration end to end. beginCombatTurn settles any phase the engine owes before reading whose turn it is. That settle can end the fight — and the old code then answered "you're not in a fight" and returned. The terminal status was already persisted, so nothing ever paid the party out: no XP, no loot, no death recorded, no run teardown. The reaper cannot recover it either, because listExpiredCombatSessions filters on status='active'. Close the fight out there, the way the !fight start path and the reaper already do. A party member was permanently soft-locked when their leader extracted and never resumed. seatedExpeditionFor (the guard) spans 'extracting'; expeditionForMember (what !expedition leave resolved through) saw only 'active'. So the member was refused any new adventure by the guard and told "No active expedition" by the command the guard points them at, with nothing sweeping stale rows and only the leader able to clear one. Resolve the exit through the same lookup as the gate. updateSupplies overwrites supplies_json wholesale, and expeditionCmdAccept folded a member's packs onto a snapshot read before the coin debit, unlocked. Handlers run one goroutine per event, so two invitees accepting genuinely interleave and one member's packs vanish. advUserLock cannot help — it is keyed by sender, so racing members take different mutexes. Add advExpeditionLock and re-read the pool under it. Closes accept-vs-accept; the six other updateSupplies callers still race and are written up separately. runHarvestInterrupt picked an elite enemy and elite narration off a local `elite` flag, then passed a hardcoded false as isElite to closeOutZoneWin. dropZoneLoot gates masterwork on isBoss||isElite, so beating an elite interrupt skipped the masterwork roll and took standard treasure weight — while the same elite fought via !zone paid out correctly. arenaSeasonRollover marked its job complete even when recordArenaSeasonTitle failed, and JobCompleted short-circuits every later run for that quarter, so a transient SQLite BUSY lost the crown forever. Defer completion on failure; the insert is ON CONFLICT DO NOTHING against PRIMARY KEY (season, kind) and a past season's data is frozen, so the retry is safe. Also: drop dead partySurvivors, collapse the zoneCombatRoster alias into fightRoster, route partyCasualtyLine through joinNames, fold four copies of the expedition column projection into expeditionSelectCols, stop replyDM sending a blank DM, and correct two doc comments describing a path that no longer exists. Deliberately not fixed, with reasons, in gogobee_code_review_followups.md — most notably that both turn-based close-outs skip grantCombatAchievements and persistDnDPostCombatSubclass, which the auto-resolve paths run.
415 lines
13 KiB
Go
415 lines
13 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"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,
|
||
}
|
||
}
|
||
|
||
// addSupplyPurchase folds a joining member's packs into the party's pool
|
||
// (N3/P6b). Everyone carries their own rations in, so both Current and Max rise
|
||
// by what they bought.
|
||
//
|
||
// Raising Max alongside Current is what keeps supplyDepletion honest: it reads
|
||
// the ratio, and a member arriving with a full pack must not read as the party
|
||
// suddenly starving. On Day 1 — the only day an invite is legal — nothing has
|
||
// burned yet, so Current == Max and the fold is exact.
|
||
func addSupplyPurchase(s ExpeditionSupplies, p SupplyPurchase) ExpeditionSupplies {
|
||
total := p.Total()
|
||
s.Current += total
|
||
s.Max += total
|
||
s.PacksStandard += p.StandardPacks
|
||
s.PacksDeluxe += p.DeluxePacks
|
||
return s
|
||
}
|
||
|
||
// 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
|
||
|
||
// applyDailyBurn is the solo-rate burn. Since P6b folded the roster in, the live
|
||
// callers all go through applyExpeditionDailyBurn instead; this survives as the
|
||
// fixture the supply tests pin the solo rate against.
|
||
func applyDailyBurn(s ExpeditionSupplies, harshActive, siege bool) (ExpeditionSupplies, float32) {
|
||
return applyDailyBurnP(s, harshActive, siege, phase5BDailyBurnRatePct)
|
||
}
|
||
|
||
// partyBurnEfficiency is the per-head discount a party gets on rations: three
|
||
// people eat 2.4 days' worth per day, not 3. Sharing a fire, a pot and a watch
|
||
// rota is worth something, and it is the one place C1 asked for a party to be
|
||
// mechanically better off than three solo runs.
|
||
//
|
||
// It is an exact ratio rather than the literal 0.8 because the rate is truncated
|
||
// to an int: float32(50*3) * 0.8 evaluates a hair under 120, and int() would
|
||
// round it to 119 — a silent, permanent tax on every party of three.
|
||
const (
|
||
partyBurnEfficiencyNum = 4
|
||
partyBurnEfficiencyDen = 5
|
||
)
|
||
|
||
// applyExpeditionDailyBurn is applyDailyBurn with the roster folded in: a party
|
||
// of N burns N × 0.8 days of supplies per day, against a pool that P6b's
|
||
// addSupplyPurchase has already grown by everyone's packs.
|
||
//
|
||
// A solo expedition — every expedition that has ever run, and everything the
|
||
// balance corpus measured — resolves to phase5BDailyBurnRatePct exactly, so this
|
||
// is a no-op for it down to the float. On a roster read error it burns the solo
|
||
// rate: undercharging a party is a bug, starving them on a SQLite hiccup is a
|
||
// lost expedition.
|
||
func applyExpeditionDailyBurn(e *Expedition, harshActive, siege bool) (ExpeditionSupplies, float32) {
|
||
return applyDailyBurnP(e.Supplies, harshActive, siege, expeditionBurnRatePct(e.ID))
|
||
}
|
||
|
||
func expeditionBurnRatePct(expeditionID string) int {
|
||
n, err := partySize(expeditionID)
|
||
if err != nil {
|
||
slog.Warn("expedition: party size read failed, burning at the solo rate",
|
||
"expedition", expeditionID, "err", err)
|
||
return phase5BDailyBurnRatePct
|
||
}
|
||
if n <= 1 {
|
||
return phase5BDailyBurnRatePct
|
||
}
|
||
return phase5BDailyBurnRatePct * n * partyBurnEfficiencyNum / partyBurnEfficiencyDen
|
||
}
|
||
|
||
// 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 reach it through applyExpeditionDailyBurn,
|
||
// which supplies the roster-scaled rate.
|
||
// 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
|
||
}
|