Files
gogobee/internal/plugin/expedition_party_test.go
prosolis d7d0230223 N3/P4: give every seat a row of its own
The turn engine seats a party since P3, but only seat 0 survived a
suspend: seats 1+ reopened from their Mods on every step, rearming their
once-per-fight one-shots -- a party Halfling rerolled a nat 1 every round.

Split CombatStatuses into the fight-scoped half (the enemy's stance, the
round cursor) and the per-character half, ActorStatuses. The embed is
anonymous and untagged, so statuses_json stays the same flat object it
always was and every in-flight prod row decodes unchanged.

Seat 0 keeps living on combat_session. Seats 1+ get combat_participant
rows. That asymmetry is the point: a solo fight -- which is every fight
that has ever run, and the whole balance corpus -- writes exactly the
bytes it wrote before, and no participant rows at all. roster_size
guards the read, so the solo loader never issues the second query.

Parties commit their seats in the same transaction as the session row;
solo keeps its single unwrapped UPDATE.

expedition_party is the co-op roster. No party_id on dnd_expedition:
expedition_id already identifies the party, and a second key would be a
second answer to "who is in this party". Absent means solo, in both new
tables, so neither needs a bootstrap backfill.

The combat characterization golden is byte-identical.

Also seeds and re-powers the two statistical subclass tests. They drew
from the package-global RNG, so their verdict depended on how much
randomness every test declared before them happened to consume -- which
is why they flaked on a clean tree. Sweeping 40 seeds: Precision Attack
had a mean margin of +127 wins against a +50 threshold but a worst case
of -42, and Assassinate averaged +12.8 with two seeds outright negative.
Both effects are real; the trial counts were too low to see them. Seeded,
and raised to 24000/6000 trials, where all 40 seeds clear.
2026-07-09 21:23:35 -07:00

291 lines
9.3 KiB
Go

package plugin
import (
"errors"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// seedExpedition inserts the minimum dnd_expedition row the party layer reads:
// the owner, the status, and enough non-null columns for scanExpedition.
func seedExpedition(t *testing.T, expeditionID string, owner id.UserID, status string) {
t.Helper()
if _, err := db.Get().Exec(`
INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, start_date)
VALUES (?, ?, 'goblin_warrens', ?, ?, ?)`,
expeditionID, string(owner), "run-"+expeditionID, status, time.Now().UTC(),
); err != nil {
t.Fatal(err)
}
}
func TestParty_SoloExpeditionHasNoRoster(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@solo:example.org")
seedExpedition(t, "exp-solo", owner, "active")
members, err := partyMembers("exp-solo")
if err != nil {
t.Fatal(err)
}
if len(members) != 0 {
t.Errorf("solo expedition has %d roster rows, want 0", len(members))
}
// Absent must read as "one player", not "zero players" — every fan-out seam
// loops over this.
if n, err := partySize("exp-solo"); err != nil || n != 1 {
t.Errorf("partySize = %d (%v), want 1", n, err)
}
ids, err := partyMemberIDs("exp-solo", string(owner))
if err != nil {
t.Fatal(err)
}
if len(ids) != 1 || ids[0] != owner {
t.Errorf("partyMemberIDs = %v, want [%s]", ids, owner)
}
}
func TestParty_OpenIsIdempotent(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", owner, "active")
for i := 0; i < 2; i++ {
if err := openParty("exp-1", owner); err != nil {
t.Fatalf("openParty #%d: %v", i+1, err)
}
}
members, err := partyMembers("exp-1")
if err != nil {
t.Fatal(err)
}
if len(members) != 1 || !members[0].IsLeader() {
t.Fatalf("roster = %+v, want one leader", members)
}
}
func TestParty_JoinSeatsMembersLeaderFirst(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead2:example.org")
seedExpedition(t, "exp-2", owner, "active")
if err := openParty("exp-2", owner); err != nil {
t.Fatal(err)
}
for _, u := range []id.UserID{"@b:x", "@c:x"} {
if err := joinParty("exp-2", u); err != nil {
t.Fatalf("joinParty %s: %v", u, err)
}
}
members, err := partyMembers("exp-2")
if err != nil {
t.Fatal(err)
}
if len(members) != 3 {
t.Fatalf("roster = %d, want 3", len(members))
}
if !members[0].IsLeader() || members[0].UserID != string(owner) {
t.Errorf("leader is not first: %+v", members)
}
if n, err := partySize("exp-2"); err != nil || n != 3 {
t.Errorf("partySize = %d (%v), want 3", n, err)
}
}
// An invite that lands before anyone called openParty must still produce a
// roster with the leader on it — partyMemberIDs reads the roster, not the
// expedition row, so a leaderless party would silently stop DMing the leader.
func TestParty_JoinSeatsTheLeaderIfMissing(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead-implicit:example.org")
seedExpedition(t, "exp-implicit", owner, "active")
// No openParty call at all.
if err := joinParty("exp-implicit", "@b:x"); err != nil {
t.Fatal(err)
}
members, err := partyMembers("exp-implicit")
if err != nil {
t.Fatal(err)
}
if len(members) != 2 {
t.Fatalf("roster = %+v, want leader + member", members)
}
if !members[0].IsLeader() || members[0].UserID != string(owner) {
t.Errorf("leader missing from roster: %+v", members)
}
// And the leader cannot then be double-seated as a member.
if err := joinParty("exp-implicit", owner); !errors.Is(err, ErrAlreadyInParty) {
t.Errorf("leader join: err = %v, want ErrAlreadyInParty", err)
}
}
func TestParty_JoinRefusesFullRoster(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead3:example.org")
seedExpedition(t, "exp-3", owner, "active")
if err := openParty("exp-3", owner); err != nil {
t.Fatal(err)
}
for _, u := range []id.UserID{"@b:x", "@c:x"} {
if err := joinParty("exp-3", u); err != nil {
t.Fatal(err)
}
}
// Leader + 2 == expeditionPartyMax.
if err := joinParty("exp-3", "@d:x"); !errors.Is(err, ErrPartyFull) {
t.Errorf("4th seat: err = %v, want ErrPartyFull", err)
}
}
func TestParty_JoinRefusesDuplicate(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead4:example.org")
seedExpedition(t, "exp-4", owner, "active")
if err := openParty("exp-4", owner); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-4", "@b:x"); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-4", "@b:x"); !errors.Is(err, ErrAlreadyInParty) {
t.Errorf("re-join: err = %v, want ErrAlreadyInParty", err)
}
// The leader is already seated, so inviting them back is a duplicate too.
if err := joinParty("exp-4", owner); !errors.Is(err, ErrAlreadyInParty) {
t.Errorf("leader re-join: err = %v, want ErrAlreadyInParty", err)
}
}
// "One active expedition per user" is code-enforced for owners
// (startExpedition). Membership must not be a way around it: a player who owns
// a run, or already rides someone else's, cannot take a third seat.
func TestParty_JoinRefusesPlayerBusyElsewhere(t *testing.T) {
setupEmptyTestDB(t)
seedExpedition(t, "exp-5", "@lead5:example.org", "active")
seedExpedition(t, "exp-6", "@lead6:example.org", "active")
if err := openParty("exp-5", "@lead5:example.org"); err != nil {
t.Fatal(err)
}
if err := openParty("exp-6", "@lead6:example.org"); err != nil {
t.Fatal(err)
}
// Owns their own active expedition.
seedExpedition(t, "exp-own", "@busy:example.org", "active")
if err := joinParty("exp-5", "@busy:example.org"); !errors.Is(err, ErrPlayerBusyElsewhere) {
t.Errorf("owner of another run: err = %v, want ErrPlayerBusyElsewhere", err)
}
// Already seated on another party.
if err := joinParty("exp-5", "@rider:example.org"); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-6", "@rider:example.org"); !errors.Is(err, ErrPlayerBusyElsewhere) {
t.Errorf("rider of another party: err = %v, want ErrPlayerBusyElsewhere", err)
}
// A leader is seated on their own party, so they are busy too.
if err := joinParty("exp-6", "@lead5:example.org"); !errors.Is(err, ErrPlayerBusyElsewhere) {
t.Errorf("leader of another party: err = %v, want ErrPlayerBusyElsewhere", err)
}
}
func TestParty_LeaveRemovesMemberButNotLeader(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead7:example.org")
seedExpedition(t, "exp-7", owner, "active")
if err := openParty("exp-7", owner); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-7", "@b:x"); err != nil {
t.Fatal(err)
}
if err := leaveParty("exp-7", "@b:x"); err != nil {
t.Fatalf("leaveParty: %v", err)
}
if n, _ := partySize("exp-7"); n != 1 {
t.Errorf("after leave, partySize = %d, want 1", n)
}
// The expedition row hangs off the leader; they extract, they don't leave.
if err := leaveParty("exp-7", owner); !errors.Is(err, ErrNotPartyLeader) {
t.Errorf("leader leave: err = %v, want ErrNotPartyLeader", err)
}
// Leaving frees the member for their own next run.
seedExpedition(t, "exp-7b", "@lead7b:example.org", "active")
if err := openParty("exp-7b", "@lead7b:example.org"); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-7b", "@b:x"); err != nil {
t.Errorf("departed member could not rejoin elsewhere: %v", err)
}
}
func TestParty_DisbandFreesEveryone(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead8:example.org")
seedExpedition(t, "exp-8", owner, "complete")
if err := openParty("exp-8", owner); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-8", "@b:x"); err != nil {
t.Fatal(err)
}
if err := disbandParty("exp-8"); err != nil {
t.Fatal(err)
}
if n, _ := partySize("exp-8"); n != 1 {
t.Errorf("after disband, partySize = %d, want 1 (no rows)", n)
}
seedExpedition(t, "exp-8b", "@lead8b:example.org", "active")
if err := openParty("exp-8b", "@lead8b:example.org"); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-8b", "@b:x"); err != nil {
t.Errorf("disbanded member still looks busy: %v", err)
}
}
// getActiveExpedition keys on dnd_expedition.user_id, so it is blind to party
// members. activeExpeditionFor is the lookup every player-facing command wants.
func TestParty_ActiveExpeditionForResolvesBothRoles(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead9:example.org")
member := id.UserID("@member9:example.org")
seedExpedition(t, "exp-9", owner, "active")
if err := openParty("exp-9", owner); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-9", member); err != nil {
t.Fatal(err)
}
// The member owns no row, so the legacy lookup misses them entirely.
if e, err := getActiveExpedition(member); err != nil || e != nil {
t.Errorf("getActiveExpedition found a row for a member: %v / %v", e, err)
}
e, isLeader, err := activeExpeditionFor(owner)
if err != nil || e == nil || !isLeader || e.ID != "exp-9" {
t.Errorf("leader lookup: %+v leader=%v err=%v", e, isLeader, err)
}
e, isLeader, err = activeExpeditionFor(member)
if err != nil || e == nil || isLeader || e.ID != "exp-9" {
t.Errorf("member lookup: %+v leader=%v err=%v", e, isLeader, err)
}
// The member resolves to the leader's expedition, clock and all.
if e != nil && e.UserID != string(owner) {
t.Errorf("member resolved to expedition owned by %q, want %q", e.UserID, owner)
}
// A stranger is on no expedition at all.
e, _, err = activeExpeditionFor("@nobody:example.org")
if err != nil || e != nil {
t.Errorf("stranger lookup: %+v / %v", e, err)
}
}