Lift caster trailers: concentration re-tick + Josie caster-aid bootstraps

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).
This commit is contained in:
prosolis
2026-06-18 06:34:16 -07:00
parent f4a39b46e9
commit cbfca525f5
11 changed files with 733 additions and 6 deletions

View File

@@ -224,6 +224,12 @@ func (p *AdventurePlugin) Init() error {
// existing caster rows once at startup so the lift reaches live // existing caster rows once at startup so the lift reaches live
// players without waiting for level-up. // players without waiting for level-up.
bootstrapCasterHPRefresh() 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 // 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 // over-archived: it treats any active dnd_zone_run row not linked to
// an active expedition as a legacy `!adventure dungeon` orphan, which // an active expedition as a legacy `!adventure dungeon` orphan, which

View File

@@ -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)
}

View File

@@ -489,6 +489,14 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
PlayerHeal: out.PlayerHeal, PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip, 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}) events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})

View File

@@ -332,6 +332,14 @@ type combatState struct {
// Phase 10 SUB2b — Abjuration Arcane Ward HP buffer. // Phase 10 SUB2b — Abjuration Arcane Ward HP buffer.
arcaneWardHP int 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 // Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is
// armed by applyAbility and read by the shared resolution primitives, so // armed by applyAbility and read by the shared resolution primitives, so
// both engines honour them; the turn-based engine additionally round-trips // both engines honour them; the turn-based engine additionally round-trips

View File

@@ -239,6 +239,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
case "spirit_weapon_strike": case "spirit_weapon_strike":
return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage) return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage)
case "concentration_tick":
return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage)
case "pet_deflect": case "pet_deflect":
return pickRand(narrativePetDeflect) 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.", "✨ 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{ var narrativePetDeflect = []string{
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.", "🐾 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.", "🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",

View File

@@ -77,6 +77,15 @@ type CombatStatuses struct {
ArcaneWardHP int `json:"arcane_ward_hp,omitempty"` ArcaneWardHP int `json:"arcane_ward_hp,omitempty"`
HealChargesLeft int `json:"heal_charges_left,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. // Once-per-fight class/race/subclass one-shots: the "already used" flags.
// Without persistence these reset every round on resume, letting a Halfling // 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. // reroll a nat 1 or an Orc rage every single round of a turn-based fight.

View File

@@ -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) { func TestTurnEngine_Flee(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 50, 50) sess := turnSession(CombatPhasePlayerTurn, 50, 50)
player, enemy := basePlayer(), baseEnemy() player, enemy := basePlayer(), baseEnemy()

View File

@@ -64,6 +64,12 @@ type turnActionEffect struct {
EnemyDamage int EnemyDamage int
PlayerHeal int PlayerHeal int
EnemySkip bool // control spell: enemy forfeits its attack this round 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 // 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, autoCrit: sess.Statuses.AutoCritFirst,
arcaneWardHP: sess.Statuses.ArcaneWardHP, arcaneWardHP: sess.Statuses.ArcaneWardHP,
healChargesLeft: sess.Statuses.HealChargesLeft, healChargesLeft: sess.Statuses.HealChargesLeft,
concentrationDmg: sess.Statuses.ConcentrationDmg,
deathSaveUsed: sess.Statuses.DeathSaveUsed, deathSaveUsed: sess.Statuses.DeathSaveUsed,
luckyUsed: sess.Statuses.LuckyUsed, luckyUsed: sess.Statuses.LuckyUsed,
raged: sess.Statuses.Raged, raged: sess.Statuses.Raged,
@@ -299,6 +306,12 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain) hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain)
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) 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{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action, Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action,
Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label, 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. // Regenerate (monster ability): the enemy knits its wounds at round end.
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP { if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP {
st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen) st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
@@ -499,6 +526,7 @@ func (te *turnEngine) commit() {
s.AutoCritFirst = st.autoCrit s.AutoCritFirst = st.autoCrit
s.ArcaneWardHP = st.arcaneWardHP s.ArcaneWardHP = st.arcaneWardHP
s.HealChargesLeft = st.healChargesLeft s.HealChargesLeft = st.healChargesLeft
s.ConcentrationDmg = st.concentrationDmg
s.DeathSaveUsed = st.deathSaveUsed s.DeathSaveUsed = st.deathSaveUsed
s.LuckyUsed = st.luckyUsed s.LuckyUsed = st.luckyUsed
s.Raged = st.raged s.Raged = st.raged

View File

@@ -239,19 +239,26 @@ func TestFireBriefings_EventAnchoredActivePlayerDelivers(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
useEventAnchored(t, exp)
wall := time.Now().UTC() wall := time.Now().UTC()
now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.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(), threshold := time.Date(now.Year(), now.Month(), now.Day(),
expeditionBriefingHour, 0, 0, 0, time.UTC) expeditionBriefingHour, 0, 0, 0, time.UTC)
activeActivity := threshold.Add(15 * time.Minute) activeActivity := threshold.Add(15 * time.Minute)
priorBriefing := now.Add(-24 * time.Hour) 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( if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`, `UPDATE dnd_expedition SET start_date = ?, last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`,
activeActivity, priorBriefing, exp.ID); err != nil { startAt, activeActivity, priorBriefing, exp.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
useEventAnchored(t, exp)
p := &AdventurePlugin{} p := &AdventurePlugin{}
p.fireExpeditionBriefings(now) p.fireExpeditionBriefings(now)

View File

@@ -919,7 +919,12 @@ func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSessio
if id := simPickSpiritualWeapon(c, uid, sess); id != "" { if id := simPickSpiritualWeapon(c, uid, sess); id != "" {
return "cast", 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 return "cast", id
} }
} }
@@ -1008,7 +1013,7 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession)
// - Among feasible candidates, prefer higher slot level (preserves // - Among feasible candidates, prefer higher slot level (preserves
// high-slot supremacy and burns the big slots first); tie-break on // high-slot supremacy and burns the big slots first); tie-break on
// expected damage from the dice string. // 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) known, err := listKnownSpells(uid)
if err != nil || len(known) == 0 { if err != nil || len(known) == 0 {
return "" return ""
@@ -1037,6 +1042,11 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
if sp.CastTime == CastReaction { if sp.CastTime == CastReaction {
continue 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 onList := false
for _, cl := range sp.Classes { for _, cl := range sp.Classes {
if cl == c.Class { if cl == c.Class {

View File

@@ -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, &notnull, &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, &notnull, &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)
}