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() } } // 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 := 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 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) 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 } 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 }