mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41:09 +00:00
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.
281 lines
10 KiB
Go
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)
|
|
}
|
|
}
|