package storage import ( "database/sql" "encoding/json" ) // PlayerDetail is one player's private, owner-only expansion — inventory, vault, // house, pets — pushed by gogobee keyed by localpart. Pete stores it in its own // keyspace (player_self_detail) and only ever serves it back to the one // authenticated user it belongs to. Token rides along so the detail page can // prove owner↔page without ever reversing the anonymous roster token. type PlayerDetail struct { Localpart string `json:"localpart"` Token string `json:"token"` Inventory []ItemView `json:"inventory,omitempty"` Vault []ItemView `json:"vault,omitempty"` House HouseView `json:"house"` Pets []PetView `json:"pets,omitempty"` } // ItemView is one backpack or vault item. type ItemView struct { Name string `json:"name"` Type string `json:"type"` Tier int `json:"tier"` Value int64 `json:"value"` Temper int `json:"temper,omitempty"` } // HouseView is the owner's housing summary. type HouseView struct { Tier int `json:"tier"` LoanBalance int `json:"loan_balance,omitempty"` Autopay bool `json:"autopay,omitempty"` Rate float64 `json:"rate,omitempty"` } // PetView is one pet slot. type PetView struct { Type string `json:"type"` Name string `json:"name"` Level int `json:"level"` XP int `json:"xp,omitempty"` ArmorTier int `json:"armor_tier,omitempty"` } // ReplacePlayerDetail swaps the whole private-detail set in one transaction — // replace, never merge, the same contract as the roster: a player who dropped // out of gogobee's push must lose their stale self-view rather than have it // linger. localpart is lowercased upstream to match how a session Username reads. func ReplacePlayerDetail(players []PlayerDetail, snapshotAt int64) error { tx, err := Get().Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() if _, err := tx.Exec(`DELETE FROM player_self_detail`); err != nil { return err } stmt, err := tx.Prepare(` INSERT INTO player_self_detail (localpart, token, detail_json, snapshot_at) VALUES (?, ?, ?, ?)`) if err != nil { return err } defer stmt.Close() for _, p := range players { if p.Localpart == "" || p.Token == "" { continue // a self-view with no owner or no page to hang on is unusable } body, err := json.Marshal(p) if err != nil { return err } if _, err := stmt.Exec(p.Localpart, p.Token, string(body), snapshotAt); err != nil { return err } } return tx.Commit() } // PlayerDetailByOwner returns the private detail for localpart, but only when it // owns the given page token. This is the ownership join the detail page needs: // the signed-in user's localpart is trusted (it comes from their verified // session), and a row exists only if gogobee pushed that same (localpart, token) // pair — so a viewer can only ever unlock the self extras on their own page, and // Pete never has to turn a token back into a handle to decide it. func PlayerDetailByOwner(localpart, token string) (PlayerDetail, bool, error) { if localpart == "" || token == "" { return PlayerDetail{}, false, nil } var storedToken, detailJSON string err := Get().QueryRow( `SELECT token, detail_json FROM player_self_detail WHERE localpart = ?`, localpart). Scan(&storedToken, &detailJSON) if err == sql.ErrNoRows { return PlayerDetail{}, false, nil } if err != nil { return PlayerDetail{}, false, err } if storedToken != token { return PlayerDetail{}, false, nil // signed in, but not the owner of this page } var pd PlayerDetail if err := json.Unmarshal([]byte(detailJSON), &pd); err != nil { return PlayerDetail{}, false, err } pd.Localpart = localpart pd.Token = storedToken return pd, true, nil } // SelfToken returns the roster token owned by localpart, if gogobee's last push // carried one. Lets the board mark "your adventurer" without exposing the // localpart↔token map anywhere public. func SelfToken(localpart string) (string, bool) { if localpart == "" { return "", false } var token string err := Get().QueryRow( `SELECT token FROM player_self_detail WHERE localpart = ?`, localpart).Scan(&token) if err != nil { return "", false } return token, true }