diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 07c7ab9..7cdde36 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -162,6 +162,12 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { e.Supplies = newSupplies e.CurrentDay++ + // E2a: daily threat drift (+3 base, GM-mood modifier). No-op after + // boss kill. May cross a threshold and append a flavor-bearing log. + if _, _, err := applyDailyThreatDrift(e); err != nil { + slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err) + } + line := pickMorningBriefing(e.CurrentDay) body := renderMorningBriefing(e, line, burn) diff --git a/internal/plugin/dnd_expedition_threat.go b/internal/plugin/dnd_expedition_threat.go new file mode 100644 index 0000000..4e88ac7 --- /dev/null +++ b/internal/plugin/dnd_expedition_threat.go @@ -0,0 +1,165 @@ +package plugin + +import ( + "fmt" + + "gogobee/internal/flavor" +) + +// Phase 12 E2a — Threat Clock state machine. +// Spec: gogobee_expedition_system.md §8. +// +// E1a already shipped the persistence (applyThreatDelta, ThreatEvent, +// threat_level / threat_siege columns). This file layers the threshold +// model on top: labels, per-band combat effects, threshold-crossing log +// flavor, and the daily threat drift applied at briefing rollover. +// +// Combat-driven modifiers (combat-in-new-room +5, escape +10, loud-ability +// +8) require the combat engine to know about expeditions, which it does +// not yet. They land alongside the !advance/combat hookup in a later phase; +// this file is wired into the bits we *can* drive: day rollover + boss +// defeat + manual threat events from camp/extraction logic. + +// ThreatBand is the bracketed state of the threat clock. +type ThreatBand int + +const ( + ThreatBandQuiet ThreatBand = iota + ThreatBandStirring + ThreatBandAlert + ThreatBandHostile + ThreatBandSiege +) + +// ThreatBandInfo bundles the per-band display + combat-side knobs. +// AC/Init/Perception adjustments are applied by the combat engine when +// it reads the active expedition's threat band; SupplyMult feeds into +// currentBurn() (already wired); ShortRest disabled is checked by the +// rest plumbing once expedition combat is wired. +type ThreatBandInfo struct { + Band ThreatBand + Label string + Perception int // enemy passive perception bonus + AC int // enemy AC bonus + InitAdv bool // enemy initiative advantage + TrapsArm bool // re-arm cleared traps each day + SupplyMult float32 // multiplier on daily supply burn + NoShortRest bool // siege-mode only +} + +// threatBandFor returns the band for a given level, honouring siege override. +func threatBandFor(level int, siege bool) ThreatBand { + if siege || level >= 81 { + return ThreatBandSiege + } + switch { + case level >= 61: + return ThreatBandHostile + case level >= 41: + return ThreatBandAlert + case level >= 21: + return ThreatBandStirring + default: + return ThreatBandQuiet + } +} + +// threatBandInfo returns the descriptor for a band. +func threatBandInfo(b ThreatBand) ThreatBandInfo { + switch b { + case ThreatBandStirring: + return ThreatBandInfo{Band: b, Label: "Stirring", Perception: 1, SupplyMult: 1} + case ThreatBandAlert: + return ThreatBandInfo{Band: b, Label: "Alert", AC: 2, Perception: 2, SupplyMult: 1} + case ThreatBandHostile: + return ThreatBandInfo{Band: b, Label: "Hostile", AC: 2, Perception: 2, InitAdv: true, TrapsArm: true, SupplyMult: 1} + case ThreatBandSiege: + return ThreatBandInfo{Band: b, Label: "Siege", AC: 2, Perception: 2, InitAdv: true, TrapsArm: true, SupplyMult: 2, NoShortRest: true} + } + return ThreatBandInfo{Band: ThreatBandQuiet, Label: "Quiet", SupplyMult: 1} +} + +// dailyThreatDrift is the +3/day base (§8.1) plus the GMMood-driven mod +// (Wrathful +5, Elated -3). Mood bands map: effusive (≥80) → Elated, +// hostile (<20) → Wrathful, the middle three are neutral. +func dailyThreatDrift(gmMood int) (int, string) { + base := 3 + mod := 0 + tag := "" + switch { + case gmMood >= 80: + mod = -3 + tag = "elated" + case gmMood < 20: + mod = 5 + tag = "wrathful" + } + delta := base + mod + reason := "day passes in zone" + if tag != "" { + reason = fmt.Sprintf("day passes in zone (%s mood mod %+d)", tag, mod) + } + return delta, reason +} + +// applyDailyThreatDrift applies the per-day threat increment at briefing +// rollover. No-op once the boss has been defeated (the zone has nothing +// left to escalate). Returns the delta actually applied. +func applyDailyThreatDrift(e *Expedition) (int, string, error) { + if e.BossDefeated { + return 0, "", nil + } + delta, reason := dailyThreatDrift(e.GMMood) + if delta == 0 { + return 0, reason, nil + } + prevBand := threatBandFor(e.ThreatLevel, e.SiegeMode) + if err := applyThreatDelta(e.ID, delta, reason); err != nil { + return 0, reason, err + } + // Mirror the change in-memory so the caller can render it. + e.ThreatLevel += delta + if e.ThreatLevel > 100 { + e.ThreatLevel = 100 + } + if e.ThreatLevel < 0 { + e.ThreatLevel = 0 + } + if e.ThreatLevel >= 100 { + e.SiegeMode = true + } + newBand := threatBandFor(e.ThreatLevel, e.SiegeMode) + if newBand != prevBand && newBand > prevBand { + _ = appendThreatTransitionLog(e, newBand) + } + return delta, reason, nil +} + +// appendThreatTransitionLog records a band-crossing in the expedition log +// with the prewritten TwinBee narration for that band. +func appendThreatTransitionLog(e *Expedition, band ThreatBand) error { + var pool []string + switch band { + case ThreatBandStirring: + pool = flavor.ThreatClockStirring + case ThreatBandAlert: + pool = flavor.ThreatClockAlert + case ThreatBandHostile: + pool = flavor.ThreatClockHostile + case ThreatBandSiege: + pool = flavor.ThreatClockSiege + default: + return nil + } + line := flavor.Pick(pool) + info := threatBandInfo(band) + summary := fmt.Sprintf("threat clock crossed into %s (%d/100)", info.Label, e.ThreatLevel) + return appendExpeditionLog(e.ID, e.CurrentDay, "threat", summary, line) +} + +// applyBossDefeatThreat records the -20 threat drop when a zone boss is +// killed (§8.1). Caller wires it from the combat completion path once +// expedition combat is hooked up. +func applyBossDefeatThreat(expID string) error { + return applyThreatDelta(expID, -20, "boss defeated") +} diff --git a/internal/plugin/dnd_expedition_threat_test.go b/internal/plugin/dnd_expedition_threat_test.go new file mode 100644 index 0000000..bc215dd --- /dev/null +++ b/internal/plugin/dnd_expedition_threat_test.go @@ -0,0 +1,178 @@ +package plugin + +import ( + "strings" + "testing" + "time" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// Phase 12 E2a — Threat Clock state-machine tests. + +func TestThreatBandFor(t *testing.T) { + cases := []struct { + level int + siege bool + want ThreatBand + }{ + {0, false, ThreatBandQuiet}, + {20, false, ThreatBandQuiet}, + {21, false, ThreatBandStirring}, + {40, false, ThreatBandStirring}, + {41, false, ThreatBandAlert}, + {60, false, ThreatBandAlert}, + {61, false, ThreatBandHostile}, + {80, false, ThreatBandHostile}, + {81, false, ThreatBandSiege}, + {100, false, ThreatBandSiege}, + {50, true, ThreatBandSiege}, + } + for _, c := range cases { + got := threatBandFor(c.level, c.siege) + if got != c.want { + t.Errorf("threatBandFor(%d, %v) = %v, want %v", c.level, c.siege, got, c.want) + } + } +} + +func TestThreatBandInfo_SiegeFlags(t *testing.T) { + info := threatBandInfo(ThreatBandSiege) + if !info.NoShortRest { + t.Error("siege should disable short rests") + } + if info.SupplyMult != 2 { + t.Errorf("siege supply mult = %v, want 2", info.SupplyMult) + } + if !info.InitAdv || !info.TrapsArm { + t.Error("siege should grant init advantage and re-arm traps") + } +} + +func TestDailyThreatDrift_MoodMod(t *testing.T) { + cases := []struct { + mood int + want int + }{ + {50, 3}, // neutral + {60, 3}, // friendly (still neutral for threat) + {80, 0}, // effusive → elated → -3 + {19, 8}, // hostile → wrathful → +5 + {0, 8}, // hostile lower bound + } + for _, c := range cases { + got, _ := dailyThreatDrift(c.mood) + if got != c.want { + t.Errorf("mood=%d drift=%d, want %d", c.mood, got, c.want) + } + } +} + +func TestApplyDailyThreatDrift_PersistsAndCrossesThreshold(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-threat-drift:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + // Pre-load to 19 so a +3 drift crosses Stirring boundary. + if err := applyThreatDelta(exp.ID, 19, "test seed"); err != nil { + t.Fatal(err) + } + exp, _ = getExpedition(exp.ID) + + delta, _, err := applyDailyThreatDrift(exp) + if err != nil { + t.Fatal(err) + } + if delta != 3 { + t.Errorf("delta = %d, want 3", delta) + } + got, _ := getExpedition(exp.ID) + if got.ThreatLevel != 22 { + t.Errorf("threat = %d, want 22", got.ThreatLevel) + } + // Threshold transition log entry should exist with TwinBee narration. + entries, _ := recentExpeditionLog(exp.ID, 10) + foundTransition := false + for _, e := range entries { + if e.Type == "threat" && strings.Contains(strings.ToLower(e.Summary), "stirring") { + if e.Flavor == "" { + t.Error("threat transition missing flavor line") + } + foundTransition = true + } + } + if !foundTransition { + t.Error("expected threat transition log entry into Stirring") + } +} + +func TestApplyDailyThreatDrift_NoOpAfterBoss(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-threat-postboss:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + if _, err := db.Get().Exec(`UPDATE dnd_expedition SET boss_defeated = 1 WHERE expedition_id = ?`, exp.ID); err != nil { + t.Fatal(err) + } + exp, _ = getExpedition(exp.ID) + delta, _, err := applyDailyThreatDrift(exp) + if err != nil { + t.Fatal(err) + } + if delta != 0 { + t.Errorf("delta = %d, want 0 after boss kill", delta) + } +} + +func TestDeliverBriefing_AppliesThreatDrift(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-brief-drift:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + p := &AdventurePlugin{} + if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.ThreatLevel != 3 { + t.Errorf("post-briefing threat = %d, want 3 (mood-neutral drift)", got.ThreatLevel) + } +} + +func TestApplyBossDefeatThreat_DropsLevel(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-boss-threat:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + if err := applyThreatDelta(exp.ID, 50, "seed"); err != nil { + t.Fatal(err) + } + if err := applyBossDefeatThreat(exp.ID); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.ThreatLevel != 30 { + t.Errorf("post-boss threat = %d, want 30", got.ThreatLevel) + } +}