diff --git a/internal/plugin/dnd_expedition_temporal.go b/internal/plugin/dnd_expedition_temporal.go index 6a5035c..09328d5 100644 --- a/internal/plugin/dnd_expedition_temporal.go +++ b/internal/plugin/dnd_expedition_temporal.go @@ -138,10 +138,18 @@ func applyZoneTemporalPostRollover(e *Expedition) []string { return underforgeTemporalPostRollover(e) case ZoneFeywildCrossing: return feywildTemporalPostRollover(e) + case ZoneDragonsLair: + return dragonsLairTemporalPostRollover(e) } return nil } +// DragonsLairAwarenessCycleDays — §7.5 awareness pulse cadence. +const DragonsLairAwarenessCycleDays = 3 + +// DragonsLairAwakenDay — §7.5 Infernax wakes if not yet at the boss. +const DragonsLairAwakenDay = 14 + // UnderforgeMaxHeat is the §7.3 cap. const UnderforgeMaxHeat = 10 @@ -360,6 +368,65 @@ func feywildTemporalPostRollover(e *Expedition) []string { return nil } +// ───────────────────────────────────────────────────────────────────── +// §7.5 — Dragon's Lair, Awareness Pulses + Infernax Awakening +// ───────────────────────────────────────────────────────────────────── + +// dragonsLairTemporalPostRollover fires an awareness pulse every 3 +// days (threat +10, narration) and the Day-14 awakening event if the +// boss hasn't been killed yet. +func dragonsLairTemporalPostRollover(e *Expedition) []string { + if e.BossDefeated { + return nil + } + day := e.CurrentDay + var lines []string + + // Day 14 awakening: one-time. Mark on RegionState; downstream + // combat-link reads "infernax_awake" to switch wandering cadence + // to every 6 hours and add the dragon encounter weighting. + if day >= DragonsLairAwakenDay { + if awake, _ := e.RegionState["infernax_awake"].(bool); !awake { + e.RegionState["infernax_awake"] = true + _ = persistRegionState(e) + line := flavor.Pick(flavor.DragonsLairAwakenWarning) + _ = appendExpeditionLog(e.ID, day, "temporal", + "infernax awakens — active hunting begins, 6h wandering checks", + line) + lines = append(lines, line) + } + } + + // Awareness pulse every 3 days (3, 6, 9, 12, ...). On Day 14, the + // awakening narration takes precedence — we still apply the +10 + // since the cadence math says it's a pulse day too. + if day >= DragonsLairAwarenessCycleDays && day%DragonsLairAwarenessCycleDays == 0 { + _ = applyThreatDelta(e.ID, 10, fmt.Sprintf("dragon awareness pulse (day %d)", day)) + // Refresh in-memory threat after the bump. + e.ThreatLevel += 10 + if e.ThreatLevel > 100 { + e.ThreatLevel = 100 + } + // Skip the pulse narration if the awaken line already fired + // today — they say largely the same thing in different keys. + alreadyAwoke := false + for _, l := range lines { + if l != "" { + alreadyAwoke = true + break + } + } + if !alreadyAwoke { + line := flavor.Pick(flavor.DragonsLairAwarenessPulse) + _ = appendExpeditionLog(e.ID, day, "temporal", + fmt.Sprintf("awareness pulse — threat +10 (day %d)", day), + 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. diff --git a/internal/plugin/dnd_expedition_temporal_test.go b/internal/plugin/dnd_expedition_temporal_test.go index e739483..1b63513 100644 --- a/internal/plugin/dnd_expedition_temporal_test.go +++ b/internal/plugin/dnd_expedition_temporal_test.go @@ -527,6 +527,148 @@ func TestFeywild_NormalDay_NoTemporalLog(t *testing.T) { } } +func TestDragonsLair_AwarenessPulseFiresEveryThreeDays(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-dragon-pulse:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneDragonsLair, "", + ExpeditionSupplies{Current: 60, Max: 60, DailyBurn: 4, HarshMod: 3}) + if err != nil { + t.Fatal(err) + } + p := &AdventurePlugin{} + + // Roll forward to Day 3 — first awareness pulse. + setExpDay(t, exp.ID, 2) + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET last_briefing_at = NULL, supplies_json = ? WHERE expedition_id = ?`, + `{"current":99,"max":99,"daily_burn":4,"harsh_mod":3,"foraged_today":false,"packs_standard":3,"packs_deluxe":0}`, + exp.ID); err != nil { + t.Fatal(err) + } + fresh, _ := getExpedition(exp.ID) + prevThreat := fresh.ThreatLevel + if err := p.deliverBriefing(fresh, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + // +10 awareness pulse + ~3 daily threat drift = 13 (mod GMMood 50 = neutral). + if got.ThreatLevel < prevThreat+10 { + t.Errorf("threat = %d, want ≥ %d (awareness pulse +10)", got.ThreatLevel, prevThreat+10) + } + + // Verify pulse log entry. + 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() + foundPulse := false + for rows.Next() { + var s string + _ = rows.Scan(&s) + if strings.Contains(s, "awareness pulse") { + foundPulse = true + } + } + if !foundPulse { + t.Error("expected an 'awareness pulse' temporal log entry") + } +} + +func TestDragonsLair_NonPulseDay_NoThreatJump(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-dragon-nonpulse:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneDragonsLair, "", + ExpeditionSupplies{Current: 60, Max: 60, DailyBurn: 4, HarshMod: 3}) + if err != nil { + t.Fatal(err) + } + setExpDay(t, exp.ID, 1) // → Day 2; not a pulse day. + fresh, _ := getExpedition(exp.ID) + prev := fresh.ThreatLevel + if err := (&AdventurePlugin{}).deliverBriefing(fresh, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + // Only the +3 daily drift should apply. + if got.ThreatLevel-prev > 5 { + t.Errorf("non-pulse day threat jump = %d, want ≤ 5", got.ThreatLevel-prev) + } +} + +func TestDragonsLair_Day14_Awakens(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-dragon-wake:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneDragonsLair, "", + ExpeditionSupplies{Current: 60, Max: 60, DailyBurn: 4, HarshMod: 3}) + if err != nil { + t.Fatal(err) + } + setExpDay(t, exp.ID, 13) // → Day 14 + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET supplies_json = ? WHERE expedition_id = ?`, + `{"current":99,"max":99,"daily_burn":4,"harsh_mod":3,"foraged_today":false,"packs_standard":3,"packs_deluxe":0}`, + exp.ID); err != nil { + t.Fatal(err) + } + fresh, _ := getExpedition(exp.ID) + if err := (&AdventurePlugin{}).deliverBriefing(fresh, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if awake, _ := got.RegionState["infernax_awake"].(bool); !awake { + t.Error("infernax_awake should be true on Day 14") + } + + // Verify both Awaken (and possibly pulse) log entries fired. + rows, _ := db.Get().Query( + `SELECT summary FROM dnd_expedition_log WHERE expedition_id = ? AND entry_type = 'temporal'`, + exp.ID) + defer rows.Close() + foundAwaken := false + for rows.Next() { + var s string + _ = rows.Scan(&s) + if strings.Contains(s, "infernax awakens") { + foundAwaken = true + } + } + if !foundAwaken { + t.Error("expected 'infernax awakens' temporal entry") + } +} + +func TestDragonsLair_BossDefeatedSilencesPulses(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-dragon-boss:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneDragonsLair, "", + ExpeditionSupplies{Current: 60, Max: 60, DailyBurn: 4, HarshMod: 3}) + if err != nil { + t.Fatal(err) + } + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET boss_defeated = 1, current_day = 5 WHERE expedition_id = ?`, + exp.ID); err != nil { + t.Fatal(err) + } + fresh, _ := getExpedition(exp.ID) + prev := fresh.ThreatLevel + if err := (&AdventurePlugin{}).deliverBriefing(fresh, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.ThreatLevel != prev { + t.Errorf("post-boss threat changed: %d → %d", prev, got.ThreatLevel) + } +} + func TestSunkenTemple_NoTidalOnNonZone(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@exp-tidal-other:example")