Files
gogobee/internal/plugin/pete_roster_test.go
prosolis b6d4e4ccec adventure: send Pete what an item actually is, and what's worn
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.
2026-07-17 06:44:45 -07:00

227 lines
8.1 KiB
Go

package plugin
import (
"sort"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// seedRosterPlayer puts a real, playable character on the board: a player_meta
// row (which carries the idle clock and the display name) plus a confirmed
// dnd_character. Real rows on purpose — the snapshot query reads two DATETIME
// columns, and the modernc affinity trap only fires against actual stored
// values, never against a hand-built struct.
func seedRosterPlayer(t *testing.T, uid id.UserID, name string, created, lastAction *time.Time) {
t.Helper()
if _, err := db.Get().Exec(
`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_player_action_at)
VALUES (?, ?, 1, ?, ?)`,
string(uid), name, created, lastAction); err != nil {
t.Fatalf("seed player_meta: %v", err)
}
if err := SaveDnDCharacter(&DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
STR: 15, DEX: 14, CON: 13, INT: 12, WIS: 10, CHA: 8,
HPMax: 30, HPCurrent: 30, ArmorClass: 16,
PendingSetup: false,
}); err != nil {
t.Fatalf("seed character: %v", err)
}
}
// TestRosterSnapshotReadsTheClock is the scan-affinity guard. The snapshot
// selects last_player_action_at and created_at as declared DATETIME columns and
// folds them in Go; a COALESCE() in the SQL would erase the affinity, the Scan
// would fail, and buildRosterSnapshot would return an error — publishing an
// empty board (every adventurer vanishes from the public page) rather than
// anything obviously broken.
func TestRosterSnapshotReadsTheClock(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-50 * time.Hour)
recent := now.Add(-3 * time.Hour)
seedRosterPlayer(t, "@quiet:test", "Quack", &old, &old)
seedRosterPlayer(t, "@recent:test", "Josie", &old, &recent)
// Never acted at all: the fold falls through to created_at.
seedRosterPlayer(t, "@never:test", "Camcast", &old, nil)
snap, err := buildRosterSnapshot(now, nil)
if err != nil {
t.Fatalf("buildRosterSnapshot: %v", err)
}
if len(snap.Adventurers) != 3 {
t.Fatalf("board has %d adventurers, want 3", len(snap.Adventurers))
}
if snap.SnapshotAt != now.Unix() {
t.Errorf("snapshot_at = %d, want %d", snap.SnapshotAt, now.Unix())
}
byName := map[string]int{}
for _, a := range snap.Adventurers {
byName[a.Name] = a.IdleHours
if a.Status != "idle" {
t.Errorf("%s status = %q, want idle (nobody is on an expedition)", a.Name, a.Status)
}
if a.Token == "" {
t.Errorf("%s has no board token", a.Name)
}
}
if got := byName["Quack"]; got != 50 {
t.Errorf("Quack idle hours = %d, want 50 — the clock didn't survive the scan", got)
}
if got := byName["Josie"]; got != 3 {
t.Errorf("Josie idle hours = %d, want 3", got)
}
if got := byName["Camcast"]; got != 50 {
t.Errorf("Camcast idle hours = %d, want 50 (fell through to created_at)", got)
}
}
// TestRosterOmitsOptedOut is the privacy contract. Pete *replaces* its whole
// board with this payload, so omission is what enforces the opt-out. Anonymizing
// instead would be a fig leaf: a standing row with class + level + zone names the
// player anyway.
func TestRosterOmitsOptedOut(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-30 * time.Hour)
seedRosterPlayer(t, "@shown:test", "Josie", &old, &old)
seedRosterPlayer(t, "@hidden:test", "Quack", &old, &old)
setNewsOptout("@hidden:test", true)
snap, err := buildRosterSnapshot(now, nil)
if err != nil {
t.Fatalf("buildRosterSnapshot: %v", err)
}
if len(snap.Adventurers) != 1 {
t.Fatalf("board has %d adventurers, want 1", len(snap.Adventurers))
}
if snap.Adventurers[0].Name != "Quack" && snap.Adventurers[0].Name != "Josie" {
t.Fatalf("unexpected adventurer %q", snap.Adventurers[0].Name)
}
if snap.Adventurers[0].Name == "Quack" {
t.Error("an opted-out player is on the public board")
}
}
// TestRosterTokenIsNotAnEventToken: the board token must not be one of the
// player's event tokens, or a standing row would become the key that links all
// their dispatches back together — the exact unlinkability eventToken exists to
// provide.
func TestRosterTokenIsNotAnEventToken(t *testing.T) {
newBoredomTestDB(t)
uid := id.UserID("@josie:test")
board := eventToken(uid, "roster")
if board == eventToken(uid, "arrival") || board == eventToken(uid, "death:crypt:1") {
t.Error("board token collides with an event token — the board would deanonymize the feed")
}
if board != eventToken(uid, "roster") {
t.Error("board token not stable — the row would churn identity every snapshot")
}
}
// pickMagicItem returns a registry item matching want, so these tests read the
// real registry rather than pinning an item ID that a later SRD dump could drop.
func pickMagicItem(t *testing.T, want func(MagicItem) bool) MagicItem {
t.Helper()
var ids []string
for id := range magicItemRegistry {
ids = append(ids, id)
}
sort.Strings(ids) // map order is random; a flaky pick is a flaky test
for _, id := range ids {
if mi := magicItemRegistry[id]; want(mi) {
return mi
}
}
t.Skip("no registry item matches this shape")
return MagicItem{}
}
// TestItemViewsKeepsTheRegistryPointerHome is the leak guard. SkillSource is two
// different things depending on the row: a player-facing skill name on
// masterwork gear ("mining"), and the internal "magic_item:<id>" pointer that
// resolves an inventory row back to the registry. Only the first is a fact about
// the item. Sending the second would put gogobee's internal IDs on a page, and
// Pete would have no way to tell them apart to filter them out.
func TestItemViewsKeepsTheRegistryPointerHome(t *testing.T) {
newBoredomTestDB(t)
mi := pickMagicItem(t, func(m MagicItem) bool { return m.Desc != "" && m.Slot != "" })
views := itemViews([]AdvItem{
{Name: mi.Name, Type: "magic_item", Tier: 3, Value: 100,
SkillSource: "magic_item:" + mi.ID},
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
Slot: SlotWeapon, SkillSource: "mining"},
})
if views[0].SkillSource != "" {
t.Errorf("the magic_item registry pointer went out on the wire: %q", views[0].SkillSource)
}
if views[0].Desc != mi.Desc {
t.Errorf("desc = %q, want the registry's %q", views[0].Desc, mi.Desc)
}
if views[0].Effect == "" {
t.Error("a magic item should carry the engine's own effect summary")
}
if views[1].SkillSource != "mining" {
t.Errorf("masterwork skill source = %q, want it kept", views[1].SkillSource)
}
}
// TestItemViewsNeverClaimABackpackBond: equipping *moves* the row out of
// adventure_inventory into magic_item_equipped, so nothing in a backpack can
// hold a bond. Attuned must stay false here whatever the item wants, or the
// panel tells a player an unworn item is working for them.
func TestItemViewsNeverClaimABackpackBond(t *testing.T) {
newBoredomTestDB(t)
mi := pickMagicItem(t, func(m MagicItem) bool { return m.Attunement && m.Slot != "" })
v := itemViews([]AdvItem{{Name: mi.Name, Type: "magic_item", Tier: 3,
SkillSource: "magic_item:" + mi.ID}})[0]
if !v.Attunement {
t.Error("an attunement item should say it wants a bond")
}
if v.Attuned {
t.Error("a backpack item claimed a bond it cannot hold")
}
}
// TestEquippedViewsCarryBondState: the worn set is the only place Attuned means
// anything, and the only way the page can show that a worn item is sitting inert
// against the cap of three.
func TestEquippedViewsCarryBondState(t *testing.T) {
newBoredomTestDB(t)
uid := id.UserID("@josie:example.org")
mi := pickMagicItem(t, func(m MagicItem) bool { return m.Attunement && m.Slot != "" })
if err := equipMagicItem(uid, mi.Slot, mi.ID, false, 0); err != nil {
t.Fatalf("equip: %v", err)
}
views := equippedViews(uid)
if len(views) != 1 {
t.Fatalf("equipped views = %d, want 1", len(views))
}
if views[0].Attuned {
t.Error("an inert worn item was reported as bonded")
}
if views[0].Slot != string(mi.Slot) {
t.Errorf("slot = %q, want %q", views[0].Slot, mi.Slot)
}
if err := equipMagicItem(uid, mi.Slot, mi.ID, true, 0); err != nil {
t.Fatalf("re-equip bonded: %v", err)
}
if !equippedViews(uid)[0].Attuned {
t.Error("a bonded worn item was reported as inert")
}
}