mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +00:00
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:
@@ -150,9 +150,15 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
|
|||||||
// deliverBriefing rolls a day forward, applies supply burn, posts the
|
// deliverBriefing rolls a day forward, applies supply burn, posts the
|
||||||
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
|
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
|
||||||
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
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.
|
// Advance day + supply burn happen together at the morning rollover.
|
||||||
harsh := e.ThreatLevel > 60 || e.TemporalStack > 0
|
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 {
|
if err := updateSupplies(e.ID, newSupplies); err != nil {
|
||||||
return err
|
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)
|
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)
|
line := pickMorningBriefing(e.CurrentDay)
|
||||||
body := renderMorningBriefing(e, line, burn)
|
body := renderMorningBriefing(e, line, burn)
|
||||||
if restSummary != "" {
|
if restSummary != "" {
|
||||||
body += "\n💤 _" + restSummary + "_\n"
|
body += "\n💤 _" + restSummary + "_\n"
|
||||||
}
|
}
|
||||||
|
for _, tl := range temporalLines {
|
||||||
|
body += "\n🌀 " + tl + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
if uid := id.UserID(e.UserID); uid != "" {
|
if uid := id.UserID(e.UserID); uid != "" {
|
||||||
if err := p.SendDM(uid, body); err != nil {
|
if err := p.SendDM(uid, body); err != nil {
|
||||||
|
|||||||
97
internal/plugin/dnd_expedition_temporal.go
Normal file
97
internal/plugin/dnd_expedition_temporal.go
Normal 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
|
||||||
|
}
|
||||||
162
internal/plugin/dnd_expedition_temporal_test.go
Normal file
162
internal/plugin/dnd_expedition_temporal_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user