From 41c60617db6cd65de6de6ed4bb3d4d2ef97a20d0 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 15:55:12 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 12 E3a: Sunken Temple tidal event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the §7.1 tidal event hook: warnings on Days 4 and 5, peak event on Day 6 with forced 2× supply burn (overrides tier-2 HarshMod 1.5×), silenced if the boss has been defeated. Wires zone-temporal events into deliverBriefing as two new hook points — pre-burn (mutates burn multiplier) and post-rollover (appends flavor-bearing log entries). Future E3b–E3f layer onto the same hooks. Reuses the prewritten flavor.SunkenTempleTidalWarning / TidalEvent pools per feedback_reuse_existing_flavor. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/dnd_expedition_cycle.go | 15 +- internal/plugin/dnd_expedition_temporal.go | 97 +++++++++++ .../plugin/dnd_expedition_temporal_test.go | 162 ++++++++++++++++++ 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 internal/plugin/dnd_expedition_temporal.go create mode 100644 internal/plugin/dnd_expedition_temporal_test.go diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index ea12449..56fc3b2 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -150,9 +150,15 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) { // deliverBriefing rolls a day forward, applies supply burn, posts the // morning briefing DM, appends a log entry, and stamps last_briefing_at. func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { + // E3: zone-specific temporal events that affect this morning's burn + // fire BEFORE applyDailyBurn (e.g. Sunken Temple Day 6 tidal forces + // 2× burn even on tier-2 where HarshMod is 1.5×). Force-double piggy- + // backs on the siege param's "≥2× floor" branch in applyDailyBurn. + forceDouble := applyZoneTemporalPreBurn(e, e.CurrentDay+1) + // Advance day + supply burn happen together at the morning rollover. harsh := e.ThreatLevel > 60 || e.TemporalStack > 0 - newSupplies, burn := applyDailyBurn(e.Supplies, harsh, e.SiegeMode) + newSupplies, burn := applyDailyBurn(e.Supplies, harsh, e.SiegeMode || forceDouble) if err := updateSupplies(e.ID, newSupplies); err != nil { return err } @@ -181,11 +187,18 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err) } + // E3: zone temporal events post-rollover narration (after the day + // has advanced, so e.CurrentDay reflects the new day). + temporalLines := applyZoneTemporalPostRollover(e) + line := pickMorningBriefing(e.CurrentDay) body := renderMorningBriefing(e, line, burn) if restSummary != "" { body += "\n💤 _" + restSummary + "_\n" } + for _, tl := range temporalLines { + body += "\n🌀 " + tl + "\n" + } if uid := id.UserID(e.UserID); uid != "" { if err := p.SendDM(uid, body); err != nil { diff --git a/internal/plugin/dnd_expedition_temporal.go b/internal/plugin/dnd_expedition_temporal.go new file mode 100644 index 0000000..336b292 --- /dev/null +++ b/internal/plugin/dnd_expedition_temporal.go @@ -0,0 +1,97 @@ +package plugin + +import ( + "fmt" + "strings" + + "gogobee/internal/flavor" +) + +// Phase 12 E3 — Zone-specific Temporal Events. +// Spec: gogobee_expedition_system.md §7. +// +// Temporal events fire at briefing rollover and produce two outputs: +// - extraHarsh: forces double supply burn for the burn fired by THIS +// briefing (overrides the §4.1 zone HarshMod when the multiplier +// should be 2× regardless of tier — e.g. tidal flooding §7.1). +// - one or more flavor-bearing log entries (warnings, event narration). +// +// applyZoneTemporalPreBurn runs BEFORE applyDailyBurn so the burn +// reflects the temporal effect for the day being entered. State that +// persists across days (Heat stacks, Portal instability) is mutated +// here as well — the per-day increment is the temporal event. +// +// Combat-side knobs (tidal +1d6 cold, Underforge +1d4 fire, etc.) are +// exposed via ZoneTemporalEffects for the combat engine to read; the +// engine hook lands with the combat-link phase. + +// ZoneTemporalEffects bundles the per-zone, per-day combat-side +// modifications produced by temporal events. Combat code reads this +// from the active expedition; nothing reads it yet (combat-link phase). +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 +} + +// applyZoneTemporalPreBurn runs at the top of deliverBriefing, before +// applyDailyBurn. It mutates e (state increments, in-memory only — +// caller persists the relevant fields) and returns whether this +// briefing's burn should be force-doubled regardless of HarshMod. +// +// nextDay is the day-number we're rolling INTO (i.e. e.CurrentDay+1 +// at call site, since the briefing increments after burn). +func applyZoneTemporalPreBurn(e *Expedition, nextDay int) (extraHarsh bool) { + switch e.ZoneID { + case ZoneSunkenTemple: + extraHarsh = sunkenTempleTemporalPreBurn(e, nextDay) + } + return extraHarsh +} + +// 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. +func applyZoneTemporalPostRollover(e *Expedition) []string { + switch e.ZoneID { + case ZoneSunkenTemple: + return sunkenTempleTemporalPostRollover(e) + } + return nil +} + +// ───────────────────────────────────────────────────────────────────── +// §7.1 — Sunken Temple, Tidal Event (Day 6) +// ───────────────────────────────────────────────────────────────────── + +// sunkenTempleTemporalPreBurn doubles burn on Day 6 (tidal peak). Skips +// entirely if the boss has been defeated — the spec's no-op clause. +func sunkenTempleTemporalPreBurn(e *Expedition, nextDay int) bool { + if e.BossDefeated { + return false + } + return nextDay == 6 +} + +// sunkenTempleTemporalPostRollover writes warnings on Day 4/5 and the +// event narration on Day 6. Boss-kill before Day 6 silences all of it. +func sunkenTempleTemporalPostRollover(e *Expedition) []string { + if e.BossDefeated { + return nil + } + day := e.CurrentDay + switch day { + case 4, 5: + line := flavor.Pick(flavor.SunkenTempleTidalWarning) + line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", day)) + summary := fmt.Sprintf("tidal warning — peak in %d day(s)", 6-day) + _ = appendExpeditionLog(e.ID, day, "temporal", summary, line) + return []string{line} + case 6: + line := flavor.Pick(flavor.SunkenTempleTidalEvent) + _ = appendExpeditionLog(e.ID, day, "temporal", + "tidal peak — flooded rooms hostile, supply burn doubled", line) + return []string{line} + } + return nil +} diff --git a/internal/plugin/dnd_expedition_temporal_test.go b/internal/plugin/dnd_expedition_temporal_test.go new file mode 100644 index 0000000..98cd652 --- /dev/null +++ b/internal/plugin/dnd_expedition_temporal_test.go @@ -0,0 +1,162 @@ +package plugin + +import ( + "strings" + "testing" + "time" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// Phase 12 E3a — Sunken Temple tidal event. + +// setExpDay rewrites the current_day column for tests. +func setExpDay(t *testing.T, expID string, day int) { + t.Helper() + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET current_day = ? WHERE expedition_id = ?`, + day, expID); err != nil { + t.Fatalf("set current_day: %v", err) + } +} + +func TestSunkenTemple_TidalWarningDay4(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-tidal-d4:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneSunkenTemple, "", + ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1.5, HarshMod: 1.5}) + if err != nil { + t.Fatal(err) + } + setExpDay(t, exp.ID, 3) // → briefing rolls to Day 4 + exp, _ = getExpedition(exp.ID) + + p := &AdventurePlugin{} + startSU := exp.Supplies.Current + if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { + t.Fatal(err) + } + + got, _ := getExpedition(exp.ID) + if got.CurrentDay != 4 { + t.Fatalf("CurrentDay = %d, want 4", got.CurrentDay) + } + // Warning day: NO doubled burn — single 1.5 SU burn. + if got.Supplies.Current != startSU-1.5 { + t.Errorf("supplies = %v, want %v (single burn)", got.Supplies.Current, startSU-1.5) + } + entries, _ := recentExpeditionLog(exp.ID, 10) + foundWarn := false + for _, e := range entries { + if e.Type == "temporal" && strings.Contains(e.Summary, "tidal warning") { + foundWarn = true + } + } + if !foundWarn { + t.Error("expected a 'temporal' tidal-warning log entry on Day 4") + } +} + +func TestSunkenTemple_TidalEventDay6_ForcesDoubleBurn(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-tidal-d6:example") + defer cleanupExpeditions(uid) + + // Tier-2 supplies: HarshMod 1.5 normally. Tidal must force 2×. + exp, err := startExpedition(uid, ZoneSunkenTemple, "", + ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1.5, HarshMod: 1.5}) + if err != nil { + t.Fatal(err) + } + setExpDay(t, exp.ID, 5) // → rolls to Day 6 + exp, _ = getExpedition(exp.ID) + + p := &AdventurePlugin{} + startSU := exp.Supplies.Current + if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { + t.Fatal(err) + } + + got, _ := getExpedition(exp.ID) + if got.CurrentDay != 6 { + t.Fatalf("CurrentDay = %d, want 6", got.CurrentDay) + } + // Forced 2× burn = 1.5 * 2 = 3.0 SU + wantBurn := float32(3.0) + if startSU-got.Supplies.Current != wantBurn { + t.Errorf("burn = %v, want %v (forced 2× tidal)", startSU-got.Supplies.Current, wantBurn) + } + entries, _ := recentExpeditionLog(exp.ID, 10) + foundEvent := false + for _, e := range entries { + if e.Type == "temporal" && strings.Contains(e.Summary, "tidal peak") { + foundEvent = true + } + } + if !foundEvent { + t.Error("expected a 'temporal' tidal-peak log entry on Day 6") + } +} + +func TestSunkenTemple_BossDefeatedSilencesTidal(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-tidal-boss:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneSunkenTemple, "", + ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1.5, HarshMod: 1.5}) + 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) + } + exp, _ = getExpedition(exp.ID) + + p := &AdventurePlugin{} + startSU := exp.Supplies.Current + if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { + t.Fatal(err) + } + + got, _ := getExpedition(exp.ID) + // Single normal burn, no tidal multiplier. + if got.Supplies.Current != startSU-1.5 { + t.Errorf("supplies = %v, want %v (no tidal mult)", got.Supplies.Current, startSU-1.5) + } + entries, _ := recentExpeditionLog(exp.ID, 10) + for _, e := range entries { + if e.Type == "temporal" { + t.Errorf("did not expect temporal log entry post-boss; got %+v", e) + } + } +} + +func TestSunkenTemple_NoTidalOnNonZone(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-tidal-other:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 20, Max: 20, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + setExpDay(t, exp.ID, 5) + exp, _ = getExpedition(exp.ID) + + p := &AdventurePlugin{} + startSU := exp.Supplies.Current + if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.Supplies.Current != startSU-1 { + t.Errorf("non-tidal zone burned %v, want 1", startSU-got.Supplies.Current) + } +}