mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +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
|
||||
// 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 {
|
||||
|
||||
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