Files
gogobee/internal/plugin/expedition_party_reads_test.go
prosolis a063e0ccd0 N3/P6d: a party where every member can see the dungeon
Every ownership lookup in the adventure module keys on a user id, and a
party member owns neither the expedition row nor the zone run. So each
player-facing read quietly told them they were not playing.

Rewire them. Reads a member should see resolve through activeExpeditionFor
/ activeZoneRunFor. Leader-only actions answer with copy that names the
leader instead of denying the expedition. Three busy-guards had to start
refusing a member outright: !zone enter, !expedition start and !sell all
keyed on the sender's own row, so a seated member could open a private
dungeon, outfit a rival expedition, or run a shop from the boss room.

Four things the rewire itself exposed:

!resources looks like a read but seed-persists harvest nodes, and
saveHarvestNodes rewrites the entire region_state blob — kills, event
gates, temporal stack — last-write-wins. Reaching it as a member would
revert the leader's walk from a stale snapshot. Persist only for the owner;
seedRoomNodes is pure, so a member re-derives the same nodes.

!zone taunt moves the party's shared mood, which is intended and safe:
applyMoodEvent lands an atomic delta. Its neighbour applyMoodDecayIfStale
writes an absolute gm_mood from the caller's snapshot, and every command
takes the *sender's* lock — a member running it against the leader's run
holds the wrong mutex. The owner check now lives on that function.

A seat outlives status='active'. releaseParty deliberately skips the
seven-day 'extracting' limbo, so the roster persists while
activeExpeditionFor goes blind — long enough for a member to open a run
that wins every lookup once the leader !resumes. seatedExpeditionFor spans
both statuses; it is what the busy-guards ask.

!expedition run was still member-blind. It is the same walk as !zone
advance, reached by its other name.

isPartyMember replaces `run != nil && !isLeader`: activeZoneRunFor reports
isLeader=false for a player with no run anywhere, so the bare test sends a
solo player to go ask their leader.

Golden byte-identical; solo T1 expedition clears end-to-end.
2026-07-09 23:56:16 -07:00

247 lines
9.2 KiB
Go

package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// N3/P6d — the player-facing `getActive*` reads, rewired.
//
// SendDM is a no-op without a Matrix client, so these tests can only observe the
// state a command path left behind. That covers the cases that carry state:
//
// - reads that were blind to a member and let them adventure twice
// (`!zone enter`, `!expedition start`, `!sell`), including across the
// `extracting` limbo where the seat outlives the expedition's 'active' status;
// - reads that write, and so must not write on a member's behalf
// (`!resources` seed-persists the leader's whole region_state blob;
// applyMoodDecayIfStale writes an absolute gm_mood);
// - the one read whose rewire deliberately does move shared state — `!zone
// taunt` swings the party's DM mood, safe only because that write is an
// atomic delta.
//
// The remaining rewires are copy-only — a member used to be told they had no
// expedition, and is now told whose it is — and both spellings leave the database
// exactly as they found it, so there is nothing here to assert. Their seam,
// activeExpeditionFor / activeZoneRunFor / isPartyMember, is pinned in
// expedition_party_resolve_test.go.
// seatedMember stands up a leader with an active expedition and zone run, plus
// a seated member holding a real character sheet. Returns both user ids.
func seatedMember(t *testing.T, tag string) (leader, member id.UserID) {
t.Helper()
leader = id.UserID("@lead-" + tag + ":example.org")
member = id.UserID("@member-" + tag + ":example.org")
seedExpedition(t, "exp-"+tag, leader, "active")
seedZoneRun(t, "run-exp-"+tag, leader)
if err := joinParty("exp-"+tag, member); err != nil {
t.Fatal(err)
}
zoneCmdTestCharacter(t, leader, 1)
zoneCmdTestCharacter(t, member, 1)
return leader, member
}
// startZoneRun only refuses a player who owns an active run, and a member owns
// none — so before P6d a member could `!zone enter` a private dungeon while
// riding the leader's. That run would then win every activeZoneRunFor lookup
// they made, quietly cutting them out of the party's state.
func TestZoneCmdEnter_SeatedMemberCannotOpenTheirOwnRun(t *testing.T) {
setupEmptyTestDB(t)
_, member := seatedMember(t, "enter")
p := &AdventurePlugin{}
if err := p.handleDnDZoneCmd(MessageContext{Sender: member}, "enter goblin_warrens"); err != nil {
t.Fatalf("enter: %v", err)
}
if run, err := getActiveZoneRun(member); err != nil || run != nil {
t.Fatalf("member opened their own run %v (err %v); they are seated in a party", run, err)
}
}
// The same hole at the expedition seam: getActiveExpedition is blind to a
// member, so the "already on expedition" guard waved them through.
func TestExpeditionCmdStart_SeatedMemberIsRefused(t *testing.T) {
setupEmptyTestDB(t)
_, member := seatedMember(t, "start")
// Fund them: a member refused for want of coins would pass this test for
// the wrong reason.
euro := &EuroPlugin{}
euro.ensureBalance(member)
euro.Credit(member, 500, "party reads test")
p := &AdventurePlugin{euro: euro}
c, err := LoadDnDCharacter(member)
if err != nil {
t.Fatal(err)
}
if err := p.expeditionCmdStart(MessageContext{Sender: member}, c, "goblin_warrens 1s"); err != nil {
t.Fatalf("start: %v", err)
}
if own, err := getActiveExpedition(member); err != nil || own != nil {
t.Fatalf("member started their own expedition %v (err %v) while seated in a party", own, err)
}
}
// A member is mid-expedition even though they own no expedition row, so Thom
// Krooke must turn them away like anyone else standing in a dungeon.
func TestSellCmd_SeatedMemberIsRefusedMidExpedition(t *testing.T) {
setupEmptyTestDB(t)
_, member := seatedMember(t, "sell")
if err := addAdvInventoryItem(member, AdvItem{
Name: "goblin ear", Type: "material", Tier: 1, Value: 5,
}); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleResourceSellCmd(MessageContext{Sender: member}, "all"); err != nil {
t.Fatalf("sell: %v", err)
}
items, err := loadAdvInventory(member)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("inventory holds %d items, want the 1 seeded; a seated member sold mid-expedition", len(items))
}
}
// `!resources` looks like a pure read, but it seed-persists harvest nodes — and
// saveHarvestNodes rewrites the leader's whole region_state blob (a last-write-
// wins UPDATE). A member holds only their own lock, so their snapshot must never
// be written back over the leader's walk. loadHarvestNodes re-derives the same
// nodes from seedRoomNodes, so the member loses nothing by not persisting.
func TestResourcesCmd_MemberDoesNotRewriteTheLeadersRegionState(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "res")
// A sentinel only the leader's walk could have written. If the member's
// !resources persists its snapshot, this is clobbered by the harvest table.
sentinel := `{"party_only_marker":"survive"}`
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET region_state = ? WHERE user_id = ?`,
sentinel, string(leader),
); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleResourcesCmd(MessageContext{Sender: member}); err != nil {
t.Fatalf("resources: %v", err)
}
var got string
if err := db.Get().QueryRow(
`SELECT region_state FROM dnd_expedition WHERE user_id = ?`, string(leader),
).Scan(&got); err != nil {
t.Fatal(err)
}
if got != sentinel {
t.Errorf("member's !resources rewrote the leader's region_state:\n got %s\nwant %s", got, sentinel)
}
}
// applyMoodDecayIfStale writes gm_mood as an absolute value derived from the
// caller's snapshot, so it must refuse a non-owner: a member running it against
// the leader's run holds the wrong lock and would drop a concurrent update.
func TestApplyMoodDecayIfStale_RefusesANonOwner(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@decay:example.org")
seedZoneRun(t, "run-decay", owner)
run, err := getActiveZoneRun(owner)
if err != nil || run == nil {
t.Fatalf("run: %v, %v", run, err)
}
// Drag the run far enough into the past that passive decay has something
// to correct, then hand it to a non-owner.
run.LastActionAt = time.Now().UTC().Add(-72 * time.Hour)
run.DMMood = 90
if err := applyMoodDecayIfStale(run, false); err != nil {
t.Fatal(err)
}
if run.DMMood != 90 {
t.Errorf("non-owner decay mutated the snapshot: mood = %d, want 90", run.DMMood)
}
fresh, err := getZoneRun("run-decay")
if err != nil || fresh == nil {
t.Fatalf("reload: %v, %v", fresh, err)
}
if fresh.DMMood != 50 {
t.Errorf("non-owner decay wrote gm_mood = %d; the row should be untouched at its 50 default", fresh.DMMood)
}
// The owner still gets the correction — the guard narrows who writes, not what.
if err := applyMoodDecayIfStale(run, true); err != nil {
t.Fatal(err)
}
if run.DMMood == 90 {
t.Error("owner decay did not apply; the guard should only exclude non-owners")
}
}
// `extracting` is a seven-day resumable limbo: releaseParty is deliberately NOT
// called, so the roster survives and `!resume` brings the party back. A member is
// still seated for that whole window, and a guard keyed on status='active' would
// wave them into a private run that then wins every activeZoneRunFor lookup once
// the leader resumes.
func TestSeatedGuards_HoldThroughTheExtractingWindow(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "extract")
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET status = 'extracting' WHERE user_id = ?`, string(leader),
); err != nil {
t.Fatal(err)
}
// activeExpeditionFor goes blind here — that is the trap seatedExpeditionFor exists for.
if exp, _, err := activeExpeditionFor(member); err != nil || exp != nil {
t.Fatalf("activeExpeditionFor during extracting = %v, %v; want nil (status filter)", exp, err)
}
seated, err := seatedExpeditionFor(member)
if err != nil || seated == nil {
t.Fatalf("seatedExpeditionFor during extracting = %v, %v; the seat outlives the status", seated, err)
}
p := &AdventurePlugin{}
if err := p.handleDnDZoneCmd(MessageContext{Sender: member}, "enter goblin_warrens"); err != nil {
t.Fatalf("enter: %v", err)
}
if run, err := getActiveZoneRun(member); err != nil || run != nil {
t.Fatalf("member opened a private run %v (err %v) while seated on an extracting party", run, err)
}
}
// Mood belongs to the run, not to whoever typed at TwinBee. A member's taunt
// moves the gauge the whole party is walking under — before P6d it moved
// nothing, because the member resolved to no run at all.
func TestZoneMoodInteraction_MemberMovesThePartyMood(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "mood")
before, err := getActiveZoneRun(leader)
if err != nil || before == nil {
t.Fatalf("leader's run: %v, %v", before, err)
}
p := &AdventurePlugin{}
if err := p.handleDnDZoneCmd(MessageContext{Sender: member}, "taunt"); err != nil {
t.Fatalf("taunt: %v", err)
}
after, err := getActiveZoneRun(leader)
if err != nil || after == nil {
t.Fatalf("leader's run after taunt: %v, %v", after, err)
}
if after.DMMood >= before.DMMood {
t.Errorf("DM mood %d → %d; a member's taunt should have annoyed TwinBee",
before.DMMood, after.DMMood)
}
}