Files
Pete/internal/storage/detail.go
T
prosolis c2a40dad64 adventure: show the party, the pets, and what you missed
Four small surfaces the game has had all along and the web has never shown.

The party roster on the adventurer page is the one with a bug behind it. The
board resolved an expedition by owner id, so a player seated on somebody else's
run has been reading as "idle in town" while standing in a tier-4 dungeon.
Seats now ride the roster detail beside the supply and threat numbers, and an
opted-out player's seat is anonymised rather than dropped: a party of three
rendered as a pair contradicts everything printed next to it.

Pets show their levelling. They have earned XP from every won fight since that
wiring was fixed and the only place a level ever appeared was a Matrix line
that scrolled away. The threshold comes from the engine, in the engine's own
centi-XP, because a copy of the curve here would drift the first time a band
moved.

"While you were away" is the one panel on the site about the reader rather than
the realm. Two stamps behind it, not one: a single column would show the news,
move the clock, and render an empty box over the same events on the reader's
first refresh.

And the story permalink names its region, which has been hardcoded empty behind
a `// reserved` comment since the page shipped.
2026-07-24 20:26:19 -07:00

268 lines
11 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"`
// Zones / Resume / Babysit are the W5b action offers: what this owner may ask
// for from the web right now, priced by gogobee. Pete renders these and does no
// arithmetic — every price and every gate is the game's, quoted at push time.
//
// An offer is NOT a permission. It is up to two minutes stale, so gogobee
// re-resolves the zone, the price and the fee when the order lands. What the
// list buys is a page that does not offer a button certain to be refused.
Zones []ZoneOffer `json:"zones,omitempty"`
Resume *ResumeOffer `json:"resume,omitempty"`
Babysit *BabysitOffer `json:"babysit,omitempty"`
}
// ZoneOffer is one place the owner may set out for. Absent entirely while they
// are already out, so an empty list means "not right now" rather than "nowhere".
type ZoneOffer struct {
ID string `json:"id"`
Display string `json:"display"`
Tier int `json:"tier"`
Hook string `json:"hook,omitempty"`
Postgame bool `json:"postgame,omitempty"`
Loadouts []LoadoutOffer `json:"loadouts,omitempty"`
}
// LoadoutOffer is one supply preset: what it is called, what it costs, and how
// many days of provisions it buys. Key is what the order carries back.
type LoadoutOffer struct {
Key string `json:"key"` // lean|balanced|heavy
Name string `json:"name"`
Blurb string `json:"blurb,omitempty"`
Cost int `json:"cost"`
Days int `json:"days"`
}
// ResumeOffer is the extracted expedition still waiting to be walked back into.
// ExpiresAt is the end of the seven-day window, so the page can say how long is
// left rather than only that there is a way back.
type ResumeOffer struct {
ZoneID string `json:"zone_id"`
Display string `json:"display"`
Tier int `json:"tier"`
Day int `json:"day"`
ExpiresAt int64 `json:"expires_at,omitempty"`
Loadouts []LoadoutOffer `json:"loadouts,omitempty"`
}
// BabysitOffer is the pet sitter's standing and the two prices they charge. It
// is pushed even when a sitter is engaged: "looked after until Tuesday" is what
// the page should say instead of a buy button.
type BabysitOffer struct {
Active bool `json:"active"`
ExpiresAt int64 `json:"expires_at,omitempty"`
WeekCost int `json:"week_cost"`
MonthCost int `json:"month_cost"`
}
// 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.
//
// XP and XPNeeded are both **centi-XP**, the game's own unit: a pet earns 1.5
// points per action and the stored ledger is an integer, so everything is kept
// times a hundred. Divide by 100 to show a number to a human; do nothing else
// with either. XPNeeded is the engine's per-band curve and is 0 at the level cap,
// which is the only way to tell "full" from "nothing left to earn".
type PetView struct {
Type string `json:"type"`
Name string `json:"name"`
Level int `json:"level"`
XP int `json:"xp,omitempty"`
XPNeeded int `json:"xp_needed,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
}