mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
N3/P6b: a party you can actually ask someone to join
!expedition invite / accept / decline / party / leave. The invitee buys their own loadout and it pools -- a party is a shared burden, not a free ride. The plan said invites close "before the first walk". That is not a window, it is a race: autoRunMinExpeditionAge leaves a fresh expedition alone for thirty minutes and then the autopilot starts walking it, and the leader's own !expedition run can beat it there. Thirty minutes is not enough to ask a friend who is asleep. So two changes to what the plan specified: - The window is all of Day 1, not the first step. Supplies burn at the night rollover, so a companion who arrives three rooms in pays and receives exactly what one who arrived at the gate does. - An unanswered invite pins the autopilot: loadExpeditionsForAutoRun skips any expedition somebody has been asked to join. The leader must not be dragged into a boss room while their friend reads the DM. Bounded by expeditionInviteTTL (2h) in the query itself, so a forgotten invite costs an afternoon, not the expedition. New table expedition_invite. Absent == nobody was asked, which is true of every expedition predating N3 -- nothing to backfill, same reading that let expedition_party and roster_size ship without one. Details worth keeping: - Outstanding invites count against expeditionPartyMax. Otherwise a leader asks four people and three accept. - Pooling raises Current *and* Max. supplyDepletion reads the ratio, so folding in only Current would read as the party suddenly starving. - A member's supplies stay in the pool when they !leave. They were spent on the expedition, not lent to it; clawing them back would let someone starve the party on their way out. - assertNotAdventuring guards expeditions and rosters but not bare zone runs, so accept checks getActiveZoneRun itself -- startExpedition does. - A party is not a taxi: zoneOpenToLevel gates the invitee on the same tier rule !expedition start applies to the leader. - releaseParty now clears invites too, or someone could accept onto a corpse. - expeditionCmdStatus and the bare `!expedition` switched to activeExpeditionFor, and a member typing `!expedition go 2` is told the leader picks the path instead of falling through to `start` and being told "2" is an unknown zone. Combat still seats one player -- handleFightCmd is P6c. go test ./... green, golden byte-identical.
This commit is contained in:
190
internal/plugin/expedition_party_invite.go
Normal file
190
internal/plugin/expedition_party_invite.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N3/P6b — the invite handshake.
|
||||
//
|
||||
// A party forms in the gap between "I started an expedition" and "we set off",
|
||||
// and that gap is short: the autopilot leaves a fresh expedition alone for
|
||||
// autoRunMinExpeditionAge (30 minutes) and then starts walking it. Thirty
|
||||
// minutes is not long enough to ask a friend who is asleep.
|
||||
//
|
||||
// So an outstanding invite pins the autopilot in place — loadExpeditionsForAutoRun
|
||||
// skips any expedition somebody has been asked to join. The pin is bounded by
|
||||
// expeditionInviteTTL, because a leader who invites someone on holiday must not
|
||||
// have their expedition frozen forever.
|
||||
//
|
||||
// The window the *leader* sees is wider than the plan's "before the first walk":
|
||||
// an invite is legal for the whole of Day 1. Tying it to the first step would
|
||||
// have made it a race against a ticker the player cannot see, and lost the
|
||||
// leader's own `!expedition run` as well. Supplies barely move on day one — the
|
||||
// pool burns at the night rollover — so a companion who arrives three rooms in
|
||||
// pays and receives the same as one who arrives at the gate.
|
||||
|
||||
const (
|
||||
// expeditionInviteTTL bounds how long an unanswered invite holds the
|
||||
// autopilot. Long enough to catch someone between sessions of an async chat
|
||||
// bot; short enough that a forgotten invite costs the leader one afternoon,
|
||||
// not their expedition.
|
||||
expeditionInviteTTL = 2 * time.Hour
|
||||
)
|
||||
|
||||
// Errors returned by the invite layer.
|
||||
var (
|
||||
ErrInviteNotFound = errors.New("no pending invite")
|
||||
ErrInviteWindowShut = errors.New("this expedition has already set off")
|
||||
ErrAlreadyInvited = errors.New("player already has a pending invite here")
|
||||
)
|
||||
|
||||
// ExpeditionInvite is one unanswered ask.
|
||||
type ExpeditionInvite struct {
|
||||
ExpeditionID string
|
||||
UserID string
|
||||
InvitedBy string
|
||||
InvitedAt time.Time
|
||||
}
|
||||
|
||||
// inviteWindowOpen reports whether a party may still take on a member. Day 1
|
||||
// only, and never after the boss is down — joining a finished expedition would
|
||||
// seat someone into a payout they did not walk to.
|
||||
func inviteWindowOpen(e *Expedition) bool {
|
||||
return e != nil && e.Status == ExpeditionStatusActive &&
|
||||
e.CurrentDay <= 1 && !e.BossDefeated
|
||||
}
|
||||
|
||||
// inviteToParty records a pending invite. It refuses a full roster — counting
|
||||
// outstanding invites against the ceiling, so a leader cannot ask four people
|
||||
// and let them race for two seats — and refuses anyone already committed to an
|
||||
// expedition of their own.
|
||||
//
|
||||
// The count, the guard, and the insert share a transaction for the same reason
|
||||
// joinParty's do: two invites sent at once must not both find the last seat free.
|
||||
func inviteToParty(expeditionID string, invitee, leader 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 seated, pending int
|
||||
if err := tx.QueryRow(
|
||||
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
|
||||
expeditionID).Scan(&seated); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.QueryRow(
|
||||
`SELECT COUNT(*) FROM expedition_invite WHERE expedition_id = ? AND invited_at > ?`,
|
||||
expeditionID, inviteCutoff()).Scan(&pending); err != nil {
|
||||
return err
|
||||
}
|
||||
if seated+pending >= expeditionPartyMax {
|
||||
return ErrPartyFull
|
||||
}
|
||||
|
||||
if err := assertNotAdventuring(tx, expeditionID, invitee); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`
|
||||
INSERT INTO expedition_invite (expedition_id, user_id, invited_by, invited_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (expedition_id, user_id) DO NOTHING`,
|
||||
expeditionID, string(invitee), string(leader), time.Now().UTC())
|
||||
if err != nil {
|
||||
return fmt.Errorf("invite %s: %w", invitee, err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrAlreadyInvited
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// inviteCutoff is the age past which an invite no longer counts as pending.
|
||||
func inviteCutoff() time.Time { return time.Now().UTC().Add(-expeditionInviteTTL) }
|
||||
|
||||
// latestInviteFor returns the freshest unanswered invite addressed to a player,
|
||||
// or ErrInviteNotFound.
|
||||
//
|
||||
// A player may hold invites from several leaders at once; `!expedition accept`
|
||||
// takes the most recent, which is the one whose DM is still on their screen. The
|
||||
// first accept wins the player — joinParty's assertNotAdventuring refuses the
|
||||
// rest — so there is nothing to reconcile.
|
||||
func latestInviteFor(userID id.UserID) (*ExpeditionInvite, error) {
|
||||
var inv ExpeditionInvite
|
||||
err := db.Get().QueryRow(`
|
||||
SELECT expedition_id, user_id, invited_by, invited_at
|
||||
FROM expedition_invite
|
||||
WHERE user_id = ? AND invited_at > ?
|
||||
ORDER BY invited_at DESC
|
||||
LIMIT 1`, string(userID), inviteCutoff()).Scan(
|
||||
&inv.ExpeditionID, &inv.UserID, &inv.InvitedBy, &inv.InvitedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrInviteNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
// pendingInvites lists an expedition's unanswered invites, for the roster view.
|
||||
func pendingInvites(expeditionID string) ([]ExpeditionInvite, error) {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT expedition_id, user_id, invited_by, invited_at
|
||||
FROM expedition_invite
|
||||
WHERE expedition_id = ? AND invited_at > ?
|
||||
ORDER BY invited_at ASC`, expeditionID, inviteCutoff())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ExpeditionInvite
|
||||
for rows.Next() {
|
||||
var inv ExpeditionInvite
|
||||
if err := rows.Scan(&inv.ExpeditionID, &inv.UserID, &inv.InvitedBy, &inv.InvitedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, inv)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// clearInvite removes one answered invite.
|
||||
func clearInvite(expeditionID string, userID id.UserID) error {
|
||||
_, err := db.Get().Exec(
|
||||
`DELETE FROM expedition_invite WHERE expedition_id = ? AND user_id = ?`,
|
||||
expeditionID, string(userID))
|
||||
return err
|
||||
}
|
||||
|
||||
// clearExpeditionInvites drops every invite an expedition ever sent. Called
|
||||
// beside disbandParty when the run reaches a terminal status.
|
||||
func clearExpeditionInvites(expeditionID string) error {
|
||||
_, err := db.Get().Exec(
|
||||
`DELETE FROM expedition_invite WHERE expedition_id = ?`, expeditionID)
|
||||
return err
|
||||
}
|
||||
|
||||
// purgeExpiredInvites deletes invites nobody answered. Every read already
|
||||
// filters on the TTL, so this reclaims rows rather than enforcing a rule; it
|
||||
// rides the existing one-minute eventTicker (D4: no net-new ticker).
|
||||
func purgeExpiredInvites() {
|
||||
if _, err := db.Get().Exec(
|
||||
`DELETE FROM expedition_invite WHERE invited_at <= ?`, inviteCutoff()); err != nil {
|
||||
slog.Warn("expedition: purge expired invites", "err", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user