mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 L4c: masterwork migrates MasterworkDropsReceived off AdvCharacter
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -298,7 +298,17 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events,
|
|||||||
- `go vet ./...` + `go test ./...` clean.
|
- `go vet ./...` + `go test ./...` clean.
|
||||||
|
|
||||||
### 6.3 L4c — Masterwork
|
### 6.3 L4c — Masterwork
|
||||||
- `adventure_masterwork.go`: no AdvCharacter mutations today; only equipment reads. Port `advMasterworkSkillBonus` to take `*DnDCharacter` (skill fields added in §2.2). MasterworkDropsReceived → `player_meta`.
|
- `adventure_masterwork.go`: no AdvCharacter mutations today; only equipment reads. ~~Port `advMasterworkSkillBonus` to take `*DnDCharacter` (skill fields added in §2.2).~~ **Correction (2026-05-09):** `advMasterworkSkillBonus` already takes `(equip, activity)` — no char param to port. MasterworkDropsReceived → `player_meta`.
|
||||||
|
|
||||||
|
**Status (2026-05-09):** SHIPPED on `adv-2.0`.
|
||||||
|
- `player_meta.masterwork_drops_received` column added via columnMigration in `internal/db/db.go`.
|
||||||
|
- `checkMasterworkDrop` signature dropped its `char *AdventureCharacter` param. First-drop detection now reads via `loadMasterworkDrops(userID)`; counter writes go through `upsertPlayerMetaMasterworkDrops` and dual-write to `AdvCharacter.MasterworkDropsReceived` via `loadAdvCharacter` + `saveAdvCharacter` during soak.
|
||||||
|
- `loadMasterworkDrops(userID)` reads `player_meta` → falls back to `adventure_characters.masterwork_drops_received` during soak (mirrors `loadHospitalVisits` shape).
|
||||||
|
- `backfillPlayerMetaMasterworkDrops()` runs on every Init; idempotent (only fills rows whose `masterwork_drops_received` is still zero).
|
||||||
|
- Note: `checkMasterworkDrop` itself is currently dead code (Phase R deprecated its only caller, the legacy daily-loop). The migration still ports the field so the column moves cleanly when future zone integration re-wires the drop hook.
|
||||||
|
- Tests: `TestPlayerMetaMasterworkDropsBackfill_Idempotent`, `TestLoadMasterworkDrops_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaMasterworkDrops_RoundTrip`.
|
||||||
|
- `go vet ./...` + `go test ./...` clean.
|
||||||
|
- Exit criterion: `grep 'AdventureCharacter\|CombatLevel' internal/plugin/adventure_masterwork.go` is empty.
|
||||||
|
|
||||||
### 6.4 L4d — Pets
|
### 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.
|
- `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.
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ func runMigrations(d *sql.DB) error {
|
|||||||
// columns keep dual-writing during soak (gogobee_legacy_migration.md §6.2).
|
// columns keep dual-writing during soak (gogobee_legacy_migration.md §6.2).
|
||||||
`ALTER TABLE player_meta ADD COLUMN rival_pool INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE player_meta ADD COLUMN rival_pool INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE player_meta ADD COLUMN rival_unlocked_notified INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE player_meta ADD COLUMN rival_unlocked_notified INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
// Adv 2.0 Phase L4c — Masterwork migration off AdvCharacter.
|
||||||
|
// 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`,
|
||||||
}
|
}
|
||||||
for _, stmt := range columnMigrations {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
|
|||||||
@@ -209,6 +209,11 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
if err := backfillPlayerMetaRivalState(); err != nil {
|
if err := backfillPlayerMetaRivalState(); err != nil {
|
||||||
slog.Error("player_meta: rival state backfill failed", "err", err)
|
slog.Error("player_meta: rival state backfill failed", "err", err)
|
||||||
}
|
}
|
||||||
|
// Adv 2.0 Phase L4c — one-shot MasterworkDropsReceived backfill into
|
||||||
|
// player_meta. Idempotent (only fills zero rows).
|
||||||
|
if err := backfillPlayerMetaMasterworkDrops(); err != nil {
|
||||||
|
slog.Error("player_meta: masterwork_drops_received 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)
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ func masterworkDropFlavorText(activity AdvActivityType, tier int) string {
|
|||||||
|
|
||||||
// ── Drop Logic ─────────────────────────────────────────────────────────────
|
// ── Drop Logic ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, outcome AdvOutcomeType) {
|
func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, outcome AdvOutcomeType) {
|
||||||
// Only full-success outcomes drop masterwork (success or exceptional)
|
// Only full-success outcomes drop masterwork (success or exceptional)
|
||||||
if outcome != AdvOutcomeSuccess && outcome != AdvOutcomeExceptional {
|
if outcome != AdvOutcomeSuccess && outcome != AdvOutcomeExceptional {
|
||||||
return
|
return
|
||||||
@@ -193,11 +193,25 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, char *AdventureC
|
|||||||
autoEquip = true // genuinely better than what they have
|
autoEquip = true // genuinely better than what they have
|
||||||
}
|
}
|
||||||
|
|
||||||
// First-drop detection
|
// First-drop detection. Read via player_meta (L4c migration); during
|
||||||
isFirstDrop := char.MasterworkDropsReceived == 0
|
// soak it falls back to AdvCharacter for users whose row hasn't been
|
||||||
char.MasterworkDropsReceived++
|
// dual-written yet.
|
||||||
if err := saveAdvCharacter(char); err != nil {
|
priorDrops, err := loadMasterworkDrops(userID)
|
||||||
slog.Error("adventure: failed to save masterwork counter", "user", userID, "err", err)
|
if err != nil {
|
||||||
|
slog.Error("adventure: failed to load masterwork counter", "user", userID, "err", err)
|
||||||
|
}
|
||||||
|
isFirstDrop := priorDrops == 0
|
||||||
|
newDrops := priorDrops + 1
|
||||||
|
if err := upsertPlayerMetaMasterworkDrops(userID, newDrops); err != nil {
|
||||||
|
slog.Error("adventure: failed to save masterwork counter (player_meta)", "user", userID, "err", err)
|
||||||
|
}
|
||||||
|
// Dual-write to AdvCharacter during soak. Best-effort: if the row is
|
||||||
|
// missing the player_meta write above is canonical.
|
||||||
|
if char, lerr := loadAdvCharacter(userID); lerr == nil {
|
||||||
|
char.MasterworkDropsReceived = newDrops
|
||||||
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
|
slog.Error("adventure: failed to save masterwork counter (legacy)", "user", userID, "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if autoEquip {
|
if autoEquip {
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ 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
|
HospitalVisits int
|
||||||
|
MasterworkDropsReceived int
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadPlayerMeta reads the player_meta row for a user. Returns a
|
// loadPlayerMeta reads the player_meta row for a user. Returns a
|
||||||
@@ -32,9 +33,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, hospital_visits FROM player_meta WHERE user_id = ?`,
|
`SELECT arena_wins, arena_losses, invasion_score, display_name, hospital_visits, masterwork_drops_received FROM player_meta WHERE user_id = ?`,
|
||||||
string(userID),
|
string(userID),
|
||||||
).Scan(&m.ArenaWins, &m.ArenaLosses, &m.InvasionScore, &m.DisplayName, &m.HospitalVisits)
|
).Scan(&m.ArenaWins, &m.ArenaLosses, &m.InvasionScore, &m.DisplayName, &m.HospitalVisits, &m.MasterworkDropsReceived)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -302,6 +303,86 @@ func backfillPlayerMetaRivalState() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upsertPlayerMetaMasterworkDrops writes masterwork_drops_received for a user,
|
||||||
|
// leaving other columns untouched. Used by the dual-write path during the
|
||||||
|
// L4c Masterwork migration: every increment on AdvCharacter.MasterworkDropsReceived
|
||||||
|
// also calls this so player_meta stays in sync until soak completes
|
||||||
|
// (gogobee_legacy_migration.md §6.3, §11).
|
||||||
|
func upsertPlayerMetaMasterworkDrops(userID id.UserID, drops int) error {
|
||||||
|
_, err := db.Get().Exec(
|
||||||
|
`INSERT INTO player_meta (user_id, masterwork_drops_received) VALUES (?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET masterwork_drops_received = excluded.masterwork_drops_received`,
|
||||||
|
string(userID), drops,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadMasterworkDrops returns the player's masterwork drop count from
|
||||||
|
// player_meta when populated, falling back to adventure_characters during
|
||||||
|
// the L4c soak window. Zero if the user has no row in either table. Mirrors
|
||||||
|
// loadHospitalVisits: the legacy column only ever moves up, so taking the
|
||||||
|
// higher of the two values is safe across the dual-write window.
|
||||||
|
func loadMasterworkDrops(userID id.UserID) (int, error) {
|
||||||
|
var drops int
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT masterwork_drops_received FROM player_meta WHERE user_id = ?`,
|
||||||
|
string(userID),
|
||||||
|
).Scan(&drops)
|
||||||
|
if err == nil && drops > 0 {
|
||||||
|
return drops, nil
|
||||||
|
}
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var legacy int
|
||||||
|
err = db.Get().QueryRow(
|
||||||
|
`SELECT masterwork_drops_received FROM adventure_characters WHERE user_id = ?`,
|
||||||
|
string(userID),
|
||||||
|
).Scan(&legacy)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return drops, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if legacy > drops {
|
||||||
|
return legacy, nil
|
||||||
|
}
|
||||||
|
return drops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// backfillPlayerMetaMasterworkDrops copies adventure_characters.masterwork_drops_received
|
||||||
|
// into player_meta.masterwork_drops_received 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 backfillPlayerMetaMasterworkDrops() 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 masterwork_drops_received = (
|
||||||
|
SELECT masterwork_drops_received FROM adventure_characters
|
||||||
|
WHERE adventure_characters.user_id = player_meta.user_id
|
||||||
|
)
|
||||||
|
WHERE masterwork_drops_received = 0
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM adventure_characters
|
||||||
|
WHERE adventure_characters.user_id = player_meta.user_id
|
||||||
|
AND adventure_characters.masterwork_drops_received > 0
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
slog.Info("player_meta: masterwork_drops_received 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
|
||||||
|
|||||||
@@ -423,3 +423,113 @@ func TestCreateAdvCharacter_DualWritesDisplayName(t *testing.T) {
|
|||||||
t.Errorf("create dual-write: got %q want %q", got, "FreshName")
|
t.Errorf("create dual-write: got %q want %q", got, "FreshName")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase L4c — Masterwork migration. Backfill is idempotent and only touches
|
||||||
|
// zero-valued rows; the upsert helper round-trips; loadMasterworkDrops falls
|
||||||
|
// back to AdvCharacter when player_meta hasn't been populated yet.
|
||||||
|
|
||||||
|
func TestPlayerMetaMasterworkDropsBackfill_Idempotent(t *testing.T) {
|
||||||
|
setupAuditTestDB(t)
|
||||||
|
|
||||||
|
uid := id.UserID("@meta-mw-bf:example")
|
||||||
|
if err := createAdvCharacter(uid, "Dropper"); err != nil {
|
||||||
|
t.Fatalf("createAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`UPDATE adventure_characters SET masterwork_drops_received = ? WHERE user_id = ?`,
|
||||||
|
3, string(uid),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`UPDATE player_meta SET masterwork_drops_received = 0 WHERE user_id = ?`,
|
||||||
|
string(uid),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("clear: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := backfillPlayerMetaMasterworkDrops(); err != nil {
|
||||||
|
t.Fatalf("backfill 1: %v", err)
|
||||||
|
}
|
||||||
|
got, err := loadMasterworkDrops(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if got != 3 {
|
||||||
|
t.Errorf("after backfill: got %d want 3", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer a dual-write increment. Backfill re-run must NOT clobber it
|
||||||
|
// back (only touches rows whose masterwork_drops_received is still zero).
|
||||||
|
if err := upsertPlayerMetaMasterworkDrops(uid, 4); err != nil {
|
||||||
|
t.Fatalf("dual-write: %v", err)
|
||||||
|
}
|
||||||
|
if err := backfillPlayerMetaMasterworkDrops(); err != nil {
|
||||||
|
t.Fatalf("backfill 2: %v", err)
|
||||||
|
}
|
||||||
|
got2, _ := loadMasterworkDrops(uid)
|
||||||
|
if got2 != 4 {
|
||||||
|
t.Errorf("backfill clobbered dual-write: got %d want 4", got2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMasterworkDrops_FallsBackToAdvCharacter(t *testing.T) {
|
||||||
|
setupAuditTestDB(t)
|
||||||
|
|
||||||
|
uid := id.UserID("@meta-mw-fb:example")
|
||||||
|
if err := createAdvCharacter(uid, "MWFallback"); err != nil {
|
||||||
|
t.Fatalf("createAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`UPDATE adventure_characters SET masterwork_drops_received = ? WHERE user_id = ?`,
|
||||||
|
2, string(uid),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`UPDATE player_meta SET masterwork_drops_received = 0 WHERE user_id = ?`,
|
||||||
|
string(uid),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("clear: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadMasterworkDrops(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if got != 2 {
|
||||||
|
t.Errorf("fallback: got %d want 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown user → 0, no error.
|
||||||
|
got2, err := loadMasterworkDrops(id.UserID("@meta-mw-nobody:example"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load missing: %v", err)
|
||||||
|
}
|
||||||
|
if got2 != 0 {
|
||||||
|
t.Errorf("missing user: got %d want 0", got2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertPlayerMetaMasterworkDrops_RoundTrip(t *testing.T) {
|
||||||
|
setupAuditTestDB(t)
|
||||||
|
|
||||||
|
uid := id.UserID("@meta-mw-rt:example")
|
||||||
|
if err := upsertPlayerMetaMasterworkDrops(uid, 1); err != nil {
|
||||||
|
t.Fatalf("upsert insert: %v", err)
|
||||||
|
}
|
||||||
|
got, err := loadMasterworkDrops(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if got != 1 {
|
||||||
|
t.Errorf("round-trip: got %d want 1", got)
|
||||||
|
}
|
||||||
|
if err := upsertPlayerMetaMasterworkDrops(uid, 9); err != nil {
|
||||||
|
t.Fatalf("upsert update: %v", err)
|
||||||
|
}
|
||||||
|
got2, _ := loadMasterworkDrops(uid)
|
||||||
|
if got2 != 9 {
|
||||||
|
t.Errorf("update: got %d want 9", got2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user