From cbfca525f598786bb9489a2a3377dc694505bbe2 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 18 Jun 2026 06:34:16 -0700 Subject: [PATCH] Lift caster trailers: concentration re-tick + Josie caster-aid bootstraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/plugin/adventure.go | 6 + internal/plugin/bootstrap_josie_caster_aid.go | 144 ++++++ internal/plugin/combat_cmd.go | 8 + internal/plugin/combat_engine.go | 8 + internal/plugin/combat_narrative.go | 10 + internal/plugin/combat_session.go | 9 + internal/plugin/combat_session_test.go | 68 +++ internal/plugin/combat_turn_engine.go | 28 ++ internal/plugin/dnd_expedition_cycle_test.go | 15 +- internal/plugin/expedition_sim.go | 14 +- internal/plugin/scenario_proddb_test.go | 429 ++++++++++++++++++ 11 files changed, 733 insertions(+), 6 deletions(-) create mode 100644 internal/plugin/bootstrap_josie_caster_aid.go create mode 100644 internal/plugin/scenario_proddb_test.go diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index ec4d1de..3e8cda3 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -224,6 +224,12 @@ func (p *AdventurePlugin) Init() error { // existing caster rows once at startup so the lift reaches live // players without waiting for level-up. bootstrapCasterHPRefresh() + // 2026-06-18 caster-aid: backfill default spells that postdate a + // character's roll (ensureSpellsForCharacter only seeds an empty book), + // and a one-off pet gift for an endgame player who never got the morning + // arrival roll. Both idempotent via JobCompleted gates. + bootstrapCasterSpellBackfill() + bootstrapGrantStarterPet() // Phase R1 orphan-archive used to run here on every Init, but it // over-archived: it treats any active dnd_zone_run row not linked to // an active expedition as a legacy `!adventure dungeon` orphan, which diff --git a/internal/plugin/bootstrap_josie_caster_aid.go b/internal/plugin/bootstrap_josie_caster_aid.go new file mode 100644 index 0000000..3537ecd --- /dev/null +++ b/internal/plugin/bootstrap_josie_caster_aid.go @@ -0,0 +1,144 @@ +package plugin + +import ( + "log/slog" + "time" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// Two one-shot startup bootstraps that lift a low-DPS caster who was stuck in a +// boss-wall "death loop". Diagnosis: the boss isn't overtuned — the player is an +// over-levelled cleric whose damage output is structurally low. Two account +// gaps, fixed idempotently here so they reach the live player without a respec. +// +// 1. bootstrapCasterSpellBackfill — characters created before a default spell +// was added to defaultKnownSpells keep their original spellbook forever: +// ensureSpellsForCharacter only seeds when the known-spell list is EMPTY, so +// later default additions never reach existing casters. This backfills any +// missing default into known+prepared (e.g. inflict_wounds, added to the +// cleric defaults after the affected character was rolled). General + future +// proof — it fixes any caster with the same stale-default gap. +// +// 2. bootstrapGrantStarterPet — a targeted gift of a combat companion to a +// specific endgame player who never received the 25% morning pet-arrival +// roll. A pet adds sustained per-round damage + deflect mitigation, which +// helps caster trailers most. + +// bootstrapCasterSpellBackfill adds any missing defaultKnownSpells entry to +// every existing caster as known+prepared. addKnownSpell is idempotent and +// leaves the prepared flag of already-known spells untouched (ON CONFLICT only +// refreshes source), so this only ever adds the genuinely-missing defaults. +func bootstrapCasterSpellBackfill() { + const jobName = "caster_default_spell_backfill_v1" + if db.JobCompleted(jobName, "once") { + return + } + + chars, err := loadAllAdvCharacters() + if err != nil { + slog.Error("bootstrap: caster spell backfill — load characters failed", "err", err) + return + } + + added := 0 + for _, ac := range chars { + c, err := LoadDnDCharacter(ac.UserID) + if err != nil || c == nil || !isSpellcaster(c) { + continue + } + known, err := listKnownSpells(c.UserID) + if err != nil { + slog.Warn("bootstrap: caster spell backfill — list known failed", "user", c.UserID, "err", err) + continue + } + have := make(map[string]bool, len(known)) + for _, k := range known { + have[k.SpellID] = true + } + defaults := defaultKnownSpells(c.Class, c.Level) + if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster { + defaults = arcaneTricksterDefaultSpells(c.Level) + } + for _, sid := range defaults { + if have[sid] { + continue + } + if err := addKnownSpell(c.UserID, sid, "class", true); err != nil { + slog.Error("bootstrap: caster spell backfill — add failed", "user", c.UserID, "spell", sid, "err", err) + continue + } + slog.Info("bootstrap: backfilled default spell", "user", c.UserID, "spell", sid) + added++ + } + } + + db.MarkJobCompleted(jobName, "once") + if added > 0 { + slog.Warn("bootstrap: caster default-spell backfill complete", "spells_added", added) + } +} + +// josieStarterPet identifies the one player the pet gift targets and the pet +// it grants. Kept as data so the intent is legible: this is an admin gift, not +// a game-wide policy. +var josieStarterPet = struct { + userID id.UserID + typ string + name string + level int +}{ + userID: "@holymachina:parodia.dev", + typ: "dog", + name: "Biscuit", + level: 10, +} + +// bootstrapGrantStarterPet gives the targeted player a combat companion if they +// have none. No-op once they have a pet (this gift, a later arrival, or one +// chased away — we don't override the player's own pet history). Idempotent via +// the job gate AND the has-pet guard. +func bootstrapGrantStarterPet() { + const jobName = "grant_starter_pet_holymachina_v1" + if db.JobCompleted(jobName, "once") { + return + } + g := josieStarterPet + + char, err := loadAdvCharacter(g.userID) + if err != nil || char == nil { + // Target not present in this DB (e.g. fresh deploy) — mark done so we + // don't re-scan every startup; the gift is a one-off, not a standing rule. + db.MarkJobCompleted(jobName, "once") + return + } + if char.PetType != "" || char.PetArrived { + slog.Info("bootstrap: starter pet — target already has a pet, skipping", "user", g.userID) + db.MarkJobCompleted(jobName, "once") + return + } + + char.PetType = g.typ + char.PetName = g.name + char.PetLevel = g.level + char.PetXP = 0 + char.PetArrived = true + char.PetChasedAway = false + if g.level >= 10 { + // Mirror the babysit path that stamps the L10 date when a pet first + // reaches the cap, so milestone/supply-shop logic stays consistent. + char.PetLevel10Date = time.Now().UTC().Format("2006-01-02") + } + if err := saveAdvCharacter(char); err != nil { + slog.Error("bootstrap: starter pet — save failed", "user", g.userID, "err", err) + return + } + if err := upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char)); err != nil { + slog.Error("bootstrap: starter pet — player_meta mirror failed", "user", g.userID, "err", err) + return + } + + db.MarkJobCompleted(jobName, "once") + slog.Warn("bootstrap: granted starter pet", "user", g.userID, "pet", g.name, "level", g.level) +} diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index 9186e72..e3da704 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -489,6 +489,14 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e PlayerHeal: out.PlayerHeal, EnemySkip: out.EnemySkip, } + // Concentration AOE damage spells linger: the burst lands this round + // (EnemyDamage) and the same value re-ticks every round_end after, via + // the engine's concentration aura. spiritual_weapon already covers the + // cleric's bonus-action half of the combo; this restores the action half. + if spell.Concentration && + (spell.Effect == EffectDamageAuto || spell.Effect == EffectDamageSave) { + eff.ConcentrationDmg = out.EnemyDamage + } } events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff}) diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index de23a9f..09ed397 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -332,6 +332,14 @@ type combatState struct { // Phase 10 SUB2b — Abjuration Arcane Ward HP buffer. arcaneWardHP int + // concentrationDmg — per-round damage of an active concentration AOE + // (Spirit Guardians et al.). Armed by a !cast of a concentration damage + // spell, ticked against the enemy every round_end until the fight ends + // or another concentration spell overwrites it. Only the turn engine + // reads it; SimulateCombat resolves whole fights in one pass and folds + // the aura's value into the picker's concentration multiplier instead. + concentrationDmg int + // Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is // armed by applyAbility and read by the shared resolution primitives, so // both engines honour them; the turn-based engine additionally round-trips diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 09509cb..1454f56 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -239,6 +239,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul case "spirit_weapon_strike": return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage) + case "concentration_tick": + return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage) + case "pet_deflect": return pickRand(narrativePetDeflect) @@ -536,6 +539,13 @@ var narrativeSpiritWeapon = []string{ "✨ The spectral blade flickers, then bites. %d damage. It does not tire. It does not blink.", } +var narrativeConcentrationTick = []string{ + "🌀 The lingering aura grinds the enemy down — %d damage. Still humming. Still hungry.", + "🌀 Your spell hasn't let go: the spirits sweep through again for %d damage.", + "🌀 The radiant field pulses once more — %d damage. Concentration holds.", + "🌀 The enemy steps wrong and the standing magic answers, %d damage. It does not move on.", +} + var narrativePetDeflect = []string{ "🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.", "🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.", diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 47fe6ba..9967ace 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -77,6 +77,15 @@ type CombatStatuses struct { ArcaneWardHP int `json:"arcane_ward_hp,omitempty"` HealChargesLeft int `json:"heal_charges_left,omitempty"` + // ConcentrationDmg is the per-round damage of the player's active + // concentration AOE (Spirit Guardians, Spike Growth, Call Lightning, + // Flaming Sphere…). A one-shot !cast lands its burst the casting round, + // then this re-ticks the aura at round_end every round until the fight + // ends or another concentration spell overwrites it — the lingering half + // of the spell the engine used to drop on the floor, which left clerics + // and druids with no sustained DPS once their burst landed. + ConcentrationDmg int `json:"concentration_dmg,omitempty"` + // Once-per-fight class/race/subclass one-shots: the "already used" flags. // Without persistence these reset every round on resume, letting a Halfling // reroll a nat 1 or an Orc rage every single round of a turn-based fight. diff --git a/internal/plugin/combat_session_test.go b/internal/plugin/combat_session_test.go index 3b1e34c..6f4b293 100644 --- a/internal/plugin/combat_session_test.go +++ b/internal/plugin/combat_session_test.go @@ -263,6 +263,74 @@ func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) { } } +func TestTurnEngine_ConcentrationTickAtRoundEnd(t *testing.T) { + sess := turnSession(CombatPhaseRoundEnd, 50, 80) + sess.Statuses = CombatStatuses{ConcentrationDmg: 12} + player, enemy := basePlayer(), baseEnemy() + + events, err := stepEngine(sess, &player, &enemy, PlayerAction{}) + if err != nil { + t.Fatal(err) + } + if sess.EnemyHP != 68 { + t.Errorf("enemy_hp = %d, want 68 (80 - 12 aura)", sess.EnemyHP) + } + if sess.Statuses.ConcentrationDmg != 12 { + t.Errorf("concentration_dmg = %d, want 12 (aura persists)", sess.Statuses.ConcentrationDmg) + } + if len(events) != 1 || events[0].Action != "concentration_tick" || events[0].Damage != 12 { + t.Errorf("expected one concentration_tick event, got %+v", events) + } + if sess.Round != 2 || sess.Phase != CombatPhasePlayerTurn { + t.Errorf("round=%d phase=%q, want 2/player_turn", sess.Round, sess.Phase) + } +} + +func TestTurnEngine_ConcentrationTickCanBeLethal(t *testing.T) { + sess := turnSession(CombatPhaseRoundEnd, 50, 9) + sess.Statuses = CombatStatuses{ConcentrationDmg: 12} + player, enemy := basePlayer(), baseEnemy() + + if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil { + t.Fatal(err) + } + if sess.Status != CombatStatusWon || sess.Phase != CombatPhaseOver { + t.Errorf("status=%q phase=%q, want won/over (aura dropped enemy)", sess.Status, sess.Phase) + } + if sess.EnemyHP != 0 { + t.Errorf("enemy_hp = %d, want 0", sess.EnemyHP) + } +} + +// A concentration damage cast lands its burst this round AND arms the aura so +// it re-ticks at every subsequent round_end. +func TestTurnEngine_ConcentrationCastArmsAura(t *testing.T) { + sess := turnSession(CombatPhasePlayerTurn, 50, 200) + player, enemy := basePlayer(), baseEnemy() + + eff := &turnActionEffect{ + Label: "Spirit Guardians", Action: "spell_cast", + EnemyDamage: 15, ConcentrationDmg: 15, + } + if _, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff}); err != nil { + t.Fatal(err) + } + if sess.EnemyHP != 185 { + t.Errorf("enemy_hp = %d, want 185 (200 - 15 burst)", sess.EnemyHP) + } + if sess.Statuses.ConcentrationDmg != 15 { + t.Fatalf("concentration_dmg = %d, want 15 (aura armed)", sess.Statuses.ConcentrationDmg) + } + // Drive to round_end and confirm the aura bites again. + sess.Phase = CombatPhaseRoundEnd + if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil { + t.Fatal(err) + } + if sess.EnemyHP != 170 { + t.Errorf("enemy_hp = %d, want 170 (185 - 15 aura tick)", sess.EnemyHP) + } +} + func TestTurnEngine_Flee(t *testing.T) { sess := turnSession(CombatPhasePlayerTurn, 50, 50) player, enemy := basePlayer(), baseEnemy() diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 91f82ff..448202c 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -64,6 +64,12 @@ type turnActionEffect struct { EnemyDamage int PlayerHeal int EnemySkip bool // control spell: enemy forfeits its attack this round + // ConcentrationDmg arms a per-round aura tick when a concentration damage + // spell is cast: EnemyDamage is the burst that lands this round, this is + // what re-ticks at every round_end after. Zero for one-shot spells; a + // non-zero value overwrites any aura already running (5e: one + // concentration at a time). + ConcentrationDmg int } // turnEngine wraps a combatState reconstructed from a persisted CombatSession @@ -125,6 +131,7 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R autoCrit: sess.Statuses.AutoCritFirst, arcaneWardHP: sess.Statuses.ArcaneWardHP, healChargesLeft: sess.Statuses.HealChargesLeft, + concentrationDmg: sess.Statuses.ConcentrationDmg, deathSaveUsed: sess.Statuses.DeathSaveUsed, luckyUsed: sess.Statuses.LuckyUsed, raged: sess.Statuses.Raged, @@ -299,6 +306,12 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain) st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) } + // Arm / replace the concentration aura. A new concentration cast overwrites + // the old one (5e: one concentration at a time); non-concentration casts + // leave any running aura alone. + if eff.ConcentrationDmg > 0 { + st.concentrationDmg = eff.ConcentrationDmg + } st.events = append(st.events, CombatEvent{ Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action, Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label, @@ -454,6 +467,20 @@ func (te *turnEngine) stepRoundEnd() { } } } + // Concentration aura (Spirit Guardians et al.): the lingering spell bites + // the enemy each round it stays up. Ticks before enemy regen so a lethal + // pulse settles the fight before the enemy knits its wounds back. + if st.concentrationDmg > 0 && st.enemyHP > 0 { + st.enemyHP = max(0, st.enemyHP-st.concentrationDmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick", + Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if st.enemyHP <= 0 { + te.finish(CombatStatusWon) + return + } + } // Regenerate (monster ability): the enemy knits its wounds at round end. if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP { st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen) @@ -499,6 +526,7 @@ func (te *turnEngine) commit() { s.AutoCritFirst = st.autoCrit s.ArcaneWardHP = st.arcaneWardHP s.HealChargesLeft = st.healChargesLeft + s.ConcentrationDmg = st.concentrationDmg s.DeathSaveUsed = st.deathSaveUsed s.LuckyUsed = st.luckyUsed s.Raged = st.raged diff --git a/internal/plugin/dnd_expedition_cycle_test.go b/internal/plugin/dnd_expedition_cycle_test.go index e597e79..b2bd358 100644 --- a/internal/plugin/dnd_expedition_cycle_test.go +++ b/internal/plugin/dnd_expedition_cycle_test.go @@ -239,19 +239,26 @@ func TestFireBriefings_EventAnchoredActivePlayerDelivers(t *testing.T) { if err != nil { t.Fatal(err) } - useEventAnchored(t, exp) - wall := time.Now().UTC() now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.UTC) threshold := time.Date(now.Year(), now.Month(), now.Day(), expeditionBriefingHour, 0, 0, 0, time.UTC) activeActivity := threshold.Add(15 * time.Minute) priorBriefing := now.Add(-24 * time.Hour) + // Pin start_date before today's threshold. Left at the default (real + // time.Now()), a suite run after 06:00 UTC lands start_date past the + // threshold and loadExpeditionsNeedingBriefing (start_date < threshold) + // filters the row out — a wall-clock-of-day flake. useEventAnchored runs + // after, so its cutoff tracks the backdated start and the run stays + // event-anchored. + startAt := now.Add(-24 * time.Hour) + exp.StartDate = startAt if _, err := db.Get().Exec( - `UPDATE dnd_expedition SET last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`, - activeActivity, priorBriefing, exp.ID); err != nil { + `UPDATE dnd_expedition SET start_date = ?, last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`, + startAt, activeActivity, priorBriefing, exp.ID); err != nil { t.Fatal(err) } + useEventAnchored(t, exp) p := &AdventurePlugin{} p.fireExpeditionBriefings(now) diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 505a2b7..04a4c4b 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -919,7 +919,12 @@ func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSessio if id := simPickSpiritualWeapon(c, uid, sess); id != "" { return "cast", id } - if id := simPickSpell(c, uid); id != "" { + // Once a concentration aura is up, a competent caster maintains it and + // attacks (or casts a non-concentration spell) rather than burning a + // slot to re-arm the same aura — so the picker excludes concentration + // spells while one is active. + auraActive := sess.Statuses.ConcentrationDmg > 0 + if id := simPickSpell(c, uid, auraActive); id != "" { return "cast", id } } @@ -1008,7 +1013,7 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession) // - Among feasible candidates, prefer higher slot level (preserves // high-slot supremacy and burns the big slots first); tie-break on // expected damage from the dice string. -func simPickSpell(c *DnDCharacter, uid id.UserID) string { +func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string { known, err := listKnownSpells(uid) if err != nil || len(known) == 0 { return "" @@ -1037,6 +1042,11 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string { if sp.CastTime == CastReaction { continue } + // An aura is already ticking — don't re-arm it; prefer attacks or a + // non-concentration spell this turn. + if auraActive && sp.Concentration { + continue + } onList := false for _, cl := range sp.Classes { if cl == c.Class { diff --git a/internal/plugin/scenario_proddb_test.go b/internal/plugin/scenario_proddb_test.go new file mode 100644 index 0000000..024584d --- /dev/null +++ b/internal/plugin/scenario_proddb_test.go @@ -0,0 +1,429 @@ +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) +}