mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user