Files
gogobee/internal/plugin/expedition_party.go
prosolis 1f1fbf0251 Review follow-ups L + I: drop dead openParty, unify the menu-index parser
L: openParty had no production callers (invite path uses joinParty -> seatLeader,
which seats the leader the same way but reads the owner off the expedition row).
Removed it; the tests now seat the leader through a seatLeaderFixture that wraps
seatLeader in a transaction.

I: promoted parseTemperIndex to parseMenuIndex and routed resolveMagicEquipReply
and handleMasterworkEquipReply through it, replacing two hand-inlined copies of
the same digit parser. Behaviour-preserving (the parsed flag was redundant with
the idx<0 guard); the retry-on-bad-parse disagreement is left as-is since this
is a pure dedup.

Full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:55:07 -07:00

270 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"database/sql"
"errors"
"fmt"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// N3/P4 — expedition party membership.
//
// A co-op expedition is one dnd_expedition row plus an expedition_party roster.
// The leader's row stays the single source of truth for the shared clock, the
// threat track, and the supply pool; members reference it rather than owning a
// row of their own. There is no party_id column — expedition_id already
// identifies the party, and a second key would be a second answer to "who is in
// this party".
//
// Absence means solo. Every expedition that existed before N3 has no rows here,
// which is exactly the right reading, so nothing needs backfilling. A solo
// expedition started after N3 writes no rows either: the roster materializes on
// the first successful invite (P6), and until then partyMembers returns empty.
// Party roles (expedition_party.role).
const (
PartyRoleLeader = "leader"
PartyRoleMember = "member"
)
// expeditionPartyMax is the roster ceiling including the leader. C1 scopes v1
// to 23 players; the combat roster and the supply pool are both sized off this.
const expeditionPartyMax = 3
// ExpeditionPartyMax exports the ceiling for cmd/expedition-sim's -party flag,
// so the harness cannot ask for a roster the seating layer will refuse.
const ExpeditionPartyMax = expeditionPartyMax
// Errors returned by the party layer.
var (
ErrPartyFull = errors.New("expedition party is full")
ErrAlreadyInParty = errors.New("player is already in this party")
ErrNotPartyLeader = errors.New("only the party leader may do that")
ErrPlayerBusyElsewhere = errors.New("player already has an active expedition")
)
// PartyMember is one seat on an expedition roster.
type PartyMember struct {
ExpeditionID string
UserID string
Role string
JoinedAt time.Time
}
// IsLeader reports whether this member owns the expedition row.
func (m PartyMember) IsLeader() bool { return m.Role == PartyRoleLeader }
// partyMembers returns the roster in join order, leader first. A solo
// expedition has no rows and returns an empty slice — callers should treat
// len(roster) == 0 and len(roster) == 1 alike, as "one player".
func partyMembers(expeditionID string) ([]PartyMember, error) {
rows, err := db.Get().Query(`
SELECT expedition_id, user_id, role, joined_at
FROM expedition_party
WHERE expedition_id = ?
ORDER BY (role <> 'leader'), joined_at ASC`, expeditionID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PartyMember
for rows.Next() {
var m PartyMember
if err := rows.Scan(&m.ExpeditionID, &m.UserID, &m.Role, &m.JoinedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// partyMemberIDs is partyMembers reduced to user ids, leader first. It is what
// the fan-out seams (digest, briefing, recap) want: a solo expedition yields
// just the owner, so a caller can loop unconditionally.
func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) {
members, err := partyMembers(expeditionID)
if err != nil {
return nil, err
}
if len(members) == 0 {
return []id.UserID{id.UserID(ownerID)}, nil
}
out := make([]id.UserID, 0, len(members))
for _, m := range members {
out = append(out, id.UserID(m.UserID))
}
return out, nil
}
// partySize is the number of seated players: 1 for a solo expedition.
func partySize(expeditionID string) (int, error) {
var n int
err := db.Get().QueryRow(
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
expeditionID).Scan(&n)
if err != nil {
return 0, err
}
if n == 0 {
return 1, nil
}
return n, nil
}
// joinParty seats a member. It refuses a full roster, a duplicate, and a player
// who already leads or rides an expedition of their own — the "one active
// expedition per user" rule that startExpedition enforces in code, extended to
// cover membership. The check and the insert share a transaction so two
// simultaneous invites cannot both find the last seat free.
//
// It seats the leader first if nobody has. A roster whose leader is missing
// would quietly drop them from every fan-out (partyMemberIDs reads the roster,
// not the expedition row), so the ordering is enforced here rather than left to
// each caller to remember.
func joinParty(expeditionID string, userID id.UserID) error {
tx, err := db.Get().Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := seatLeader(tx, expeditionID); err != nil {
return err
}
var n int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
expeditionID).Scan(&n); err != nil {
return err
}
if n >= expeditionPartyMax {
return ErrPartyFull
}
if err := assertNotAdventuring(tx, expeditionID, userID); err != nil {
return err
}
if _, err := tx.Exec(`
INSERT INTO expedition_party (expedition_id, user_id, role)
VALUES (?, ?, 'member')`,
expeditionID, string(userID)); err != nil {
return fmt.Errorf("seat %s: %w", userID, err)
}
return tx.Commit()
}
// seatLeader converts a solo expedition row into a party of one, inside a
// caller's transaction. It reads the owner off the expedition row rather than
// trusting a passed-in id, so the roster's leader can never disagree with
// dnd_expedition.user_id. Idempotent (ON CONFLICT DO NOTHING), so the invite
// path can call it unconditionally before adding a member.
func seatLeader(tx *sql.Tx, expeditionID string) error {
var owner string
err := tx.QueryRow(
`SELECT user_id FROM dnd_expedition WHERE expedition_id = ?`,
expeditionID).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("expedition %s not found", expeditionID)
}
if err != nil {
return err
}
_, err = tx.Exec(`
INSERT INTO expedition_party (expedition_id, user_id, role)
VALUES (?, ?, 'leader')
ON CONFLICT (expedition_id, user_id) DO NOTHING`, expeditionID, owner)
return err
}
// assertNotAdventuring fails if the player is already committed elsewhere:
// seated on this or any other party, or owning an active expedition row. Runs
// inside joinParty's transaction so the guard and the insert are atomic.
func assertNotAdventuring(tx *sql.Tx, expeditionID string, userID id.UserID) error {
var seatedIn string
err := tx.QueryRow(`
SELECT expedition_id FROM expedition_party WHERE user_id = ? LIMIT 1`,
string(userID)).Scan(&seatedIn)
switch {
case err == nil && seatedIn == expeditionID:
return ErrAlreadyInParty
case err == nil:
return ErrPlayerBusyElsewhere
case !errors.Is(err, sql.ErrNoRows):
return err
}
var owned int
if err := tx.QueryRow(`
SELECT COUNT(*) FROM dnd_expedition
WHERE user_id = ? AND status IN ('active', 'extracting')`,
string(userID)).Scan(&owned); err != nil {
return err
}
if owned > 0 {
return ErrPlayerBusyElsewhere
}
return nil
}
// leaveParty removes one member. The leader cannot leave — an expedition
// without its owner has no row to hang the shared clock on; the leader ends the
// run for everyone with !extract instead.
func leaveParty(expeditionID string, userID id.UserID) error {
res, err := db.Get().Exec(`
DELETE FROM expedition_party
WHERE expedition_id = ? AND user_id = ? AND role <> 'leader'`,
expeditionID, string(userID))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotPartyLeader
}
return nil
}
// disbandParty clears the roster. Called when an expedition reaches a terminal
// status, so every member is free to start their own next run.
func disbandParty(expeditionID string) error {
_, err := db.Get().Exec(
`DELETE FROM expedition_party WHERE expedition_id = ?`, expeditionID)
return err
}
// expeditionForMember resolves the expedition a player is seated on but does
// not own. getActiveExpedition keys on dnd_expedition.user_id and so is blind to
// members; every player-facing lookup wants both, which is what
// activeExpeditionFor provides.
func expeditionForMember(userID id.UserID) (*Expedition, error) {
row := db.Get().QueryRow(`
SELECT`+expeditionSelectCols+`
FROM dnd_expedition e
JOIN expedition_party p ON p.expedition_id = e.expedition_id
WHERE p.user_id = ? AND p.role <> 'leader' AND e.status = 'active'
ORDER BY e.start_date DESC
LIMIT 1`, string(userID))
e, err := scanExpedition(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return e, err
}
// activeExpeditionFor returns the expedition a player is on, whether they lead
// it or ride it, plus whether they lead it. This is the lookup every
// player-facing command wants; getActiveExpedition alone silently tells a party
// member they have no expedition.
func activeExpeditionFor(userID id.UserID) (e *Expedition, isLeader bool, err error) {
if e, err = getActiveExpedition(userID); err != nil || e != nil {
return e, e != nil, err
}
e, err = expeditionForMember(userID)
return e, false, err
}