Files
gogobee/internal/plugin/pete_roster.go
T
prosolis 34519c9145 adventure: give the summary long enough to wake the model up
The run summary had sixty seconds, which is a generation budget, and
this call almost never gets to just generate. Runs end hours apart,
Ollama drops an idle model after about five minutes, so the steady state
is weights on disk and a cold load before the first token. The old
budget expired during that load every time and filed the empty beat that
means "no summary, ever" — there is no retry, the beat itself is the row
that stops the sweep re-picking the run. Five minutes now, sized for
load-then-generate, so a timeout means what the comment always claimed
it meant: the box is down.

That can't sit on a two-minute ticker, so it doesn't. The sweep starts
beside the ticker behind a single-flight flag; ticks that land during a
load skip instead of queueing. Nothing reorders — the summary is written
to the local buffer with the next seq and still ships behind the run's
own log.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 22:07:47 -07:00

774 lines
27 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()
// Self-rate-limited to realmPushInterval: the realm is aggregate scans
// over the whole run history and none of it moves at roster speed.
p.pushRealm()
p.pushRunBeats()
// After the beats, not before: the summary is the last beat of a run's
// story and has no business overtaking the log it is about. It is also the
// only step here that can talk to the model, which is why it lives on a
// ticker at all rather than at the moment a run ends — and why it starts
// beside the ticker rather than on it, since a cold model takes longer to
// load than the interval between two pushes.
p.sweepRunSummariesAsync()
}
}
// 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)
}
// W5b: what this owner may ask for from the web, priced. See pete_offers.go
// on why a quote is not a permission.
pd.Zones = zoneOffersFor(uid)
pd.Resume = resumeOfferFor(uid)
pd.Babysit = babysitOfferFor(adv)
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.
//
// XPNeeded rides along so the web can draw the progress toward the next level.
// It is the engine's number, not Pete's: the curve steps by level band and a
// copy of it on the web side would be a second answer to "how close is my dog"
// that drifts the first time the band moves.
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, XPNeeded: petXPNeededCenti(adv.PetLevel),
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, XPNeeded: petXPNeededCenti(adv.Pet2Level),
ArmorTier: adv.Pet2ArmorTier,
})
}
return out
}
// partySeatViews describes who is on this expedition for the public detail page.
// Returns nil for a solo run: expeditionParty always hands back at least the
// leader, and a "party" of one chair is a worse thing to draw than nothing.
//
// The opt-out rule is the Siege contributor's, not the realm occupant's: a seat
// belonging to an opted-out player is kept and anonymised. Deleting it would
// make a party of three read as a pair, and the numbers beside it — the supply
// burn, the threat, the enemy scaling — all felt three bodies. What an unnamed
// seat discloses is that somebody else is down there, which the zone and day on
// this same page already say about everyone in the party.
//
// Levels come from a per-seat character load. Parties cap at three and the
// companion needs no load at all, so this is at most two extra reads for a
// player who is actually in one.
func partySeatViews(exp *Expedition) []peteclient.PartySeatView {
seats, err := expeditionParty(exp.ID, exp.UserID)
if err != nil {
slog.Debug("pete: party seats unavailable", "expedition", exp.ID, "err", err)
return nil
}
if len(seats) < 2 {
return nil // solo, or a roster that only holds its leader
}
out := make([]peteclient.PartySeatView, 0, len(seats))
for _, s := range seats {
if s.Kind == SeatCompanion {
// The hireling is named unconditionally: he is not a player, has no
// board row to link to and no privacy to protect.
out = append(out, peteclient.PartySeatView{
Kind: "companion", Name: companionDisplayName,
})
continue
}
kind := "member"
if s.Kind == SeatLeader {
kind = "leader"
}
v := peteclient.PartySeatView{Kind: kind}
if !isNewsOptedOut(s.UserID) {
v.Name = charName(s.UserID)
if v.Name != "" {
// Token only alongside a name: a link to a page that says who they are
// would undo the anonymising below all by itself.
v.Token = eventToken(s.UserID, "roster")
if c, cerr := LoadDnDCharacter(s.UserID); cerr == nil && c != nil {
v.Level = c.Level
}
}
}
out = append(out, v)
}
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)
// activeExpeditionFor, not getActiveExpedition: the latter keys on
// dnd_expedition.user_id and is blind to members, so a player seated on
// somebody else's run has been reading as "idle in town" on the public board
// for the whole life of N3 parties — standing in a tier-4 dungeon. The
// expedition it resolves to is the leader's row, which is the right answer:
// a party shares one clock, one supply pool and one run.
if exp, _, _ := activeExpeditionFor(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
e.Detail.Party = partySeatViews(exp)
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
}