mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user