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

@@ -364,6 +364,16 @@ func applyThreatDelta(expID string, delta int, reason string) error {
return err
}
// updateTemporalStack persists the zone-temporal stack (heat / instability).
func updateTemporalStack(expID string, stack int) error {
_, err := db.Get().Exec(`
UPDATE dnd_expedition
SET temporal_stack = ?,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, stack, expID)
return err
}
// advanceExpeditionDay bumps current_day by one. Real-time day cycle (E1d)
// calls this from the 06:00 cron.
func advanceExpeditionDay(expID string) error {

View File

@@ -241,6 +241,14 @@ func processOvernightCamp(e *Expedition) string {
_ = applyThreatDelta(e.ID, -5, "long rest in fortified camp")
}
// §7.3 Underforge: fortified rest drops heat stacks by 2.
heatReduced := 0
if (kind == CampTypeFortified || kind == CampTypeBase) && e.ZoneID == ZoneUnderforge {
before := e.TemporalStack
after := reduceUnderforgeHeat(e)
heatReduced = before - after
}
// Auto-break the camp now that the rest has been applied.
_ = updateCamp(e.ID, nil)
e.Camp = nil
@@ -255,8 +263,12 @@ func processOvernightCamp(e *Expedition) string {
case CampTypeStandard:
return fmt.Sprintf("Long rest: HP %d → %d, spell slots & resources refreshed.", prevHP, c.HPCurrent)
case CampTypeFortified, CampTypeBase:
return fmt.Sprintf("Fortified rest: HP %d → %d (+1d6 = %d bonus); threat clock 5; resources refreshed.",
summary := fmt.Sprintf("Fortified rest: HP %d → %d (+1d6 = %d bonus); threat clock 5; resources refreshed.",
prevHP, c.HPCurrent, bonusHP)
if heatReduced > 0 {
summary += fmt.Sprintf(" Heat stacks %d.", heatReduced)
}
return summary
}
return ""
}

View File

@@ -157,7 +157,7 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
forceDouble := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
// Advance day + supply burn happen together at the morning rollover.
harsh := e.ThreatLevel > 60 || e.TemporalStack > 0
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
newSupplies, burn := applyDailyBurn(e.Supplies, harsh, e.SiegeMode || forceDouble)
if err := updateSupplies(e.ID, newSupplies); err != nil {
return err

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
}

View File

@@ -247,6 +247,154 @@ func TestManorReset_PredicateOnly(t *testing.T) {
}
}
func TestUnderforge_HeatStackIncrementsAndBands(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-uf-heat:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneUnderforge, "",
ExpeditionSupplies{Current: 60, Max: 60, DailyBurn: 2, HarshMod: 2})
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
// Drive 11 mornings — heat should top out at 10.
for i := 0; i < 11; i++ {
fresh, _ := getExpedition(exp.ID)
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET last_briefing_at = NULL WHERE expedition_id = ?`,
exp.ID); err != nil {
t.Fatal(err)
}
// Top up supplies so we don't hit starvation; not relevant here.
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET supplies_json = ? WHERE expedition_id = ?`,
`{"current":99,"max":99,"daily_burn":2,"harsh_mod":2,"foraged_today":false,"packs_standard":3,"packs_deluxe":0}`,
exp.ID); err != nil {
t.Fatal(err)
}
fresh, _ = getExpedition(exp.ID)
if err := p.deliverBriefing(fresh, time.Now().UTC()); err != nil {
t.Fatalf("briefing %d: %v", i, err)
}
}
got, _ := getExpedition(exp.ID)
if got.TemporalStack != 10 {
t.Errorf("heat stack = %d, want 10 (capped)", got.TemporalStack)
}
// Verify warning + critical narrations both fired.
rows, _ := db.Get().Query(
`SELECT summary FROM dnd_expedition_log WHERE expedition_id = ? AND entry_type = 'temporal' ORDER BY entry_id`,
exp.ID)
defer rows.Close()
var summaries []string
for rows.Next() {
var s string
_ = rows.Scan(&s)
summaries = append(summaries, s)
}
want := []string{"warning band", "+50%", "exhaustion"}
for _, w := range want {
found := false
for _, s := range summaries {
if strings.Contains(s, w) {
found = true
break
}
}
if !found {
t.Errorf("missing temporal entry containing %q in %v", w, summaries)
}
}
}
func TestUnderforge_HarshAtHeat7(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-uf-harsh:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneUnderforge, "",
ExpeditionSupplies{Current: 60, Max: 60, DailyBurn: 2, HarshMod: 2})
if err != nil {
t.Fatal(err)
}
// Heat 6 — should NOT be harsh.
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET temporal_stack = 6, current_day = 7 WHERE expedition_id = ?`,
exp.ID); err != nil {
t.Fatal(err)
}
exp, _ = getExpedition(exp.ID)
if zoneTemporalHarsh(exp) {
t.Error("heat=6 should not trigger harsh")
}
// Heat 7 — should be harsh.
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET temporal_stack = 7 WHERE expedition_id = ?`,
exp.ID); err != nil {
t.Fatal(err)
}
exp, _ = getExpedition(exp.ID)
if !zoneTemporalHarsh(exp) {
t.Error("heat=7 should trigger harsh")
}
}
func TestUnderforgeHeatBandFor(t *testing.T) {
cases := []struct {
stacks int
want string
}{
{0, "none"},
{1, "ambient"}, {3, "ambient"},
{4, "warning"}, {6, "warning"},
{7, "supply"}, {9, "supply"},
{10, "critical"},
}
for _, c := range cases {
if got := UnderforgeHeatBandFor(c.stacks); got != c.want {
t.Errorf("UnderforgeHeatBandFor(%d) = %q, want %q", c.stacks, got, c.want)
}
}
}
func TestUnderforge_FortifiedRestReducesHeat(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-uf-rest:example")
defer cleanupExpeditions(uid)
exp := &Expedition{
ID: "fake-uf",
ZoneID: ZoneUnderforge,
TemporalStack: 5,
}
// Insert minimal row so updateTemporalStack works.
if _, err := db.Get().Exec(`
INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, start_date, current_day, supplies_json, threat_events, region_state, last_activity, temporal_stack)
VALUES (?, ?, ?, NULL, 'active', CURRENT_TIMESTAMP, 1, '{}', '[]', '{}', CURRENT_TIMESTAMP, 5)`,
exp.ID, string(uid), string(ZoneUnderforge)); err != nil {
t.Fatal(err)
}
defer db.Get().Exec(`DELETE FROM dnd_expedition WHERE expedition_id = ?`, exp.ID)
got := reduceUnderforgeHeat(exp)
if got != 3 {
t.Errorf("reduceUnderforgeHeat(5) = %d, want 3", got)
}
exp.TemporalStack = 1
got = reduceUnderforgeHeat(exp)
if got != 0 {
t.Errorf("reduceUnderforgeHeat(1) = %d, want 0 (no underflow)", got)
}
// Wrong zone — no-op.
other := &Expedition{ZoneID: ZoneGoblinWarrens, TemporalStack: 5}
if reduceUnderforgeHeat(other) != 5 {
t.Error("reduceUnderforgeHeat on non-Underforge should be no-op")
}
}
func TestSunkenTemple_NoTidalOnNonZone(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-tidal-other:example")