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. // // Temporal events fire at briefing rollover and produce two outputs: // - extraHarsh: forces double supply burn for the burn fired by THIS // briefing (overrides the §4.1 zone HarshMod when the multiplier // should be 2× regardless of tier — e.g. tidal flooding §7.1). // - one or more flavor-bearing log entries (warnings, event narration). // // applyZoneTemporalPreBurn runs BEFORE applyDailyBurn so the burn // reflects the temporal effect for the day being entered. State that // persists across days (Heat stacks, Portal instability) is mutated // here as well — the per-day increment is the temporal event. // // Combat-side knobs (tidal +1d6 cold, Underforge +1d4 fire, etc.) are // exposed via ZoneTemporalEffects for the combat engine to read; the // engine hook lands with the combat-link phase. // ZoneTemporalEffects bundles the per-zone, per-day combat-side // modifications produced by temporal events. Combat code reads this // from the active expedition; nothing reads it yet (combat-link phase). type ZoneTemporalEffects struct { // TidalActive — Sunken Temple Day 6 only. Combat: +1d6 cold per // turn while wading; kuo-toa +2 AC, +1d4 attack rolls. TidalActive bool // UnderforgeHeatStacks — §7.3, 0–10. 1–3 ambient, 4–6 +1d4 fire // per round in combat, 7–9 disadvantage on CON saves, 10 -2 to // all rolls + forced rest/extraction. UnderforgeHeatStacks int // UnderforgeHeatBand classifies the stacks into the spec's // behavior bands: "ambient", "warning", "supply", "critical". UnderforgeHeatBand string } // UnderforgeHeatBandFor returns the §7.3 band label for a heat count. func UnderforgeHeatBandFor(stacks int) string { switch { case stacks <= 0: return "none" case stacks <= 3: return "ambient" case stacks <= 6: return "warning" case stacks <= 9: return "supply" default: return "critical" } } // zoneTemporalHarsh reports whether per-zone temporal state forces // harsh-conditions burn for THIS day's burn calculation. (Force-double // — overriding HarshMod with a 2× floor — uses the separate pre-burn // hook instead.) func zoneTemporalHarsh(e *Expedition) bool { switch e.ZoneID { case ZoneUnderforge: // §7.3: heat 7–9 → supply burn +50%. Heat 10 also harsh. return e.TemporalStack >= 7 } 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 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) TemporalBurnOverride { switch e.ZoneID { case ZoneSunkenTemple: if sunkenTempleTemporalPreBurn(e, nextDay) { return TemporalBurnOverride{Multiplier: 2.0, Reason: "sunken_temple_tidal"} } case ZoneFeywildCrossing: return feywildTemporalPreBurn(e, nextDay) case ZoneAbyssPortal: // §7.6 81–99: "Supply burn doubles." Hit unraveling band before // today's burn fires so the multiplier applies on the same day // the band activates. if !e.BossDefeated && e.TemporalStack >= 81 && e.TemporalStack < 100 { return TemporalBurnOverride{Multiplier: 2.0, Reason: "abyss_unraveling"} } } return TemporalBurnOverride{} } // ManorResetCycleDays is the §7.2 reset cadence — non-boss rooms // respawn one enemy each on the morning of every Nth day. const ManorResetCycleDays = 3 // applyZoneTemporalPostRollover runs AFTER advance + threat drift. // It appends per-zone narration log entries for the day that just // became current. Returns lines to append to the briefing body. func applyZoneTemporalPostRollover(e *Expedition) []string { switch e.ZoneID { case ZoneSunkenTemple: return sunkenTempleTemporalPostRollover(e) case ZoneManorBlackspire: return manorTemporalPostRollover(e) case ZoneUnderforge: return underforgeTemporalPostRollover(e) case ZoneFeywildCrossing: return feywildTemporalPostRollover(e) case ZoneDragonsLair: return dragonsLairTemporalPostRollover(e) case ZoneAbyssPortal: return abyssPortalTemporalPostRollover(e) } return nil } // DragonsLairAwarenessCycleDays — §7.5 awareness pulse cadence. const DragonsLairAwarenessCycleDays = 3 // DragonsLairAwakenDay — §7.5 Infernax wakes if not yet at the boss. const DragonsLairAwakenDay = 14 // UnderforgeMaxHeat is the §7.3 cap. const UnderforgeMaxHeat = 10 // ───────────────────────────────────────────────────────────────────── // §7.1 — Sunken Temple, Tidal Event (Day 6) // ───────────────────────────────────────────────────────────────────── // sunkenTempleTemporalPreBurn doubles burn on Day 6 (tidal peak). Skips // entirely if the boss has been defeated — the spec's no-op clause. func sunkenTempleTemporalPreBurn(e *Expedition, nextDay int) bool { if e.BossDefeated { return false } return nextDay == 6 } // sunkenTempleTemporalPostRollover writes warnings on Day 4/5 and the // event narration on Day 6. Boss-kill before Day 6 silences all of it. func sunkenTempleTemporalPostRollover(e *Expedition) []string { if e.BossDefeated { return nil } day := e.CurrentDay switch day { case 4, 5: line := flavor.Pick(flavor.SunkenTempleTidalWarning) line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", day)) summary := fmt.Sprintf("tidal warning — peak in %d day(s)", 6-day) _ = appendExpeditionLog(e.ID, day, "temporal", summary, line) return []string{line} case 6: line := flavor.Pick(flavor.SunkenTempleTidalEvent) _ = appendExpeditionLog(e.ID, day, "temporal", "tidal peak — flooded rooms hostile, supply burn doubled", line) return []string{line} } return nil } // ───────────────────────────────────────────────────────────────────── // §7.2 — Haunted Manor of Blackspire, Nightly Reset // ───────────────────────────────────────────────────────────────────── // ManorReset reports whether the briefing rolling onto `day` is the // reset morning (every 3rd day: 3, 6, 9, ...). Boss-defeated suppresses. func ManorReset(e *Expedition, day int) bool { if e.BossDefeated || e.ZoneID != ZoneManorBlackspire { return false } return day >= ManorResetCycleDays && day%ManorResetCycleDays == 0 } // manorTemporalPostRollover writes a "house reset" entry on Day 3, 6, // 9, ... and a quiet pre-reset warning on the day after that (Day 4, // 7, ...) — which corresponds to the briefing fired on the morning // after the warning night, so we anchor narration at the briefing. // // Spec §7.2: "TwinBee notes on Night 2: 'The house has been quiet. // That will not last.' On Night 3 morning: 'Overnight, something moved // back in.'" — we fire the reset narration on the reset morning. The // quiet-warning Night 2 line lives on the recap path (E2b's deliverRecap // will add it as a separate hook in a future commit if desired); for // E3b we keep the rule simple: only the reset morning fires here. func manorTemporalPostRollover(e *Expedition) []string { day := e.CurrentDay if !ManorReset(e, day) { return nil } line := flavor.Pick(flavor.HauntedManorResetMorning) _ = appendExpeditionLog(e.ID, day, "temporal", fmt.Sprintf("manor reset — non-boss rooms respawned one enemy each (cycle day %d)", day), line) return []string{line} } // ───────────────────────────────────────────────────────────────────── // §7.3 — The Underforge, Heat Accumulation // ───────────────────────────────────────────────────────────────────── // underforgeTemporalPostRollover increments Heat Stacks by 1/day (cap // 10), persists the change, and emits narration on band-crossing into // "warning" (4) and "critical" (10). Boss-defeated suppresses further // accumulation but does not reset existing heat — the pit stays hot. func underforgeTemporalPostRollover(e *Expedition) []string { if e.BossDefeated { return nil } prev := e.TemporalStack if prev >= UnderforgeMaxHeat { return nil } next := prev + 1 if next > UnderforgeMaxHeat { next = UnderforgeMaxHeat } e.TemporalStack = next if err := updateTemporalStack(e.ID, next); err != nil { return nil } prevBand := UnderforgeHeatBandFor(prev) newBand := UnderforgeHeatBandFor(next) var lines []string switch { case prevBand != "warning" && newBand == "warning": // First crossing into 4–6. line := flavor.Pick(flavor.UnderforgHeapWarning) line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", next)) _ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal", fmt.Sprintf("heat stack %d — warning band: +1d4 fire damage in combat rounds", next), line) lines = append(lines, line) case prevBand != "supply" && newBand == "supply": // Crossed into 7–9: supply +50%, dis on CON saves. line := flavor.Pick(flavor.UnderforgHeapWarning) line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", next)) _ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal", fmt.Sprintf("heat stack %d — supply burn +50%%, disadvantage on CON saves", next), line) lines = append(lines, line) case prevBand != "critical" && newBand == "critical": // Crossed into 10: exhaustion threshold. line := flavor.Pick(flavor.UnderforgHeapCritical) _ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal", "heat stack 10 — exhaustion: -2 to all rolls; rest in fortified camp or extract", line) lines = append(lines, line) } 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) // §3 Zone 08 — Time Loop returns previously-harvested resources // to the current room's nodes. Combat loot stays gone; only the // renewable harvest nodes loop back. out := []string{line} if restored := restoreHarvestNodesInRoom(e, currentNodeIDFor(e)); restored { out = append(out, "_Harvest nodes in this room have re-emerged — the timeline isn't sure they were ever taken._") } return out } return nil } // ───────────────────────────────────────────────────────────────────── // §7.5 — Dragon's Lair, Awareness Pulses + Infernax Awakening // ───────────────────────────────────────────────────────────────────── // dragonsLairTemporalPostRollover fires an awareness pulse every 3 // days (threat +10, narration) and the Day-14 awakening event if the // boss hasn't been killed yet. func dragonsLairTemporalPostRollover(e *Expedition) []string { if e.BossDefeated { return nil } day := e.CurrentDay var lines []string // Day 14 awakening: one-time. Mark on RegionState; downstream // combat-link reads "infernax_awake" to switch wandering cadence // to every 6 hours and add the dragon encounter weighting. if day >= DragonsLairAwakenDay { if awake, _ := e.RegionState["infernax_awake"].(bool); !awake { e.RegionState["infernax_awake"] = true _ = persistRegionState(e) line := flavor.Pick(flavor.DragonsLairAwakenWarning) _ = appendExpeditionLog(e.ID, day, "temporal", "infernax awakens — active hunting begins, 6h wandering checks", line) lines = append(lines, line) } } // Awareness pulse every 3 days (3, 6, 9, 12, ...). On Day 14, the // awakening narration takes precedence — we still apply the +10 // since the cadence math says it's a pulse day too. if day >= DragonsLairAwarenessCycleDays && day%DragonsLairAwarenessCycleDays == 0 { _ = applyThreatDelta(e.ID, 10, fmt.Sprintf("dragon awareness pulse (day %d)", day)) // Refresh in-memory threat after the bump. e.ThreatLevel += 10 if e.ThreatLevel > 100 { e.ThreatLevel = 100 } // Skip the pulse narration if the awaken line already fired // today — they say largely the same thing in different keys. alreadyAwoke := false for _, l := range lines { if l != "" { alreadyAwoke = true break } } if !alreadyAwoke { line := flavor.Pick(flavor.DragonsLairAwarenessPulse) _ = appendExpeditionLog(e.ID, day, "temporal", fmt.Sprintf("awareness pulse — threat +10 (day %d)", day), line) lines = append(lines, line) } } return lines } // ───────────────────────────────────────────────────────────────────── // §7.6 — Abyss Portal, Destabilization // ───────────────────────────────────────────────────────────────────── // AbyssInstabilityPerDay is the §7.6 increment. const AbyssInstabilityPerDay = 5 // AbyssMaxInstability — at 100 the portal collapses and forces // extraction. const AbyssMaxInstability = 100 // AbyssInstabilityBandFor classifies the §7.6 instability tiers. // // normal: 0–20 // mild: 21–40 (-1 WIS) // warp: 41–60 (rooms shift order) // surges: 61–80 (12h wandering checks) // unravel: 81–99 (supply ×2, -2 all rolls) // collapse: 100 func AbyssInstabilityBandFor(stack int) string { switch { case stack <= 20: return "normal" case stack <= 40: return "mild" case stack <= 60: return "warp" case stack <= 80: return "surges" case stack < 100: return "unravel" default: return "collapse" } } // abyssPortalTemporalPostRollover increments instability +5/day, // emits narration on band-crossing into "unravel" (81+) and on // collapse at 100, and triggers forced extraction at 100. Boss- // defeated suppresses further accumulation. func abyssPortalTemporalPostRollover(e *Expedition) []string { if e.BossDefeated { return nil } prev := e.TemporalStack if prev >= AbyssMaxInstability { return nil } next := prev + AbyssInstabilityPerDay if next > AbyssMaxInstability { next = AbyssMaxInstability } e.TemporalStack = next if err := updateTemporalStack(e.ID, next); err != nil { return nil } prevBand := AbyssInstabilityBandFor(prev) newBand := AbyssInstabilityBandFor(next) if prevBand == newBand { return nil } switch newBand { case "warp": line := flavor.Pick(flavor.AbyssPortalDestabilizationMid) line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", next)) _ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal", fmt.Sprintf("portal instability %d — reality warps (rooms shift)", next), line) return []string{line} case "surges": line := flavor.Pick(flavor.AbyssPortalDestabilizationMid) line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", next)) _ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal", fmt.Sprintf("portal instability %d — demon surges (12h wandering)", next), line) return []string{line} case "unravel": line := flavor.Pick(flavor.AbyssPortalDestabilizationCritical) _ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal", fmt.Sprintf("portal instability %d — unraveling: supply ×2, -2 all rolls", next), line) return []string{line} case "collapse": line := flavor.Pick(flavor.AbyssPortalCollapse) forced := flavor.Pick(flavor.ExtractionForced) _ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal", "portal collapsed — expedition forcibly extracted", line) // §10.2: forced extraction → abandoned. Coin tax is applied by // the cycle layer (which holds the euro handle) once it sees // the row flipped to 'abandoned'. _, _, _ = forcedExtractExpedition(e.ID, "abyss portal collapse") e.Status = ExpeditionStatusAbandoned _ = appendExpeditionLog(e.ID, e.CurrentDay, "narrative", "forced extraction", forced) return []string{line, forced} } return nil } // ReduceAbyssInstability drops instability by N for a major story // objective completion (§7.6: -10 per objective). Combat-link wires // this when objectives are recorded; exposed here for symmetry with // reduceUnderforgeHeat. func ReduceAbyssInstability(e *Expedition, amount int) int { if e.ZoneID != ZoneAbyssPortal || amount <= 0 { return e.TemporalStack } next := e.TemporalStack - amount if next < 0 { next = 0 } if next == e.TemporalStack { return next } e.TemporalStack = next _ = updateTemporalStack(e.ID, next) return next } // 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. func reduceUnderforgeHeat(e *Expedition) int { if e.ZoneID != ZoneUnderforge { return e.TemporalStack } next := e.TemporalStack - 2 if next < 0 { next = 0 } if next == e.TemporalStack { return next } e.TemporalStack = next _ = updateTemporalStack(e.ID, next) return next }