diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md index 07276e3..9da5ce2 100644 --- a/gogobee_long_expedition_plan.md +++ b/gogobee_long_expedition_plan.md @@ -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 ` 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 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. diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index f4e12f2..1695921 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -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 diff --git a/internal/plugin/dnd_expedition_supplies.go b/internal/plugin/dnd_expedition_supplies.go index ee23b77..2dc619f 100644 --- a/internal/plugin/dnd_expedition_supplies.go +++ b/internal/plugin/dnd_expedition_supplies.go @@ -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 diff --git a/internal/plugin/dnd_expedition_supplies_test.go b/internal/plugin/dnd_expedition_supplies_test.go index a6d8b03..15f27fd 100644 --- a/internal/plugin/dnd_expedition_supplies_test.go +++ b/internal/plugin/dnd_expedition_supplies_test.go @@ -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 diff --git a/internal/plugin/expedition_autorun_digest.go b/internal/plugin/expedition_autorun_digest.go index 2496ab5..0d3f244 100644 --- a/internal/plugin/expedition_autorun_digest.go +++ b/internal/plugin/expedition_autorun_digest.go @@ -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)