mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 01:42:42 +00:00
The self-view listed a name, a tier and a price — everything except what a player decides on. The facts were all there, just not on the wire. Three things the contract spec got wrong, found by reading both sides: Equipping *moves* the row out of adventure_inventory into magic_item_equipped, so the two sets are disjoint. The spec's `attuned` on a backpack item can never be true — bond state isn't false there, it's undefined. The real gap was that worn items weren't sent at all: the panel showed the backpack and hid the sword. Hence Equipped, where Attuned means something and an inert item can be seen. Stat modifiers ARE modeled. The spec said they weren't, and that shipping them meant either an engine change or a display-only approximation that lies the first time it disagrees with the engine. But magicItemEffectSummary is the engine's own summary — the same function the game speaks with. Sending it can't drift, because there's nothing to drift from. SkillSource is two different things: "mining" on masterwork gear, and the internal "magic_item:<id>" registry pointer on magic-item rows. Sending it raw would put gogobee's IDs on a page, and Pete couldn't tell them apart to filter them. Only the skill name goes out. Desc and Effect resolve at the push site because an inventory row carries neither — descriptions live on MagicItem/EquipmentDef, and the combat delta is computed, never stored. Shop gear resolves by (slot, tier); Name is decorative there. All additive and omitempty on the private /api/ingest/detail push, so neither side has to deploy first.
459 lines
15 KiB
Go
459 lines
15 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()
|
|
}
|
|
}
|
|
|
|
// 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 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
|
|
} 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
} 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
|
|
}
|
|
|
|
// 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
|
|
}
|