The one adventure ask that carries intent back to the game box, built the mischief way: no new network route, Pete records the equip/unequip and gogobee polls it and files a verdict. The who page grows Equip / Take off buttons on the owner's own worn and backpack panels, and a pending-changes strip that shows 'queued' until the verdict lands, never claiming a change it can't see. The item handle is the inventory row id, sent only on wearable magic items, so a non-zero id is also what gates the button. gogobee resolves the owner by localpart, never a name.
154 lines
5.5 KiB
Go
154 lines
5.5 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"`
|
|
Equipped []ItemView `json:"equipped,omitempty"`
|
|
House HouseView `json:"house"`
|
|
Pets []PetView `json:"pets,omitempty"`
|
|
}
|
|
|
|
// ItemView is one item in a private panel — backpack, vault, or worn.
|
|
//
|
|
// Desc and Effect arrive already resolved: gogobee's inventory rows carry no
|
|
// description, and the combat delta is computed from the item rather than
|
|
// stored. Effect is the game engine's own summary, not Pete's guess at one — if
|
|
// it ever disagrees with what the item does in a fight, that is a gogobee bug
|
|
// and not something Pete can paper over here.
|
|
//
|
|
// Attunement means the item wants a bond; Attuned means it has one. Only worn
|
|
// items can be Attuned — equipping moves the row out of gogobee's inventory
|
|
// table entirely, so a backpack item's bond state isn't false, it's undefined.
|
|
type ItemView struct {
|
|
// ID is the adventure_inventory row id, sent only for a backpack item that can
|
|
// be worn through the magic-item path — so a non-zero ID doubles as "this item
|
|
// has an Equip button." Worn items carry none: unequip keys on Slot. The id is
|
|
// the handle an equip order round-trips back to gogobee to name the item.
|
|
ID int64 `json:"id,omitempty"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Tier int `json:"tier"`
|
|
Value int64 `json:"value"`
|
|
Temper int `json:"temper,omitempty"`
|
|
Slot string `json:"slot,omitempty"`
|
|
SkillSource string `json:"skill_source,omitempty"`
|
|
Desc string `json:"desc,omitempty"`
|
|
Effect string `json:"effect,omitempty"`
|
|
Attunement bool `json:"attunement,omitempty"`
|
|
Attuned bool `json:"attuned,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
|
|
}
|