mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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:
@@ -245,6 +245,7 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.mortgageTicker()
|
||||
go p.expeditionBriefingTicker()
|
||||
go p.expeditionRecapTicker()
|
||||
go p.expeditionAmbientTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
|
||||
401
internal/plugin/expedition_ambient.go
Normal file
401
internal/plugin/expedition_ambient.go
Normal file
@@ -0,0 +1,401 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/flavor"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 12 E7 — Ambient between-day events. The expedition autopilot's
|
||||
// "background ticking" pass: while an active expedition is sitting
|
||||
// between briefings and recaps, the dungeon does its own small things
|
||||
// every few hours. Most fires are pure flavor (TwinBee narrating a
|
||||
// dripping sound, a draft, a polite possum); a few apply tiny
|
||||
// mechanical nudges (±SU, +threat, small coin find, +1 HP if camped).
|
||||
//
|
||||
// Design constraints:
|
||||
// • At most one event per expedition per ambientCooldown window.
|
||||
// • Idempotency via compare-and-set on dnd_expedition.last_ambient_at
|
||||
// — survives bot restarts, double-fires, clock skew.
|
||||
// • Skip if a turn-based combat session is open: the per-round combat
|
||||
// DMs are the player's feed right now; ambient noise would talk
|
||||
// over them.
|
||||
// • Skip if we're inside the politeness window around the scheduled
|
||||
// 06:00 briefing or 21:00 recap so we don't pile DMs on top of the
|
||||
// big daily message.
|
||||
// • Effects are deliberately small — ambient is texture, not balance.
|
||||
|
||||
const (
|
||||
// ambientCooldown — minimum time between ambient events for one
|
||||
// expedition. Tuned so a player offline for a workday sees ~3 hits,
|
||||
// not a flood, but a multi-day expedition feels alive.
|
||||
ambientCooldown = 3 * time.Hour
|
||||
|
||||
// ambientNearScheduleWindow — skip if we're within this many minutes
|
||||
// of a scheduled briefing (06:00 UTC) or recap (21:00 UTC).
|
||||
ambientNearScheduleWindow = 60 * time.Minute
|
||||
|
||||
// ambientTickInterval — how often the ticker wakes up to check.
|
||||
// The cooldown gate is what actually paces events; the tick just
|
||||
// has to be frequent enough that the cooldown is enforced cleanly.
|
||||
ambientTickInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
// expeditionAmbientTicker — fires ambient events every ambientTickInterval.
|
||||
func (p *AdventurePlugin) expeditionAmbientTicker() {
|
||||
ticker := time.NewTicker(ambientTickInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.fireExpeditionAmbients(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fireExpeditionAmbients walks active expeditions whose last_ambient_at
|
||||
// is older than ambientCooldown and fires one ambient event per.
|
||||
func (p *AdventurePlugin) fireExpeditionAmbients(now time.Time) {
|
||||
if inAmbientQuietWindow(now) {
|
||||
return
|
||||
}
|
||||
due, err := loadExpeditionsForAmbient(now.Add(-ambientCooldown))
|
||||
if err != nil {
|
||||
slog.Error("expedition: load ambient candidates", "err", err)
|
||||
return
|
||||
}
|
||||
for _, expID := range due {
|
||||
// Re-load each row fresh — the candidate list was a cheap
|
||||
// id-only scan, and per-expedition state may have moved.
|
||||
e, err := getExpedition(expID)
|
||||
if err != nil || e == nil {
|
||||
continue
|
||||
}
|
||||
if e.Status != ExpeditionStatusActive {
|
||||
continue
|
||||
}
|
||||
uid := id.UserID(e.UserID)
|
||||
if hasActiveCombatSession(uid) {
|
||||
continue
|
||||
}
|
||||
if err := p.deliverAmbient(e, now); err != nil {
|
||||
slog.Warn("expedition: ambient deliver", "expedition", e.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inAmbientQuietWindow reports whether `now` is inside the politeness
|
||||
// window around 06:00 UTC (briefing) or 21:00 UTC (recap). Centered on
|
||||
// the scheduled minute: ±ambientNearScheduleWindow.
|
||||
func inAmbientQuietWindow(now time.Time) bool {
|
||||
briefing := time.Date(now.Year(), now.Month(), now.Day(),
|
||||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||||
recap := time.Date(now.Year(), now.Month(), now.Day(),
|
||||
expeditionRecapHour, 0, 0, 0, time.UTC)
|
||||
for _, t := range []time.Time{briefing, recap} {
|
||||
d := now.Sub(t)
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d < ambientNearScheduleWindow {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loadExpeditionsForAmbient — active expedition IDs whose last_ambient_at
|
||||
// is NULL or older than the cutoff. Also requires start_date older than
|
||||
// the cutoff so a freshly-started expedition gets a real beat of silence
|
||||
// before the dungeon starts chiming in.
|
||||
func loadExpeditionsForAmbient(cutoff time.Time) ([]string, error) {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT expedition_id
|
||||
FROM dnd_expedition
|
||||
WHERE status = 'active'
|
||||
AND start_date < ?
|
||||
AND (last_ambient_at IS NULL OR last_ambient_at < ?)`,
|
||||
cutoff, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, s)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// deliverAmbient claims the slot, picks an event, applies its effect,
|
||||
// DMs the player, and appends a log entry. Idempotent: the CAS update
|
||||
// short-circuits on rowsAffected == 0 so a double-fire is a no-op.
|
||||
func (p *AdventurePlugin) deliverAmbient(e *Expedition, now time.Time) error {
|
||||
cutoff := now.Add(-ambientCooldown)
|
||||
res, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET last_ambient_at = ?,
|
||||
last_activity = ?
|
||||
WHERE expedition_id = ?
|
||||
AND status = 'active'
|
||||
AND (last_ambient_at IS NULL OR last_ambient_at < ?)`,
|
||||
now, now, e.ID, cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil // someone else got there first
|
||||
}
|
||||
|
||||
ev := pickAmbientEvent(e, defaultAmbientRNG())
|
||||
line := flavor.Pick(ev.Pool)
|
||||
if line == "" {
|
||||
// Pool was empty — degrade to a generic monologue rather than
|
||||
// firing an event with no narration.
|
||||
ev = ambientEventMonologue()
|
||||
line = flavor.Pick(ev.Pool)
|
||||
}
|
||||
|
||||
footer := p.applyAmbientEffect(e, ev)
|
||||
body := renderAmbientDM(e, ev, line, footer)
|
||||
|
||||
if uid := id.UserID(e.UserID); uid != "" {
|
||||
if err := p.SendDM(uid, body); err != nil {
|
||||
slog.Warn("expedition: ambient DM", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
summary := fmt.Sprintf("ambient: %s", ev.Kind)
|
||||
if footer != "" {
|
||||
summary += " — " + footer
|
||||
}
|
||||
if err := appendExpeditionLog(e.ID, e.CurrentDay, "ambient",
|
||||
summary, line); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Event table ──────────────────────────────────────────────────────────────
|
||||
|
||||
// ambientEvent — one entry in the ambient pool. Weight is relative; the
|
||||
// resolver normalizes over eligible events at pick time.
|
||||
type ambientEvent struct {
|
||||
Kind string
|
||||
Pool []string
|
||||
Weight int
|
||||
Eligible func(*Expedition) bool
|
||||
}
|
||||
|
||||
// ambientEventMonologue — the always-eligible flavor fallback. Pulled
|
||||
// out so deliverAmbient can degrade to it if a chosen pool is empty.
|
||||
func ambientEventMonologue() ambientEvent {
|
||||
return ambientEvent{
|
||||
Kind: "monologue",
|
||||
Pool: flavor.AmbientMonologue,
|
||||
Weight: 35,
|
||||
Eligible: func(*Expedition) bool { return true },
|
||||
}
|
||||
}
|
||||
|
||||
// ambientEvents returns the full event table. Constructed per-call so
|
||||
// it's cheap to test in isolation and free of package-level state.
|
||||
func ambientEvents() []ambientEvent {
|
||||
always := func(*Expedition) bool { return true }
|
||||
return []ambientEvent{
|
||||
ambientEventMonologue(),
|
||||
{
|
||||
Kind: "small_find",
|
||||
Pool: flavor.AmbientSmallFind,
|
||||
Weight: 10,
|
||||
Eligible: always,
|
||||
},
|
||||
{
|
||||
Kind: "noise",
|
||||
Pool: flavor.AmbientNoise,
|
||||
Weight: 15,
|
||||
Eligible: always,
|
||||
},
|
||||
{
|
||||
Kind: "pack_rat",
|
||||
Pool: flavor.AmbientPackRat,
|
||||
Weight: 12,
|
||||
// Pack rats need something to nibble. If supplies are
|
||||
// already at the floor, skip — no point in the player
|
||||
// getting a "you lost 0.0 SU" DM.
|
||||
Eligible: func(e *Expedition) bool {
|
||||
return e.Supplies.Current >= 1
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: "lucky_find",
|
||||
Pool: flavor.AmbientLuckyFind,
|
||||
Weight: 10,
|
||||
Eligible: always,
|
||||
},
|
||||
{
|
||||
Kind: "camp_visitor",
|
||||
Pool: flavor.AmbientCampVisitor,
|
||||
Weight: 8,
|
||||
Eligible: func(e *Expedition) bool {
|
||||
return e.Camp != nil && e.Camp.Active
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: "faction_whisper",
|
||||
Pool: flavor.AmbientFactionWhisper,
|
||||
Weight: 10,
|
||||
// Whispers from a faction that doesn't know you exist
|
||||
// land flat. Gate behind a real threat level.
|
||||
Eligible: func(e *Expedition) bool {
|
||||
return e.ThreatLevel >= 30 && !e.SiegeMode
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// pickAmbientEvent runs a weighted pick over eligible events.
|
||||
// Exposed for tests; the rng parameter lets them seed deterministically.
|
||||
func pickAmbientEvent(e *Expedition, rng *rand.Rand) ambientEvent {
|
||||
events := ambientEvents()
|
||||
total := 0
|
||||
for _, ev := range events {
|
||||
if ev.Eligible(e) {
|
||||
total += ev.Weight
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return ambientEventMonologue()
|
||||
}
|
||||
r := rng.IntN(total)
|
||||
for _, ev := range events {
|
||||
if !ev.Eligible(e) {
|
||||
continue
|
||||
}
|
||||
if r < ev.Weight {
|
||||
return ev
|
||||
}
|
||||
r -= ev.Weight
|
||||
}
|
||||
return ambientEventMonologue() // unreachable barring float drift
|
||||
}
|
||||
|
||||
func defaultAmbientRNG() *rand.Rand {
|
||||
return rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))
|
||||
}
|
||||
|
||||
// ── Effects ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// applyAmbientEffect mutates expedition state per event Kind and returns a
|
||||
// terse footer string for the DM ("Supplies −0.3", "+3 coins", "Threat +1").
|
||||
// Pure-flavor kinds return "".
|
||||
func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) string {
|
||||
rng := defaultAmbientRNG()
|
||||
switch ev.Kind {
|
||||
case "monologue", "small_find":
|
||||
return ""
|
||||
case "noise":
|
||||
if err := applyThreatDelta(e.ID, 1, "ambient: distant noise"); err != nil {
|
||||
slog.Warn("expedition: ambient threat delta", "expedition", e.ID, "err", err)
|
||||
}
|
||||
return "Threat +1"
|
||||
case "faction_whisper":
|
||||
if err := applyThreatDelta(e.ID, 2, "ambient: faction whisper"); err != nil {
|
||||
slog.Warn("expedition: ambient threat delta", "expedition", e.ID, "err", err)
|
||||
}
|
||||
return "Threat +2"
|
||||
case "pack_rat":
|
||||
drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3
|
||||
ns := e.Supplies
|
||||
ns.Current -= drain
|
||||
if ns.Current < 0 {
|
||||
ns.Current = 0
|
||||
}
|
||||
if err := updateSupplies(e.ID, ns); err != nil {
|
||||
slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("Supplies −%.1f SU", drain)
|
||||
case "lucky_find":
|
||||
coins := 1 + rng.IntN(4) // 1d4
|
||||
if p.euro != nil {
|
||||
p.euro.Credit(id.UserID(e.UserID), float64(coins), "expedition ambient: lucky find")
|
||||
}
|
||||
// Mirror into coins_earned so the post-expedition tally is honest.
|
||||
if _, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET coins_earned = coins_earned + ?,
|
||||
last_activity = CURRENT_TIMESTAMP
|
||||
WHERE expedition_id = ?`, coins, e.ID); err != nil {
|
||||
slog.Warn("expedition: ambient coin tally", "expedition", e.ID, "err", err)
|
||||
}
|
||||
return fmt.Sprintf("+%d coins", coins)
|
||||
case "camp_visitor":
|
||||
// Tiny morale-style HP +1, clipped at max. Only fires when
|
||||
// camped (gated in Eligible). No-op if already full.
|
||||
uid := id.UserID(e.UserID)
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
return ""
|
||||
}
|
||||
if c.HPCurrent >= c.HPMax {
|
||||
return ""
|
||||
}
|
||||
c.HPCurrent++
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
slog.Warn("expedition: ambient HP nudge", "user", uid, "err", err)
|
||||
return ""
|
||||
}
|
||||
return "+1 HP"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderAmbientDM builds the DM body. Short header so it doesn't look
|
||||
// like a briefing, then the flavor line, then a one-line footer when
|
||||
// there was a mechanical effect.
|
||||
func renderAmbientDM(e *Expedition, ev ambientEvent, line, footer string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("🌫 *Day %d — between briefings*\n\n", e.CurrentDay))
|
||||
b.WriteString(line)
|
||||
if footer != "" {
|
||||
b.WriteString("\n\n_")
|
||||
b.WriteString(footer)
|
||||
b.WriteString("_")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Test seam — sql.NullTime helper kept here so tests can compose ──────────
|
||||
|
||||
// expeditionAmbientStamp returns the last_ambient_at value for a row, or
|
||||
// (zero, false) if NULL. Used in tests to assert idempotency.
|
||||
func expeditionAmbientStamp(expID string) (time.Time, bool, error) {
|
||||
var raw sql.NullTime
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT last_ambient_at FROM dnd_expedition WHERE expedition_id = ?`,
|
||||
expID).Scan(&raw)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
if !raw.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
return raw.Time, true, nil
|
||||
}
|
||||
150
internal/plugin/expedition_ambient_test.go
Normal file
150
internal/plugin/expedition_ambient_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user