Long expeditions D5-c: wire Ranger forage SU

§4.2's "Ranger, 1d4 SU/day" perk had been dead since Phase 12 E1b —
SupplyForageMaxSU was defined but unreferenced, ForagedToday was only
ever reset to false. New applyRangerForage helper grants 1d4 SU once
per day (headroom-capped, Ranger-only), fires at the top of
nightRolloverBurn, and surfaces as a "forage" line in the end-of-day
digest. No DC roll — accessibility over crunch, and the D5-a caps
already give all loadouts comfortable headroom.
This commit is contained in:
prosolis
2026-05-27 19:48:44 -07:00
parent 26cda148fb
commit 9be85ba954
5 changed files with 123 additions and 2 deletions

View File

@@ -120,8 +120,9 @@ Each successful background walk now logs a `walk` entry (`appendExpeditionLog(..
**D5-b (shipped 2026-05-27):** "Pick your loadout" preset prompt at launch. `!expedition start <zone>` with no pack arg now DMs a Lean / Balanced / Heavy menu (sized per zone tier via new `loadoutPurchase(tier, SupplyLoadout)` in `dnd_expedition_supplies.go`); player replies with `!expedition start <zone> lean|balanced|heavy` (short forms `l`/`b`/`h` also work). `!resume` got the same treatment. Raw `Ns Md` syntax remains the advanced override — `resolveLoadoutOrParse` in `dnd_expedition_cmd.go` tries a single-token preset first, falls back to `parseSupplyArgs`. Heavy always equals `supplyPackCaps` (the D5-a harsh×3 ceiling); Lean covers intended days at raw burn; Balanced sits between. `parseSupplyArgs("")` still returns 1 standard for any tier — it's no longer reachable from `!expedition start` (the empty path is intercepted to prompt), but the `1s` default is preserved so future callers don't get surprise zero-pack purchases. Help text updated; two existing scenario tests now pass `lean` explicitly, three new tests cover preset resolution + the prompt-vs-start guard.
**Remaining work (D5-c+):**
- Forage and Ranger forage re-baseline against the new caps.
**D5-c (shipped 2026-05-27):** Ranger forage wired against the new caps. Audit found `SupplyForageMaxSU = 4` defined in `dnd_expedition_supplies.go` since Phase 12 E1b but never referenced — `ForagedToday` was only ever reset to `false`, never set to `true`, so the §4.2 "Ranger, WIS DC 12, 1d4 SU/day" perk had never actually granted supplies. New helper `applyRangerForage(e, c, rng)` rolls 1d4 SU once per day for Ranger characters, headroom-capped against `Supplies.Max` so a Heavy loadout doesn't overflow its purchased ceiling; non-Rangers and post-roll repeats are no-ops. Fires at the top of `nightRolloverBurn` (before the daily burn) so the +SU lands on today's bag and a "forage" log entry flows into the end-of-day digest via a new `renderEndOfDayDigest` case. No DC roll — accessibility over crunch (`feedback_accessibility_over_dnd_crunch`); the DC-12 gate would've added a fail case with no upside given the new headroom. Average +2.5 SU/day off a Ranger is a quiet Lean-loadout cushion (~1 extra day of T5 late-stage burn over a 7-day run), not a Heavy multiplier. Tests cover ranger-grants, non-ranger no-op, repeat-day no-op, headroom-cap, full-bag-still-stamps-flag, nil-safety.
**Remaining work (D5-d+):**
- DailyBurn / `phase5BDailyBurnRatePct` revisit once D7 sim measures real elapsed-day counts.
**Exit criteria:** balanced loadout completes intended-duration expedition in ≥ 85% of sim runs without starvation extraction.

View File

@@ -82,6 +82,16 @@ type nightRolloverResult struct {
// to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp
// between the two so a fortified camp's 5 lands before drift's +3.
func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
// D5-c: Ranger forage runs before the daily burn so the +SU lands on
// today's supplies, not tomorrow's. Logged so the end-of-day digest
// can surface the gain; pure no-op for non-Ranger characters.
if c, err := LoadDnDCharacter(id.UserID(e.UserID)); err == nil && c != nil {
if gain := applyRangerForage(e, c, nil); gain > 0 {
_ = appendExpeditionLog(e.ID, e.CurrentDay, "forage",
fmt.Sprintf("ranger forage +%g SU", gain),
flavor.Pick(flavor.HarvestForageSuccess))
}
}
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
var newSupplies ExpeditionSupplies
var burn float32

View File

@@ -2,6 +2,7 @@ package plugin
import (
"fmt"
"math/rand/v2"
"strings"
)
@@ -138,6 +139,45 @@ func supplyHarshMultiplier(tier ZoneTier) float32 {
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

View File

@@ -166,6 +166,67 @@ func TestMakeSupplies_FillsFromTier(t *testing.T) {
}
}
func TestApplyRangerForage(t *testing.T) {
ranger := &DnDCharacter{Class: ClassRanger}
fighter := &DnDCharacter{Class: ClassFighter}
det := func(n int) int { return n - 1 } // always rolls the max (1d4 = 4)
// Ranger, fresh day, plenty of headroom: max 1d4 = 4 SU added, flag set.
exp := &Expedition{Supplies: ExpeditionSupplies{Current: 10, Max: 50}}
if gain := applyRangerForage(exp, ranger, det); gain != 4 {
t.Errorf("ranger forage gain = %v, want 4", gain)
}
if exp.Supplies.Current != 14 {
t.Errorf("current after forage = %v, want 14", exp.Supplies.Current)
}
if !exp.Supplies.ForagedToday {
t.Error("ForagedToday should be set after a successful grant")
}
// Same day, second call: no-op (already foraged).
if gain := applyRangerForage(exp, ranger, det); gain != 0 {
t.Errorf("repeat forage gain = %v, want 0", gain)
}
if exp.Supplies.Current != 14 {
t.Errorf("current after repeat = %v, want 14", exp.Supplies.Current)
}
// Non-Ranger: never grants, never sets the flag.
exp2 := &Expedition{Supplies: ExpeditionSupplies{Current: 10, Max: 50}}
if gain := applyRangerForage(exp2, fighter, det); gain != 0 {
t.Errorf("non-ranger gain = %v, want 0", gain)
}
if exp2.Supplies.ForagedToday {
t.Error("non-ranger should not stamp ForagedToday")
}
// Headroom cap: 2 SU short of Max → grant clamps to 2 even on a max roll.
exp3 := &Expedition{Supplies: ExpeditionSupplies{Current: 48, Max: 50}}
if gain := applyRangerForage(exp3, ranger, det); gain != 2 {
t.Errorf("headroom-capped gain = %v, want 2", gain)
}
if exp3.Supplies.Current != 50 {
t.Errorf("current should clamp to Max, got %v", exp3.Supplies.Current)
}
// Already at Max: no grant, but flag still set so the day's roll is spent.
exp4 := &Expedition{Supplies: ExpeditionSupplies{Current: 50, Max: 50}}
if gain := applyRangerForage(exp4, ranger, det); gain != 0 {
t.Errorf("full-bag gain = %v, want 0", gain)
}
if !exp4.Supplies.ForagedToday {
t.Error("full-bag should still consume the day's forage attempt")
}
// Nil character / nil expedition: never panics, returns 0.
if gain := applyRangerForage(exp, nil, det); gain != 0 {
t.Errorf("nil char gain = %v, want 0", gain)
}
if gain := applyRangerForage(nil, ranger, det); gain != 0 {
t.Errorf("nil exp gain = %v, want 0", gain)
}
}
func TestApplyDailyBurn_DrainsAndClamps(t *testing.T) {
// Phase 5-B (shipped): applyDailyBurn now scales by
// phase5BDailyBurnRatePct = 50 by default — every expected value

View File

@@ -40,6 +40,7 @@ func renderEndOfDayDigest(expID string, prevDay int) string {
walks int
harvests int
interrupts int
forageLines []string
threatLines []string
milestoneLine []string
narrativeBits []string
@@ -53,6 +54,8 @@ func renderEndOfDayDigest(expID string, prevDay int) string {
if strings.Contains(e.Summary, "success") {
harvests++
}
case "forage":
forageLines = append(forageLines, e.Summary)
case "interrupt":
interrupts++
case "threat":
@@ -88,6 +91,12 @@ func renderEndOfDayDigest(expID string, prevDay int) string {
b.WriteString(fmt.Sprintf("• Came back with **%d** harvest%s.\n", harvests, pluralS(harvests)))
bulleted = true
}
for _, f := range forageLines {
b.WriteString("• ")
b.WriteString(f)
b.WriteString("\n")
bulleted = true
}
for _, t := range threatLines {
b.WriteString("• ")
b.WriteString(t)