mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 01:42:42 +00:00
For each backpack magic item, compute the per-stat diff against whatever occupies the slot it would equip into (mi.Slot — the same slot the web Equip button targets, so the card describes the trade that actually happens). The verdict is strict dominance: all-gain is an upgrade, all-loss a downgrade, mixed a sidegrade with no winner claimed (the case the two opaque effect strings could never show). Empty slot reads 'new'; an attunement item with no free bond reads 'inert', which overrides the stat verdict because wearing it does nothing. The diff is over tempered effects on both sides and reuses the engine's own magicItemEffectFor, so nothing here re-derives power math that could drift from the game. Rides an additive Compare object on the private backpack ItemView — no migration, no endpoint, no public surface. Rings collapse to the same path: every ring equips to ring_1 (ring_2 is declared but never assigned by any live code), so a backpack ring simply compares against the ring_1 occupant.
676 lines
23 KiB
Go
676 lines
23 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 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
|
|
}
|