Adv 2.0 D&D Phase 12 E3d: Feywild Crossing time distortion

§7.4 daily 1d6 distortion roll fires at briefing time:
  • 1–2 normal day (no override)
  • 3–4 half-day → 0.5× burn multiplier override
  • 5    double-day → 2.0× burn + extra wandering check at recap
  • 6    time loop → narration only; combat-link reads on next room

Refactors applyZoneTemporalPreBurn to return TemporalBurnOverride
(Multiplier-based) instead of a force-double bool. Sunken Temple's
Day-6 tidal piggybacks on the same override pipeline (2.0×) instead
of the siege-flag hack.

Persists the day's distortion bucket to RegionState["feywild_today"]
so the recap path can fire the §7.4 extra wandering check on
double-day.

Reuses flavor.FeywildTimeDistortionHalf / Double / Loop per
feedback_reuse_existing_flavor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 16:03:50 -07:00
parent 044baddcce
commit 3a000ada8c
3 changed files with 287 additions and 12 deletions

View File

@@ -150,15 +150,26 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
// deliverBriefing rolls a day forward, applies supply burn, posts the
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
// E3: zone-specific temporal events that affect this morning's burn
// fire BEFORE applyDailyBurn (e.g. Sunken Temple Day 6 tidal forces
// 2× burn even on tier-2 where HarshMod is 1.5×). Force-double piggy-
// backs on the siege param's "≥2× floor" branch in applyDailyBurn.
forceDouble := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
// E3: zone-specific temporal events fire BEFORE applyDailyBurn and
// can override the entire burn calculation with a fixed multiplier
// (Sunken Temple tidal 2.0×, Feywild half-day 0.5×, etc.).
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
// Advance day + supply burn happen together at the morning rollover.
var newSupplies ExpeditionSupplies
var burn float32
if burnOverride.Multiplier > 0 {
burn = e.Supplies.DailyBurn * burnOverride.Multiplier
newSupplies = e.Supplies
newSupplies.Current -= burn
if newSupplies.Current < 0 {
newSupplies.Current = 0
}
newSupplies.ForagedToday = false
} else {
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
newSupplies, burn := applyDailyBurn(e.Supplies, harsh, e.SiegeMode || forceDouble)
newSupplies, burn = applyDailyBurn(e.Supplies, harsh, e.SiegeMode)
}
if err := updateSupplies(e.ID, newSupplies); err != nil {
return err
}
@@ -234,6 +245,15 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
slog.Warn("expedition: night check", "expedition", e.ID, "err", err)
}
night = &nc
// §7.4: Feywild double-day fires an extra wandering check.
if e.ZoneID == ZoneFeywildCrossing {
if today, _ := e.RegionState["feywild_today"].(string); today == string(FeywildDistortionDouble) {
extra := resolveWanderingCheck(e, charClass, nil)
if err := processNightCheck(e, extra); err != nil {
slog.Warn("expedition: feywild extra night check", "expedition", e.ID, "err", err)
}
}
}
// Refresh in-memory threat after possible signs-of-passage bump.
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
e.ThreatLevel = fresh.ThreatLevel

View File

@@ -1,12 +1,33 @@
package plugin
import (
"encoding/json"
"fmt"
"math/rand/v2"
"strings"
"gogobee/internal/db"
"gogobee/internal/flavor"
)
// randIntN wraps math/rand/v2 IntN so feywildRollFn's seam stays
// dependency-free for tests.
func randIntN(n int) int { return rand.IntN(n) }
// persistRegionState writes the in-memory RegionState back to the DB.
func persistRegionState(e *Expedition) error {
b, err := json.Marshal(e.RegionState)
if err != nil {
return err
}
_, err = db.Get().Exec(`
UPDATE dnd_expedition
SET region_state = ?,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, string(b), e.ID)
return err
}
// Phase 12 E3 — Zone-specific Temporal Events.
// Spec: gogobee_expedition_system.md §7.
//
@@ -72,19 +93,32 @@ func zoneTemporalHarsh(e *Expedition) bool {
return false
}
// TemporalBurnOverride lets a zone-temporal event short-circuit the
// default §4.1 burn pipeline with a fixed multiplier on DailyBurn.
// When Multiplier == 0, the default pipeline (HarshMod / siege) is
// used. Non-zero overrides the entire calculation.
type TemporalBurnOverride struct {
Multiplier float32 // 0 = no override; 0.5 / 2.0 / etc. otherwise
Reason string // short tag for telemetry
}
// applyZoneTemporalPreBurn runs at the top of deliverBriefing, before
// applyDailyBurn. It mutates e (state increments, in-memory only —
// caller persists the relevant fields) and returns whether this
// briefing's burn should be force-doubled regardless of HarshMod.
// caller persists the relevant fields) and returns an optional
// override on this morning's burn calculation.
//
// nextDay is the day-number we're rolling INTO (i.e. e.CurrentDay+1
// at call site, since the briefing increments after burn).
func applyZoneTemporalPreBurn(e *Expedition, nextDay int) (extraHarsh bool) {
func applyZoneTemporalPreBurn(e *Expedition, nextDay int) TemporalBurnOverride {
switch e.ZoneID {
case ZoneSunkenTemple:
extraHarsh = sunkenTempleTemporalPreBurn(e, nextDay)
if sunkenTempleTemporalPreBurn(e, nextDay) {
return TemporalBurnOverride{Multiplier: 2.0, Reason: "sunken_temple_tidal"}
}
return extraHarsh
case ZoneFeywildCrossing:
return feywildTemporalPreBurn(e, nextDay)
}
return TemporalBurnOverride{}
}
// ManorResetCycleDays is the §7.2 reset cadence — non-boss rooms
@@ -102,6 +136,8 @@ func applyZoneTemporalPostRollover(e *Expedition) []string {
return manorTemporalPostRollover(e)
case ZoneUnderforge:
return underforgeTemporalPostRollover(e)
case ZoneFeywildCrossing:
return feywildTemporalPostRollover(e)
}
return nil
}
@@ -237,6 +273,93 @@ func underforgeTemporalPostRollover(e *Expedition) []string {
return lines
}
// ─────────────────────────────────────────────────────────────────────
// §7.4 — Feywild Crossing, Time Distortion
// ─────────────────────────────────────────────────────────────────────
// FeywildDistortion is the resolved effect of a daily 1d6 distortion
// roll. Persisted to RegionState["feywild_today"] for the day so
// downstream systems (extra wandering monster check at recap, time
// loop signaling for the room engine) can read it.
type FeywildDistortion string
const (
FeywildDistortionNormal FeywildDistortion = "normal"
FeywildDistortionHalf FeywildDistortion = "half"
FeywildDistortionDouble FeywildDistortion = "double"
FeywildDistortionLoop FeywildDistortion = "loop"
)
// feywildRollFn is the test seam for the 1d6 distortion roll.
var feywildRollFn = func() int { return 1 + randIntN(6) }
// feywildResolveDistortion maps a 1d6 to the §7.4 effect bucket.
func feywildResolveDistortion(d6 int) FeywildDistortion {
switch d6 {
case 1, 2:
return FeywildDistortionNormal
case 3, 4:
return FeywildDistortionHalf
case 5:
return FeywildDistortionDouble
default:
return FeywildDistortionLoop
}
}
// feywildTemporalPreBurn rolls today's distortion, persists it to
// RegionState["feywild_today"], and returns the burn-override based on
// the result. Boss-defeated suppresses entirely.
func feywildTemporalPreBurn(e *Expedition, nextDay int) TemporalBurnOverride {
if e.BossDefeated {
return TemporalBurnOverride{}
}
d6 := feywildRollFn()
effect := feywildResolveDistortion(d6)
if e.RegionState == nil {
e.RegionState = map[string]any{}
}
e.RegionState["feywild_today"] = string(effect)
e.RegionState["feywild_today_d6"] = d6
_ = persistRegionState(e)
switch effect {
case FeywildDistortionHalf:
return TemporalBurnOverride{Multiplier: 0.5, Reason: "feywild_half_day"}
case FeywildDistortionDouble:
return TemporalBurnOverride{Multiplier: 2.0, Reason: "feywild_double_day"}
}
return TemporalBurnOverride{}
}
// feywildTemporalPostRollover narrates today's distortion. Normal-day
// rolls produce no narration (they're the baseline).
func feywildTemporalPostRollover(e *Expedition) []string {
if e.BossDefeated {
return nil
}
raw, _ := e.RegionState["feywild_today"].(string)
switch FeywildDistortion(raw) {
case FeywildDistortionHalf:
line := flavor.Pick(flavor.FeywildTimeDistortionHalf)
_ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal",
"feywild distortion: half-day (0.5× burn)", line)
return []string{line}
case FeywildDistortionDouble:
line := flavor.Pick(flavor.FeywildTimeDistortionDouble)
_ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal",
"feywild distortion: double-day (2× burn, extra wandering check pending)", line)
return []string{line}
case FeywildDistortionLoop:
line := flavor.Pick(flavor.FeywildTimeLoop)
_ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal",
"feywild distortion: time loop (room re-enters with new enemies; loot already taken)",
line)
return []string{line}
}
return nil
}
// reduceUnderforgeHeat is called by processOvernightCamp when the
// active camp is fortified (or base) in the Underforge — long rest
// drops heat stacks by 2 per §7.3. Returns the new stack count.

View File

@@ -395,6 +395,138 @@ func TestUnderforge_FortifiedRestReducesHeat(t *testing.T) {
}
}
func TestFeywild_ResolveDistortion_Buckets(t *testing.T) {
cases := []struct {
d6 int
want FeywildDistortion
}{
{1, FeywildDistortionNormal},
{2, FeywildDistortionNormal},
{3, FeywildDistortionHalf},
{4, FeywildDistortionHalf},
{5, FeywildDistortionDouble},
{6, FeywildDistortionLoop},
}
for _, c := range cases {
if got := feywildResolveDistortion(c.d6); got != c.want {
t.Errorf("d6=%d → %q, want %q", c.d6, got, c.want)
}
}
}
func TestFeywild_HalfDay_HalvesBurn(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-fey-half:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneFeywildCrossing, "",
ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 3, HarshMod: 2.5})
if err != nil {
t.Fatal(err)
}
prevRoll := feywildRollFn
defer func() { feywildRollFn = prevRoll }()
feywildRollFn = func() int { return 3 } // half
startSU := exp.Supplies.Current
if err := (&AdventurePlugin{}).deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
wantBurn := float32(1.5) // 3 * 0.5
if startSU-got.Supplies.Current != wantBurn {
t.Errorf("burn = %v, want %v (half-day)", startSU-got.Supplies.Current, wantBurn)
}
if today, _ := got.RegionState["feywild_today"].(string); today != "half" {
t.Errorf("RegionState feywild_today = %q, want %q", today, "half")
}
}
func TestFeywild_DoubleDay_DoublesBurn(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-fey-double:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneFeywildCrossing, "",
ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 3, HarshMod: 2.5})
if err != nil {
t.Fatal(err)
}
prevRoll := feywildRollFn
defer func() { feywildRollFn = prevRoll }()
feywildRollFn = func() int { return 5 }
startSU := exp.Supplies.Current
if err := (&AdventurePlugin{}).deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
wantBurn := float32(6.0)
if startSU-got.Supplies.Current != wantBurn {
t.Errorf("burn = %v, want %v (double-day)", startSU-got.Supplies.Current, wantBurn)
}
}
func TestFeywild_TimeLoop_LogsAndDoesNotChangeBurn(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-fey-loop:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneFeywildCrossing, "",
ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 3, HarshMod: 2.5})
if err != nil {
t.Fatal(err)
}
prevRoll := feywildRollFn
defer func() { feywildRollFn = prevRoll }()
feywildRollFn = func() int { return 6 }
startSU := exp.Supplies.Current
if err := (&AdventurePlugin{}).deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
// Loop has no burn override → default pipeline (no harsh, no siege) = 3 SU.
if startSU-got.Supplies.Current != 3 {
t.Errorf("burn = %v, want 3 (loop default)", startSU-got.Supplies.Current)
}
entries, _ := recentExpeditionLog(exp.ID, 10)
found := false
for _, e := range entries {
if e.Type == "temporal" && strings.Contains(e.Summary, "time loop") {
found = true
}
}
if !found {
t.Error("expected a 'time loop' temporal log entry")
}
}
func TestFeywild_NormalDay_NoTemporalLog(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-fey-norm:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneFeywildCrossing, "",
ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 3, HarshMod: 2.5})
if err != nil {
t.Fatal(err)
}
prevRoll := feywildRollFn
defer func() { feywildRollFn = prevRoll }()
feywildRollFn = func() int { return 1 }
if err := (&AdventurePlugin{}).deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err)
}
entries, _ := recentExpeditionLog(exp.ID, 10)
for _, e := range entries {
if e.Type == "temporal" {
t.Errorf("normal-day distortion should not log temporal; got %+v", e)
}
}
}
func TestSunkenTemple_NoTidalOnNonZone(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-tidal-other:example")