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

@@ -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"}
}
case ZoneFeywildCrossing:
return feywildTemporalPreBurn(e, nextDay)
}
return extraHarsh
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.