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>
This commit is contained in:
prosolis
2026-05-08 16:00:40 -07:00
parent 9d4085f4c3
commit 044baddcce
5 changed files with 290 additions and 2 deletions

View File

@@ -32,6 +32,44 @@ 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
@@ -62,10 +100,15 @@ func applyZoneTemporalPostRollover(e *Expedition) []string {
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)
// ─────────────────────────────────────────────────────────────────────
@@ -137,3 +180,78 @@ func manorTemporalPostRollover(e *Expedition) []string {
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
}