Files
gogobee/internal/plugin/pete_detail_test.go
T
prosolis e5d4bd6dc5 adventure: tell Pete who else is on the expedition
Party seats ride the roster detail, beside the supply and threat numbers the
same expedition already publishes. Leader first, the hireling named without a
token he has no board row for, and an opted-out player's seat kept and
anonymised — dropping it would make a party of three read as a pair while the
burn rate and enemy scaling beside it still felt three bodies.

The board itself was wrong about this: it resolved an expedition with a lookup
keyed on dnd_expedition.user_id, which is blind to members, so for the whole
life of N3 parties a seated player has shown as idle in town while standing in
a dungeon.

Pets also send the engine's XP threshold for their current level band, so the
web can draw progress without keeping a copy of a curve that would drift.
2026-07-24 20:26:28 -07:00

281 lines
10 KiB
Go

package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// The adventurer detail-page push has two halves, matched to two sensitivities:
// the public sheet (stats + gear) rides the roster snapshot keyed by the
// anonymous token, and the private self-view (inventory/vault/house/pets) rides
// its own push keyed by localpart. These tests pin both halves at the gogobee
// end — that the public detail carries no handle, and that the private detail is
// keyed so Pete can only ever hand it back to its owner.
// seedDetailPlayer builds a real, playable adventurer: player_meta + tier-0
// equipment (via createAdvCharacter) plus a confirmed combat sheet. Real rows on
// purpose — the same modernc scan hazard the roster snapshot dances around.
func seedDetailPlayer(t *testing.T, uid id.UserID, name string, level int) {
t.Helper()
if err := createAdvCharacter(uid, name); err != nil {
t.Fatalf("createAdvCharacter(%s): %v", uid, err)
}
if err := SaveDnDCharacter(&DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
STR: 16, DEX: 14, CON: 15, INT: 10, WIS: 12, CHA: 8,
HPMax: 42, HPCurrent: 30, TempHP: 5, ArmorClass: 17,
PendingSetup: false,
}); err != nil {
t.Fatalf("SaveDnDCharacter(%s): %v", uid, err)
}
}
// TestRosterDetailPublicSheet: buildRosterSnapshot hangs the public sheet on
// each board entry — HP/AC/abilities/mods and equipped gear — and nothing that
// could name the player. This is the click-through page's whole payload.
func TestRosterDetailPublicSheet(t *testing.T) {
newMischiefTestDB(t)
uid := id.UserID("@josie:test")
seedDetailPlayer(t, uid, "Josie", 5)
snap, err := buildRosterSnapshot(time.Now().UTC(), 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))
}
e := snap.Adventurers[0]
if e.Detail == nil {
t.Fatal("board entry carries no detail sheet — the detail page would fall back to the bare row")
}
d := e.Detail
if d.HPMax != 42 || d.HPCurrent != 30 || d.TempHP != 5 || d.ArmorClass != 17 {
t.Errorf("detail sheet = %+v, want HP 30/42 (+5 temp), AC 17", d)
}
if d.Abilities != [6]int{16, 14, 15, 10, 12, 8} {
t.Errorf("abilities = %v, want STR..CHA 16,14,15,10,12,8", d.Abilities)
}
// CON 15 → +2; a mismatched mods slice would misprint the whole sheet.
if d.Modifiers[2] != 2 {
t.Errorf("CON modifier = %d, want +2", d.Modifiers[2])
}
// createAdvCharacter seeds tier-0 gear in every slot, so the panel is full.
if len(d.Gear) != len(allSlots) {
t.Errorf("gear panel has %d pieces, want %d (one per slot)", len(d.Gear), len(allSlots))
}
for _, g := range d.Gear {
if g.Slot == "" || g.Name == "" {
t.Errorf("gear piece is unlabelled: %+v", g)
}
}
}
// TestRosterDetailExpeditionContext: a player who is out on a run gets the live
// expedition fields layered onto their sheet — supplies, threat, and the room
// counter — so the detail page shows where they are, not just who they are.
func TestRosterDetailExpeditionContext(t *testing.T) {
newMischiefTestDB(t)
uid := id.UserID("@onrun:test")
exp := seedMischiefTarget(t, uid, 5, 60) // createAdvCharacter + sheet + live run
_ = exp
snap, err := buildRosterSnapshot(time.Now().UTC(), 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))
}
e := snap.Adventurers[0]
if e.Status != "expedition" {
t.Fatalf("status = %q, want expedition", e.Status)
}
if e.Detail == nil {
t.Fatal("on-run entry carries no detail")
}
if e.Detail.Room == "" {
t.Error("expedition detail has no room counter — the run context didn't layer on")
}
}
// TestDetailSnapshotKeyedByLocalpart is the ownership contract at the source.
// The private set is keyed by localpart and carries the player's current board
// token, so Pete can answer "is this signed-in viewer the owner of this page?"
// by a join — never by reversing the one-way token. And it carries the actual
// private goods: inventory, vault, house, pets.
func TestDetailSnapshotKeyedByLocalpart(t *testing.T) {
newMischiefTestDB(t)
uid := id.UserID("@quack:test")
seedDetailPlayer(t, uid, "Quack", 7)
// Backpack + vault: two distinct stashes that must not blur together.
if err := addAdvInventoryItem(uid, AdvItem{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}); err != nil {
t.Fatal(err)
}
if err := addAdvInventoryItem(uid, AdvItem{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}); err != nil {
t.Fatal(err)
}
if got := vaultStoreItem(uid, "crown"); got == "" {
t.Fatal("failed to vault the crown")
}
// House + a pet, through the canonical save path.
adv, err := loadAdvCharacter(uid)
if err != nil {
t.Fatalf("loadAdvCharacter: %v", err)
}
adv.HouseTier = 2
adv.HouseLoanBalance = 1500
adv.PetType = "cat"
adv.PetName = "Mittens"
adv.PetLevel = 3
if err := saveAdvCharacter(adv); err != nil {
t.Fatalf("saveAdvCharacter: %v", err)
}
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
if err != nil {
t.Fatalf("buildDetailSnapshot: %v", err)
}
if len(snap.Players) != 1 {
t.Fatalf("detail set has %d players, want 1", len(snap.Players))
}
pd := snap.Players[0]
if pd.Localpart != "quack" {
t.Errorf("localpart = %q, want the lowercase mxid localpart 'quack'", pd.Localpart)
}
if pd.Token != eventToken(uid, "roster") {
t.Error("detail token is not the board token — the ownership join on Pete would never match the page")
}
// Inventory holds the ore (crown was vaulted out of it); vault holds the crown.
if len(pd.Inventory) != 1 || pd.Inventory[0].Name != "Iron Ore" {
t.Errorf("inventory = %+v, want just the ore", pd.Inventory)
}
if len(pd.Vault) != 1 || pd.Vault[0].Name != "Jeweled Crown" {
t.Errorf("vault = %+v, want just the crown", pd.Vault)
}
if pd.House.Tier != 2 || pd.House.LoanBalance != 1500 {
t.Errorf("house = %+v, want tier 2 / loan 1500", pd.House)
}
if len(pd.Pets) != 1 || pd.Pets[0].Name != "Mittens" || pd.Pets[0].Level != 3 {
t.Errorf("pets = %+v, want the cat Mittens L3", pd.Pets)
}
}
// TestDetailSnapshotIgnoresOptOut: the news opt-out governs the *public* board,
// not the private self-view. A player who opted out must still receive their own
// sheet — Pete only ever serves this back to them — so buildDetailSnapshot must
// NOT filter on it, unlike buildRosterSnapshot.
func TestDetailSnapshotIgnoresOptOut(t *testing.T) {
newMischiefTestDB(t)
shown := id.UserID("@shown:test")
hidden := id.UserID("@hidden:test")
seedDetailPlayer(t, shown, "Shown", 4)
seedDetailPlayer(t, hidden, "Hidden", 4)
setNewsOptout(hidden, true)
// The public board drops the opted-out player...
roster, err := buildRosterSnapshot(time.Now().UTC(), nil)
if err != nil {
t.Fatalf("buildRosterSnapshot: %v", err)
}
if len(roster.Adventurers) != 1 || roster.Adventurers[0].Name != "Shown" {
t.Fatalf("public board = %d entries, want just Shown", len(roster.Adventurers))
}
// ...but the private detail set keeps them both.
detail, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
if err != nil {
t.Fatalf("buildDetailSnapshot: %v", err)
}
if len(detail.Players) != 2 {
t.Fatalf("private detail set = %d players, want 2 — opt-out must not deny a player their own self-view", len(detail.Players))
}
byLP := map[string]bool{}
for _, p := range detail.Players {
byLP[p.Localpart] = true
}
if !byLP["hidden"] {
t.Error("opted-out player is missing from the private self-view set")
}
}
// TestDetailSnapshotSkipsDeadPlayers: the set is drawn from alive players only.
// A dead character has no live board page to own, and pushing their stale
// inventory would leave it standing on Pete after they're gone.
func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) {
newMischiefTestDB(t)
alive := id.UserID("@alive:test")
dead := id.UserID("@dead:test")
seedDetailPlayer(t, alive, "Alive", 4)
seedDetailPlayer(t, dead, "Dead", 4)
if _, err := db.Get().Exec(`UPDATE player_meta SET alive = 0 WHERE user_id = ?`, string(dead)); err != nil {
t.Fatalf("kill player: %v", err)
}
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
if err != nil {
t.Fatalf("buildDetailSnapshot: %v", err)
}
if len(snap.Players) != 1 || snap.Players[0].Localpart != "alive" {
t.Fatalf("detail set = %+v, want just the living player", snap.Players)
}
}
// TestPetXPRidesTheCurveNotACopyOfIt pins the unit and the cap, which are the two
// ways this can go quietly wrong on the web. XP is stored in centi-XP — a pet
// earns 1.5 points an action and the ledger is an int — so a page that read XP
// against a whole-number threshold would draw a bar 100x too full. And a capped
// pet must report 0 needed rather than the next band's number, or its bar sits
// forever short of a level it can never gain.
func TestPetXPRidesTheCurveNotACopyOfIt(t *testing.T) {
newMischiefTestDB(t)
uid := id.UserID("@quack:test")
seedDetailPlayer(t, uid, "Quack", 7)
adv, err := loadAdvCharacter(uid)
if err != nil {
t.Fatalf("loadAdvCharacter: %v", err)
}
adv.PetType = "cat"
adv.PetName = "Mittens"
adv.PetLevel = 4
adv.PetXP = 750 // 7.5 of the 20 points level 4 wants
adv.Pet2Type = "dog"
adv.Pet2Name = "Rex"
adv.Pet2Level = petMaxLevel
adv.Pet2XP = 0
if err := saveAdvCharacter(adv); err != nil {
t.Fatalf("saveAdvCharacter: %v", err)
}
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
if err != nil {
t.Fatalf("buildDetailSnapshot: %v", err)
}
pets := snap.Players[0].Pets
if len(pets) != 2 {
t.Fatalf("pets = %+v, want both slots", pets)
}
byName := map[string]int{}
for i, p := range pets {
byName[p.Name] = i
}
mittens := pets[byName["Mittens"]]
if mittens.XP != 750 {
t.Errorf("Mittens XP = %d, want the stored centi-XP 750", mittens.XP)
}
if want := petXPToNextLevel(4) * 100; mittens.XPNeeded != want {
t.Errorf("Mittens XPNeeded = %d, want %d — the curve is in centi-XP too",
mittens.XPNeeded, want)
}
if rex := pets[byName["Rex"]]; rex.XPNeeded != 0 {
t.Errorf("a capped pet needs %d more XP; want 0, meaning nothing left to earn", rex.XPNeeded)
}
}