diff --git a/internal/plugin/dnd_expedition_temporal.go b/internal/plugin/dnd_expedition_temporal.go index 336b292..38285ae 100644 --- a/internal/plugin/dnd_expedition_temporal.go +++ b/internal/plugin/dnd_expedition_temporal.go @@ -49,6 +49,10 @@ func applyZoneTemporalPreBurn(e *Expedition, nextDay int) (extraHarsh bool) { 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. @@ -56,6 +60,8 @@ func applyZoneTemporalPostRollover(e *Expedition) []string { switch e.ZoneID { case ZoneSunkenTemple: return sunkenTempleTemporalPostRollover(e) + case ZoneManorBlackspire: + return manorTemporalPostRollover(e) } return nil } @@ -95,3 +101,39 @@ func sunkenTempleTemporalPostRollover(e *Expedition) []string { } 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} +} diff --git a/internal/plugin/dnd_expedition_temporal_test.go b/internal/plugin/dnd_expedition_temporal_test.go index 98cd652..2a35ccb 100644 --- a/internal/plugin/dnd_expedition_temporal_test.go +++ b/internal/plugin/dnd_expedition_temporal_test.go @@ -137,6 +137,116 @@ func TestSunkenTemple_BossDefeatedSilencesTidal(t *testing.T) { } } +func TestManor_NightlyReset_FiresEveryThreeDays(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-manor-reset:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneManorBlackspire, "", + ExpeditionSupplies{Current: 60, Max: 60, DailyBurn: 2, HarshMod: 2}) + if err != nil { + t.Fatal(err) + } + p := &AdventurePlugin{} + + cases := []struct { + fromDay int + wantReset bool + }{ + {1, false}, // → Day 2 + {2, true}, // → Day 3 + {3, false}, // → Day 4 + {4, false}, // → Day 5 + {5, true}, // → Day 6 + {8, true}, // → Day 9 + } + for _, c := range cases { + setExpDay(t, exp.ID, c.fromDay) + // Reset last_briefing_at so the next briefing actually fires. + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET last_briefing_at = NULL WHERE expedition_id = ?`, exp.ID); err != nil { + t.Fatal(err) + } + fresh, _ := getExpedition(exp.ID) + // Drain log entries from prior iterations so we count only today's. + preCount := manorResetLogCount(t, exp.ID) + if err := p.deliverBriefing(fresh, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got := manorResetLogCount(t, exp.ID) + gained := got - preCount + if c.wantReset && gained == 0 { + t.Errorf("from Day %d → expected a manor-reset entry; got none", c.fromDay) + } + if !c.wantReset && gained != 0 { + t.Errorf("from Day %d → expected no reset; got %d new entries", c.fromDay, gained) + } + } +} + +func manorResetLogCount(t *testing.T, expID string) int { + t.Helper() + rows, err := db.Get().Query(` + SELECT COUNT(*) FROM dnd_expedition_log + WHERE expedition_id = ? AND entry_type = 'temporal' + AND summary LIKE 'manor reset%'`, expID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + if !rows.Next() { + return 0 + } + var n int + _ = rows.Scan(&n) + return n +} + +func TestManor_BossDefeatedSilencesReset(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-manor-boss:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneManorBlackspire, "", + ExpeditionSupplies{Current: 60, Max: 60, DailyBurn: 2, HarshMod: 2}) + if err != nil { + t.Fatal(err) + } + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET boss_defeated = 1, current_day = 2 WHERE expedition_id = ?`, + exp.ID); err != nil { + t.Fatal(err) + } + exp, _ = getExpedition(exp.ID) + + p := &AdventurePlugin{} + if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { + t.Fatal(err) + } + if got := manorResetLogCount(t, exp.ID); got != 0 { + t.Errorf("boss-defeated manor should not reset; got %d entries", got) + } +} + +func TestManorReset_PredicateOnly(t *testing.T) { + e := &Expedition{ZoneID: ZoneManorBlackspire} + for _, d := range []int{0, 1, 2, 4, 5, 7, 8} { + if ManorReset(e, d) { + t.Errorf("ManorReset(day %d) = true; want false", d) + } + } + for _, d := range []int{3, 6, 9, 12, 30} { + if !ManorReset(e, d) { + t.Errorf("ManorReset(day %d) = false; want true", d) + } + } + // Wrong zone is always false. + other := &Expedition{ZoneID: ZoneGoblinWarrens} + if ManorReset(other, 3) { + t.Error("ManorReset on non-Manor zone should be false") + } +} + func TestSunkenTemple_NoTidalOnNonZone(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@exp-tidal-other:example")