mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adds -party N / -party-classes to expedition-sim. Followers are seated
through the real !expedition invite / !expedition accept pair, so the
harness measures the tier gate, the busy guard and the supply pooling
rather than a roster hand-built to succeed. A follower who is refused
halts the run: a party reading taken from a walk that was secretly solo
is worse than no reading.
It immediately found what it was built to find. Only elite and boss
doorways seat the roster; exploration rooms, patrols and harvest
interrupts all resolve through SimulateCombat against ctx.Sender, and
P6d made the walk commands leader-only. So on a 38-room T5 expedition
the party fights 2-3 rooms together and the leader solos the rest -
then dies alone, tearing down the run rows only they own.
Measured at L15/16 over dragons_lair + abyss_portal, party of 3, n=15
per cell: zero TPKs and zero member deaths across 240 seats. Every
failure is the leader falling while two untouched members stand at full
HP. Fighter clears 100% (solo: 47-67%), cleric 33-53% (solo: 13-47%).
The band is unreadable until inline combat seats the party, so the C1
contingency - +35% monster HP per member - stays on the shelf; it would
punish the trash the leader already fights alone.
Hence the outcome vocabulary grows a third word. "tpk" used to mean any
run-ending event, which is how a leader dying beside a healthy party
stayed invisible through P5 and P6. Now: tpk (roster dead),
leader_down, fled (run over, leader alive) - read off the death flag,
not off HP, which the close-out leaves anywhere. A one-seat roster
makes leader-dead and all-dead the same predicate, so solo keeps its
labels for a real death.
Two bugs found in review before this landed:
- An unknown class was not an error anywhere: -class fightr built a
1-HP character and reported an ordinary outcome for it. Guarded in
BuildCharacter, not the flag parser, since the leader had the bug
long before parties did.
- The roster short-rest healed the dead - handleDnDShortRest does not
gate on the death flag, so a member killed in a won boss fight got
rested back above 0 and stopped counting as a casualty.
Golden byte-identical; go test ./... green.
286 lines
9.5 KiB
Go
286 lines
9.5 KiB
Go
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 2–3 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
|
||
}
|
||
|
||
// 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
|
||
// 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 is openParty 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.
|
||
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 e.expedition_id, e.user_id, e.zone_id, e.run_id, e.status,
|
||
e.start_date, e.current_day, e.current_region, e.boss_defeated,
|
||
e.supplies_json, e.camp_json, e.threat_level, e.threat_siege,
|
||
e.threat_events, e.temporal_stack, e.region_state,
|
||
e.xp_earned, e.coins_earned, e.gm_mood,
|
||
e.last_briefing_at, e.last_recap_at, e.last_ambient_kind,
|
||
e.last_activity, e.completed_at
|
||
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
|
||
}
|