Files
gogobee/internal/plugin/dnd_expedition_temporal.go
prosolis 044baddcce Adv 2.0 D&D Phase 12 E3c: Underforge heat accumulation
§7.3 heat stacks: +1/day in the Underforge (cap 10), persisted via
new updateTemporalStack helper. Band thresholds:
  • 1–3 ambient (no narration)
  • 4–6 warning band (combat fire damage applies, narration logged)
  • 7–9 supply band (forces harsh-conditions burn = +50%, narration)
  • 10 critical (exhaustion narration, log entry)

Fortified/base camp long rest in the Underforge drops heat by 2
(updates briefing's "Fortified rest" summary line), per §7.3.

Replaces the placeholder `TemporalStack > 0 → harsh` predicate in
deliverBriefing with zoneTemporalHarsh(), which is now per-zone aware
— Heat 1–3 in the Underforge is correctly flavor-only and no longer
prematurely doubles supply burn from Day 1.

Combat-side knobs (+1d4 fire round-start damage at heat 4–6, dis CON
saves at 7–9, -2 to all rolls at 10) are exposed via heat band but
not yet applied — combat-link phase reads heat band off the active
expedition.

Reuses flavor.UnderforgHeapWarning / UnderforgHeapCritical per
feedback_reuse_existing_flavor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00

258 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"fmt"
"strings"
"gogobee/internal/flavor"
)
// 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, 010. 13 ambient, 46 +1d4 fire
// per round in combat, 79 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 79 → supply burn +50%. Heat 10 also harsh.
return e.TemporalStack >= 7
}
return false
}
// 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.
//
// 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) {
switch e.ZoneID {
case ZoneSunkenTemple:
extraHarsh = sunkenTempleTemporalPreBurn(e, nextDay)
}
return extraHarsh
}
// 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)
}
return nil
}
// 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 46.
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 79: 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
}
// 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
}