Files
gogobee/internal/plugin/expedition_ambient_test.go
prosolis 41adfce9f1 Expedition autopilot Phase 4 + rogue sneak attack + TwinBee voice sweep
Tedium-removal pass driven by live play feedback. Three big threads:

Expedition autopilot Phase 4 — background auto-run + harvest-until-dry:
- New expeditionAutoRunTicker walks active expeditions every 15min
  (5min tick, per-expedition CAS on new last_autorun_at column). Skips
  combat sessions, briefing/recap quiet windows, expeditions <30min old.
- Walks up to 3 rooms/tick with compact narration; suppresses DMs when
  0 rooms walked or just hitting the per-tick cap (no "stretch complete"
  filler). Player only hears from the bot when a real decision is needed.
- Compact mode: one-line combat narration for trash/elite, auto-resolves
  elite doorways via the forward-sim engine (boss still pauses). Threaded
  via new advanceOnceWithOpts → resolveRoom → resolveCombatRoom.
- Auto-harvest now grinds each Common/Uncommon node until dry (cap at 8
  attempts/visit), mirroring manual !scavenge retries. Rare+ still pauses.
- Ambient ticker: anti-repeat by Kind via new last_ambient_kind column
  (avoids two pack_rat DMs in a row when the pool only has 6 lines).
- !expedition go <n> now routes to the fork-choice handler when active +
  numeric; fork footer rewritten to suggest !expedition go instead of
  !zone go. Boss/Elite doorway: formatNextRoomMessage routes the action
  hint by next room type and autopilot loop breaks via new nextRoomType
  field so "Room X/Y — Boss" doesn't double-print with contradictory
  hints (!zone advance vs !fight).
- runAutopilotWalk extracted from expeditionCmdRun so foreground and
  background share the loop body.

Rogue Sneak Attack actually exists now:
- Audit found the rogue's "Sneak Attack" passive was AutoCritFirst +
  5% damage rider. No Nd6, ever. Phase 2 Monte Carlo masked this
  with a small flat buff; the class's defining mechanic never matched
  its tooltip or 5e identity.
- Added SneakAttackDie int to CombatModifiers, per-hit Nd6 in
  combat_primitives.go (same lane as DivineStrikePerHit). Scales with
  level: 1d6 at L1-2 ... 4d6 at L7-8 ... capped at 10d6 at L19-20.
- AutoCritFirst + 5% rider retained as bonuses on top of working sneak
  attack — preserves the opener-burst feel.
- L7 rogue expected per-hit ~8 → ~22 damage. Valdris fight math goes
  from "16 rounds to kill, die in 8" to winnable.

TwinBee voice sweep (Phase B3):
- ~530 line changes across 24 files replacing third-person "TwinBee
  notes/files/tracks/respects/..." constructions with first-person
  inside flavor string literals. Code identifiers (TwinBeeLine,
  twinBeeLine, etc.), chat-prefix labels (🎭 **TwinBee:**), item names
  (TwinBee's Bell), achievements, and game-title references in
  fun.go (Konami TwinBee ship) left intact.
- Catches a real bug: Valdris fight rendered "TwinBee marks the
  Legendary Resistance" mid-combat in third person, contradicting
  the Phase B2 convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:40:17 -07:00

180 lines
5.9 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 TestPickAmbientEvent_AntiRepeatRerollsOnce(t *testing.T) {
// When the first roll lands on avoidKind, we re-roll once. We can't
// guarantee the second roll diverges (the same Kind can win twice
// legitimately), but across many seeds the same-Kind repeat rate
// must drop materially vs the no-avoid baseline.
e := &Expedition{Supplies: ExpeditionSupplies{Current: 5}}
const trials = 2000
baseline := 0 // Kind == "pack_rat" with no avoid
guarded := 0 // Kind == "pack_rat" with avoidKind == "pack_rat"
for i := 0; i < trials; i++ {
seedA := uint64(i*2 + 1)
seedB := uint64(i*2 + 2)
if pickAmbientEvent(e, rand.New(rand.NewPCG(seedA, seedB)), "").Kind == "pack_rat" {
baseline++
}
if pickAmbientEvent(e, rand.New(rand.NewPCG(seedA, seedB)), "pack_rat").Kind == "pack_rat" {
guarded++
}
}
if guarded >= baseline {
t.Fatalf("anti-repeat did not reduce pack_rat hits: baseline=%d guarded=%d", baseline, guarded)
}
// Sanity: the guarded count should be small (≈ baseline²/total), not
// just one less. Allow generous slack but flag obvious regressions.
if guarded*3 > baseline {
t.Errorf("anti-repeat reduction weaker than expected: baseline=%d guarded=%d", baseline, guarded)
}
}
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)
}
}