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. 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 }