diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index 227dfd3..53676cb 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -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 { diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index be478f8..010b7f0 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -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 "" } diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 56fc3b2..3231cee 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -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 diff --git a/internal/plugin/dnd_expedition_temporal.go b/internal/plugin/dnd_expedition_temporal.go index 38285ae..97a7eed 100644 --- a/internal/plugin/dnd_expedition_temporal.go +++ b/internal/plugin/dnd_expedition_temporal.go @@ -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, 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 } // 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 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 +} + +// 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 +} diff --git a/internal/plugin/dnd_expedition_temporal_test.go b/internal/plugin/dnd_expedition_temporal_test.go index 2a35ccb..8609427 100644 --- a/internal/plugin/dnd_expedition_temporal_test.go +++ b/internal/plugin/dnd_expedition_temporal_test.go @@ -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")