mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 L4a: hospital migrates HospitalVisits + cost formula off AdvCharacter
player_meta.hospital_visits column added with idempotent backfill. Hospital cost now DnDCharacter.Level x 50k (5x before insurance), falling back to dndLevelFromCombatLevel when no D&D row exists. Revive paths also restore DnDCharacter HP to full; char.Alive still dual-writes during soak. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -274,6 +274,15 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events,
|
|||||||
### 6.1 L4a — Hospital
|
### 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`.
|
- `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
|
### 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).
|
- `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.
|
- Move rival flavor into existing `adventure_flavor_rival.go` — no new file.
|
||||||
|
|||||||
@@ -190,6 +190,10 @@ func runMigrations(d *sql.DB) error {
|
|||||||
// player_meta gets the canonical column; AdvCharacter.display_name
|
// player_meta gets the canonical column; AdvCharacter.display_name
|
||||||
// keeps dual-writing during soak (gogobee_legacy_migration.md §7).
|
// keeps dual-writing during soak (gogobee_legacy_migration.md §7).
|
||||||
`ALTER TABLE player_meta ADD COLUMN display_name TEXT NOT NULL DEFAULT ''`,
|
`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 {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
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
|
-- counters. The dual-write rule (§11) applies: writes go to both this
|
||||||
-- table and adventure_characters until a phase soaks clean for one week.
|
-- table and adventure_characters until a phase soaks clean for one week.
|
||||||
CREATE TABLE IF NOT EXISTS player_meta (
|
CREATE TABLE IF NOT EXISTS player_meta (
|
||||||
user_id TEXT PRIMARY KEY,
|
user_id TEXT PRIMARY KEY,
|
||||||
arena_wins INTEGER NOT NULL DEFAULT 0,
|
arena_wins INTEGER NOT NULL DEFAULT 0,
|
||||||
arena_losses INTEGER NOT NULL DEFAULT 0,
|
arena_losses INTEGER NOT NULL DEFAULT 0,
|
||||||
invasion_score 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
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,11 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
if err := backfillPlayerMetaDisplayName(); err != nil {
|
if err := backfillPlayerMetaDisplayName(); err != nil {
|
||||||
slog.Error("player_meta: display_name backfill failed", "err", err)
|
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
|
// Phase L3 — cancel any open/active legacy coop dungeon runs and
|
||||||
// refund member contributions + unsettled bets. Idempotent.
|
// refund member contributions + unsettled bets. Idempotent.
|
||||||
closeAndRefundLegacyCoopRuns(p.euro)
|
closeAndRefundLegacyCoopRuns(p.euro)
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ type advPendingHospitalPay struct {
|
|||||||
Cost int64 // after-insurance amount (recomputed at confirm time for TOCTOU safety)
|
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 ───────────────────────────────────────────────────────
|
// ── Hospital Command ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
|
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)
|
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.")
|
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)
|
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute costs
|
// Compute costs (Phase L4a: DnD level × 50k after insurance, 5× before)
|
||||||
beforeInsurance := int64(char.CombatLevel) * 125_000
|
beforeInsurance, afterInsurance := hospitalCostsForUser(char)
|
||||||
afterInsurance := int64(char.CombatLevel) * 25_000
|
|
||||||
|
|
||||||
// Check balance
|
// Check balance
|
||||||
balance := p.euro.GetBalance(ctx.Sender)
|
balance := p.euro.GetBalance(ctx.Sender)
|
||||||
@@ -67,8 +90,12 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
|
|||||||
// Build the full multi-act DM
|
// Build the full multi-act DM
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Phase L4a: HospitalVisits sourced from player_meta (with AdvCharacter
|
||||||
|
// fallback during soak).
|
||||||
|
visits, _ := loadHospitalVisits(char.UserID)
|
||||||
|
|
||||||
// Act 1 — Admission
|
// Act 1 — Admission
|
||||||
if char.HospitalVisits == 0 {
|
if visits == 0 {
|
||||||
sb.WriteString(nurseJoyFirstVisit)
|
sb.WriteString(nurseJoyFirstVisit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +104,7 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
|
|||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
// Act 2 — The Bill
|
// Act 2 — The Bill
|
||||||
sb.WriteString(generateItemizedBill(beforeInsurance, afterInsurance, char.HospitalVisits, isHol))
|
sb.WriteString(generateItemizedBill(beforeInsurance, afterInsurance, visits, isHol))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery")
|
delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery")
|
||||||
@@ -139,11 +166,15 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
|
|||||||
char.Alive = true
|
char.Alive = true
|
||||||
char.DeadUntil = nil
|
char.DeadUntil = nil
|
||||||
_ = saveAdvCharacter(char)
|
_ = saveAdvCharacter(char)
|
||||||
|
if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil {
|
||||||
|
dnd.HPCurrent = dnd.HPMax
|
||||||
|
_ = SaveDnDCharacter(dnd)
|
||||||
|
}
|
||||||
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
|
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recompute cost from current combat level (don't trust cached value)
|
// Recompute cost from current DnD level (don't trust cached value)
|
||||||
cost := int64(char.CombatLevel) * 25_000
|
_, cost := hospitalCostsForUser(char)
|
||||||
|
|
||||||
// Check balance
|
// Check balance
|
||||||
balance := p.euro.GetBalance(ctx.Sender)
|
balance := p.euro.GetBalance(ctx.Sender)
|
||||||
@@ -171,7 +202,10 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
|
|||||||
trackTaxPaid(ctx.Sender, potCut)
|
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.Alive = true
|
||||||
char.DeadUntil = nil
|
char.DeadUntil = nil
|
||||||
char.HospitalVisits++
|
char.HospitalVisits++
|
||||||
@@ -181,6 +215,15 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
|
|||||||
p.euro.Credit(ctx.Sender, float64(cost), "hospital_revival_refund")
|
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.")
|
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
|
// Discharge DM
|
||||||
p.SendDM(ctx.Sender, fmt.Sprintf(
|
p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
@@ -220,8 +263,7 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
|
|||||||
// ── Hospital Ad (sent after death) ─────────────────────────────────────────
|
// ── Hospital Ad (sent after death) ─────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharacter) {
|
func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharacter) {
|
||||||
beforeInsurance := int64(char.CombatLevel) * 125_000
|
beforeInsurance, afterInsurance := hospitalCostsForUser(char)
|
||||||
afterInsurance := int64(char.CombatLevel) * 25_000
|
|
||||||
respawnTime := "unknown"
|
respawnTime := "unknown"
|
||||||
if char.DeadUntil != nil {
|
if char.DeadUntil != nil {
|
||||||
respawnTime = char.DeadUntil.Format("15:04")
|
respawnTime = char.DeadUntil.Format("15:04")
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ import (
|
|||||||
// fields a phase has migrated are meaningful; unmigrated fields stay
|
// fields a phase has migrated are meaningful; unmigrated fields stay
|
||||||
// zero-valued and are sourced from AdvCharacter.
|
// zero-valued and are sourced from AdvCharacter.
|
||||||
type PlayerMeta struct {
|
type PlayerMeta struct {
|
||||||
UserID id.UserID
|
UserID id.UserID
|
||||||
ArenaWins int
|
ArenaWins int
|
||||||
ArenaLosses int
|
ArenaLosses int
|
||||||
InvasionScore int
|
InvasionScore int
|
||||||
DisplayName string
|
DisplayName string
|
||||||
|
HospitalVisits int
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadPlayerMeta reads the player_meta row for a user. Returns a
|
// 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) {
|
func loadPlayerMeta(userID id.UserID) (*PlayerMeta, error) {
|
||||||
m := &PlayerMeta{UserID: userID}
|
m := &PlayerMeta{UserID: userID}
|
||||||
err := db.Get().QueryRow(
|
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),
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -121,6 +122,88 @@ func loadDisplayName(userID id.UserID) (string, error) {
|
|||||||
return name, err
|
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
|
// backfillPlayerMetaDisplayName copies adventure_characters.display_name
|
||||||
// into player_meta.display_name for any row whose display_name is still
|
// 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
|
// the empty default. Idempotent: safe to re-run; only updates rows that
|
||||||
|
|||||||
@@ -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) {
|
func TestCreateAdvCharacter_DualWritesDisplayName(t *testing.T) {
|
||||||
setupAuditTestDB(t)
|
setupAuditTestDB(t)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user