mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
Adv 2.0 D&D Phase 12 E3b: Haunted Manor nightly reset
§7.2 cycle: every 3rd day's morning briefing in Blackspire fires the "manor reset" temporal log — non-boss rooms respawn one enemy each. Boss-defeated suppresses. Wrong-zone is a no-op. Exposes ManorReset(e, day) bool for the combat-link phase to read at !advance time when applying the actual respawn (per the deferral pattern from E2b's wandering-encounter combat). Reuses flavor.HauntedManorResetMorning per feedback_reuse_existing_flavor. Deferred: Night-2 quiet warning narration on the recap path — needs a new hook in deliverRecap; not load-bearing for the reset itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,10 @@ func applyZoneTemporalPreBurn(e *Expedition, nextDay int) (extraHarsh bool) {
|
|||||||
return extraHarsh
|
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.
|
// applyZoneTemporalPostRollover runs AFTER advance + threat drift.
|
||||||
// It appends per-zone narration log entries for the day that just
|
// It appends per-zone narration log entries for the day that just
|
||||||
// became current. Returns lines to append to the briefing body.
|
// became current. Returns lines to append to the briefing body.
|
||||||
@@ -56,6 +60,8 @@ func applyZoneTemporalPostRollover(e *Expedition) []string {
|
|||||||
switch e.ZoneID {
|
switch e.ZoneID {
|
||||||
case ZoneSunkenTemple:
|
case ZoneSunkenTemple:
|
||||||
return sunkenTempleTemporalPostRollover(e)
|
return sunkenTempleTemporalPostRollover(e)
|
||||||
|
case ZoneManorBlackspire:
|
||||||
|
return manorTemporalPostRollover(e)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -95,3 +101,39 @@ func sunkenTempleTemporalPostRollover(e *Expedition) []string {
|
|||||||
}
|
}
|
||||||
return nil
|
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}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestSunkenTemple_NoTidalOnNonZone(t *testing.T) {
|
||||||
setupZoneRunTestDB(t)
|
setupZoneRunTestDB(t)
|
||||||
uid := id.UserID("@exp-tidal-other:example")
|
uid := id.UserID("@exp-tidal-other:example")
|
||||||
|
|||||||
Reference in New Issue
Block a user