mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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.
|
||||
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
|
||||
newSupplies, burn := applyDailyBurn(e.Supplies, harsh, e.SiegeMode || forceDouble)
|
||||
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)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user