mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user