Expedition autopilot Phase 3: ambient between-day ticker

Active expeditions now tick on a 3-hour cadence between the 06:00
briefing and 21:00 recap. Each fire DMs the player a short TwinBee
moment — mostly pure flavor (dripping sounds, polite possums,
diplomatic moths) with a 50/50 chance of a small mechanical nudge
(±SU, +threat, 1d4 coins from a found purse, +1 HP if camped).

The dungeon is now louder while you're offline without being
balance-relevant. Multi-day expeditions feel populated by something
other than the player's calendar.

Mechanics:
- new last_ambient_at column with CAS idempotency (mirrors
  briefing/recap pattern in dnd_expedition_cycle.go)
- ambientCooldown=3h, skipped if in turn-based combat session
- 60min politeness window around scheduled briefing/recap so we
  don't pile DMs on the daily messages
- weighted event pool with per-event eligibility (camp_visitor
  requires camped, pack_rat requires SU>=1, faction_whisper
  requires threat>=30 and not siege)
- effects: applyThreatDelta, updateSupplies, euro.Credit +
  coins_earned bump, SaveDnDCharacter HP nudge (clipped at max)

Files:
- internal/db/db.go: ALTER TABLE + CREATE TABLE update
- internal/flavor/twinbee_ambient_flavor.go: 7 humor-heavy pools
- internal/plugin/expedition_ambient.go: ticker + resolver
- internal/plugin/expedition_ambient_test.go: 8 pure-logic tests
- internal/plugin/adventure.go: wire goroutine in Start()
This commit is contained in:
prosolis
2026-05-14 23:28:54 -07:00
parent 632e5ee315
commit 368b980650
5 changed files with 669 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
package plugin
import (
"math/rand/v2"
"strings"
"testing"
"time"
)
// Pure-logic tests for Phase 12 E7 (ambient ticker). The full
// deliverAmbient path mutates the prod DB; those gates already live
// behind setupAuditTestDB in other expedition tests. The behavior tested
// here — eligibility, weighted pick, quiet-window math, DM render —
// covers the resolution layer in isolation.
func TestInAmbientQuietWindow(t *testing.T) {
day := func(h, m int) time.Time {
return time.Date(2026, 5, 14, h, m, 0, 0, time.UTC)
}
cases := []struct {
name string
t time.Time
want bool
}{
{"on briefing", day(6, 0), true},
{"30 min before briefing", day(5, 30), true},
{"59 min before briefing", day(5, 1), true},
{"61 min before briefing", day(4, 59), false},
{"mid-afternoon", day(14, 17), false},
{"on recap", day(21, 0), true},
{"30 min after recap", day(21, 30), true},
{"61 min after recap", day(22, 1), false},
{"deep night", day(3, 0), false},
}
for _, c := range cases {
if got := inAmbientQuietWindow(c.t); got != c.want {
t.Errorf("%s: inAmbientQuietWindow(%v) = %v, want %v", c.name, c.t, got, c.want)
}
}
}
func TestPickAmbientEvent_AlwaysReturnsEligible(t *testing.T) {
// Brand-new expedition: no camp, no threat. Only "always" events
// should ever come back. We sample many times with different seeds
// to brute-force the weighted pick.
e := &Expedition{Supplies: ExpeditionSupplies{Current: 5}}
disallowed := map[string]bool{
"camp_visitor": true,
"faction_whisper": true,
}
for i := 0; i < 200; i++ {
rng := rand.New(rand.NewPCG(uint64(i), uint64(i*7+1)))
ev := pickAmbientEvent(e, rng)
if disallowed[ev.Kind] {
t.Fatalf("seed %d picked ineligible event %q", i, ev.Kind)
}
}
}
func TestPickAmbientEvent_CampVisitorRequiresCamp(t *testing.T) {
e := &Expedition{
Supplies: ExpeditionSupplies{Current: 5},
Camp: &CampState{Active: true},
}
// With camp active, camp_visitor should appear at least once in 500 spins.
sawVisitor := false
for i := 0; i < 500; i++ {
rng := rand.New(rand.NewPCG(uint64(i+1), uint64(i*13+2)))
if pickAmbientEvent(e, rng).Kind == "camp_visitor" {
sawVisitor = true
break
}
}
if !sawVisitor {
t.Errorf("expected camp_visitor to be reachable when camped")
}
}
func TestPickAmbientEvent_PackRatNeedsSupplies(t *testing.T) {
// Starving expedition: pack_rat should never fire — there's nothing
// for the rat to take, and a "0.0 SU" DM would be silly.
e := &Expedition{Supplies: ExpeditionSupplies{Current: 0}}
for i := 0; i < 200; i++ {
rng := rand.New(rand.NewPCG(uint64(i+3), uint64(i*5+7)))
if pickAmbientEvent(e, rng).Kind == "pack_rat" {
t.Fatalf("seed %d: pack_rat fired with no supplies", i)
}
}
}
func TestPickAmbientEvent_WhisperNeedsThreat(t *testing.T) {
low := &Expedition{Supplies: ExpeditionSupplies{Current: 5}, ThreatLevel: 10}
for i := 0; i < 200; i++ {
rng := rand.New(rand.NewPCG(uint64(i+5), uint64(i*11+3)))
if pickAmbientEvent(low, rng).Kind == "faction_whisper" {
t.Fatalf("seed %d: whisper fired below threshold", i)
}
}
hot := &Expedition{Supplies: ExpeditionSupplies{Current: 5}, ThreatLevel: 60}
saw := false
for i := 0; i < 500; i++ {
rng := rand.New(rand.NewPCG(uint64(i+9), uint64(i*17+5)))
if pickAmbientEvent(hot, rng).Kind == "faction_whisper" {
saw = true
break
}
}
if !saw {
t.Errorf("expected faction_whisper to be reachable at threat 60")
}
}
func TestPickAmbientEvent_SiegeSuppressesWhisper(t *testing.T) {
// In siege mode the faction has stopped whispering and started
// kicking the door. The ambient whisper would undersell the moment.
e := &Expedition{
Supplies: ExpeditionSupplies{Current: 5},
ThreatLevel: 100,
SiegeMode: true,
}
for i := 0; i < 200; i++ {
rng := rand.New(rand.NewPCG(uint64(i+13), uint64(i*19+11)))
if pickAmbientEvent(e, rng).Kind == "faction_whisper" {
t.Fatalf("seed %d: whisper fired during siege", i)
}
}
}
func TestRenderAmbientDM_NoFooterPureFlavor(t *testing.T) {
e := &Expedition{CurrentDay: 4}
ev := ambientEventMonologue()
body := renderAmbientDM(e, ev, "A pebble blinks.", "")
if !strings.Contains(body, "Day 4") {
t.Errorf("missing day header: %q", body)
}
if !strings.Contains(body, "A pebble blinks.") {
t.Errorf("missing flavor line: %q", body)
}
if strings.Contains(body, "_") && strings.Count(body, "_") >= 2 {
t.Errorf("unexpected italic footer in pure-flavor DM: %q", body)
}
}
func TestRenderAmbientDM_WithFooter(t *testing.T) {
e := &Expedition{CurrentDay: 7}
body := renderAmbientDM(e, ambientEventMonologue(), "A door slams.", "Threat +1")
if !strings.Contains(body, "Threat +1") {
t.Errorf("missing footer: %q", body)
}
}