Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
132 lines
4.2 KiB
Go
132 lines
4.2 KiB
Go
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
|
|
}
|