From 3a000ada8c60b683d0db371db22ca76f3e9c5e09 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 16:03:50 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 12 E3d: Feywild Crossing time distortion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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) --- internal/plugin/dnd_expedition_cycle.go | 34 ++++- internal/plugin/dnd_expedition_temporal.go | 133 +++++++++++++++++- .../plugin/dnd_expedition_temporal_test.go | 132 +++++++++++++++++ 3 files changed, 287 insertions(+), 12 deletions(-) diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 3231cee..a9b2bfb 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -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 diff --git a/internal/plugin/dnd_expedition_temporal.go b/internal/plugin/dnd_expedition_temporal.go index 97a7eed..6a5035c 100644 --- a/internal/plugin/dnd_expedition_temporal.go +++ b/internal/plugin/dnd_expedition_temporal.go @@ -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. diff --git a/internal/plugin/dnd_expedition_temporal_test.go b/internal/plugin/dnd_expedition_temporal_test.go index 8609427..e739483 100644 --- a/internal/plugin/dnd_expedition_temporal_test.go +++ b/internal/plugin/dnd_expedition_temporal_test.go @@ -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")