diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index 16a5e38..068e7e4 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -274,6 +274,15 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events, ### 6.1 L4a — Hospital - `adventure_hospital.go`: cost formula `CombatLevel × 25k` → `Level × 50k`. `HospitalVisits` → `player_meta`. Revive flips DnDCharacter HP from 0 → full instead of `Alive=true`. +**Status (2026-05-09):** SHIPPED on `adv-2.0`. +- `player_meta.hospital_visits` column added via columnMigration in `internal/db/db.go`. +- `hospitalCostsForUser(char)` returns `(before, after)` using DnDCharacter.Level × 50k (5× before insurance), with a `dndLevelFromCombatLevel` fallback when no D&D row exists yet. Replaces the inline `CombatLevel × {125k,25k}` math at all three sites (handleHospitalCmd, resolveHospitalPay, sendHospitalAd). +- `loadHospitalVisits(userID)` reads `player_meta.hospital_visits` → falls back to `adventure_characters.hospital_visits` during soak. +- `upsertPlayerMetaHospitalVisits(userID, visits)` dual-writes the post-revive count alongside `saveAdvCharacter`. +- `backfillPlayerMetaHospitalVisits()` runs on every Init; idempotent (only fills zero rows so dual-writes survive re-runs). +- Revive (paid + on-demand + race-after-timer-expired) now also restores `DnDCharacter.HPCurrent = HPMax` via `LoadDnDCharacter` / `SaveDnDCharacter`. `char.Alive=true` still flips during soak — other systems (arena, scheduler) still read `Alive` and migrate later. +- Tests: `TestPlayerMetaHospitalVisitsBackfill_Idempotent`, `TestLoadHospitalVisits_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaHospitalVisits_RoundTrip` (skip without prod DB, matching the L2/L4f-prep pattern). + ### 6.2 L4b — Rival - `adventure_rival.go`: unlock at Level ≥ 3 (was CombatLevel ≥ 5). RivalPool → `player_meta`. Duel combat already uses `combat_engine` — port to `simulateCombat` here too (small extra step). - Move rival flavor into existing `adventure_flavor_rival.go` — no new file. diff --git a/internal/db/db.go b/internal/db/db.go index 1abb583..d145c19 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -190,6 +190,10 @@ func runMigrations(d *sql.DB) error { // player_meta gets the canonical column; AdvCharacter.display_name // keeps dual-writing during soak (gogobee_legacy_migration.md §7). `ALTER TABLE player_meta ADD COLUMN display_name TEXT NOT NULL DEFAULT ''`, + // Adv 2.0 Phase L4a — Hospital migration off AdvCharacter. + // HospitalVisits moves to player_meta; AdvCharacter.hospital_visits + // keeps dual-writing during soak (gogobee_legacy_migration.md §6.1). + `ALTER TABLE player_meta ADD COLUMN hospital_visits INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { @@ -1710,10 +1714,12 @@ CREATE TABLE IF NOT EXISTS dnd_known_recipe ( -- counters. The dual-write rule (§11) applies: writes go to both this -- table and adventure_characters until a phase soaks clean for one week. CREATE TABLE IF NOT EXISTS player_meta ( - user_id TEXT PRIMARY KEY, - arena_wins INTEGER NOT NULL DEFAULT 0, - arena_losses INTEGER NOT NULL DEFAULT 0, - invasion_score INTEGER NOT NULL DEFAULT 0 + user_id TEXT PRIMARY KEY, + arena_wins INTEGER NOT NULL DEFAULT 0, + arena_losses INTEGER NOT NULL DEFAULT 0, + invasion_score INTEGER NOT NULL DEFAULT 0, + display_name TEXT NOT NULL DEFAULT '', + hospital_visits INTEGER NOT NULL DEFAULT 0 ); ` diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 6ce3abf..f1a7791 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -199,6 +199,11 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaDisplayName(); err != nil { slog.Error("player_meta: display_name backfill failed", "err", err) } + // Adv 2.0 Phase L4a — one-shot HospitalVisits backfill into + // player_meta. Idempotent (only fills zero rows). + if err := backfillPlayerMetaHospitalVisits(); err != nil { + slog.Error("player_meta: hospital_visits backfill failed", "err", err) + } // Phase L3 — cancel any open/active legacy coop dungeon runs and // refund member contributions + unsettled bets. Idempotent. closeAndRefundLegacyCoopRuns(p.euro) diff --git a/internal/plugin/adventure_hospital.go b/internal/plugin/adventure_hospital.go index 609b282..b830ea0 100644 --- a/internal/plugin/adventure_hospital.go +++ b/internal/plugin/adventure_hospital.go @@ -16,6 +16,24 @@ type advPendingHospitalPay struct { Cost int64 // after-insurance amount (recomputed at confirm time for TOCTOU safety) } +// hospitalCostsForUser returns (beforeInsurance, afterInsurance) for the +// hospital revival bill. Phase L4a switches the formula off CombatLevel +// onto DnDCharacter.Level: `Level × 50_000` after insurance, 5× that +// before. Falls back to the legacy combat-level mapping when no D&D +// character row exists yet (createDnDCharacterFromAdv hasn't run). +func hospitalCostsForUser(char *AdventureCharacter) (int64, int64) { + level := 0 + if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil { + level = dnd.Level + } + if level <= 0 { + level = dndLevelFromCombatLevel(char.CombatLevel) + } + after := int64(level) * 50_000 + before := after * 5 + return before, after +} + // ── Hospital Command ─────────────────────────────────────────────────────── func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error { @@ -38,12 +56,17 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error { slog.Error("hospital: on-demand revive failed", "user", char.UserID, "err", err) return p.SendDM(ctx.Sender, "Something went wrong at the hospital. Try again in a moment.") } + if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil { + dnd.HPCurrent = dnd.HPMax + if err := SaveDnDCharacter(dnd); err != nil { + slog.Error("hospital: failed to restore DnDCharacter HP on natural revive", "user", char.UserID, "err", err) + } + } return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived) } - // Compute costs - beforeInsurance := int64(char.CombatLevel) * 125_000 - afterInsurance := int64(char.CombatLevel) * 25_000 + // Compute costs (Phase L4a: DnD level × 50k after insurance, 5× before) + beforeInsurance, afterInsurance := hospitalCostsForUser(char) // Check balance balance := p.euro.GetBalance(ctx.Sender) @@ -67,8 +90,12 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error { // Build the full multi-act DM var sb strings.Builder + // Phase L4a: HospitalVisits sourced from player_meta (with AdvCharacter + // fallback during soak). + visits, _ := loadHospitalVisits(char.UserID) + // Act 1 — Admission - if char.HospitalVisits == 0 { + if visits == 0 { sb.WriteString(nurseJoyFirstVisit) } @@ -77,7 +104,7 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error { sb.WriteString("\n\n") // Act 2 — The Bill - sb.WriteString(generateItemizedBill(beforeInsurance, afterInsurance, char.HospitalVisits, isHol)) + sb.WriteString(generateItemizedBill(beforeInsurance, afterInsurance, visits, isHol)) sb.WriteString("\n\n") delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery") @@ -139,11 +166,15 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad char.Alive = true char.DeadUntil = nil _ = saveAdvCharacter(char) + if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil { + dnd.HPCurrent = dnd.HPMax + _ = SaveDnDCharacter(dnd) + } return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived) } - // Recompute cost from current combat level (don't trust cached value) - cost := int64(char.CombatLevel) * 25_000 + // Recompute cost from current DnD level (don't trust cached value) + _, cost := hospitalCostsForUser(char) // Check balance balance := p.euro.GetBalance(ctx.Sender) @@ -171,7 +202,10 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad trackTaxPaid(ctx.Sender, potCut) } - // Revive + // Revive. Phase L4a: restore DnDCharacter HP to full (canonical + // post-L5 path) and keep flipping AdvCharacter.Alive during soak + // since other systems still read it. HospitalVisits dual-writes + // to player_meta + AdvCharacter (§11). char.Alive = true char.DeadUntil = nil char.HospitalVisits++ @@ -181,6 +215,15 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad p.euro.Credit(ctx.Sender, float64(cost), "hospital_revival_refund") return p.SendDM(ctx.Sender, "Something went wrong during recovery. Your payment has been refunded.") } + if err := upsertPlayerMetaHospitalVisits(char.UserID, char.HospitalVisits); err != nil { + slog.Error("hospital: failed to dual-write hospital_visits to player_meta", "user", char.UserID, "err", err) + } + if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil { + dnd.HPCurrent = dnd.HPMax + if err := SaveDnDCharacter(dnd); err != nil { + slog.Error("hospital: failed to restore DnDCharacter HP", "user", char.UserID, "err", err) + } + } // Discharge DM p.SendDM(ctx.Sender, fmt.Sprintf( @@ -220,8 +263,7 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad // ── Hospital Ad (sent after death) ───────────────────────────────────────── func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharacter) { - beforeInsurance := int64(char.CombatLevel) * 125_000 - afterInsurance := int64(char.CombatLevel) * 25_000 + beforeInsurance, afterInsurance := hospitalCostsForUser(char) respawnTime := "unknown" if char.DeadUntil != nil { respawnTime = char.DeadUntil.Format("15:04") diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index 21154fd..e4bdc44 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -17,11 +17,12 @@ import ( // fields a phase has migrated are meaningful; unmigrated fields stay // zero-valued and are sourced from AdvCharacter. type PlayerMeta struct { - UserID id.UserID - ArenaWins int - ArenaLosses int - InvasionScore int - DisplayName string + UserID id.UserID + ArenaWins int + ArenaLosses int + InvasionScore int + DisplayName string + HospitalVisits int } // loadPlayerMeta reads the player_meta row for a user. Returns a @@ -31,9 +32,9 @@ type PlayerMeta struct { func loadPlayerMeta(userID id.UserID) (*PlayerMeta, error) { m := &PlayerMeta{UserID: userID} err := db.Get().QueryRow( - `SELECT arena_wins, arena_losses, invasion_score, display_name FROM player_meta WHERE user_id = ?`, + `SELECT arena_wins, arena_losses, invasion_score, display_name, hospital_visits FROM player_meta WHERE user_id = ?`, string(userID), - ).Scan(&m.ArenaWins, &m.ArenaLosses, &m.InvasionScore, &m.DisplayName) + ).Scan(&m.ArenaWins, &m.ArenaLosses, &m.InvasionScore, &m.DisplayName, &m.HospitalVisits) if err == sql.ErrNoRows { return m, nil } @@ -121,6 +122,88 @@ func loadDisplayName(userID id.UserID) (string, error) { return name, err } +// upsertPlayerMetaHospitalVisits writes hospital_visits for a user, leaving +// other columns untouched. Used by the dual-write path during the L4a +// Hospital migration: every increment on AdvCharacter.HospitalVisits also +// calls this so player_meta.hospital_visits stays in sync until soak +// completes (gogobee_legacy_migration.md §6.1, §11). +func upsertPlayerMetaHospitalVisits(userID id.UserID, visits int) error { + _, err := db.Get().Exec( + `INSERT INTO player_meta (user_id, hospital_visits) VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET hospital_visits = excluded.hospital_visits`, + string(userID), visits, + ) + return err +} + +// loadHospitalVisits returns the player's hospital visit count from +// player_meta when present, falling back to adventure_characters during +// the L4a soak window. Zero if the user has no row in either table. +func loadHospitalVisits(userID id.UserID) (int, error) { + var visits int + err := db.Get().QueryRow( + `SELECT hospital_visits FROM player_meta WHERE user_id = ?`, + string(userID), + ).Scan(&visits) + if err == nil && visits > 0 { + return visits, nil + } + if err != nil && err != sql.ErrNoRows { + return 0, err + } + // player_meta row missing or zero → fall back to AdvCharacter for the + // soak window. A zero value in player_meta could legitimately mean "no + // visits yet", but the fallback only returns non-zero if the legacy + // column has a higher value, so this is safe either way. + var legacy int + err = db.Get().QueryRow( + `SELECT hospital_visits FROM adventure_characters WHERE user_id = ?`, + string(userID), + ).Scan(&legacy) + if err == sql.ErrNoRows { + return visits, nil + } + if err != nil { + return 0, err + } + if legacy > visits { + return legacy, nil + } + return visits, nil +} + +// backfillPlayerMetaHospitalVisits copies adventure_characters.hospital_visits +// into player_meta.hospital_visits for any row whose value is still the +// default zero. Idempotent: only updates rows that haven't been populated +// yet (via backfill or dual-write). +func backfillPlayerMetaHospitalVisits() error { + if _, err := db.Get().Exec(` + INSERT OR IGNORE INTO player_meta (user_id) + SELECT user_id FROM adventure_characters + `); err != nil { + return err + } + res, err := db.Get().Exec(` + UPDATE player_meta + SET hospital_visits = ( + SELECT hospital_visits FROM adventure_characters + WHERE adventure_characters.user_id = player_meta.user_id + ) + WHERE hospital_visits = 0 + AND EXISTS ( + SELECT 1 FROM adventure_characters + WHERE adventure_characters.user_id = player_meta.user_id + AND adventure_characters.hospital_visits > 0 + ) + `) + if err != nil { + return err + } + n, _ := res.RowsAffected() + slog.Info("player_meta: hospital_visits backfilled", "rows", n) + return nil +} + // backfillPlayerMetaDisplayName copies adventure_characters.display_name // into player_meta.display_name for any row whose display_name is still // the empty default. Idempotent: safe to re-run; only updates rows that diff --git a/internal/plugin/player_meta_test.go b/internal/plugin/player_meta_test.go index ea2ba42..5ac9ccd 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -187,6 +187,116 @@ func TestLoadDisplayName_FallsBackToAdvCharacter(t *testing.T) { } } +// Phase L4a — Hospital migration. Backfill is idempotent and only touches +// zero-valued rows; the upsert helper round-trips; loadHospitalVisits falls +// back to AdvCharacter when player_meta hasn't been populated yet. + +func TestPlayerMetaHospitalVisitsBackfill_Idempotent(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-hv-bf:example") + if err := createAdvCharacter(uid, "HVisitor"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters SET hospital_visits = ? WHERE user_id = ?`, + 4, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET hospital_visits = 0 WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + if err := backfillPlayerMetaHospitalVisits(); err != nil { + t.Fatalf("backfill 1: %v", err) + } + got, err := loadHospitalVisits(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != 4 { + t.Errorf("after backfill: got %d want 4", got) + } + + // Layer a dual-write increment. Backfill re-run must NOT clobber it + // back (only touches rows whose hospital_visits is still zero). + if err := upsertPlayerMetaHospitalVisits(uid, 5); err != nil { + t.Fatalf("dual-write: %v", err) + } + if err := backfillPlayerMetaHospitalVisits(); err != nil { + t.Fatalf("backfill 2: %v", err) + } + got2, _ := loadHospitalVisits(uid) + if got2 != 5 { + t.Errorf("backfill clobbered dual-write: got %d want 5", got2) + } +} + +func TestLoadHospitalVisits_FallsBackToAdvCharacter(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-hv-fb:example") + if err := createAdvCharacter(uid, "Fallbacker"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters SET hospital_visits = ? WHERE user_id = ?`, + 3, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET hospital_visits = 0 WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + got, err := loadHospitalVisits(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != 3 { + t.Errorf("fallback: got %d want 3", got) + } + + // Unknown user → 0, no error. + got2, err := loadHospitalVisits(id.UserID("@meta-hv-nobody:example")) + if err != nil { + t.Fatalf("load missing: %v", err) + } + if got2 != 0 { + t.Errorf("missing user: got %d want 0", got2) + } +} + +func TestUpsertPlayerMetaHospitalVisits_RoundTrip(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-hv-rt:example") + if err := upsertPlayerMetaHospitalVisits(uid, 2); err != nil { + t.Fatalf("upsert insert: %v", err) + } + got, err := loadHospitalVisits(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != 2 { + t.Errorf("round-trip: got %d want 2", got) + } + if err := upsertPlayerMetaHospitalVisits(uid, 7); err != nil { + t.Fatalf("upsert update: %v", err) + } + got2, _ := loadHospitalVisits(uid) + if got2 != 7 { + t.Errorf("update: got %d want 7", got2) + } +} + func TestCreateAdvCharacter_DualWritesDisplayName(t *testing.T) { setupAuditTestDB(t)