mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 L4d: pets dual-write pet_* off AdvCharacter to player_meta
Eight pet_* columns added to player_meta (flags packed into pet_flags_json). PetState + load/upsert/backfill helpers; dual-writes wired at every pet mutation site (arrival, naming, supply-shop unlock, babysit trickle, morning defense, misty reactivation). Backfill idempotent, runs on Init. L4 grep-empty exit criterion intentionally NOT met for adventure_pets.go: helpers still take *AdventureCharacter because external callers (babysit, scheduler, npcs, combat_bridge, combat_stats, dnd_sheet, arena, character) read pet fields directly. Cross-file signature flip belongs after the dual-write soak. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -313,6 +313,16 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events,
|
||||
### 6.4 L4d — Pets
|
||||
- `adventure_pets.go`: pet fields move from AdvCharacter to `player_meta` (see §2.1 `pet_*` columns). Pet combat hooks (PetMorningDefense, pet damage rolls) target DnDCharacter HP.
|
||||
|
||||
**Status (2026-05-09):** SHIPPED on `adv-2.0` (column + dual-write only; reader flip deferred — see note).
|
||||
- `player_meta` gains `pet_type`, `pet_name`, `pet_xp`, `pet_level`, `pet_armor_tier`, `pet_flags_json`, `pet_supply_shop_unlocked`, `pet_level_10_date` via columnMigration in `internal/db/db.go`. The four flags (arrived, chased_away, reactivated, morning_defense) serialize as a single JSON column to avoid a four-bool ALTER train on each future flag addition.
|
||||
- New helpers in `player_meta.go`: `PetState` struct, `upsertPlayerMetaPetState(userID, PetState)`, `loadPetState(userID)` (player_meta canonical, falls back to AdvCharacter when `pet_type` is empty), `backfillPlayerMetaPetState()` (idempotent — copies rows whose player_meta `pet_type` is still default empty), and `petStateFromAdvChar(*AdventureCharacter)` for the dual-write projection.
|
||||
- Dual-writes wired at every pet mutation site: `resolvePetArrival` (chase), `resolvePetName` (adopt), `petMidnightCheck` (supply-shop unlock), `runBabysitDailyTrickle` save in scheduler, morning `PetMorningDefense` set, and `mistyReactivatePet` save in `adventure_npcs.go`.
|
||||
- `petGrantXP` (in `adventure_pets.go`) is currently dead code — only exercised by tests; once the legacy daily-loop XP path is rewired by Phase R/zone integration, that caller will need to add a dual-write at its save site.
|
||||
- Backfill: `backfillPlayerMetaPetState()` runs on every Init; idempotent (only fills rows whose `pet_type` is still empty).
|
||||
- Tests: `TestPlayerMetaPetStateBackfill_Idempotent`, `TestLoadPetState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaPetState_RoundTrip` in `player_meta_test.go`.
|
||||
- `go vet ./...` + `go test ./...` clean.
|
||||
- **Reader flip deferred.** The L4 exit criterion (`grep 'AdventureCharacter\|CombatLevel' adventure_pets.go` empty) is NOT met yet: `adventure_pets.go`'s helpers (`petGrantXP`, `petRollCombatActions`, `petRollDitchRecovery`, `petVictoryText`, `petDeathText`, `petShouldArrive`, `petMorningEvent`, `petCheckSupplyShopUnlock`, `mistyHousingHint`, `mistyReactivatePet`) still take `*AdventureCharacter` because they're called from many external files (`adventure_babysit.go`, `adventure_scheduler.go`, `adventure_npcs.go`, `combat_bridge.go`, `combat_stats.go`, `dnd_sheet.go`, `adventure_arena.go`, `adventure_character.go`). Flipping these signatures is a cross-file refactor that can land after the soak window — at which point the helpers can take `userID` (and call `loadPetState`) and the call sites drop their `*AdventureCharacter` access. The DB-level migration (this step) is the prerequisite that unblocks that refactor.
|
||||
|
||||
### 6.5 L4e — Housing & mortgage
|
||||
- `adventure_housing.go`, `adventure_mortgage.go`: HouseTier/loan fields → `player_meta`. `houseHPBonus` → applied to `DnDCharacter.HPMax` calculation (already a parallel hook in `dnd_sheet.go`'s `recomputeHPMax`).
|
||||
- Mortgage scheduler tick stays — only the field source changes.
|
||||
|
||||
@@ -203,6 +203,19 @@ func runMigrations(d *sql.DB) error {
|
||||
// MasterworkDropsReceived moves to player_meta; AdvCharacter column
|
||||
// keeps dual-writing during soak (gogobee_legacy_migration.md §6.3).
|
||||
`ALTER TABLE player_meta ADD COLUMN masterwork_drops_received INTEGER NOT NULL DEFAULT 0`,
|
||||
// Adv 2.0 Phase L4d — Pets migration off AdvCharacter.
|
||||
// PetType / PetName / PetXP / PetLevel / PetArmorTier and the four
|
||||
// pet flags (arrived, chased_away, reactivated, morning_defense)
|
||||
// move to player_meta; AdvCharacter columns keep dual-writing during
|
||||
// soak (gogobee_legacy_migration.md §6.4).
|
||||
`ALTER TABLE player_meta ADD COLUMN pet_type TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet_name TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet_xp INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet_level INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet_armor_tier INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet_flags_json TEXT NOT NULL DEFAULT '{}'`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet_supply_shop_unlocked INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet_level_10_date TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
|
||||
@@ -214,6 +214,11 @@ func (p *AdventurePlugin) Init() error {
|
||||
if err := backfillPlayerMetaMasterworkDrops(); err != nil {
|
||||
slog.Error("player_meta: masterwork_drops_received backfill failed", "err", err)
|
||||
}
|
||||
// Adv 2.0 Phase L4d — one-shot pet state backfill into player_meta.
|
||||
// Idempotent (only fills rows whose pet_type is still empty).
|
||||
if err := backfillPlayerMetaPetState(); err != nil {
|
||||
slog.Error("player_meta: pet state backfill failed", "err", err)
|
||||
}
|
||||
// Phase L3 — cancel any open/active legacy coop dungeon runs and
|
||||
// refund member contributions + unsettled bets. Idempotent.
|
||||
closeAndRefundLegacyCoopRuns(p.euro)
|
||||
|
||||
@@ -239,6 +239,7 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
|
||||
|
||||
reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))]
|
||||
|
||||
|
||||
@@ -241,6 +241,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
||||
char.PetChasedAway = true
|
||||
char.PetReactivated = false
|
||||
_ = saveAdvCharacter(char)
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
|
||||
return p.SendDM(ctx.Sender, "You chased it away. It disappeared around the corner and didn't come back.\n\nThe house is quiet again.")
|
||||
}
|
||||
|
||||
@@ -345,6 +346,7 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to save.")
|
||||
}
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
|
||||
|
||||
emoji := "🐶"
|
||||
if data.PetType == "cat" {
|
||||
@@ -432,6 +434,7 @@ func (p *AdventurePlugin) petMidnightCheck() {
|
||||
if petCheckSupplyShopUnlock(&char) {
|
||||
char.PetSupplyShopUnlocked = true
|
||||
_ = saveAdvCharacter(&char)
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&char))
|
||||
slog.Info("pet: supply shop unlocked", "user", char.UserID, "pet", char.PetName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
if err := saveAdvCharacter(&char); err != nil {
|
||||
slog.Error("babysit: failed to save after daily trickle", "user", char.UserID, "err", err)
|
||||
}
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&char))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -124,6 +125,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
if petEvent != "" {
|
||||
char.PetMorningDefense = true
|
||||
_ = saveAdvCharacter(&char)
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&char))
|
||||
}
|
||||
|
||||
// Send morning DM with choices
|
||||
|
||||
@@ -2,6 +2,7 @@ package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -383,6 +384,223 @@ func backfillPlayerMetaMasterworkDrops() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PetState is the in-memory mirror of player_meta's pet_* columns. Phase L4d
|
||||
// ports pet ownership and progression off AdvCharacter (gogobee_legacy_migration.md
|
||||
// §6.4). The four flag bools serialize as a single pet_flags_json column to
|
||||
// avoid an explosion of boolean ALTERs when future pet behaviors land.
|
||||
type PetState struct {
|
||||
Type string
|
||||
Name string
|
||||
XP int
|
||||
Level int
|
||||
ArmorTier int
|
||||
SupplyShopUnlocked bool
|
||||
Level10Date string
|
||||
Arrived bool
|
||||
ChasedAway bool
|
||||
Reactivated bool
|
||||
MorningDefense bool
|
||||
}
|
||||
|
||||
// petFlagsJSON is the on-disk encoding of the four pet bools. Adding a new
|
||||
// flag is an additive JSON field — old rows decode as false.
|
||||
type petFlagsJSON struct {
|
||||
Arrived bool `json:"arrived"`
|
||||
ChasedAway bool `json:"chased_away"`
|
||||
Reactivated bool `json:"reactivated"`
|
||||
MorningDefense bool `json:"morning_defense"`
|
||||
}
|
||||
|
||||
// upsertPlayerMetaPetState writes the full pet column set for a user. Pet
|
||||
// state mutates as a unit (arrival sets type/name/level/flags together;
|
||||
// level-ups touch xp/level/level10date), so this helper takes the whole
|
||||
// PetState rather than splitting per-field. Used by the dual-write path
|
||||
// during the L4d soak window.
|
||||
func upsertPlayerMetaPetState(userID id.UserID, s PetState) error {
|
||||
flags, err := json.Marshal(petFlagsJSON{
|
||||
Arrived: s.Arrived,
|
||||
ChasedAway: s.ChasedAway,
|
||||
Reactivated: s.Reactivated,
|
||||
MorningDefense: s.MorningDefense,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
supplyInt := 0
|
||||
if s.SupplyShopUnlocked {
|
||||
supplyInt = 1
|
||||
}
|
||||
_, err = db.Get().Exec(
|
||||
`INSERT INTO player_meta (
|
||||
user_id, pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
|
||||
pet_flags_json, pet_supply_shop_unlocked, pet_level_10_date
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
pet_type = excluded.pet_type,
|
||||
pet_name = excluded.pet_name,
|
||||
pet_xp = excluded.pet_xp,
|
||||
pet_level = excluded.pet_level,
|
||||
pet_armor_tier = excluded.pet_armor_tier,
|
||||
pet_flags_json = excluded.pet_flags_json,
|
||||
pet_supply_shop_unlocked = excluded.pet_supply_shop_unlocked,
|
||||
pet_level_10_date = excluded.pet_level_10_date`,
|
||||
string(userID), s.Type, s.Name, s.XP, s.Level, s.ArmorTier,
|
||||
string(flags), supplyInt, s.Level10Date,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// loadPetState returns the player's pet state from player_meta when populated,
|
||||
// falling back to adventure_characters during the L4d soak window. A
|
||||
// player_meta row whose pet_type is empty is treated as unmigrated and falls
|
||||
// through to AdvCharacter — matching the "type is the canonical
|
||||
// has-a-pet marker" semantics used by HasPet().
|
||||
func loadPetState(userID id.UserID) (PetState, error) {
|
||||
var (
|
||||
s PetState
|
||||
flagsRaw string
|
||||
supplyInt int
|
||||
)
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
|
||||
pet_flags_json, pet_supply_shop_unlocked, pet_level_10_date
|
||||
FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&s.Type, &s.Name, &s.XP, &s.Level, &s.ArmorTier,
|
||||
&flagsRaw, &supplyInt, &s.Level10Date)
|
||||
if err == nil && s.Type != "" {
|
||||
s.SupplyShopUnlocked = supplyInt == 1
|
||||
var f petFlagsJSON
|
||||
if flagsRaw != "" {
|
||||
_ = json.Unmarshal([]byte(flagsRaw), &f)
|
||||
}
|
||||
s.Arrived = f.Arrived
|
||||
s.ChasedAway = f.ChasedAway
|
||||
s.Reactivated = f.Reactivated
|
||||
s.MorningDefense = f.MorningDefense
|
||||
return s, nil
|
||||
}
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return PetState{}, err
|
||||
}
|
||||
// Fallback to AdvCharacter during soak.
|
||||
var (
|
||||
legacyType, legacyName, legacyL10Date string
|
||||
legacyXP, legacyLevel, legacyArmor int
|
||||
legacySupply int
|
||||
arrived, chased, reactivated, morningDefense int
|
||||
)
|
||||
err = db.Get().QueryRow(
|
||||
`SELECT pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
|
||||
pet_arrived, pet_chased_away, pet_reactivated, pet_morning_defense,
|
||||
pet_supply_shop_unlocked, pet_level_10_date
|
||||
FROM adventure_characters WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&legacyType, &legacyName, &legacyXP, &legacyLevel, &legacyArmor,
|
||||
&arrived, &chased, &reactivated, &morningDefense,
|
||||
&legacySupply, &legacyL10Date)
|
||||
if err == sql.ErrNoRows {
|
||||
return PetState{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return PetState{}, err
|
||||
}
|
||||
return PetState{
|
||||
Type: legacyType,
|
||||
Name: legacyName,
|
||||
XP: legacyXP,
|
||||
Level: legacyLevel,
|
||||
ArmorTier: legacyArmor,
|
||||
SupplyShopUnlocked: legacySupply == 1,
|
||||
Level10Date: legacyL10Date,
|
||||
Arrived: arrived == 1,
|
||||
ChasedAway: chased == 1,
|
||||
Reactivated: reactivated == 1,
|
||||
MorningDefense: morningDefense == 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// backfillPlayerMetaPetState copies the pet_* columns from adventure_characters
|
||||
// into player_meta for any row whose pet_type is still the empty default.
|
||||
// Idempotent: only updates rows that haven't been populated yet (via backfill
|
||||
// or dual-write). Pet flags are encoded as JSON to match the runtime helper.
|
||||
func backfillPlayerMetaPetState() error {
|
||||
if _, err := db.Get().Exec(`
|
||||
INSERT OR IGNORE INTO player_meta (user_id)
|
||||
SELECT user_id FROM adventure_characters
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT ac.user_id, ac.pet_type, ac.pet_name, ac.pet_xp, ac.pet_level,
|
||||
ac.pet_armor_tier, ac.pet_arrived, ac.pet_chased_away,
|
||||
ac.pet_reactivated, ac.pet_morning_defense,
|
||||
ac.pet_supply_shop_unlocked, ac.pet_level_10_date
|
||||
FROM adventure_characters ac
|
||||
JOIN player_meta pm ON pm.user_id = ac.user_id
|
||||
WHERE pm.pet_type = ''
|
||||
AND ac.pet_type <> ''
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
type pending struct {
|
||||
uid id.UserID
|
||||
s PetState
|
||||
}
|
||||
var todo []pending
|
||||
for rows.Next() {
|
||||
var (
|
||||
uid string
|
||||
s PetState
|
||||
arrived, chased, reactivated, morningDefense int
|
||||
supply int
|
||||
)
|
||||
if err := rows.Scan(&uid, &s.Type, &s.Name, &s.XP, &s.Level,
|
||||
&s.ArmorTier, &arrived, &chased, &reactivated, &morningDefense,
|
||||
&supply, &s.Level10Date); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Arrived = arrived == 1
|
||||
s.ChasedAway = chased == 1
|
||||
s.Reactivated = reactivated == 1
|
||||
s.MorningDefense = morningDefense == 1
|
||||
s.SupplyShopUnlocked = supply == 1
|
||||
todo = append(todo, pending{uid: id.UserID(uid), s: s})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range todo {
|
||||
if err := upsertPlayerMetaPetState(p.uid, p.s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
slog.Info("player_meta: pet state backfilled", "rows", len(todo))
|
||||
return nil
|
||||
}
|
||||
|
||||
// petStateFromAdvChar projects the pet-related fields off an AdventureCharacter
|
||||
// into a PetState. Used by the dual-write path: every site that mutates
|
||||
// AdvCharacter pet fields then calls upsertPlayerMetaPetState with this
|
||||
// projection so the column moves cleanly during the L4d soak window.
|
||||
func petStateFromAdvChar(c *AdventureCharacter) PetState {
|
||||
return PetState{
|
||||
Type: c.PetType,
|
||||
Name: c.PetName,
|
||||
XP: c.PetXP,
|
||||
Level: c.PetLevel,
|
||||
ArmorTier: c.PetArmorTier,
|
||||
SupplyShopUnlocked: c.PetSupplyShopUnlocked,
|
||||
Level10Date: c.PetLevel10Date,
|
||||
Arrived: c.PetArrived,
|
||||
ChasedAway: c.PetChasedAway,
|
||||
Reactivated: c.PetReactivated,
|
||||
MorningDefense: c.PetMorningDefense,
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -533,3 +533,146 @@ func TestUpsertPlayerMetaMasterworkDrops_RoundTrip(t *testing.T) {
|
||||
t.Errorf("update: got %d want 9", got2)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase L4d — Pet state migration. Backfill is idempotent and only touches
|
||||
// rows with empty pet_type; the upsert helper round-trips the full PetState
|
||||
// (including the four flag bools encoded into pet_flags_json); loadPetState
|
||||
// falls back to AdvCharacter when player_meta hasn't been populated yet.
|
||||
|
||||
func TestPlayerMetaPetStateBackfill_Idempotent(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
|
||||
uid := id.UserID("@meta-pet-bf:example")
|
||||
if err := createAdvCharacter(uid, "PetOwner"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE adventure_characters
|
||||
SET pet_type = ?, pet_name = ?, pet_xp = ?, pet_level = ?,
|
||||
pet_armor_tier = ?, pet_arrived = ?, pet_chased_away = ?,
|
||||
pet_reactivated = ?, pet_morning_defense = ?,
|
||||
pet_supply_shop_unlocked = ?, pet_level_10_date = ?
|
||||
WHERE user_id = ?`,
|
||||
"dog", "Rex", 750, 4, 2, 1, 0, 0, 1, 0, "", string(uid),
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE player_meta SET pet_type = '' WHERE user_id = ?`,
|
||||
string(uid),
|
||||
); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
|
||||
if err := backfillPlayerMetaPetState(); err != nil {
|
||||
t.Fatalf("backfill 1: %v", err)
|
||||
}
|
||||
got, err := loadPetState(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got.Type != "dog" || got.Name != "Rex" || got.XP != 750 || got.Level != 4 ||
|
||||
got.ArmorTier != 2 || !got.Arrived || got.ChasedAway || got.Reactivated ||
|
||||
!got.MorningDefense {
|
||||
t.Errorf("after backfill: got %+v", got)
|
||||
}
|
||||
|
||||
// Layer a dual-write that promotes the pet (level-up + supply shop).
|
||||
got.Level = 10
|
||||
got.XP = 0
|
||||
got.Level10Date = "2026-05-09"
|
||||
got.SupplyShopUnlocked = true
|
||||
if err := upsertPlayerMetaPetState(uid, got); err != nil {
|
||||
t.Fatalf("dual-write: %v", err)
|
||||
}
|
||||
if err := backfillPlayerMetaPetState(); err != nil {
|
||||
t.Fatalf("backfill 2: %v", err)
|
||||
}
|
||||
got2, _ := loadPetState(uid)
|
||||
if got2.Level != 10 || !got2.SupplyShopUnlocked || got2.Level10Date != "2026-05-09" {
|
||||
t.Errorf("backfill clobbered dual-write: got %+v", got2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPetState_FallsBackToAdvCharacter(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
|
||||
uid := id.UserID("@meta-pet-fb:example")
|
||||
if err := createAdvCharacter(uid, "PetFallback"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE adventure_characters
|
||||
SET pet_type = ?, pet_name = ?, pet_xp = ?, pet_level = ?,
|
||||
pet_arrived = ?, pet_chased_away = ?, pet_reactivated = ?
|
||||
WHERE user_id = ?`,
|
||||
"cat", "Whiskers", 50, 2, 1, 1, 1, string(uid),
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE player_meta SET pet_type = '' WHERE user_id = ?`,
|
||||
string(uid),
|
||||
); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
|
||||
got, err := loadPetState(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got.Type != "cat" || got.Name != "Whiskers" || got.Level != 2 ||
|
||||
!got.Arrived || !got.ChasedAway || !got.Reactivated {
|
||||
t.Errorf("fallback: got %+v", got)
|
||||
}
|
||||
|
||||
// Unknown user → zero PetState, no error.
|
||||
got2, err := loadPetState(id.UserID("@meta-pet-nobody:example"))
|
||||
if err != nil {
|
||||
t.Fatalf("load missing: %v", err)
|
||||
}
|
||||
if got2.Type != "" || got2.Level != 0 {
|
||||
t.Errorf("missing user: got %+v", got2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertPlayerMetaPetState_RoundTrip(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
|
||||
uid := id.UserID("@meta-pet-rt:example")
|
||||
in := PetState{
|
||||
Type: "dog",
|
||||
Name: "Boon",
|
||||
XP: 1234,
|
||||
Level: 7,
|
||||
ArmorTier: 3,
|
||||
SupplyShopUnlocked: true,
|
||||
Level10Date: "2026-05-09",
|
||||
Arrived: true,
|
||||
ChasedAway: true,
|
||||
Reactivated: true,
|
||||
MorningDefense: true,
|
||||
}
|
||||
if err := upsertPlayerMetaPetState(uid, in); err != nil {
|
||||
t.Fatalf("upsert insert: %v", err)
|
||||
}
|
||||
got, err := loadPetState(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got != in {
|
||||
t.Errorf("round-trip mismatch:\n got=%+v\nwant=%+v", got, in)
|
||||
}
|
||||
|
||||
// Mutating one field updates only that field via the same upsert.
|
||||
in.Level = 8
|
||||
in.XP = 0
|
||||
in.MorningDefense = false
|
||||
if err := upsertPlayerMetaPetState(uid, in); err != nil {
|
||||
t.Fatalf("upsert update: %v", err)
|
||||
}
|
||||
got2, _ := loadPetState(uid)
|
||||
if got2 != in {
|
||||
t.Errorf("update mismatch:\n got=%+v\nwant=%+v", got2, in)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user