mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
adventure: gogobee pushes each adventurer's sheet to Pete's detail page
The board on Pete shows flat rows; this hands it two more channels so a name can become a page. Public stats + equipped gear ride the roster snapshot (RosterDetail on each entry, keyed by the anonymous token, no handle). The private self-view — inventory, vault, house, pets — rides its own push keyed by localpart, so Pete only ever serves it back to the one signed-in owner it belongs to; the board token rides along so the ownership check is a join, never a reversal of the one-way token. The private set skips no one for opt-out (that governs the public board only) and skips the dead (no live page to own).
This commit is contained in:
@@ -245,6 +245,39 @@ type RosterEntry struct {
|
|||||||
Region string `json:"region,omitempty"`
|
Region string `json:"region,omitempty"`
|
||||||
Day int `json:"day,omitempty"`
|
Day int `json:"day,omitempty"`
|
||||||
IdleHours int `json:"idle_hours,omitempty"`
|
IdleHours int `json:"idle_hours,omitempty"`
|
||||||
|
|
||||||
|
// Detail is the public, expanded sheet — stats and equipped gear — shown on
|
||||||
|
// the click-through detail page. Nil for an entry we couldn't fully load.
|
||||||
|
// Public-tier: no Matrix handle, keyed only by the anonymous Token, same as
|
||||||
|
// the summary fields above.
|
||||||
|
Detail *RosterDetail `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RosterDetail is everything a visitor may see on an adventurer's detail page:
|
||||||
|
// the current combat sheet and equipped gear, plus live expedition context. It
|
||||||
|
// rides the roster push because it is small and shares the board's semantics —
|
||||||
|
// a photograph of the present, fine to drop and refresh, never a handle.
|
||||||
|
type RosterDetail struct {
|
||||||
|
HPCurrent int `json:"hp_current"`
|
||||||
|
HPMax int `json:"hp_max"`
|
||||||
|
TempHP int `json:"temp_hp,omitempty"`
|
||||||
|
ArmorClass int `json:"armor_class"`
|
||||||
|
Abilities [6]int `json:"abilities"` // STR, DEX, CON, INT, WIS, CHA scores
|
||||||
|
Modifiers [6]int `json:"modifiers"` // matching ability modifiers
|
||||||
|
Gear []GearItem `json:"gear,omitempty"`
|
||||||
|
// Expedition context, present only while on a run.
|
||||||
|
Supplies int `json:"supplies,omitempty"`
|
||||||
|
ThreatLevel int `json:"threat_level,omitempty"`
|
||||||
|
Room string `json:"room,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GearItem is one equipped piece for the armor/gear panel.
|
||||||
|
type GearItem 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MischiefBalance is one buyer's advisory euro balance, ridden along with the
|
// MischiefBalance is one buyer's advisory euro balance, ridden along with the
|
||||||
@@ -303,6 +336,71 @@ func PushRoster(ctx context.Context, snap RosterSnapshot) error {
|
|||||||
return std.post(ctx, "/api/ingest/roster", payload)
|
return std.post(ctx, "/api/ingest/roster", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlayerDetail is the private, owner-only expansion for one player: inventory,
|
||||||
|
// vault, house, and pets. Like MischiefBalance it is keyed by localpart (the
|
||||||
|
// sign-in name), in its own keyspace on Pete — Pete only ever serves it back to
|
||||||
|
// the one authenticated user it belongs to, never on the public board. It
|
||||||
|
// carries the player's current board Token too, so Pete can answer "is the
|
||||||
|
// signed-in viewer the owner of the adventurer on this page?" by a join, without
|
||||||
|
// ever having to reverse the one-way token.
|
||||||
|
type PlayerDetail struct {
|
||||||
|
Localpart string `json:"localpart"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Inventory []ItemView `json:"inventory,omitempty"`
|
||||||
|
Vault []ItemView `json:"vault,omitempty"`
|
||||||
|
House HouseView `json:"house"`
|
||||||
|
Pets []PetView `json:"pets,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemView is one backpack or vault item for the private inventory panel.
|
||||||
|
type ItemView struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
Temper int `json:"temper,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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailSnapshot is the complete private-detail set, pushed whole and replacing
|
||||||
|
// Pete's copy — same complete-snapshot contract as the roster, so a player who
|
||||||
|
// drops out of the game stops having a stale self-view on Pete.
|
||||||
|
type DetailSnapshot struct {
|
||||||
|
SnapshotAt int64 `json:"snapshot_at"`
|
||||||
|
Players []PlayerDetail `json:"players"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushDetails sends the private self-detail set to Pete, best-effort. Like the
|
||||||
|
// roster it is dropped on failure — the next tick carries a fresher copy — and
|
||||||
|
// it rides its own endpoint (not the roster body) so a fat inventory can't blow
|
||||||
|
// the roster push's size budget or its drop-the-lie semantics.
|
||||||
|
func PushDetails(ctx context.Context, snap DetailSnapshot) error {
|
||||||
|
if !Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(snap)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return std.post(ctx, "/api/ingest/detail", payload)
|
||||||
|
}
|
||||||
|
|
||||||
// post sends one payload to a Pete endpoint with bearer auth. Mirrors the
|
// post sends one payload to a Pete endpoint with bearer auth. Mirrors the
|
||||||
// bearer-POST pattern in email_nag.go:sendCode.
|
// bearer-POST pattern in email_nag.go:sendCode.
|
||||||
func (c *Client) post(ctx context.Context, path string, payload []byte) error {
|
func (c *Client) post(ctx context.Context, path string, payload []byte) error {
|
||||||
|
|||||||
228
internal/plugin/pete_detail_test.go
Normal file
228
internal/plugin/pete_detail_test.go
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The adventurer detail-page push has two halves, matched to two sensitivities:
|
||||||
|
// the public sheet (stats + gear) rides the roster snapshot keyed by the
|
||||||
|
// anonymous token, and the private self-view (inventory/vault/house/pets) rides
|
||||||
|
// its own push keyed by localpart. These tests pin both halves at the gogobee
|
||||||
|
// end — that the public detail carries no handle, and that the private detail is
|
||||||
|
// keyed so Pete can only ever hand it back to its owner.
|
||||||
|
|
||||||
|
// seedDetailPlayer builds a real, playable adventurer: player_meta + tier-0
|
||||||
|
// equipment (via createAdvCharacter) plus a confirmed combat sheet. Real rows on
|
||||||
|
// purpose — the same modernc scan hazard the roster snapshot dances around.
|
||||||
|
func seedDetailPlayer(t *testing.T, uid id.UserID, name string, level int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := createAdvCharacter(uid, name); err != nil {
|
||||||
|
t.Fatalf("createAdvCharacter(%s): %v", uid, err)
|
||||||
|
}
|
||||||
|
if err := SaveDnDCharacter(&DnDCharacter{
|
||||||
|
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
|
||||||
|
STR: 16, DEX: 14, CON: 15, INT: 10, WIS: 12, CHA: 8,
|
||||||
|
HPMax: 42, HPCurrent: 30, TempHP: 5, ArmorClass: 17,
|
||||||
|
PendingSetup: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveDnDCharacter(%s): %v", uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRosterDetailPublicSheet: buildRosterSnapshot hangs the public sheet on
|
||||||
|
// each board entry — HP/AC/abilities/mods and equipped gear — and nothing that
|
||||||
|
// could name the player. This is the click-through page's whole payload.
|
||||||
|
func TestRosterDetailPublicSheet(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
uid := id.UserID("@josie:test")
|
||||||
|
seedDetailPlayer(t, uid, "Josie", 5)
|
||||||
|
|
||||||
|
snap, err := buildRosterSnapshot(time.Now().UTC(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
if len(snap.Adventurers) != 1 {
|
||||||
|
t.Fatalf("board has %d adventurers, want 1", len(snap.Adventurers))
|
||||||
|
}
|
||||||
|
e := snap.Adventurers[0]
|
||||||
|
if e.Detail == nil {
|
||||||
|
t.Fatal("board entry carries no detail sheet — the detail page would fall back to the bare row")
|
||||||
|
}
|
||||||
|
d := e.Detail
|
||||||
|
if d.HPMax != 42 || d.HPCurrent != 30 || d.TempHP != 5 || d.ArmorClass != 17 {
|
||||||
|
t.Errorf("detail sheet = %+v, want HP 30/42 (+5 temp), AC 17", d)
|
||||||
|
}
|
||||||
|
if d.Abilities != [6]int{16, 14, 15, 10, 12, 8} {
|
||||||
|
t.Errorf("abilities = %v, want STR..CHA 16,14,15,10,12,8", d.Abilities)
|
||||||
|
}
|
||||||
|
// CON 15 → +2; a mismatched mods slice would misprint the whole sheet.
|
||||||
|
if d.Modifiers[2] != 2 {
|
||||||
|
t.Errorf("CON modifier = %d, want +2", d.Modifiers[2])
|
||||||
|
}
|
||||||
|
// createAdvCharacter seeds tier-0 gear in every slot, so the panel is full.
|
||||||
|
if len(d.Gear) != len(allSlots) {
|
||||||
|
t.Errorf("gear panel has %d pieces, want %d (one per slot)", len(d.Gear), len(allSlots))
|
||||||
|
}
|
||||||
|
for _, g := range d.Gear {
|
||||||
|
if g.Slot == "" || g.Name == "" {
|
||||||
|
t.Errorf("gear piece is unlabelled: %+v", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRosterDetailExpeditionContext: a player who is out on a run gets the live
|
||||||
|
// expedition fields layered onto their sheet — supplies, threat, and the room
|
||||||
|
// counter — so the detail page shows where they are, not just who they are.
|
||||||
|
func TestRosterDetailExpeditionContext(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
uid := id.UserID("@onrun:test")
|
||||||
|
exp := seedMischiefTarget(t, uid, 5, 60) // createAdvCharacter + sheet + live run
|
||||||
|
_ = exp
|
||||||
|
|
||||||
|
snap, err := buildRosterSnapshot(time.Now().UTC(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
if len(snap.Adventurers) != 1 {
|
||||||
|
t.Fatalf("board has %d adventurers, want 1", len(snap.Adventurers))
|
||||||
|
}
|
||||||
|
e := snap.Adventurers[0]
|
||||||
|
if e.Status != "expedition" {
|
||||||
|
t.Fatalf("status = %q, want expedition", e.Status)
|
||||||
|
}
|
||||||
|
if e.Detail == nil {
|
||||||
|
t.Fatal("on-run entry carries no detail")
|
||||||
|
}
|
||||||
|
if e.Detail.Room == "" {
|
||||||
|
t.Error("expedition detail has no room counter — the run context didn't layer on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetailSnapshotKeyedByLocalpart is the ownership contract at the source.
|
||||||
|
// The private set is keyed by localpart and carries the player's current board
|
||||||
|
// token, so Pete can answer "is this signed-in viewer the owner of this page?"
|
||||||
|
// by a join — never by reversing the one-way token. And it carries the actual
|
||||||
|
// private goods: inventory, vault, house, pets.
|
||||||
|
func TestDetailSnapshotKeyedByLocalpart(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
uid := id.UserID("@quack:test")
|
||||||
|
seedDetailPlayer(t, uid, "Quack", 7)
|
||||||
|
|
||||||
|
// Backpack + vault: two distinct stashes that must not blur together.
|
||||||
|
if err := addAdvInventoryItem(uid, AdvItem{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := addAdvInventoryItem(uid, AdvItem{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := vaultStoreItem(uid, "crown"); got == "" {
|
||||||
|
t.Fatal("failed to vault the crown")
|
||||||
|
}
|
||||||
|
|
||||||
|
// House + a pet, through the canonical save path.
|
||||||
|
adv, err := loadAdvCharacter(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
adv.HouseTier = 2
|
||||||
|
adv.HouseLoanBalance = 1500
|
||||||
|
adv.PetType = "cat"
|
||||||
|
adv.PetName = "Mittens"
|
||||||
|
adv.PetLevel = 3
|
||||||
|
if err := saveAdvCharacter(adv); err != nil {
|
||||||
|
t.Fatalf("saveAdvCharacter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, err := buildDetailSnapshot(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
if len(snap.Players) != 1 {
|
||||||
|
t.Fatalf("detail set has %d players, want 1", len(snap.Players))
|
||||||
|
}
|
||||||
|
pd := snap.Players[0]
|
||||||
|
if pd.Localpart != "quack" {
|
||||||
|
t.Errorf("localpart = %q, want the lowercase mxid localpart 'quack'", pd.Localpart)
|
||||||
|
}
|
||||||
|
if pd.Token != eventToken(uid, "roster") {
|
||||||
|
t.Error("detail token is not the board token — the ownership join on Pete would never match the page")
|
||||||
|
}
|
||||||
|
// Inventory holds the ore (crown was vaulted out of it); vault holds the crown.
|
||||||
|
if len(pd.Inventory) != 1 || pd.Inventory[0].Name != "Iron Ore" {
|
||||||
|
t.Errorf("inventory = %+v, want just the ore", pd.Inventory)
|
||||||
|
}
|
||||||
|
if len(pd.Vault) != 1 || pd.Vault[0].Name != "Jeweled Crown" {
|
||||||
|
t.Errorf("vault = %+v, want just the crown", pd.Vault)
|
||||||
|
}
|
||||||
|
if pd.House.Tier != 2 || pd.House.LoanBalance != 1500 {
|
||||||
|
t.Errorf("house = %+v, want tier 2 / loan 1500", pd.House)
|
||||||
|
}
|
||||||
|
if len(pd.Pets) != 1 || pd.Pets[0].Name != "Mittens" || pd.Pets[0].Level != 3 {
|
||||||
|
t.Errorf("pets = %+v, want the cat Mittens L3", pd.Pets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetailSnapshotIgnoresOptOut: the news opt-out governs the *public* board,
|
||||||
|
// not the private self-view. A player who opted out must still receive their own
|
||||||
|
// sheet — Pete only ever serves this back to them — so buildDetailSnapshot must
|
||||||
|
// NOT filter on it, unlike buildRosterSnapshot.
|
||||||
|
func TestDetailSnapshotIgnoresOptOut(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
shown := id.UserID("@shown:test")
|
||||||
|
hidden := id.UserID("@hidden:test")
|
||||||
|
seedDetailPlayer(t, shown, "Shown", 4)
|
||||||
|
seedDetailPlayer(t, hidden, "Hidden", 4)
|
||||||
|
setNewsOptout(hidden, true)
|
||||||
|
|
||||||
|
// The public board drops the opted-out player...
|
||||||
|
roster, err := buildRosterSnapshot(time.Now().UTC(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRosterSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
if len(roster.Adventurers) != 1 || roster.Adventurers[0].Name != "Shown" {
|
||||||
|
t.Fatalf("public board = %d entries, want just Shown", len(roster.Adventurers))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...but the private detail set keeps them both.
|
||||||
|
detail, err := buildDetailSnapshot(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
if len(detail.Players) != 2 {
|
||||||
|
t.Fatalf("private detail set = %d players, want 2 — opt-out must not deny a player their own self-view", len(detail.Players))
|
||||||
|
}
|
||||||
|
byLP := map[string]bool{}
|
||||||
|
for _, p := range detail.Players {
|
||||||
|
byLP[p.Localpart] = true
|
||||||
|
}
|
||||||
|
if !byLP["hidden"] {
|
||||||
|
t.Error("opted-out player is missing from the private self-view set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetailSnapshotSkipsDeadPlayers: the set is drawn from alive players only.
|
||||||
|
// A dead character has no live board page to own, and pushing their stale
|
||||||
|
// inventory would leave it standing on Pete after they're gone.
|
||||||
|
func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) {
|
||||||
|
newMischiefTestDB(t)
|
||||||
|
alive := id.UserID("@alive:test")
|
||||||
|
dead := id.UserID("@dead:test")
|
||||||
|
seedDetailPlayer(t, alive, "Alive", 4)
|
||||||
|
seedDetailPlayer(t, dead, "Dead", 4)
|
||||||
|
if _, err := db.Get().Exec(`UPDATE player_meta SET alive = 0 WHERE user_id = ?`, string(dead)); err != nil {
|
||||||
|
t.Fatalf("kill player: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, err := buildDetailSnapshot(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
if len(snap.Players) != 1 || snap.Players[0].Localpart != "alive" {
|
||||||
|
t.Fatalf("detail set = %+v, want just the living player", snap.Players)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ func (p *AdventurePlugin) peteRosterTicker() {
|
|||||||
continue // master switch off: the board goes stale on Pete and says so
|
continue // master switch off: the board goes stale on Pete and says so
|
||||||
}
|
}
|
||||||
p.pushRoster()
|
p.pushRoster()
|
||||||
|
p.pushDetails()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +90,159 @@ func (p *AdventurePlugin) pushRoster() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detailPushOK mirrors rosterPushOK for the private self-detail push: log the
|
||||||
|
// transitions and nothing else.
|
||||||
|
var detailPushOK bool
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) pushDetails() {
|
||||||
|
snap, err := buildDetailSnapshot(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("roster: build detail snapshot failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := peteclient.PushDetails(ctx, snap); err != nil {
|
||||||
|
if detailPushOK {
|
||||||
|
slog.Warn("roster: detail push failed, self-view will go stale on Pete", "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Debug("roster: detail push failed, dropping snapshot", "err", err)
|
||||||
|
}
|
||||||
|
detailPushOK = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !detailPushOK {
|
||||||
|
slog.Info("roster: private self-detail accepted by Pete", "players", len(snap.Players))
|
||||||
|
detailPushOK = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosterDetail assembles the public detail sheet for one adventurer: the combat
|
||||||
|
// stats every visitor may see, plus equipped gear. Expedition context (supplies,
|
||||||
|
// threat, room) is layered on by the caller when a run is active.
|
||||||
|
func rosterDetail(uid id.UserID, c *DnDCharacter) *peteclient.RosterDetail {
|
||||||
|
d := &peteclient.RosterDetail{
|
||||||
|
HPCurrent: c.HPCurrent,
|
||||||
|
HPMax: c.HPMax,
|
||||||
|
TempHP: c.TempHP,
|
||||||
|
ArmorClass: c.ArmorClass,
|
||||||
|
Abilities: [6]int{c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA},
|
||||||
|
Modifiers: c.Modifiers(),
|
||||||
|
}
|
||||||
|
if equip, err := loadAdvEquipment(uid); err == nil {
|
||||||
|
for _, slot := range allSlots {
|
||||||
|
e, ok := equip[slot]
|
||||||
|
if !ok || e == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d.Gear = append(d.Gear, peteclient.GearItem{
|
||||||
|
Slot: string(slot),
|
||||||
|
Name: e.Name,
|
||||||
|
Tier: e.Tier,
|
||||||
|
Condition: e.Condition,
|
||||||
|
Masterwork: e.Masterwork,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDetailSnapshot assembles the private, owner-only detail set — inventory,
|
||||||
|
// vault, house, and pets for every alive player, keyed by localpart. It does NOT
|
||||||
|
// skip opted-out players: the news opt-out governs the *public* board, but this
|
||||||
|
// data is never shown there — Pete only ever serves it back to the one signed-in
|
||||||
|
// owner it belongs to, so hiding a player from it would only deny them their own
|
||||||
|
// sheet. The board token rides along so Pete can match owner↔page without ever
|
||||||
|
// reversing the one-way token.
|
||||||
|
func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
|
||||||
|
snap := peteclient.DetailSnapshot{SnapshotAt: now.Unix()}
|
||||||
|
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
|
||||||
|
if err != nil {
|
||||||
|
return snap, err
|
||||||
|
}
|
||||||
|
var uids []id.UserID
|
||||||
|
for rows.Next() {
|
||||||
|
var uid string
|
||||||
|
if err := rows.Scan(&uid); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return snap, err
|
||||||
|
}
|
||||||
|
uids = append(uids, id.UserID(uid))
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return snap, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, uid := range uids {
|
||||||
|
lp := localpartOf(uid)
|
||||||
|
if lp == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
adv, err := loadAdvCharacter(uid)
|
||||||
|
if err != nil || adv == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pd := peteclient.PlayerDetail{
|
||||||
|
Localpart: lp,
|
||||||
|
Token: eventToken(uid, "roster"),
|
||||||
|
House: peteclient.HouseView{
|
||||||
|
Tier: adv.HouseTier,
|
||||||
|
LoanBalance: adv.HouseLoanBalance,
|
||||||
|
Autopay: adv.HouseAutopay,
|
||||||
|
Rate: adv.HouseCurrentRate,
|
||||||
|
},
|
||||||
|
Pets: petViews(adv),
|
||||||
|
}
|
||||||
|
if items, err := loadAdvInventory(uid); err == nil {
|
||||||
|
pd.Inventory = itemViews(items)
|
||||||
|
}
|
||||||
|
if items, err := loadAdvVault(uid); err == nil {
|
||||||
|
pd.Vault = itemViews(items)
|
||||||
|
}
|
||||||
|
snap.Players = append(snap.Players, pd)
|
||||||
|
}
|
||||||
|
return snap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func itemViews(items []AdvItem) []peteclient.ItemView {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]peteclient.ItemView, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
out = append(out, peteclient.ItemView{
|
||||||
|
Name: it.Name,
|
||||||
|
Type: it.Type,
|
||||||
|
Tier: it.Tier,
|
||||||
|
Value: it.Value,
|
||||||
|
Temper: it.Temper,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// petViews returns the player's live pet slots. A pet that was chased away is
|
||||||
|
// omitted — it isn't with them right now, and the self-view shows the present.
|
||||||
|
func petViews(adv *AdventureCharacter) []peteclient.PetView {
|
||||||
|
var out []peteclient.PetView
|
||||||
|
if adv.PetType != "" && !adv.PetChasedAway {
|
||||||
|
out = append(out, peteclient.PetView{
|
||||||
|
Type: adv.PetType, Name: adv.PetName, Level: adv.PetLevel,
|
||||||
|
XP: adv.PetXP, ArmorTier: adv.PetArmorTier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if adv.Pet2Type != "" && !adv.Pet2ChasedAway {
|
||||||
|
out = append(out, peteclient.PetView{
|
||||||
|
Type: adv.Pet2Type, Name: adv.Pet2Name, Level: adv.Pet2Level,
|
||||||
|
XP: adv.Pet2XP, ArmorTier: adv.Pet2ArmorTier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// buildRosterSnapshot assembles the complete board.
|
// buildRosterSnapshot assembles the complete board.
|
||||||
//
|
//
|
||||||
// Complete is the contract: Pete *replaces* its board with this, so anyone we
|
// Complete is the contract: Pete *replaces* its board with this, so anyone we
|
||||||
@@ -172,6 +327,8 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap
|
|||||||
Status: "idle",
|
Status: "idle",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.Detail = rosterDetail(pl.uid, c)
|
||||||
|
|
||||||
if exp, _ := getActiveExpedition(pl.uid); exp != nil {
|
if exp, _ := getActiveExpedition(pl.uid); exp != nil {
|
||||||
zone := zoneOrFallback(exp.ZoneID)
|
zone := zoneOrFallback(exp.ZoneID)
|
||||||
e.Status = "expedition"
|
e.Status = "expedition"
|
||||||
@@ -182,6 +339,15 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap
|
|||||||
e.Region = r.Name
|
e.Region = r.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if e.Detail != nil {
|
||||||
|
e.Detail.Supplies = int(exp.Supplies.Current)
|
||||||
|
e.Detail.ThreatLevel = exp.ThreatLevel
|
||||||
|
if exp.RunID != "" {
|
||||||
|
if run, rerr := getZoneRun(exp.RunID); rerr == nil && run != nil && run.TotalRooms > 0 {
|
||||||
|
e.Detail.Room = fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if pl.lastAction != nil {
|
} else if pl.lastAction != nil {
|
||||||
if h := int(now.Sub(*pl.lastAction).Hours()); h > 0 {
|
if h := int(now.Sub(*pl.lastAction).Hours()); h > 0 {
|
||||||
e.IdleHours = h
|
e.IdleHours = h
|
||||||
|
|||||||
Reference in New Issue
Block a user