mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +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
|
// deliverBriefing rolls a day forward, applies supply burn, posts the
|
||||||
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
|
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
|
||||||
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||||
// E3: zone-specific temporal events that affect this morning's burn
|
// E3: zone-specific temporal events fire BEFORE applyDailyBurn and
|
||||||
// fire BEFORE applyDailyBurn (e.g. Sunken Temple Day 6 tidal forces
|
// can override the entire burn calculation with a fixed multiplier
|
||||||
// 2× burn even on tier-2 where HarshMod is 1.5×). Force-double piggy-
|
// (Sunken Temple tidal 2.0×, Feywild half-day 0.5×, etc.).
|
||||||
// backs on the siege param's "≥2× floor" branch in applyDailyBurn.
|
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
|
||||||
forceDouble := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
|
|
||||||
|
|
||||||
// Advance day + supply burn happen together at the morning rollover.
|
// 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)
|
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 {
|
if err := updateSupplies(e.ID, newSupplies); err != nil {
|
||||||
return err
|
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)
|
slog.Warn("expedition: night check", "expedition", e.ID, "err", err)
|
||||||
}
|
}
|
||||||
night = &nc
|
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.
|
// Refresh in-memory threat after possible signs-of-passage bump.
|
||||||
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
|
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
|
||||||
e.ThreatLevel = fresh.ThreatLevel
|
e.ThreatLevel = fresh.ThreatLevel
|
||||||
|
|||||||
@@ -1,12 +1,33 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
"gogobee/internal/flavor"
|
"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.
|
// Phase 12 E3 — Zone-specific Temporal Events.
|
||||||
// Spec: gogobee_expedition_system.md §7.
|
// Spec: gogobee_expedition_system.md §7.
|
||||||
//
|
//
|
||||||
@@ -72,19 +93,32 @@ func zoneTemporalHarsh(e *Expedition) bool {
|
|||||||
return false
|
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
|
// applyZoneTemporalPreBurn runs at the top of deliverBriefing, before
|
||||||
// applyDailyBurn. It mutates e (state increments, in-memory only —
|
// applyDailyBurn. It mutates e (state increments, in-memory only —
|
||||||
// caller persists the relevant fields) and returns whether this
|
// caller persists the relevant fields) and returns an optional
|
||||||
// briefing's burn should be force-doubled regardless of HarshMod.
|
// override on this morning's burn calculation.
|
||||||
//
|
//
|
||||||
// nextDay is the day-number we're rolling INTO (i.e. e.CurrentDay+1
|
// nextDay is the day-number we're rolling INTO (i.e. e.CurrentDay+1
|
||||||
// at call site, since the briefing increments after burn).
|
// 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 {
|
switch e.ZoneID {
|
||||||
case ZoneSunkenTemple:
|
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
|
// ManorResetCycleDays is the §7.2 reset cadence — non-boss rooms
|
||||||
@@ -102,6 +136,8 @@ func applyZoneTemporalPostRollover(e *Expedition) []string {
|
|||||||
return manorTemporalPostRollover(e)
|
return manorTemporalPostRollover(e)
|
||||||
case ZoneUnderforge:
|
case ZoneUnderforge:
|
||||||
return underforgeTemporalPostRollover(e)
|
return underforgeTemporalPostRollover(e)
|
||||||
|
case ZoneFeywildCrossing:
|
||||||
|
return feywildTemporalPostRollover(e)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -237,6 +273,93 @@ func underforgeTemporalPostRollover(e *Expedition) []string {
|
|||||||
return lines
|
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
|
// reduceUnderforgeHeat is called by processOvernightCamp when the
|
||||||
// active camp is fortified (or base) in the Underforge — long rest
|
// active camp is fortified (or base) in the Underforge — long rest
|
||||||
// drops heat stacks by 2 per §7.3. Returns the new stack count.
|
// 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) {
|
func TestSunkenTemple_NoTidalOnNonZone(t *testing.T) {
|
||||||
setupZoneRunTestDB(t)
|
setupZoneRunTestDB(t)
|
||||||
uid := id.UserID("@exp-tidal-other:example")
|
uid := id.UserID("@exp-tidal-other:example")
|
||||||
|
|||||||
Reference in New Issue
Block a user