Files
gogobee/internal/plugin/expedition_ambient_test.go
prosolis 368b980650 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()
2026-05-14 23:28:54 -07:00

151 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}