mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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>
180 lines
5.9 KiB
Go
180 lines
5.9 KiB
Go
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)
|
||
}
|
||
}
|