Renders what gogobee now sends: descriptions, the engine's own effect summary, slot and skill tags, and a Worn panel with the bond count. One row template for all three panels — worn, backpack, vault show the same facts and only the frame differs. The one distinction worth the wrapper struct: "inert" is a real problem state — the item is on you doing nothing because all three bonds are spoken for. The same item in a backpack isn't inert, it's just not worn yet. Rendering both the same would invent a problem the player doesn't have, on the exact panel they'd go to to fix it. An ItemView can't tell you which panel it's in, so itemRow carries it, and TestWhoInertOnlyWhenWorn pins it. Effect arrives resolved and Pete renders it as given. If it ever disagrees with what an item does in a fight, that's a gogobee bug — deriving it here from tier and slot would just be a second opinion that drifts. No migration: detail rides as a JSON blob.
149 lines
5.2 KiB
Go
149 lines
5.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"`
|
|
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 {
|
|
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
|
|
}
|