Files
Pete/internal/storage/detail.go
T
prosolis b0aeffd218 adventure: ask 7 — full equipment management from the web
Extends equip-from-the-web (ask 5, magic-only) to all five standard gear
slots. Owners get an Equipment panel on their own who page with:
  - Take off for worn masterwork/arena pieces (round-trippable to pack)
  - Upgrade to the next shop tier (spends euros, confirm-gated)
  - Repair a damaged slot (spends euros, confirm-gated)
The public Gear panel is hidden for the owner since this supersedes it.

Wire: equip_orders gains a tier column; new actions upgrade/repair; new
verdicts rejected_downgrade / rejected_insufficient_funds / rejected_max_tier.
PlayerDetail carries Slots (EquipSlotView x5) + Balance for the confirm
dialogs. handleEquipOrder resolves take-off/upgrade/repair from pd.Slots
server-side and rejects a client-forged tier (409), same as ask 5 trusts
only Pete's own record.

Verified: full suite green, headless render of the panel + confirm dialog
in both day and night phases. gogobee ships the poll-apply half separately;
Pete deploys first so its ingest accepts the new verdicts before gogobee
emits them.
2026-07-17 20:34:24 -07:00

208 lines
8.4 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"`
// Slots is the 5 standard equipment slots (weapon/armor/helmet/boots/tool) —
// owner-only, the input to the web equipment-management panel. Worn
// masterwork/arena pieces surface HERE (via CanTakeOff), not in Equipped, which
// stays magic-only (the DnD slots). See EquipSlotView.
Slots []EquipSlotView `json:"slots,omitempty"`
// Balance is the owner's euro balance, for the upgrade/repair confirm dialogs.
Balance float64 `json:"balance,omitempty"`
}
// EquipSlotView is one of the 5 standard equipment slots as gogobee pushed it,
// carrying everything the management panel needs to render its controls: what is
// worn now, whether it can be taken off (masterwork/arena round-trip to the pack),
// the next tier's name and price for an upgrade offer, and a repair cost when the
// piece is damaged. Pete renders it verbatim and trusts only these facts — a
// client-forged tier or price is ignored, resolved back against this view.
type EquipSlotView struct {
Slot string `json:"slot"` // weapon|armor|helmet|boots|tool
Name string `json:"name"`
Tier int `json:"tier"`
Condition int `json:"condition"`
Masterwork bool `json:"masterwork,omitempty"`
ArenaTier int `json:"arena_tier,omitempty"`
CanTakeOff bool `json:"can_take_off,omitempty"` // masterwork/arena → round-trippable to the pack
NextTier int `json:"next_tier,omitempty"` // 0 = at max tier (5), no upgrade offered
NextName string `json:"next_name,omitempty"`
NextPrice float64 `json:"next_price,omitempty"`
RepairCost int `json:"repair_cost,omitempty"` // 0 = full condition, nothing to repair
}
// 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
}