mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Diagnosed a cleric "death loop" (L14 dying at T2/T3 bosses while over-levelled): the boss isn't overtuned — caster sustained DPS is under-delivered, compounded by a fragile healer build. Engine fix — concentration AOE re-tick: - Concentration damage spells (spirit_guardians, heat_metal, spike_growth, call_lightning, flaming_sphere) now tick the enemy every round at round_end instead of resolving as a one-shot, via a new CombatStatuses.ConcentrationDmg armed on cast and round-tripped through the turn engine. Closes the long-tracked turn-engine concentration gap; the burst still lands the casting round, then the aura lingers. - Sim picker skips re-casting an already-active aura (models competent play and prevents a burst+aura double-dip). Re-baseline (n=30 sweep + n=100 confirm): bard +47pp T3 (heat_metal), druid +3-7, cleric/mage flat, fighter unchanged — no regressions. Player-data bootstraps (idempotent, run once on Init): - bootstrapCasterSpellBackfill: ensureSpellsForCharacter only seeds an empty book, so defaults added after a character's roll never reach it. Backfill missing defaults into known+prepared for existing casters (gives the affected cleric inflict_wounds + a working healing_word, since her healing_word_spell is a dead alias). - bootstrapGrantStarterPet: one-off L10 pet for an endgame player who never got the morning arrival roll; adds per-round proc damage + deflect. - TestScenario_JosieCasterAid verifies both against a copy of the live DB, incl. idempotency. Also fix a pre-existing wall-clock flake in TestFireBriefings_EventAnchoredActivePlayerDelivers (start_date defaulted to real now, filtering the row out when the suite runs after 06:00 UTC).
430 lines
14 KiB
Go
430 lines
14 KiB
Go
package plugin
|
|
|
|
// Scenario tests run against a copy of the prod DB (data/gogobee.db).
|
|
// Gated on GOGOBEE_PRODDB_SCENARIOS=1 so they don't run on default
|
|
// `go test ./...` invocations. Pattern mirrors setupAuditTestDB.
|
|
//
|
|
// Run with: GOGOBEE_PRODDB_SCENARIOS=1 go test -run TestScenario_ -v \
|
|
// ./internal/plugin/
|
|
|
|
import (
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gogobee/internal/db"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
func requireScenarioEnv(t *testing.T) {
|
|
t.Helper()
|
|
if os.Getenv("GOGOBEE_PRODDB_SCENARIOS") != "1" {
|
|
t.Skip("scenario tests gated on GOGOBEE_PRODDB_SCENARIOS=1")
|
|
}
|
|
}
|
|
|
|
// ── Scenario: Josie caster-aid bootstraps ──────────────────────────────────
|
|
//
|
|
// Verifies (against a copy of the live DB) that the two 2026-06-18 caster-aid
|
|
// bootstraps land for @holymachina: the spell backfill adds inflict_wounds to
|
|
// her known+prepared book, and the pet gift grants a L10 dog mirrored into
|
|
// player_meta. Both must be idempotent on a second run.
|
|
func TestScenario_JosieCasterAid(t *testing.T) {
|
|
requireScenarioEnv(t)
|
|
setupAuditTestDB(t)
|
|
|
|
const uid = id.UserID("@holymachina:parodia.dev")
|
|
|
|
hasInflict := func() bool {
|
|
known, err := listKnownSpells(uid)
|
|
if err != nil {
|
|
t.Fatalf("list known: %v", err)
|
|
}
|
|
for _, k := range known {
|
|
if k.SpellID == "inflict_wounds" {
|
|
return k.Prepared
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
if hasInflict() {
|
|
t.Fatal("precondition failed: target already knows inflict_wounds")
|
|
}
|
|
char, err := loadAdvCharacter(uid)
|
|
if err != nil || char == nil {
|
|
t.Fatalf("load target char: %v", err)
|
|
}
|
|
if char.PetArrived || char.PetType != "" {
|
|
t.Fatal("precondition failed: target already has a pet")
|
|
}
|
|
|
|
// Run twice to prove idempotency.
|
|
for i := 0; i < 2; i++ {
|
|
bootstrapCasterSpellBackfill()
|
|
bootstrapGrantStarterPet()
|
|
}
|
|
|
|
if !hasInflict() {
|
|
t.Error("spell backfill did not add inflict_wounds as prepared")
|
|
}
|
|
|
|
got, err := loadAdvCharacter(uid)
|
|
if err != nil || got == nil {
|
|
t.Fatalf("reload target char: %v", err)
|
|
}
|
|
if !got.PetArrived || got.PetType != "dog" || got.PetLevel != 10 {
|
|
t.Errorf("pet grant: arrived=%v type=%q level=%d, want true/dog/10",
|
|
got.PetArrived, got.PetType, got.PetLevel)
|
|
}
|
|
pet, err := loadPetState(uid)
|
|
if err != nil {
|
|
t.Fatalf("load pet state: %v", err)
|
|
}
|
|
if !pet.HasPet() || pet.Level != 10 {
|
|
t.Errorf("player_meta pet mirror: hasPet=%v level=%d, want true/10", pet.HasPet(), pet.Level)
|
|
}
|
|
}
|
|
|
|
// ── Scenario 1: Phase 5-B HP bootstrap ─────────────────────────────────────
|
|
//
|
|
// Expected: bootstrapPhase5BHPRefresh() walks dnd_character rows where
|
|
// dnd_level > 0, refreshes hp_max upward toward computeMaxHP (which
|
|
// applies phase5BHPMult=1.5), bumps hp_current by the same delta, and
|
|
// marks the daily_prefetch job key so reruns are no-ops.
|
|
func TestScenario_Phase5BHPBootstrap(t *testing.T) {
|
|
requireScenarioEnv(t)
|
|
setupAuditTestDB(t)
|
|
|
|
type charRow struct {
|
|
userID string
|
|
class string
|
|
level int
|
|
conScore int
|
|
hpMax int
|
|
hpCurrent int
|
|
}
|
|
snapshot := func() map[string]charRow {
|
|
rows, err := db.Get().Query(`
|
|
SELECT user_id, class, dnd_level, con_score, hp_max, hp_current
|
|
FROM dnd_character WHERE dnd_level > 0`)
|
|
if err != nil {
|
|
t.Fatalf("query: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
out := map[string]charRow{}
|
|
for rows.Next() {
|
|
var r charRow
|
|
if err := rows.Scan(&r.userID, &r.class, &r.level, &r.conScore, &r.hpMax, &r.hpCurrent); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
out[r.userID] = r
|
|
}
|
|
return out
|
|
}
|
|
|
|
before := snapshot()
|
|
t.Logf("[pre-bootstrap] %d characters with dnd_level > 0", len(before))
|
|
for _, r := range before {
|
|
t.Logf(" %s class=%q L%d con=%d hp=%d/%d",
|
|
r.userID, r.class, r.level, r.conScore, r.hpCurrent, r.hpMax)
|
|
}
|
|
|
|
if db.JobCompleted("phase5b_hp_refresh_v1", "once") {
|
|
t.Fatalf("job already marked completed before bootstrap — snapshot was post-bootstrap?")
|
|
}
|
|
|
|
bootstrapPhase5BHPRefresh()
|
|
|
|
after := snapshot()
|
|
if !db.JobCompleted("phase5b_hp_refresh_v1", "once") {
|
|
t.Errorf("expected JobCompleted=true after bootstrap")
|
|
}
|
|
|
|
refreshed := 0
|
|
for uid, b := range before {
|
|
a := after[uid]
|
|
conMod := abilityModifier(b.conScore)
|
|
_, ok := classInfo(DnDClass(b.class))
|
|
var expectedMax int
|
|
if !ok {
|
|
expectedMax = 1 // computeMaxHP returns 1 for unknown class.
|
|
} else {
|
|
expectedMax = computeMaxHP(DnDClass(b.class), conMod, b.level)
|
|
}
|
|
// Bootstrap skips rows where newMax <= oldMax (never lowers HP).
|
|
if expectedMax <= b.hpMax {
|
|
if a.hpMax != b.hpMax {
|
|
t.Errorf("%s: expected hp_max unchanged (%d), got %d", uid, b.hpMax, a.hpMax)
|
|
}
|
|
continue
|
|
}
|
|
delta := expectedMax - b.hpMax
|
|
expectedCurrent := b.hpCurrent + delta
|
|
if expectedCurrent > expectedMax {
|
|
expectedCurrent = expectedMax
|
|
}
|
|
if expectedCurrent < 1 {
|
|
expectedCurrent = 1
|
|
}
|
|
if a.hpMax != expectedMax {
|
|
t.Errorf("%s: hp_max want %d got %d", uid, expectedMax, a.hpMax)
|
|
}
|
|
if a.hpCurrent != expectedCurrent {
|
|
t.Errorf("%s: hp_current want %d (was %d, +delta %d) got %d",
|
|
uid, expectedCurrent, b.hpCurrent, delta, a.hpCurrent)
|
|
}
|
|
// Wound-preservation invariant: absolute wound (max-current) stays
|
|
// constant unless clamped at floor 1 or at the new ceiling.
|
|
preWound := b.hpMax - b.hpCurrent
|
|
postWound := a.hpMax - a.hpCurrent
|
|
if preWound != postWound && expectedCurrent != 1 && expectedCurrent != expectedMax {
|
|
t.Errorf("%s: wound size changed pre=%d post=%d (no clamp expected)",
|
|
uid, preWound, postWound)
|
|
}
|
|
refreshed++
|
|
t.Logf("[refreshed] %s: hp_max %d→%d (+%d), hp_current %d→%d",
|
|
uid, b.hpMax, a.hpMax, delta, b.hpCurrent, a.hpCurrent)
|
|
}
|
|
t.Logf("[post-bootstrap] %d/%d characters refreshed", refreshed, len(before))
|
|
|
|
// Idempotency: second call is a no-op.
|
|
bootstrapPhase5BHPRefresh()
|
|
after2 := snapshot()
|
|
for uid, a := range after {
|
|
if after2[uid].hpMax != a.hpMax || after2[uid].hpCurrent != a.hpCurrent {
|
|
t.Errorf("%s: second bootstrap call mutated HP", uid)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Scenario 2: Magic-item plumbing ────────────────────────────────────────
|
|
//
|
|
// Expected:
|
|
// - magic_item_equipped table exists (Phase 5 migration).
|
|
// - magicItemRegistry is non-empty; rarity index covers every rarity.
|
|
// - Slot classifier output (baked into magic_items_srd_data.go via gen)
|
|
// puts known edge-case items in the right slot per the UX S4 fix.
|
|
// - dailyCuriosStock() returns a non-empty rotating shelf.
|
|
func TestScenario_MagicItemPlumbing(t *testing.T) {
|
|
requireScenarioEnv(t)
|
|
setupAuditTestDB(t)
|
|
|
|
// (a) Migration created the table.
|
|
var n int
|
|
err := db.Get().QueryRow(`
|
|
SELECT COUNT(*) FROM sqlite_master
|
|
WHERE type='table' AND name='magic_item_equipped'`).Scan(&n)
|
|
if err != nil || n != 1 {
|
|
t.Fatalf("magic_item_equipped table missing (n=%d err=%v)", n, err)
|
|
}
|
|
|
|
// (b) Schema sanity — required columns.
|
|
cols, err := db.Get().Query(`PRAGMA table_info(magic_item_equipped)`)
|
|
if err != nil {
|
|
t.Fatalf("pragma: %v", err)
|
|
}
|
|
defer cols.Close()
|
|
have := map[string]bool{}
|
|
for cols.Next() {
|
|
var cid int
|
|
var name, ctype string
|
|
var notnull, pk int
|
|
var dflt any
|
|
_ = cols.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk)
|
|
have[name] = true
|
|
}
|
|
for _, want := range []string{"user_id", "item_id", "slot"} {
|
|
if !have[want] {
|
|
t.Errorf("magic_item_equipped missing column %q (have: %v)", want, have)
|
|
}
|
|
}
|
|
|
|
// (c) Registry populated and rarity index covers every rarity.
|
|
if len(magicItemRegistry) == 0 {
|
|
t.Fatalf("magicItemRegistry is empty")
|
|
}
|
|
t.Logf("magicItemRegistry: %d items", len(magicItemRegistry))
|
|
byRarity := magicItemsByRarity()
|
|
for _, r := range []DnDRarity{RarityCommon, RarityUncommon, RarityRare, RarityVeryRare, RarityLegendary} {
|
|
if len(byRarity[r]) == 0 {
|
|
t.Errorf("rarity %q has no items in index", r)
|
|
} else {
|
|
t.Logf(" rarity %s: %d items", r, len(byRarity[r]))
|
|
}
|
|
}
|
|
|
|
// (d) Slot baking — known edge-case items from UX S4 B4 should land
|
|
// in the slot the fix intended. Slots are baked into the generated
|
|
// data file by the importer's classifier; the lookup is a fixed table.
|
|
type slotCheck struct {
|
|
id string
|
|
wantSlot DnDSlot
|
|
mustNotBeRingForSubstring string // sanity vs word-boundary regressions
|
|
}
|
|
checks := []slotCheck{
|
|
{"ring_of_protection", DnDSlotRing1, ""},
|
|
{"boots_of_striding_and_springing", DnDSlotFeet, "springing"},
|
|
{"gloves_of_missile_snaring", DnDSlotHands, "snaring"},
|
|
{"bag_of_devouring", "", "devouring"}, // wondrous w/ no carryable noun
|
|
{"cloak_of_displacement", DnDSlotCloak, ""},
|
|
}
|
|
for _, c := range checks {
|
|
item, ok := magicItemRegistry[c.id]
|
|
if !ok {
|
|
t.Errorf("registry missing %q", c.id)
|
|
continue
|
|
}
|
|
if c.wantSlot != "" && item.Slot != c.wantSlot {
|
|
t.Errorf("%s: slot=%q want %q", c.id, item.Slot, c.wantSlot)
|
|
}
|
|
if item.Slot == DnDSlotRing1 || item.Slot == DnDSlotRing2 {
|
|
if c.mustNotBeRingForSubstring != "" {
|
|
t.Errorf("%s: misclassified as ring (substring trap %q)",
|
|
c.id, c.mustNotBeRingForSubstring)
|
|
}
|
|
}
|
|
t.Logf(" %s → kind=%s slot=%q rarity=%s attune=%v",
|
|
c.id, item.Kind, item.Slot, item.Rarity, item.Attunement)
|
|
}
|
|
|
|
// (e) Daily curios shelf rotates and returns a non-empty list.
|
|
shelf := dailyCuriosStock()
|
|
if len(shelf) == 0 {
|
|
t.Errorf("dailyCuriosStock returned empty")
|
|
} else {
|
|
t.Logf("dailyCuriosStock: %d items (first: %s @ %d, rarity %s)",
|
|
len(shelf), shelf[0].Name, shelf[0].Value, shelf[0].Rarity)
|
|
}
|
|
}
|
|
|
|
// ── Scenario 3: Expedition autopilot plumbing ──────────────────────────────
|
|
//
|
|
// Expected:
|
|
// - dnd_expedition.last_ambient_at column exists (Phase 3 migration).
|
|
// - autopilotFooter renders non-empty paused-state copy for pause
|
|
// reasons; renders empty for terminal/already-narrated reasons.
|
|
// - Ambient event pool has positive weights and non-empty flavor pools.
|
|
func TestScenario_ExpeditionAutopilotPlumbing(t *testing.T) {
|
|
requireScenarioEnv(t)
|
|
setupAuditTestDB(t)
|
|
|
|
// (a) Migration column present.
|
|
cols, err := db.Get().Query(`PRAGMA table_info(dnd_expedition)`)
|
|
if err != nil {
|
|
t.Fatalf("pragma: %v", err)
|
|
}
|
|
defer cols.Close()
|
|
have := map[string]bool{}
|
|
for cols.Next() {
|
|
var cid int
|
|
var name, ctype string
|
|
var notnull, pk int
|
|
var dflt any
|
|
_ = cols.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk)
|
|
have[name] = true
|
|
}
|
|
if !have["last_ambient_at"] {
|
|
t.Errorf("dnd_expedition.last_ambient_at missing — Phase 3 migration didn't run")
|
|
}
|
|
|
|
// (b) Stop-reason footers — pause reasons render copy, terminal
|
|
// reasons render empty (death narration / completion block / etc.
|
|
// is the final).
|
|
type footerCheck struct {
|
|
reason stopReason
|
|
wantText bool
|
|
}
|
|
for _, c := range []footerCheck{
|
|
{stopFork, true},
|
|
{stopElite, true},
|
|
{stopBoss, true},
|
|
{stopHarvestCombat, true},
|
|
{stopOK, true}, // hit room cap → "stretch complete"
|
|
{stopEnded, false},
|
|
{stopComplete, false},
|
|
{stopBlocked, false},
|
|
{stopBossSafety, false}, // res.final carries the held-back line
|
|
} {
|
|
got := autopilotFooter(c.reason, 3)
|
|
if c.wantText && got == "" {
|
|
t.Errorf("stop reason %v: expected non-empty footer, got empty", c.reason)
|
|
}
|
|
if !c.wantText && got != "" {
|
|
t.Errorf("stop reason %v: expected empty footer, got %q", c.reason, got)
|
|
}
|
|
t.Logf(" %v (3 rooms) → %q", c.reason, got)
|
|
}
|
|
|
|
// (c) Ambient event pool — every event has a positive weight and a
|
|
// non-empty flavor pool. Build a temporary Expedition to satisfy
|
|
// pickAmbientEvent's eligibility predicates without persisting.
|
|
events := ambientEvents()
|
|
if len(events) == 0 {
|
|
t.Fatalf("ambientEvents() empty")
|
|
}
|
|
t.Logf("ambient pool: %d events", len(events))
|
|
for _, ev := range events {
|
|
if ev.Weight <= 0 {
|
|
t.Errorf("ambient event %q has non-positive weight %d", ev.Kind, ev.Weight)
|
|
}
|
|
if len(ev.Pool) == 0 {
|
|
t.Errorf("ambient event %q has empty pool", ev.Kind)
|
|
} else {
|
|
t.Logf(" %-22s weight=%d pool=%d sample=%q",
|
|
ev.Kind, ev.Weight, len(ev.Pool), ev.Pool[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Scenario 4: Spell/help jargon regression ───────────────────────────────
|
|
//
|
|
// Expected: live merged spell registry has no banned-phrase leaks across
|
|
// Description. Mirrors TestSpellDescriptionsAreJargonFree at runtime so
|
|
// it shows up in this pass.
|
|
func TestScenario_SpellJargonRegression(t *testing.T) {
|
|
requireScenarioEnv(t)
|
|
setupAuditTestDB(t)
|
|
|
|
// Mirror dnd_spells_prose_test's TestSpellDescriptionsAreJargonFree:
|
|
// substring matches for jargon, regex for the SRD-importer placeholder
|
|
// signature ("Whatever " + lowercase, distinct from legit contractions).
|
|
bannedSubstrings := []string{
|
|
"saving throw", "Saving Throw",
|
|
"spell slot of", "Spell Slot of",
|
|
"ability modifier", "Ability Modifier",
|
|
"hit points equal to",
|
|
}
|
|
bannedRegexes := []*regexp.Regexp{
|
|
regexp.MustCompile(`\bd(4|6|8|10|12|20|100)\b`),
|
|
regexp.MustCompile(`\b\d+d\d+\b`),
|
|
regexp.MustCompile(`\bWhatever [a-z]`), // placeholder signature
|
|
regexp.MustCompile(`(?:\.\.\.|…)\s*$`), // trailing ellipsis
|
|
regexp.MustCompile(`\b[a-z]{1,3}(?:\.\.\.|…)\s*$`), // truncated word
|
|
regexp.MustCompile(`(?i)\bno larger than in any\b`),
|
|
}
|
|
totalSpells := 0
|
|
leaks := 0
|
|
for id, s := range dndSpellRegistry {
|
|
totalSpells++
|
|
desc := s.Description
|
|
if desc == "" {
|
|
continue
|
|
}
|
|
for _, b := range bannedSubstrings {
|
|
if strings.Contains(desc, b) {
|
|
t.Errorf("spell %q leaks banned phrase %q: %q", id, b, desc)
|
|
leaks++
|
|
}
|
|
}
|
|
for _, re := range bannedRegexes {
|
|
if re.MatchString(desc) {
|
|
t.Errorf("spell %q matches banned pattern %v: %q", id, re, desc)
|
|
leaks++
|
|
}
|
|
}
|
|
}
|
|
t.Logf("scanned %d spells; %d jargon leaks", totalSpells, leaks)
|
|
}
|