Adv 2.0 D&D Phase 12 E3a: Sunken Temple tidal event

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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 15:55:12 -07:00
parent 9cb66d46ac
commit 41c60617db
3 changed files with 273 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}
}