Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2

Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 08:21:44 -07:00
parent 8e0fe0230c
commit 9e1a1f606c
80 changed files with 19088 additions and 210 deletions

View File

@@ -44,6 +44,10 @@ func Init(dataDir string) error {
return fmt.Errorf("run migrations: %w", err)
}
if err := snapshotPreDnD(d); err != nil {
slog.Error("db: pre-D&D snapshot failed (non-fatal)", "err", err)
}
globalDB = d
dataPath = dataDir
slog.Info("database initialized", "path", dbPath)
@@ -144,6 +148,31 @@ func runMigrations(d *sql.DB) error {
`ALTER TABLE adventure_characters ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN death_source TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN death_location TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN auto_babysit_focus TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN treasures_locked INTEGER NOT NULL DEFAULT 0`,
// D&D layer (Phase 1) — additive columns on existing equipment/inventory tables
`ALTER TABLE adventure_equipment ADD COLUMN dnd_rarity TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_equipment ADD COLUMN dnd_stat_bonus_json TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_equipment ADD COLUMN dnd_attuned INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_inventory ADD COLUMN dnd_rarity TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_inventory ADD COLUMN dnd_stat_bonus_json TEXT NOT NULL DEFAULT ''`,
// Phase 2 cleanup: auto-migrated chars (created on first combat for
// players who never ran !setup). !setup can freely overwrite these.
`ALTER TABLE dnd_character ADD COLUMN auto_migrated INTEGER NOT NULL DEFAULT 0`,
// Phase 6 rest mechanics
`ALTER TABLE dnd_character ADD COLUMN last_short_rest_at DATETIME`,
`ALTER TABLE dnd_character ADD COLUMN last_long_rest_at DATETIME`,
// Phase 6 active abilities: armed for next combat
`ALTER TABLE dnd_character ADD COLUMN armed_ability TEXT NOT NULL DEFAULT ''`,
// Audit fix F: prevent onboarding DM from re-firing after !setup cancel
`ALTER TABLE dnd_character ADD COLUMN onboarding_sent INTEGER NOT NULL DEFAULT 0`,
// Phase 9 — spell system. pending_cast holds a JSON blob describing
// the queued spell (id, slot level, target, rolled effect args) that
// fires on the next one-shot combat. concentration_* track the
// currently-active concentration spell for buff persistence.
`ALTER TABLE dnd_character ADD COLUMN pending_cast TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE dnd_character ADD COLUMN concentration_spell TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE dnd_character ADD COLUMN concentration_expires_at DATETIME`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -156,6 +185,29 @@ func runMigrations(d *sql.DB) error {
return nil
}
// snapshotPreDnD copies the current adventure_characters rows into
// adventure_characters_pre_dnd as a one-shot rollback safety net for the
// D&D-layer migration. Idempotent: only inserts rows for user_ids that
// aren't already snapshotted, so running on a fresh install (no characters)
// or on subsequent boots is a no-op.
func snapshotPreDnD(d *sql.DB) error {
_, err := d.Exec(`
INSERT OR IGNORE INTO adventure_characters_pre_dnd (user_id, snapshot_json)
SELECT user_id,
json_object(
'combat_level', combat_level,
'combat_xp', combat_xp,
'mining_skill', mining_skill, 'mining_xp', mining_xp,
'foraging_skill', foraging_skill, 'foraging_xp', foraging_xp,
'fishing_skill', fishing_skill, 'fishing_xp', fishing_xp,
'arena_wins', arena_wins, 'arena_losses', arena_losses,
'current_streak', current_streak, 'best_streak', best_streak
)
FROM adventure_characters
`)
return err
}
// JobCompleted checks if a scheduled job has already completed for the given date key.
// Use date "2006-01-02" for daily jobs, or "2006-W01" style for weekly jobs.
func JobCompleted(jobName, dateKey string) bool {
@@ -1457,6 +1509,92 @@ CREATE TABLE IF NOT EXISTS coop_dungeon_gifts (
stack_lead_id INTEGER -- NULL for lead/standalone; follower points to lead's id
);
CREATE INDEX IF NOT EXISTS idx_coop_gifts_run_day ON coop_dungeon_gifts(run_id, day, vote_result);
-- ── D&D Layer (Phase 1) ────────────────────────────────────────────────────
-- Per-player D&D character state. Sits alongside adventure_characters; players
-- without a row here continue using the legacy adventure system unchanged.
-- pending_setup=1 means a draft (race/class/stats may be partial).
-- pending_setup=0 means !setup confirmed; D&D-gated commands available.
CREATE TABLE IF NOT EXISTS dnd_character (
user_id TEXT PRIMARY KEY,
race TEXT NOT NULL DEFAULT '',
class TEXT NOT NULL DEFAULT '',
dnd_level INTEGER NOT NULL DEFAULT 1,
dnd_xp INTEGER NOT NULL DEFAULT 0,
str_score INTEGER NOT NULL DEFAULT 8,
dex_score INTEGER NOT NULL DEFAULT 8,
con_score INTEGER NOT NULL DEFAULT 8,
int_score INTEGER NOT NULL DEFAULT 8,
wis_score INTEGER NOT NULL DEFAULT 8,
cha_score INTEGER NOT NULL DEFAULT 8,
hp_current INTEGER NOT NULL DEFAULT 0,
hp_max INTEGER NOT NULL DEFAULT 0,
temp_hp INTEGER NOT NULL DEFAULT 0,
armor_class INTEGER NOT NULL DEFAULT 10,
pending_setup INTEGER NOT NULL DEFAULT 1,
last_respec_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS dnd_abilities (
user_id TEXT NOT NULL,
ability_id TEXT NOT NULL,
uses_left INTEGER NOT NULL DEFAULT 0,
acquired_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, ability_id)
);
CREATE TABLE IF NOT EXISTS dnd_resources (
user_id TEXT NOT NULL,
resource_type TEXT NOT NULL, -- stamina|focus|mana|divine_favor|spell_slot_1
current_value INTEGER NOT NULL DEFAULT 0,
max_value INTEGER NOT NULL DEFAULT 0,
last_reset_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, resource_type)
);
CREATE TABLE IF NOT EXISTS dnd_combat_state (
user_id TEXT PRIMARY KEY,
enemy_id TEXT NOT NULL DEFAULT '',
round INTEGER NOT NULL DEFAULT 0,
player_turn INTEGER NOT NULL DEFAULT 1,
conditions_json TEXT NOT NULL DEFAULT '[]',
log_json TEXT NOT NULL DEFAULT '[]',
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- ── D&D Layer (Phase 9 — spells) ──────────────────────────────────────────
-- Per-player known spell list. For Cleric, prepared=0 means the spell is on
-- the class list but not currently prepared (cast unavailable until prepared).
-- For Mage/Ranger, prepared is always 1 (spells known are always castable).
CREATE TABLE IF NOT EXISTS dnd_known_spells (
user_id TEXT NOT NULL,
spell_id TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '', -- class | subclass | racial
prepared INTEGER NOT NULL DEFAULT 1,
learned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, spell_id)
);
-- Per-player spell slot pool, one row per (user, slot_level). slot_level is
-- 1..5 (no cantrip row — cantrips have no slot cost). Refreshed on long rest.
CREATE TABLE IF NOT EXISTS dnd_spell_slots (
user_id TEXT NOT NULL,
slot_level INTEGER NOT NULL,
total INTEGER NOT NULL DEFAULT 0,
used INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, slot_level)
);
-- One-shot snapshot of adventure_characters taken at first deploy of the D&D layer.
-- Rollback safety net: if anything in adventure_characters diverges unexpectedly,
-- compare to this snapshot to detect data corruption.
CREATE TABLE IF NOT EXISTS adventure_characters_pre_dnd (
user_id TEXT PRIMARY KEY,
snapshot_json TEXT NOT NULL,
snapshotted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.

View File

@@ -0,0 +1,189 @@
package db
import (
"database/sql"
"io"
"os"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
// TestProdDBMigrations runs the full migration suite against a copy of the
// real prod database to verify Phase 1 + Phase 2 schema changes apply cleanly.
//
// Gated on the prod-db file actually existing — skips silently in CI.
// Mutations write only to the tempdir copy; the real prod file is untouched.
func TestProdDBMigrations(t *testing.T) {
src := "/home/reala-misaki/git/gogobee/data/gogobee.db"
if _, err := os.Stat(src); err != nil {
t.Skip("prod db not present at " + src)
}
dir := t.TempDir()
dst := filepath.Join(dir, "gogobee.db")
copyFile(t, src, dst)
// Snapshot row counts BEFORE migration via a raw connection.
preCounts := tableCounts(t, dst, []string{
"adventure_characters",
"euro_balances",
"user_archetypes",
"adventure_equipment",
"adventure_inventory",
"adventure_treasures",
})
t.Logf("pre-migration counts: %+v", preCounts)
// Reset global state (other tests may have run Init already).
Close()
// Run migrations via the production code path.
if err := Init(dir); err != nil {
t.Fatalf("Init failed: %v", err)
}
t.Cleanup(Close)
d := Get()
// 1. New D&D tables must exist.
for _, table := range []string{
"dnd_character", "dnd_abilities", "dnd_resources",
"dnd_combat_state", "adventure_characters_pre_dnd",
} {
var n int
if err := d.QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`,
table,
).Scan(&n); err != nil {
t.Fatalf("query %s: %v", table, err)
}
if n != 1 {
t.Errorf("table %s missing after migrations", table)
}
}
// 2. Existing-data row counts unchanged.
postCounts := tableCounts(t, dst, []string{
"adventure_characters",
"euro_balances",
"user_archetypes",
"adventure_equipment",
"adventure_inventory",
"adventure_treasures",
})
for k, pre := range preCounts {
if post := postCounts[k]; post != pre {
t.Errorf("row count drift in %s: pre=%d post=%d", k, pre, post)
}
}
t.Logf("post-migration counts: %+v", postCounts)
// 3. Pre-DnD snapshot populated for every adventure character.
var advCount, snapCount int
d.QueryRow(`SELECT COUNT(*) FROM adventure_characters`).Scan(&advCount)
d.QueryRow(`SELECT COUNT(*) FROM adventure_characters_pre_dnd`).Scan(&snapCount)
if snapCount != advCount {
t.Errorf("snapshot rows %d != adventure_characters %d", snapCount, advCount)
}
t.Logf("snapshot populated: %d rows", snapCount)
// 4. Snapshot JSON parses and contains key fields. SQLite's single-conn
// pool means we must drain the rows before issuing follow-up queries.
rows, err := d.Query(`SELECT user_id, snapshot_json FROM adventure_characters_pre_dnd`)
if err != nil {
t.Fatalf("query snapshot: %v", err)
}
type snapPair struct{ uid, j string }
var snaps []snapPair
for rows.Next() {
var uid, j string
if err := rows.Scan(&uid, &j); err != nil {
rows.Close()
t.Fatal(err)
}
snaps = append(snaps, snapPair{uid, j})
}
rows.Close()
for _, s := range snaps {
var checkCL int
if err := d.QueryRow(`SELECT json_extract(?, '$.combat_level')`, s.j).Scan(&checkCL); err != nil {
t.Errorf("snapshot for %s: bad json: %v", s.uid, err)
continue
}
var realCL int
d.QueryRow(`SELECT combat_level FROM adventure_characters WHERE user_id=?`, s.uid).Scan(&realCL)
if checkCL != realCL {
t.Errorf("snapshot %s: combat_level mismatch snap=%d real=%d", s.uid, checkCL, realCL)
}
}
// 5. New columns on adventure_equipment / adventure_inventory exist and default empty.
rows2, err := d.Query(`SELECT dnd_rarity, dnd_stat_bonus_json, dnd_attuned FROM adventure_equipment LIMIT 5`)
if err != nil {
t.Errorf("new equipment columns query failed: %v", err)
} else {
rows2.Close()
}
rows3, err := d.Query(`SELECT dnd_rarity, dnd_stat_bonus_json FROM adventure_inventory LIMIT 5`)
if err != nil {
t.Errorf("new inventory columns query failed: %v", err)
} else {
rows3.Close()
}
// 6. auto_migrated column on dnd_character exists.
if _, err := d.Exec(`SELECT auto_migrated FROM dnd_character LIMIT 0`); err != nil {
t.Errorf("auto_migrated column missing: %v", err)
}
// 7. Idempotent: running Init again must not error or change row counts.
Close()
if err := Init(dir); err != nil {
t.Fatalf("second Init failed: %v", err)
}
postCounts2 := tableCounts(t, dst, []string{"adventure_characters_pre_dnd"})
if postCounts2["adventure_characters_pre_dnd"] != snapCount {
t.Errorf("snapshot row count drifted on second Init: %d → %d",
snapCount, postCounts2["adventure_characters_pre_dnd"])
}
}
func copyFile(t *testing.T, src, dst string) {
t.Helper()
in, err := os.Open(src)
if err != nil {
t.Fatal(err)
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
t.Fatal(err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
t.Fatal(err)
}
}
func tableCounts(t *testing.T, dbPath string, tables []string) map[string]int {
t.Helper()
d, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
defer d.Close()
out := map[string]int{}
for _, tbl := range tables {
var n int
err := d.QueryRow("SELECT COUNT(*) FROM " + tbl).Scan(&n)
if err != nil {
out[tbl] = -1
continue
}
out[tbl] = n
}
return out
}