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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user