mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Adv 2.0 L4b: rival migrates RivalPool + gate/stake off AdvCharacter
Gate switches from CombatLevel >= 5 to D&D Level >= 3; stake formula becomes (level / 3) * 1000 (Level 3 → €1000 mirrors legacy CL5 → €1000, tops €6000 at L20). RivalPool / RivalUnlockedNotified dual-write to player_meta; readers in selectRivalPair, handleRivalsCmd, and morning DM render flip to loadRivalState. Backfill wired into Init, idempotent. Migration doc note about porting rival combat to simulateCombat was wrong — rival uses RPS, no combat_engine port needed. Doc corrected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -284,9 +284,19 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events,
|
||||
- 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).
|
||||
- `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).~~ **Correction (2026-05-09):** rival duel combat is RPS, not `combat_engine` — no combat-engine port needed.
|
||||
- Move rival flavor into existing `adventure_flavor_rival.go` — no new file.
|
||||
|
||||
**Status (2026-05-09):** SHIPPED on `adv-2.0`.
|
||||
- `player_meta.rival_pool` + `player_meta.rival_unlocked_notified` columns added via columnMigration in `internal/db/db.go`.
|
||||
- New `rivalMinLevel = 3` (was `rivalMinCombatLevel = 5`). Stake formula `(level / 3) * 1000` (was `(combatLevel / 5) * 1000`) — same magnitude at the unlock threshold (Level 3 → €1000 mirrors legacy CL5 → €1000), tops out around €6000 at Level 20. Tunable later.
|
||||
- `rivalLevelForUser(char)` reads `DnDCharacter.Level` with `dndLevelFromCombatLevel(char.CombatLevel)` fallback when no D&D row exists yet — mirrors `hospitalCostsForUser` shape.
|
||||
- `checkRivalPoolUnlock` now reads D&D level, dual-writes `RivalPool` / `RivalUnlockedNotified` to player_meta via `upsertPlayerMetaRivalState`. Read sites flipped to `loadRivalState(userID)` in `selectRivalPair`, `handleRivalsCmd`, and the morning-DM rival status in `adventure_render.go`.
|
||||
- `loadRivalState(userID)` reads `player_meta` → falls back to `adventure_characters.rival_pool / rival_unlocked_notified` during soak.
|
||||
- `backfillPlayerMetaRivalState()` runs on every Init; idempotent (only fills rows whose rival columns are still zero, so dual-writes survive re-runs).
|
||||
- Tests: `TestPlayerMetaRivalStateBackfill_Idempotent`, `TestLoadRivalState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaRivalState_RoundTrip` (skip without prod DB, matching the L4a/L4f-prep pattern).
|
||||
- `go vet ./...` + `go test ./...` clean.
|
||||
|
||||
### 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`.
|
||||
|
||||
|
||||
@@ -194,6 +194,11 @@ func runMigrations(d *sql.DB) error {
|
||||
// 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`,
|
||||
// Adv 2.0 Phase L4b — Rival migration off AdvCharacter.
|
||||
// RivalPool / RivalUnlockedNotified move to player_meta; AdvCharacter
|
||||
// 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_unlocked_notified INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
|
||||
@@ -204,6 +204,11 @@ func (p *AdventurePlugin) Init() error {
|
||||
if err := backfillPlayerMetaHospitalVisits(); err != nil {
|
||||
slog.Error("player_meta: hospital_visits backfill failed", "err", err)
|
||||
}
|
||||
// Adv 2.0 Phase L4b — one-shot rival state backfill into player_meta.
|
||||
// Idempotent (only fills rows whose rival columns are still zero).
|
||||
if err := backfillPlayerMetaRivalState(); err != nil {
|
||||
slog.Error("player_meta: rival 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)
|
||||
|
||||
@@ -194,8 +194,9 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
|
||||
sb.WriteString(fmt.Sprintf("\n🍼 Babysitting: %s (focus: %s)\n", remaining, char.BabysitSkillFocus))
|
||||
}
|
||||
|
||||
// Rival status
|
||||
if char.RivalPool == 1 {
|
||||
// Rival status — read from player_meta (Adv 2.0 Phase L4b).
|
||||
rivalPoolFlag, _, _ := loadRivalState(char.UserID)
|
||||
if rivalPoolFlag == 1 {
|
||||
records, _ := loadAllRivalRecords(char.UserID)
|
||||
sb.WriteString("\n⚔️ Rivals: Unlocked")
|
||||
if len(records) > 0 {
|
||||
|
||||
@@ -18,13 +18,26 @@ import (
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
rivalMinCombatLevel = 5
|
||||
// Adv 2.0 Phase L4b — gate switched off CombatLevel onto DnDCharacter.Level.
|
||||
// Legacy gate was CombatLevel >= 5; new gate is Level >= 3
|
||||
// (gogobee_legacy_migration.md §2.3).
|
||||
rivalMinLevel = 3
|
||||
rivalChallengeWindow = 24 * time.Hour
|
||||
rivalSamePairCooldown = 7 * 24 * time.Hour
|
||||
rivalMinIntervalHours = 3 * 24 // 3 days in hours
|
||||
rivalMaxIntervalHours = 4 * 24 // 4 days in hours
|
||||
)
|
||||
|
||||
// rivalLevelForUser returns the player's D&D level for rival gating + stake
|
||||
// math, falling back to a CombatLevel-derived level when no D&D row exists
|
||||
// yet. Mirrors hospitalCostsForUser's fallback pattern.
|
||||
func rivalLevelForUser(char *AdventureCharacter) int {
|
||||
if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil && dnd.Level > 0 {
|
||||
return dnd.Level
|
||||
}
|
||||
return dndLevelFromCombatLevel(char.CombatLevel)
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type advRivalChallenge struct {
|
||||
@@ -52,19 +65,38 @@ type advPendingRivalRPS struct {
|
||||
|
||||
// ── Stake Calculation ────────────────────────────────────────────────────────
|
||||
|
||||
func rivalStake(combatLevel int) int {
|
||||
return (combatLevel / 5) * 1000
|
||||
// rivalStake returns the duel stake in € for a given D&D Level.
|
||||
// Adv 2.0 Phase L4b — previously `(combatLevel / 5) * 1000`. New formula
|
||||
// `(level / 3) * 1000` keeps the same magnitude at the unlock threshold
|
||||
// (Level 3 = €1000, matching legacy CombatLevel 5 = €1000) and tops out
|
||||
// around €6000 at Level 20. Players below `rivalMinLevel` produce stake 0,
|
||||
// which selectRivalPair already filters out.
|
||||
func rivalStake(level int) int {
|
||||
return (level / 3) * 1000
|
||||
}
|
||||
|
||||
// ── Rival Pool Unlock ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) checkRivalPoolUnlock(char *AdventureCharacter) {
|
||||
if char.CombatLevel >= rivalMinCombatLevel && char.RivalPool == 0 {
|
||||
char.RivalPool = 1
|
||||
if !char.RivalUnlockedNotified {
|
||||
char.RivalUnlockedNotified = true
|
||||
p.SendDM(char.UserID, rivalUnlockDM)
|
||||
}
|
||||
level := rivalLevelForUser(char)
|
||||
if level < rivalMinLevel {
|
||||
return
|
||||
}
|
||||
pool, notified, _ := loadRivalState(char.UserID)
|
||||
if pool > 0 && notified {
|
||||
// Already unlocked + notified; nothing to do.
|
||||
return
|
||||
}
|
||||
// Promote to pool + send unlock DM if not yet announced.
|
||||
char.RivalPool = 1
|
||||
pool = 1
|
||||
if !notified {
|
||||
char.RivalUnlockedNotified = true
|
||||
notified = true
|
||||
p.SendDM(char.UserID, rivalUnlockDM)
|
||||
}
|
||||
if err := upsertPlayerMetaRivalState(char.UserID, pool, notified); err != nil {
|
||||
slog.Error("player_meta: rival unlock dual-write failed", "user", char.UserID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,13 +335,17 @@ func (p *AdventurePlugin) selectRivalPair() (*AdventureCharacter, *AdventureChar
|
||||
// Filter to eligible pool members.
|
||||
var pool []AdventureCharacter
|
||||
for _, c := range chars {
|
||||
if c.RivalPool == 0 || !c.Alive || c.BabysitActive {
|
||||
if !c.Alive || c.BabysitActive {
|
||||
continue
|
||||
}
|
||||
poolFlag, _, _ := loadRivalState(c.UserID)
|
||||
if poolFlag == 0 {
|
||||
continue
|
||||
}
|
||||
if hasActiveChallenge(c.UserID) {
|
||||
continue
|
||||
}
|
||||
stake := rivalStake(c.CombatLevel)
|
||||
stake := rivalStake(rivalLevelForUser(&c))
|
||||
if stake <= 0 {
|
||||
continue
|
||||
}
|
||||
@@ -349,7 +385,7 @@ func (p *AdventurePlugin) selectRivalPair() (*AdventureCharacter, *AdventureChar
|
||||
// ── Challenge Issuance ───────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) issueRivalChallenge(challenger, challenged *AdventureCharacter) {
|
||||
stake := rivalStake(challenged.CombatLevel)
|
||||
stake := rivalStake(rivalLevelForUser(challenged))
|
||||
challenge := &advRivalChallenge{
|
||||
ChallengeID: uuid.New().String()[:12],
|
||||
ChallengerID: challenger.UserID,
|
||||
@@ -828,10 +864,11 @@ func (p *AdventurePlugin) handleRivalsCmd(ctx MessageContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if char.RivalPool == 0 {
|
||||
pool, _, _ := loadRivalState(char.UserID)
|
||||
if pool == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("You need Combat Level %d to enter the rival pool. Currently: %d.",
|
||||
rivalMinCombatLevel, char.CombatLevel))
|
||||
fmt.Sprintf("You need Level %d to enter the rival pool. Currently: %d.",
|
||||
rivalMinLevel, rivalLevelForUser(char)))
|
||||
}
|
||||
|
||||
records, err := loadAllRivalRecords(char.UserID)
|
||||
|
||||
@@ -204,6 +204,104 @@ func backfillPlayerMetaHospitalVisits() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertPlayerMetaRivalState writes rival_pool / rival_unlocked_notified for
|
||||
// a user, leaving other columns untouched. Used by the dual-write path during
|
||||
// the L4b Rival migration: every place that mutates AdvCharacter.RivalPool /
|
||||
// RivalUnlockedNotified also calls this so player_meta stays in sync until
|
||||
// soak completes (gogobee_legacy_migration.md §6.2, §11).
|
||||
func upsertPlayerMetaRivalState(userID id.UserID, pool int, notified bool) error {
|
||||
notifiedInt := 0
|
||||
if notified {
|
||||
notifiedInt = 1
|
||||
}
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO player_meta (user_id, rival_pool, rival_unlocked_notified) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
rival_pool = excluded.rival_pool,
|
||||
rival_unlocked_notified = excluded.rival_unlocked_notified`,
|
||||
string(userID), pool, notifiedInt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// loadRivalState returns rival_pool / rival_unlocked_notified for a user from
|
||||
// player_meta when populated, falling back to adventure_characters during the
|
||||
// L4b soak window. Treats a missing row as (0, false). The fallback returns
|
||||
// the AdvCharacter values whenever player_meta has the unmigrated default
|
||||
// (pool=0, notified=0) — a true "not yet in pool" state and the pre-backfill
|
||||
// state are indistinguishable at the column level, but the legacy column
|
||||
// only ever moves toward unlocked, so picking the higher of the two values
|
||||
// is safe.
|
||||
func loadRivalState(userID id.UserID) (pool int, notified bool, err error) {
|
||||
var notifiedInt int
|
||||
err = db.Get().QueryRow(
|
||||
`SELECT rival_pool, rival_unlocked_notified FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&pool, ¬ifiedInt)
|
||||
if err == nil && (pool > 0 || notifiedInt > 0) {
|
||||
return pool, notifiedInt == 1, nil
|
||||
}
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return 0, false, err
|
||||
}
|
||||
// Fallback during soak.
|
||||
var legacyPool, legacyNotified int
|
||||
err = db.Get().QueryRow(
|
||||
`SELECT rival_pool, rival_unlocked_notified FROM adventure_characters WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&legacyPool, &legacyNotified)
|
||||
if err == sql.ErrNoRows {
|
||||
return pool, notifiedInt == 1, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if legacyPool > pool {
|
||||
pool = legacyPool
|
||||
}
|
||||
if legacyNotified > notifiedInt {
|
||||
notifiedInt = legacyNotified
|
||||
}
|
||||
return pool, notifiedInt == 1, nil
|
||||
}
|
||||
|
||||
// backfillPlayerMetaRivalState copies rival_pool / rival_unlocked_notified
|
||||
// from adventure_characters into player_meta for any row whose values are
|
||||
// still the default zero. Idempotent: only updates rows that haven't been
|
||||
// populated yet (via backfill or dual-write).
|
||||
func backfillPlayerMetaRivalState() 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 rival_pool = (
|
||||
SELECT rival_pool FROM adventure_characters
|
||||
WHERE adventure_characters.user_id = player_meta.user_id
|
||||
),
|
||||
rival_unlocked_notified = (
|
||||
SELECT rival_unlocked_notified FROM adventure_characters
|
||||
WHERE adventure_characters.user_id = player_meta.user_id
|
||||
)
|
||||
WHERE rival_pool = 0 AND rival_unlocked_notified = 0
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM adventure_characters
|
||||
WHERE adventure_characters.user_id = player_meta.user_id
|
||||
AND (adventure_characters.rival_pool > 0
|
||||
OR adventure_characters.rival_unlocked_notified > 0)
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
slog.Info("player_meta: rival state 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
|
||||
|
||||
@@ -297,6 +297,117 @@ func TestUpsertPlayerMetaHospitalVisits_RoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase L4b — Rival migration. Backfill is idempotent and only fills rows
|
||||
// whose rival columns are still default zero; the upsert helper round-trips;
|
||||
// loadRivalState falls back to AdvCharacter when player_meta hasn't been
|
||||
// populated yet.
|
||||
|
||||
func TestPlayerMetaRivalStateBackfill_Idempotent(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
|
||||
uid := id.UserID("@meta-rv-bf:example")
|
||||
if err := createAdvCharacter(uid, "Rivaler"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE adventure_characters SET rival_pool = ?, rival_unlocked_notified = ? WHERE user_id = ?`,
|
||||
1, 1, string(uid),
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE player_meta SET rival_pool = 0, rival_unlocked_notified = 0 WHERE user_id = ?`,
|
||||
string(uid),
|
||||
); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
|
||||
if err := backfillPlayerMetaRivalState(); err != nil {
|
||||
t.Fatalf("backfill 1: %v", err)
|
||||
}
|
||||
pool, notified, err := loadRivalState(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if pool != 1 || !notified {
|
||||
t.Errorf("after backfill: got pool=%d notified=%v want 1, true", pool, notified)
|
||||
}
|
||||
|
||||
// Layer a dual-write update. Backfill re-run must NOT clobber it back
|
||||
// (only touches rows whose rival columns are still both zero).
|
||||
if err := upsertPlayerMetaRivalState(uid, 1, false); err != nil {
|
||||
t.Fatalf("dual-write: %v", err)
|
||||
}
|
||||
if err := backfillPlayerMetaRivalState(); err != nil {
|
||||
t.Fatalf("backfill 2: %v", err)
|
||||
}
|
||||
pool2, notified2, _ := loadRivalState(uid)
|
||||
if pool2 != 1 || notified2 {
|
||||
t.Errorf("backfill clobbered dual-write: got pool=%d notified=%v want 1, false", pool2, notified2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRivalState_FallsBackToAdvCharacter(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
|
||||
uid := id.UserID("@meta-rv-fb:example")
|
||||
if err := createAdvCharacter(uid, "RivalFallback"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE adventure_characters SET rival_pool = ?, rival_unlocked_notified = ? WHERE user_id = ?`,
|
||||
1, 1, string(uid),
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE player_meta SET rival_pool = 0, rival_unlocked_notified = 0 WHERE user_id = ?`,
|
||||
string(uid),
|
||||
); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
|
||||
pool, notified, err := loadRivalState(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if pool != 1 || !notified {
|
||||
t.Errorf("fallback: got pool=%d notified=%v want 1, true", pool, notified)
|
||||
}
|
||||
|
||||
// Unknown user → zeros, no error.
|
||||
pool2, notified2, err := loadRivalState(id.UserID("@meta-rv-nobody:example"))
|
||||
if err != nil {
|
||||
t.Fatalf("load missing: %v", err)
|
||||
}
|
||||
if pool2 != 0 || notified2 {
|
||||
t.Errorf("missing user: got pool=%d notified=%v want 0, false", pool2, notified2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertPlayerMetaRivalState_RoundTrip(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
|
||||
uid := id.UserID("@meta-rv-rt:example")
|
||||
if err := upsertPlayerMetaRivalState(uid, 1, false); err != nil {
|
||||
t.Fatalf("upsert insert: %v", err)
|
||||
}
|
||||
pool, notified, err := loadRivalState(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if pool != 1 || notified {
|
||||
t.Errorf("round-trip 1: got pool=%d notified=%v want 1, false", pool, notified)
|
||||
}
|
||||
if err := upsertPlayerMetaRivalState(uid, 1, true); err != nil {
|
||||
t.Fatalf("upsert update: %v", err)
|
||||
}
|
||||
pool2, notified2, _ := loadRivalState(uid)
|
||||
if pool2 != 1 || !notified2 {
|
||||
t.Errorf("round-trip 2: got pool=%d notified=%v want 1, true", pool2, notified2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdvCharacter_DualWritesDisplayName(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user