On the owner's own page, each wearable backpack item now carries a compare card: a verdict chip (upgrade / downgrade / sidegrade / new / inert / same), the per-stat deltas, and the name of the worn item it's measured against. gogobee computes all of it — the power math needs tempering and bond state, which live in the engine — so Pete only colours the result and does no arithmetic. It rides the owner-private inventory, so it never reaches the public page; the item names are game-authored, so unlike the LLM dispatch prose there's no injection surface. The verdict chip is always visible (a phone has no hover to lean on) with the deltas beside it. Chip colours mix a fixed hue into --ink for text and --card for fill, so they land on the readable side of the card in all four phases — the same by-construction contrast trick the dungeon map uses, not a Tailwind dark: variant. Screenshot-verified day and night, all six verdict states, including the purple 'new' chip that the theme-contrast history warned about. output.css rebuilt and committed.
181 lines
6.8 KiB
Go
181 lines
6.8 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"`
|
|
// Compare, set only on backpack magic items (the ones carrying an equip ID),
|
|
// pairs this item against what is worn in the slot it would equip into. gogobee
|
|
// computes the verdict and per-stat deltas (the power math needs tempering and
|
|
// bond state, which live in the engine); Pete only renders it. Owner-private,
|
|
// rides detail_json — no public exposure. Item names here are game-authored, so
|
|
// there is no injection surface like the LLM dispatch prose.
|
|
Compare *ItemCompare `json:"compare,omitempty"`
|
|
}
|
|
|
|
// ItemCompare is gogobee's verdict for equipping a backpack magic item over what
|
|
// is currently worn in its slot. Pete renders it verbatim and does no arithmetic.
|
|
type ItemCompare struct {
|
|
// Verdict: upgrade, downgrade, sidegrade, same, new, or inert.
|
|
Verdict string `json:"verdict"`
|
|
// VsName is the worn item being replaced; "" when Verdict is new (empty slot).
|
|
VsName string `json:"vs_name,omitempty"`
|
|
// VsSlot is the slot the item would land in (e.g. "ring_1").
|
|
VsSlot string `json:"vs_slot,omitempty"`
|
|
// Deltas is one entry per changed stat, each pre-flagged better/worse.
|
|
Deltas []ItemDelta `json:"deltas,omitempty"`
|
|
}
|
|
|
|
// ItemDelta is one stat's change between the candidate and the worn item.
|
|
type ItemDelta struct {
|
|
Label string `json:"label"`
|
|
Better bool `json:"better"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// 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
|
|
}
|