Files
gogobee/internal/plugin/pete_roster.go
T
prosolis 0570afc2e4 worldboss: push the Siege to Pete and file its three dispatches
The Siege had no web presence at all, and three dispatch templates
(siege_start / siege_win / siege_loss) have been sitting written and
unemitted in Pete's renderAdventure since the adventure section shipped.
Both halves fixed here.

The snapshot rides the existing roster ticker with the roster's rules:
pushed whole, replacing Pete's copy, dropped rather than retried on
failure. Retrying would be a lie about how much HP is left, and the next
tick carries the truth anyway.

The muster carries EVERY alive adventurer, not just contributors. The
zero-fight rows are the point: one bout per person per day means somebody
who hasn't swung today is a hit the town hasn't taken, and Pete can only
draw that column if the people in it are on the wire.

Opt-out departs from the board's rule on purpose. The board omits an
opted-out player outright — class + level + zone re-identifies them. A
contributor here is anonymised instead: the damage they did is on the
boss and is part of what the town accomplished, so deleting it would
understate the shared effort and stop the totals adding up. They keep
their damage and their rank, and carry no board token, so nothing links
back to a page that names them. An opted-out player who never fought is
still dropped — nothing to account for.

The three dispatches key their GUID on the boss row id, so a resolution
path re-entered (a redeploy mid-window, the ticker's safety net firing
after an inline kill) files the same guid and Pete dedupes it, rather
than announcing one Siege to the room twice.

Also retires the stale "deploy Pete first, an unknown event_type is a
400" note on emitBoredomDeparture. Pete now publishes an untemplated
type on a neutral fallback and counts it for the operator, so the
ordering is a property of the system rather than a rule to remember.
2026-07-24 15:21:36 -07:00

690 lines
24 KiB
Go

package plugin
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// The live adventurer board pushed to Pete (gogobee_boredom_plan.md's sibling —
// see the roster section of the Pete plan).
//
// Everything else we send Pete is an accomplishment: a death, a clear, a
// milestone. Those are clippings — they read as archive the moment they land, no
// matter how fast we deliver them. The board is the other kind of thing: state
// that is *currently true*, which is the only thing that can make a page feel
// alive. So it is a snapshot, pushed whole, replacing whatever Pete had.
//
// It is also, by design, a target list. The plan is to let people who aren't
// even playing hire assassins and mobs against adventurers who are out in the
// world right now — so the board carries a stable per-player token and real zone
// depth, not just a pretty display string, and it shows the zone *live* while
// they're still in it.
const (
// rosterTickInterval — how often we push. Pete's staleness window is several
// times this, so a missed push or two is invisible; a real outage isn't.
rosterTickInterval = 2 * time.Minute
// rosterPushTimeout — the push is dropped on failure, never retried (a stale
// snapshot is a lie, and the next tick carries the truth), so it must not be
// able to pile up.
rosterPushTimeout = 15 * time.Second
)
// peteRosterTicker pushes the board to Pete forever.
func (p *AdventurePlugin) peteRosterTicker() {
if !peteclient.Enabled() {
return
}
ticker := time.NewTicker(rosterTickInterval)
defer ticker.Stop()
for range ticker.C {
if !newsEmissionOn() {
continue // master switch off: the board goes stale on Pete and says so
}
p.pushRoster()
p.pushDetails()
p.pushSiege()
}
}
// rosterPushOK tracks the last push's outcome so we can log the transitions and
// nothing else. A push every 2 minutes forever is far too noisy to log at INFO,
// but total silence is worse: a ticker that is succeeding quietly looks exactly
// like one that never started, and that ambiguity already cost an operator a
// wrong-turn debug during the first deploy. So: say something the first time it
// works, say something when it breaks, say something when it recovers.
var rosterPushOK bool
func (p *AdventurePlugin) pushRoster() {
snap, err := buildRosterSnapshot(time.Now().UTC(), p.euro)
if err != nil {
slog.Error("roster: build snapshot failed", "err", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout)
defer cancel()
if err := peteclient.PushRoster(ctx, snap); err != nil {
// The failure itself is not alarming — the next tick retries by simply
// being a fresher snapshot, and if we stay down Pete's board correctly
// stops claiming to be live. Only the *transition* is worth a line.
if rosterPushOK {
slog.Warn("roster: push failed, board will go stale on Pete", "err", err)
} else {
slog.Debug("roster: push failed, dropping snapshot", "err", err)
}
rosterPushOK = false
return
}
if !rosterPushOK {
slog.Info("roster: board accepted by Pete — live adventurer board is publishing",
"adventurers", len(snap.Adventurers))
rosterPushOK = true
}
}
// detailPushOK mirrors rosterPushOK for the private self-detail push: log the
// transitions and nothing else.
var detailPushOK bool
func (p *AdventurePlugin) pushDetails() {
snap, err := p.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 (p *AdventurePlugin) 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 equipped, err := loadEquippedMagicItems(uid); err == nil {
attachInventoryCompares(pd.Inventory, items, equipped)
}
}
if items, err := loadAdvVault(uid); err == nil {
pd.Vault = itemViews(items)
}
pd.Equipped = equippedViews(uid)
// Ask 7: the 5 standard slots for the web management panel, plus the euro
// balance the upgrade/repair confirm dialogs show. Balance is nil-guarded so
// the free-standing tests (which build no euro plugin) still run.
pd.Slots = buildEquipSlotViews(uid)
if p.euro != nil {
pd.Balance = p.euro.GetBalance(uid)
}
snap.Players = append(snap.Players, pd)
}
return snap, nil
}
// itemViews renders inventory or vault rows for the private panel, resolving
// the display facts the row itself doesn't carry.
//
// Attuned is always false here and that is not an omission: equipping *moves*
// the row out of adventure_inventory into magic_item_equipped, so nothing in a
// backpack can hold a bond. Worn items come from equippedViews instead.
func itemViews(items []AdvItem) []peteclient.ItemView {
if len(items) == 0 {
return nil
}
out := make([]peteclient.ItemView, 0, len(items))
for _, it := range items {
v := peteclient.ItemView{
Name: it.Name,
Type: it.Type,
Tier: it.Tier,
Value: it.Value,
Temper: it.Temper,
Slot: string(it.Slot),
}
// SkillSource is dual-use: a real skill name on masterwork gear, an
// internal registry pointer on magic-item rows. Only the former is a
// fact about the item; the latter is plumbing and stays home.
if !strings.HasPrefix(it.SkillSource, "magic_item:") {
v.SkillSource = it.SkillSource
}
if mi, ok := magicItemFromAdvItem(it); ok {
eff := temperedItem(mi, it.Temper)
v.Slot = string(eff.Slot)
v.Desc = eff.Desc
v.Effect = magicItemEffectSummary(eff)
v.Attunement = eff.Attunement
// The row id is the equip handle: a slotted magic item is the one thing
// the web equip path can wear, so only it carries an id. Mundane gear (the
// branch below) and unslotted curios get none, so no Equip button. This
// runs for vault rows too — a vault magic item would carry an id — but Pete
// offers the button on the backpack panel alone, so that's inert, not a leak.
if eff.Slot != "" {
v.ID = it.ID
}
} else if it.Slot != "" {
// Shop equipment resolves by (slot, tier) — Name is decorative.
v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description
// A masterwork/arena piece carries a real slot, so it can be worn from the
// web (into a standard slot) — give it the equip handle. Plain shop gear in
// the pack stays button-less: its slot is just a category, not a wearable.
if it.Type == "MasterworkGear" || it.Type == "ArenaGear" {
v.ID = it.ID
}
}
out = append(out, v)
}
return out
}
// attachInventoryCompares fills the Compare card on every backpack magic item —
// the ones that carry an equip id, which is exactly the set the web Equip button
// acts on (equippedViews/vault rows are excluded by construction). views and items
// are index-aligned: itemViews appends one view per item and skips none.
func attachInventoryCompares(views []peteclient.ItemView, items []AdvItem, equipped map[DnDSlot]EquippedMagicItem) {
for i := range views {
if views[i].ID == 0 {
continue // no equip id → mundane gear or unslotted curio → no button, no compare
}
mi, ok := magicItemFromAdvItem(items[i])
if !ok || mi.Slot == "" {
continue
}
views[i].Compare = magicItemCompare(mi, items[i].Temper, equipped)
}
}
// magicItemCompare pairs a candidate backpack item against whatever is worn in
// the slot it would equip into (mi.Slot — the same slot applyMagicEquip targets,
// so the card describes the trade the Equip button actually makes). The diff is
// over *tempered* effects on both sides; bond availability decides the inert case.
func magicItemCompare(cand MagicItem, temper int, equipped map[DnDSlot]EquippedMagicItem) *peteclient.ItemCompare {
if cand.Slot == "" {
return nil
}
candEff := magicItemEffectFor(temperedItem(cand, temper))
worn, wornExists := equipped[cand.Slot]
if wornExists && worn.Item.ID == "" {
wornExists = false // an empty EquippedMagicItem is not a real occupant
}
// An empty slot compares against neutral: DamageReductMult is a multiplier, so
// its neutral is 1.0, not the zero value (0 would read as -100% damage taken).
wornEff := magicItemEffect{DamageReductMult: 1.0}
vsName := ""
if wornExists {
wornEff = magicItemEffectFor(worn.Effective())
vsName = worn.Effective().Name
}
deltas := magicItemDeltas(candEff, wornEff)
// Inert: the item wants a bond and none is free. Equipping evicts the slot's
// occupant first, so an attuned occupant frees its own bond — count post-swap.
inert := false
if cand.Attunement {
bonds := countAttunedMagicItems(equipped)
if wornExists && worn.Attuned {
bonds--
}
inert = bonds >= dndMagicItemAttuneLimit
}
return &peteclient.ItemCompare{
Verdict: compareVerdict(deltas, !wornExists, inert),
VsName: vsName,
VsSlot: string(cand.Slot),
Deltas: deltas,
}
}
// compareVerdict classifies a set of deltas by strict dominance. Different stats
// are not fungible — the engine can't say +3% damage beats -4 HP — so a mixed
// result is a sidegrade with no winner claimed, which is the whole reason the
// card exists. inert and new override the stat verdict.
func compareVerdict(deltas []peteclient.ItemDelta, empty, inert bool) string {
if inert {
return "inert" // wearing it does nothing until a bond frees; stat diff is moot
}
if empty {
return "new"
}
if len(deltas) == 0 {
return "same"
}
gains, losses := 0, 0
for _, d := range deltas {
if d.Better {
gains++
} else {
losses++
}
}
switch {
case losses == 0:
return "upgrade"
case gains == 0:
return "downgrade"
default:
return "sidegrade"
}
}
// magicItemDeltas returns one entry per stat that visibly changes between the
// candidate and the worn item. It diffs the structured effect fields, never the
// summary string (which drops zero fields and would lose deltas). A change too
// small to show at the rendered precision is omitted, so the verdict matches what
// the player sees.
func magicItemDeltas(cand, worn magicItemEffect) []peteclient.ItemDelta {
var d []peteclient.ItemDelta
// DamageBonus / DamageReductMult are fractions rendered as whole percents.
if pct := (cand.DamageBonus - worn.DamageBonus) * 100; roundedPct(pct) != 0 {
d = append(d, peteclient.ItemDelta{Label: "damage", Better: pct > 0, Text: signedPct(pct, "damage")})
}
// DamageReductMult is a multiplier on damage TAKEN, so lower is better. Express
// the change as damage taken: a positive number means you take more (worse).
if taken := (cand.DamageReductMult - worn.DamageReductMult) * 100; roundedPct(taken) != 0 {
d = append(d, peteclient.ItemDelta{Label: "defense", Better: taken < 0, Text: signedPct(taken, "damage taken")})
}
if diff := cand.FlatDmgStart - worn.FlatDmgStart; diff != 0 {
d = append(d, peteclient.ItemDelta{Label: "opening", Better: diff > 0, Text: signedInt(diff, "opening damage")})
}
if diff := cand.MaxHP - worn.MaxHP; diff != 0 {
d = append(d, peteclient.ItemDelta{Label: "hp", Better: diff > 0, Text: signedInt(diff, "HP")})
}
if cand.InitiativeBias != worn.InitiativeBias {
faster := cand.InitiativeBias > worn.InitiativeBias
text := "slower to act"
if faster {
text = "faster to act"
}
d = append(d, peteclient.ItemDelta{Label: "speed", Better: faster, Text: text})
}
return d
}
// roundedPct is the whole-percent a delta renders as; used to drop sub-percent
// noise so the verdict never disagrees with the displayed chips.
func roundedPct(v float64) int {
if v < 0 {
return int(v - 0.5)
}
return int(v + 0.5)
}
func signedPct(v float64, noun string) string { return fmt.Sprintf("%+d%% %s", roundedPct(v), noun) }
func signedInt(v int, noun string) string { return fmt.Sprintf("%+d %s", v, noun) }
// equippedViews returns the magic items the player is actually wearing. This is
// the only place Attuned means anything: the bond lives on the equipped row, and
// with a cap of dndMagicItemAttuneLimit a worn item can be inert.
func equippedViews(uid id.UserID) []peteclient.ItemView {
equipped, err := loadEquippedMagicItems(uid)
if err != nil || len(equipped) == 0 {
return nil
}
out := make([]peteclient.ItemView, 0, len(equipped))
for _, e := range equipped {
eff := e.Effective()
out = append(out, peteclient.ItemView{
Name: eff.Name,
Type: string(eff.Kind),
Value: int64(eff.Value),
Temper: e.Temper,
Slot: string(e.Slot),
Desc: eff.Desc,
Effect: magicItemEffectSummary(eff),
Attunement: eff.Attunement,
Attuned: e.Attuned,
})
}
// Map iteration is random; the panel must not reshuffle every 60s poll.
sort.Slice(out, func(i, j int) bool { return out[i].Slot < out[j].Slot })
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.
//
// Complete is the contract: Pete *replaces* its board with this, so anyone we
// omit drops off the public page. That is exactly how the opt-out is enforced —
// an opted-out player is simply never in the payload, rather than being sent and
// anonymized. A standing row showing class + level + zone is trivially
// re-identifiable (there is one level-14 cleric), so "an adventurer" would have
// been a fig leaf; absence is the only honest option.
func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnapshot, error) {
snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix(), Tiers: mischiefTierCatalog()}
// Both DATETIME columns selected raw and folded in Go — NOT COALESCE()'d in
// SQL. modernc.org/sqlite rebuilds a time.Time from the column's *declared*
// type, and COALESCE() erases that affinity: the value comes back a string
// and the Scan fails. Same trap playerIsIdle documents.
rows, err := db.Get().Query(`
SELECT user_id, last_player_action_at, created_at
FROM player_meta
WHERE alive = 1`)
if err != nil {
return snap, err
}
defer rows.Close()
type player struct {
uid id.UserID
lastAction *time.Time
}
var players []player
for rows.Next() {
var uid string
var lastAction, created *time.Time
if err := rows.Scan(&uid, &lastAction, &created); err != nil {
return snap, err
}
if lastAction == nil {
lastAction = created
}
players = append(players, player{id.UserID(uid), lastAction})
}
if err := rows.Err(); err != nil {
return snap, err
}
for _, pl := range players {
// The buyer's own advisory balance rides along in a separate keyspace,
// keyed by localpart (their sign-in name), and is collected *before* the
// opt-out skip: opt-out hides a player from the public board, but their own
// balance is private on Pete — only ever read for the user asking about
// themselves — so there is no reason to deny an opted-out player the
// storefront's affordability hint. localpart is lowercase, matching how the
// buyer signs in and how a web order's username is resolved back to an MXID.
if euro != nil {
if lp := localpartOf(pl.uid); lp != "" {
snap.Balances = append(snap.Balances, peteclient.MischiefBalance{
Username: lp,
Euro: euro.GetBalance(pl.uid),
})
}
}
if isNewsOptedOut(pl.uid) {
continue
}
c, err := LoadDnDCharacter(pl.uid)
if err != nil || c == nil || c.PendingSetup {
continue // no character to show; a half-made one has no name yet
}
name := charName(pl.uid)
if name == "" {
continue // never fall back to a Matrix handle on a public page
}
e := peteclient.RosterEntry{
// Stable per-player board token: salted, so it can't be recomputed
// from the handle, and distinct from every event token, so the board
// doesn't become the key that links a player's dispatches together.
Token: eventToken(pl.uid, "roster"),
Name: name,
Level: c.Level,
ClassRace: classRaceLabel(c),
Status: "idle",
}
e.Detail = rosterDetail(pl.uid, c)
if exp, _ := getActiveExpedition(pl.uid); exp != nil {
zone := zoneOrFallback(exp.ZoneID)
e.Status = "expedition"
e.Zone = zone.Display
e.Day = exp.CurrentDay
if IsMultiRegionZone(exp.ZoneID) {
if r, ok := CurrentRegion(exp); ok {
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)
e.Detail.Map = buildRosterMap(run)
}
}
}
} else if pl.lastAction != nil {
if h := int(now.Sub(*pl.lastAction).Hours()); h > 0 {
e.IdleHours = h
}
}
snap.Adventurers = append(snap.Adventurers, e)
}
return snap, nil
}
// buildRosterMap computes the fog-of-war cut of a run's zone graph for the
// public roster. It sends every visited node with its true kind, plus the
// one-hop frontier — the destinations of edges leading out of visited nodes,
// with their kind withheld as "unknown". Edges are directed and stored by
// from-node, so "one hop out of a visited node" is exactly g.Edges[visited].
// A frontier node's edges are NOT walked, so nothing past the first closed
// door reaches the wire — "view source to find the boss room" is not fog of
// war. Node Label/Content never leave the game box.
//
// Output order is deterministic (visited-path order, then frontier in
// discovery order) so an unchanged run produces a byte-identical snapshot and
// the roster push does not churn.
func buildRosterMap(run *DungeonRun) *peteclient.RosterMap {
g, ok := loadZoneGraph(run.ZoneID)
if !ok || len(run.VisitedNodes) == 0 {
return nil
}
// Unique visited nodes in path order — VisitedNodes repeats on backtrack.
orderedVisited := make([]string, 0, len(run.VisitedNodes))
seen := make(map[string]bool, len(run.VisitedNodes))
for _, id := range run.VisitedNodes {
if !seen[id] {
seen[id] = true
orderedVisited = append(orderedVisited, id)
}
}
m := &peteclient.RosterMap{
ZoneID: string(run.ZoneID),
CurrentNode: run.CurrentNode,
Visited: orderedVisited,
}
// Visited nodes first, in path order, with their real kind.
emitted := make(map[string]bool, len(orderedVisited))
for _, id := range orderedVisited {
emitted[id] = true
if n, ok := g.Nodes[id]; ok {
m.Nodes = append(m.Nodes, peteclient.RosterMapNode{ID: id, Kind: string(n.Kind)})
}
}
// Frontier: destinations of edges out of visited nodes, kind withheld.
// Walk visited in path order so the frontier order is stable.
for _, from := range orderedVisited {
for _, edge := range g.Edges[from] {
lock := edge.Lock
if lock == LockNone {
lock = "" // an open door needs no mark; omitempty drops it
}
m.Edges = append(m.Edges, peteclient.RosterMapEdge{
From: edge.From,
To: edge.To,
Lock: string(lock),
})
if !seen[edge.To] && !emitted[edge.To] {
emitted[edge.To] = true
m.Nodes = append(m.Nodes, peteclient.RosterMapNode{ID: edge.To, Kind: "unknown"})
}
}
}
return m
}
// resolveRosterToken maps a board token back to the adventurer it names. The
// token is a one-way HMAC (eventToken), so it can't be inverted — instead we
// recompute every live player's token and match. The salt is DB-persisted, so a
// token minted before a restart still resolves after one. O(players) per call,
// which is nothing at realm scale and only runs when a web order is being placed.
func resolveRosterToken(token string) (id.UserID, bool) {
if token == "" {
return "", false
}
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
if err != nil {
return "", false
}
defer rows.Close()
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
continue
}
if eventToken(id.UserID(uid), "roster") == token {
return id.UserID(uid), true
}
}
return "", false
}
// mischiefTierCatalog is the storefront price list, pushed on every tick so the
// web shop always renders gogobee's current prices — a fee retune here reaches
// Pete within a snapshot, and Pete never hardcodes a number of its own. The
// signed fee is the same +25% sink a Matrix buyer pays to put their name on it.
func mischiefTierCatalog() []peteclient.MischiefTier {
out := make([]peteclient.MischiefTier, 0, len(mischiefTiers))
for _, t := range mischiefTiers {
out = append(out, peteclient.MischiefTier{
Key: t.Key,
Display: t.Display,
Fee: t.Fee,
SignedFee: mischiefSignedFee(t.Fee),
Blurb: t.Blurb,
})
}
return out
}