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
This commit is contained in:
prosolis
2026-07-10 08:55:07 -07:00
parent d7a5333048
commit 1f1fbf0251
6 changed files with 48 additions and 77 deletions

View File

@@ -435,18 +435,8 @@ func (p *AdventurePlugin) handleMasterworkEquipReply(ctx MessageContext, interac
return p.SendDM(ctx.Sender, "Equip cancelled.") return p.SendDM(ctx.Sender, "Equip cancelled.")
} }
// Parse number idx, ok := parseMenuIndex(reply, len(data.Items))
idx := 0 if !ok {
for _, c := range reply {
if c >= '0' && c <= '9' {
idx = idx*10 + int(c-'0')
} else {
break
}
}
idx-- // 1-indexed to 0-indexed
if idx < 0 || idx >= len(data.Items) {
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".") return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
} }

View File

@@ -140,8 +140,12 @@ func findTemperMaterial(items []AdvItem) (AdvItem, bool) {
return AdvItem{}, false return AdvItem{}, false
} }
// parseTemperIndex reads a leading 1-indexed number and bounds-checks it. // parseMenuIndex reads a leading 1-indexed number off a menu reply and returns
func parseTemperIndex(reply string, n int) (int, bool) { // the bounds-checked 0-indexed position. The single parser behind every "reply
// with a number from the list" prompt — temper, magic-item equip, masterwork
// equip. Leading whitespace and any trailing text after the digits are ignored;
// a reply with no leading digit, or one outside [1, n], returns ok=false.
func parseMenuIndex(reply string, n int) (int, bool) {
idx, parsed := 0, false idx, parsed := 0, false
for _, c := range strings.TrimSpace(reply) { for _, c := range strings.TrimSpace(reply) {
if c < '0' || c > '9' { if c < '0' || c > '9' {
@@ -214,7 +218,7 @@ func (p *AdventurePlugin) resolveTemperPick(ctx MessageContext, interaction *adv
if strings.EqualFold(reply, "cancel") { if strings.EqualFold(reply, "cancel") {
return p.SendDM(ctx.Sender, "_The forge keeps burning._ Come back when you've decided.") return p.SendDM(ctx.Sender, "_The forge keeps burning._ Come back when you've decided.")
} }
idx, ok := parseTemperIndex(reply, len(data.Targets)) idx, ok := parseMenuIndex(reply, len(data.Targets))
if !ok { if !ok {
p.pending.Store(string(ctx.Sender), interaction) p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Reply with a number from the list, or \"cancel\".") return p.SendDM(ctx.Sender, "Reply with a number from the list, or \"cancel\".")

View File

@@ -346,9 +346,9 @@ func TestParseTemperIndex(t *testing.T) {
{"cancel", 3, 0, false}, {"cancel", 3, 0, false},
} }
for _, tc := range tests { for _, tc := range tests {
got, ok := parseTemperIndex(tc.in, tc.n) got, ok := parseMenuIndex(tc.in, tc.n)
if ok != tc.wantOK || (ok && got != tc.want) { if ok != tc.wantOK || (ok && got != tc.want) {
t.Errorf("parseTemperIndex(%q, %d) = (%d, %v), want (%d, %v)", t.Errorf("parseMenuIndex(%q, %d) = (%d, %v), want (%d, %v)",
tc.in, tc.n, got, ok, tc.want, tc.wantOK) tc.in, tc.n, got, ok, tc.want, tc.wantOK)
} }
} }

View File

@@ -115,18 +115,6 @@ func partySize(expeditionID string) (int, error) {
return n, nil return n, nil
} }
// openParty seats the leader, converting a solo expedition row into a party of
// one. It is idempotent: a second call on the same expedition is a no-op, so
// the invite path can call it unconditionally before adding a member.
func openParty(expeditionID string, leaderID id.UserID) error {
_, err := db.Get().Exec(`
INSERT INTO expedition_party (expedition_id, user_id, role)
VALUES (?, ?, 'leader')
ON CONFLICT (expedition_id, user_id) DO NOTHING`,
expeditionID, string(leaderID))
return err
}
// joinParty seats a member. It refuses a full roster, a duplicate, and a player // 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 // who already leads or rides an expedition of their own — the "one active
// expedition per user" rule that startExpedition enforces in code, extended to // expedition per user" rule that startExpedition enforces in code, extended to
@@ -171,9 +159,11 @@ func joinParty(expeditionID string, userID id.UserID) error {
return tx.Commit() return tx.Commit()
} }
// seatLeader is openParty inside a caller's transaction: it reads the owner off // seatLeader converts a solo expedition row into a party of one, inside a
// the expedition row rather than trusting a passed-in id, so the roster's leader // caller's transaction. It reads the owner off the expedition row rather than
// can never disagree with dnd_expedition.user_id. // 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 { func seatLeader(tx *sql.Tx, expeditionID string) error {
var owner string var owner string
err := tx.QueryRow( err := tx.QueryRow(

View File

@@ -22,6 +22,24 @@ func seedExpedition(t *testing.T, expeditionID string, owner id.UserID, status s
} }
} }
// seatLeaderFixture seats the expedition's leader the way the invite path does —
// through seatLeader in its own transaction — reading the owner off the row that
// seedExpedition already wrote. Replaces the retired openParty helper.
func seatLeaderFixture(t *testing.T, expeditionID string) {
t.Helper()
tx, err := db.Get().Begin()
if err != nil {
t.Fatal(err)
}
if err := seatLeader(tx, expeditionID); err != nil {
_ = tx.Rollback()
t.Fatalf("seatLeader %s: %v", expeditionID, err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
}
func TestParty_SoloExpeditionHasNoRoster(t *testing.T) { func TestParty_SoloExpeditionHasNoRoster(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
owner := id.UserID("@solo:example.org") owner := id.UserID("@solo:example.org")
@@ -53,10 +71,9 @@ func TestParty_OpenIsIdempotent(t *testing.T) {
owner := id.UserID("@lead:example.org") owner := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", owner, "active") seedExpedition(t, "exp-1", owner, "active")
// seatLeader is idempotent — a second call on the same expedition is a no-op.
for i := 0; i < 2; i++ { for i := 0; i < 2; i++ {
if err := openParty("exp-1", owner); err != nil { seatLeaderFixture(t, "exp-1")
t.Fatalf("openParty #%d: %v", i+1, err)
}
} }
members, err := partyMembers("exp-1") members, err := partyMembers("exp-1")
if err != nil { if err != nil {
@@ -71,9 +88,7 @@ func TestParty_JoinSeatsMembersLeaderFirst(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
owner := id.UserID("@lead2:example.org") owner := id.UserID("@lead2:example.org")
seedExpedition(t, "exp-2", owner, "active") seedExpedition(t, "exp-2", owner, "active")
if err := openParty("exp-2", owner); err != nil { seatLeaderFixture(t, "exp-2")
t.Fatal(err)
}
for _, u := range []id.UserID{"@b:x", "@c:x"} { for _, u := range []id.UserID{"@b:x", "@c:x"} {
if err := joinParty("exp-2", u); err != nil { if err := joinParty("exp-2", u); err != nil {
t.Fatalf("joinParty %s: %v", u, err) t.Fatalf("joinParty %s: %v", u, err)
@@ -127,9 +142,7 @@ func TestParty_JoinRefusesFullRoster(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
owner := id.UserID("@lead3:example.org") owner := id.UserID("@lead3:example.org")
seedExpedition(t, "exp-3", owner, "active") seedExpedition(t, "exp-3", owner, "active")
if err := openParty("exp-3", owner); err != nil { seatLeaderFixture(t, "exp-3")
t.Fatal(err)
}
for _, u := range []id.UserID{"@b:x", "@c:x"} { for _, u := range []id.UserID{"@b:x", "@c:x"} {
if err := joinParty("exp-3", u); err != nil { if err := joinParty("exp-3", u); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -145,9 +158,7 @@ func TestParty_JoinRefusesDuplicate(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
owner := id.UserID("@lead4:example.org") owner := id.UserID("@lead4:example.org")
seedExpedition(t, "exp-4", owner, "active") seedExpedition(t, "exp-4", owner, "active")
if err := openParty("exp-4", owner); err != nil { seatLeaderFixture(t, "exp-4")
t.Fatal(err)
}
if err := joinParty("exp-4", "@b:x"); err != nil { if err := joinParty("exp-4", "@b:x"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -167,12 +178,8 @@ func TestParty_JoinRefusesPlayerBusyElsewhere(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
seedExpedition(t, "exp-5", "@lead5:example.org", "active") seedExpedition(t, "exp-5", "@lead5:example.org", "active")
seedExpedition(t, "exp-6", "@lead6:example.org", "active") seedExpedition(t, "exp-6", "@lead6:example.org", "active")
if err := openParty("exp-5", "@lead5:example.org"); err != nil { seatLeaderFixture(t, "exp-5")
t.Fatal(err) seatLeaderFixture(t, "exp-6")
}
if err := openParty("exp-6", "@lead6:example.org"); err != nil {
t.Fatal(err)
}
// Owns their own active expedition. // Owns their own active expedition.
seedExpedition(t, "exp-own", "@busy:example.org", "active") seedExpedition(t, "exp-own", "@busy:example.org", "active")
@@ -198,9 +205,7 @@ func TestParty_LeaveRemovesMemberButNotLeader(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
owner := id.UserID("@lead7:example.org") owner := id.UserID("@lead7:example.org")
seedExpedition(t, "exp-7", owner, "active") seedExpedition(t, "exp-7", owner, "active")
if err := openParty("exp-7", owner); err != nil { seatLeaderFixture(t, "exp-7")
t.Fatal(err)
}
if err := joinParty("exp-7", "@b:x"); err != nil { if err := joinParty("exp-7", "@b:x"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -217,9 +222,7 @@ func TestParty_LeaveRemovesMemberButNotLeader(t *testing.T) {
} }
// Leaving frees the member for their own next run. // Leaving frees the member for their own next run.
seedExpedition(t, "exp-7b", "@lead7b:example.org", "active") seedExpedition(t, "exp-7b", "@lead7b:example.org", "active")
if err := openParty("exp-7b", "@lead7b:example.org"); err != nil { seatLeaderFixture(t, "exp-7b")
t.Fatal(err)
}
if err := joinParty("exp-7b", "@b:x"); err != nil { if err := joinParty("exp-7b", "@b:x"); err != nil {
t.Errorf("departed member could not rejoin elsewhere: %v", err) t.Errorf("departed member could not rejoin elsewhere: %v", err)
} }
@@ -229,9 +232,7 @@ func TestParty_DisbandFreesEveryone(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
owner := id.UserID("@lead8:example.org") owner := id.UserID("@lead8:example.org")
seedExpedition(t, "exp-8", owner, "complete") seedExpedition(t, "exp-8", owner, "complete")
if err := openParty("exp-8", owner); err != nil { seatLeaderFixture(t, "exp-8")
t.Fatal(err)
}
if err := joinParty("exp-8", "@b:x"); err != nil { if err := joinParty("exp-8", "@b:x"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -242,9 +243,7 @@ func TestParty_DisbandFreesEveryone(t *testing.T) {
t.Errorf("after disband, partySize = %d, want 1 (no rows)", n) t.Errorf("after disband, partySize = %d, want 1 (no rows)", n)
} }
seedExpedition(t, "exp-8b", "@lead8b:example.org", "active") seedExpedition(t, "exp-8b", "@lead8b:example.org", "active")
if err := openParty("exp-8b", "@lead8b:example.org"); err != nil { seatLeaderFixture(t, "exp-8b")
t.Fatal(err)
}
if err := joinParty("exp-8b", "@b:x"); err != nil { if err := joinParty("exp-8b", "@b:x"); err != nil {
t.Errorf("disbanded member still looks busy: %v", err) t.Errorf("disbanded member still looks busy: %v", err)
} }
@@ -257,9 +256,7 @@ func TestParty_ActiveExpeditionForResolvesBothRoles(t *testing.T) {
owner := id.UserID("@lead9:example.org") owner := id.UserID("@lead9:example.org")
member := id.UserID("@member9:example.org") member := id.UserID("@member9:example.org")
seedExpedition(t, "exp-9", owner, "active") seedExpedition(t, "exp-9", owner, "active")
if err := openParty("exp-9", owner); err != nil { seatLeaderFixture(t, "exp-9")
t.Fatal(err)
}
if err := joinParty("exp-9", member); err != nil { if err := joinParty("exp-9", member); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -579,18 +579,8 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
return p.SendDM(ctx.Sender, "Equip cancelled.") return p.SendDM(ctx.Sender, "Equip cancelled.")
} }
idx := 0 idx, ok := parseMenuIndex(reply, len(data.Items))
parsed := false if !ok {
for _, c := range reply {
if c >= '0' && c <= '9' {
idx = idx*10 + int(c-'0')
parsed = true
} else {
break
}
}
idx-- // 1-indexed → 0-indexed
if !parsed || idx < 0 || idx >= len(data.Items) {
p.pending.Store(string(ctx.Sender), interaction) p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".") return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
} }