diff --git a/internal/db/db.go b/internal/db/db.go index f439887..b2196e7 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -319,6 +319,10 @@ func runMigrations(d *sql.DB) error { // jump back into combat — they're actually resting for the duration. `ALTER TABLE dnd_character ADD COLUMN short_rest_charges INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE dnd_character ADD COLUMN resting_until DATETIME`, + // Phase 12 E7 (expedition autopilot Phase 3) — ambient ticker timestamp. + // Real-time between-day events fire at most once per ambientCooldown + // while an expedition is active; idempotent CAS on this column. + `ALTER TABLE dnd_expedition ADD COLUMN last_ambient_at DATETIME`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { @@ -1803,6 +1807,7 @@ CREATE TABLE IF NOT EXISTS dnd_expedition ( gm_mood INTEGER NOT NULL DEFAULT 50, last_briefing_at DATETIME, last_recap_at DATETIME, + last_ambient_at DATETIME, last_activity DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME ); diff --git a/internal/flavor/twinbee_ambient_flavor.go b/internal/flavor/twinbee_ambient_flavor.go new file mode 100644 index 0000000..45f43c4 --- /dev/null +++ b/internal/flavor/twinbee_ambient_flavor.go @@ -0,0 +1,112 @@ +// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE +// twinbee_ambient_flavor.go +// TwinBee GM Dialogue — Ambient between-day micro-events. Fired by the +// expedition ambient ticker (Phase 12 E7) every few hours while the player +// is offline or idle. Mostly flavor; occasional micro-mechanical nudges. +// Add freely. Never remove or alter existing entries. + +package flavor + +// ───────────────────────────────────────────────────────────────────────────── +// AMBIENT — Pure flavor (no mechanical effect) +// ───────────────────────────────────────────────────────────────────────────── + +var AmbientMonologue = []string{ + "TwinBee has been counting the dripping sound. The dripping sound has not been keeping pace. TwinBee suspects the dripping sound is doing it on purpose.", + "A draft moves through the corridor in a way that suggests the dungeon is breathing, then in a way that suggests it isn't, then back. TwinBee declines to call it.", + "Somewhere far off, a door closes. Nothing has opened a door. TwinBee files this under 'noted, not investigated.'", + "Your bedroll has shifted approximately two inches to the left over the course of the morning. TwinBee did not move it. You did not move it. TwinBee is going to stop watching the bedroll now.", + "A small stone has rolled across the floor with no visible cause. It came to rest in a position TwinBee describes as 'mildly conversational.'", + "TwinBee tried to think of a song and forgot the words. The dungeon offered a few. TwinBee politely declined.", + "A spiderweb in the corner has rearranged itself overnight into a shape that's almost a sentence. TwinBee can read about three letters of it before deciding it's safer to look at the floor.", + "The torch is burning at exactly the same height it was an hour ago. No wax has accumulated. TwinBee considers asking the torch about this and decides against.", + "You have not moved in some time. The dungeon also has not moved. TwinBee considers this an even draw and declares the round complete.", + "A faint smell of bread has drifted through. There is no bread within several days of you. TwinBee does not mention it because mentioning it would make it worse.", + "TwinBee has been alphabetizing your inventory in its head. TwinBee got as far as 'flask, empty' before being distracted by the question of whether 'empty' counts.", + "The shadow in the far corner has been the same shape for three hours. Shadows are generally not committed to one shape. TwinBee has stopped looking at it.", + "You realize you've been holding your breath. You weren't doing it on purpose. TwinBee was, and quietly resumes.", + "A pebble on the floor has, by all appearances, blinked. TwinBee reviews the definition of 'blink' and finds it unhelpful here.", + "The dungeon is quiet in the specific way that means something is being quiet *at* you. TwinBee acknowledges the distinction and moves on.", + "TwinBee has been composing an imaginary letter home. The letter is mostly about lichen. The lichen is, frankly, fascinating.", + "You hear what is almost certainly a cough. It is unclear which of you it came from. TwinBee assumes responsibility and apologizes.", + "The map in your pack has folded itself one extra time while you weren't looking. TwinBee declines to unfold it for science.", + "Your own footsteps echo back to you a half-second late, in slightly the wrong order. TwinBee notes this and chooses to walk in silence for a bit.", + "A long, slow scrape happens two rooms over. Then nothing. TwinBee waits for a second scrape. There isn't one. The first scrape was the whole sentence.", +} + +// ───────────────────────────────────────────────────────────────────────────── +// AMBIENT — Noises (+1 threat) +// ───────────────────────────────────────────────────────────────────────────── + +var AmbientNoise = []string{ + "Something far off makes a wet, considered noise — the kind of noise that's been thinking about itself. TwinBee marks it on the imaginary map. Threat ticks up.", + "A door slams somewhere it shouldn't. TwinBee notes that 'shouldn't' is doing heroic work in that sentence. The dungeon is more aware of you now.", + "A patrol passes a corridor or two over — boots, low voices, the particular silence of people who hunt in shifts. They didn't find you. They know the shape of where you might be.", + "The dungeon hums for a moment, like a refrigerator coming on. There is no refrigerator. There is also, distinctly, a humming. The faction's pulse is faster today.", + "Stones shift overhead in a way that is not settling and not random. Something walked across the ceiling. TwinBee chooses not to elaborate on what 'across the ceiling' implies.", + "A horn sounds, very far away — the kind of horn that's a signal to a thing that signals to more things. TwinBee acknowledges the chain.", + "You hear someone whistling your name. You don't have that name. TwinBee notes the whistler is workshopping options.", +} + +// ───────────────────────────────────────────────────────────────────────────── +// AMBIENT — Pack Rat / Critter ate supplies (−0.2 SU) +// ───────────────────────────────────────────────────────────────────────────── + +var AmbientPackRat = []string{ + "A rat has formed strong opinions about your trail mix. The opinions are now inside the rat. Some supplies were lost in transit.", + "You catch a small mammal mid-burglary. It looks at you with the unbothered confidence of a creature that has read the lease and concluded it has rights. You lose a little supply.", + "Something with too many legs has been at the rations. TwinBee won't say how many legs. TwinBee will say: fewer rations now.", + "A bird the size of a thumbnail has stolen a piece of jerky three times its size, by a process TwinBee describes as 'unclear but enviable.' Supplies tick down.", + "Mold. Just normal, ambitious mold. It has made itself at home in a corner of the pack. You evict it; some supplies go with it.", + "You discover a small hole in the side of your pack and a smaller, more confident animal asleep beside it, full. Supplies were the rent.", +} + +// ───────────────────────────────────────────────────────────────────────────── +// AMBIENT — Lucky Find (+1d6 coins) +// ───────────────────────────────────────────────────────────────────────────── + +var AmbientLuckyFind = []string{ + "TwinBee finds a copper piece in your boot. You did not put it there. TwinBee did not put it there. TwinBee elects not to investigate further and pockets it.", + "You notice a coin wedged in a wall crack at exactly eye height, as though placed for the next passerby. You are the next passerby. The coin is yours now.", + "A small pouch is lying in the middle of the floor. TwinBee inspects it from a polite distance — no trap, no glyph, just coins. The dungeon occasionally tips.", + "You step on something flat. It is a coin. It is also two more coins under the first coin. TwinBee suspects a coin-laying creature and chooses not to share the theory.", + "A skeleton you walked past three days ago has, on review, a small purse you missed. TwinBee retrieves it with the discretion of a librarian recovering an overdue book.", + "You find coins in the lining of your own cloak. They were always there. TwinBee gently suggests counting your pockets more often.", +} + +// ───────────────────────────────────────────────────────────────────────────── +// AMBIENT — Camp Critter (camped only; tiny HP +1 or supply nibble) +// ───────────────────────────────────────────────────────────────────────────── + +var AmbientCampVisitor = []string{ + "A possum has joined the camp. It seems diplomatic — it brought a leaf. TwinBee accepts the leaf. Morale ticks up.", + "A small luminous moth lands on your hand for a full minute, then leaves, taking with it a portion of whatever was weighing on you. You can't explain it. You feel slightly better.", + "A cat. There should not be a cat down here. There is a cat. It sits at the edge of the firelight and watches you sleep, professionally. You rest a little better.", + "A frog finds the camp acceptable and gives no further reviews. Its presence is, somehow, soothing. You feel marginally more whole.", + "A small fox eats some of your jerky and gives you a look afterwards that seems to be gratitude or possibly contempt. Hard to read. The jerky is gone either way.", + "A field mouse studies your bedroll, judges it, and leaves. Nothing is missing. You feel oddly honored.", +} + +// ───────────────────────────────────────────────────────────────────────────── +// AMBIENT — Threat-aware whisper (only when threat ≥ 30; +2 threat) +// ───────────────────────────────────────────────────────────────────────────── + +var AmbientFactionWhisper = []string{ + "You overhear two voices a corridor away, going over a list. The list is short. One of the items on it is the corridor you're standing in. They move on. So should you.", + "A piece of paper is pinned to a wall you haven't reached yet. TwinBee can see the corner of it from here. It is the right shape and size to have your description on it.", + "Something taps three times on stone, pauses, taps three times again. Five seconds later, the same sequence answers it from a different direction. You are between the two.", + "A scent of lamp oil that isn't yours drifts through. Someone is reading by it. Nearby. They have not moved for some time. TwinBee adjusts the route.", + "A bell rings exactly once, somewhere structural. TwinBee has learned what one bell means in this zone. TwinBee declines to share but increases pace.", +} + +// ───────────────────────────────────────────────────────────────────────────── +// AMBIENT — TwinBee found a small item / minor stash hint (flavor only) +// ───────────────────────────────────────────────────────────────────────────── + +var AmbientSmallFind = []string{ + "TwinBee notices a loose stone you walked past earlier. Under it: a button. Just a button. TwinBee pockets it anyway, because TwinBee.", + "A bird's nest in an unlikely place yields a single bead, blue. TwinBee likes it. It goes in the bag of things TwinBee likes.", + "A scrap of fabric has caught on a nail at exactly knee height. TwinBee saves it without explanation. There may be an explanation later.", + "You find a single playing card on the floor. The four of cups. TwinBee considers this neither omen nor coincidence and pockets it neutrally.", + "A small key. To a small door. Neither of which is currently relevant. TwinBee files it under 'eventually.'", +} diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index ded3187..89b8f29 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -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() diff --git a/internal/plugin/expedition_ambient.go b/internal/plugin/expedition_ambient.go new file mode 100644 index 0000000..ce436b5 --- /dev/null +++ b/internal/plugin/expedition_ambient.go @@ -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 +} diff --git a/internal/plugin/expedition_ambient_test.go b/internal/plugin/expedition_ambient_test.go new file mode 100644 index 0000000..fadeb56 --- /dev/null +++ b/internal/plugin/expedition_ambient_test.go @@ -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) + } +}