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:
prosolis
2026-07-09 22:30:11 -07:00
parent 0f144fa335
commit 1928f75c19
9 changed files with 827 additions and 8 deletions

View File

@@ -116,13 +116,21 @@ func (p *AdventurePlugin) fireExpeditionAutoRuns(now time.Time) {
// is older than ageCutoff so a freshly-started expedition isn't yanked
// out from under the player on tick 1.
func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error) {
// N3/P6b: an expedition with an unanswered invite does not walk. The
// leader must not be dragged into a boss room while their friend is still
// reading the DM. Bounded by expeditionInviteTTL, which the invited_at
// comparison enforces here rather than trusting the purge to have run.
rows, err := db.Get().Query(`
SELECT expedition_id
FROM dnd_expedition
FROM dnd_expedition e
WHERE status = 'active'
AND start_date < ?
AND (last_autorun_at IS NULL OR last_autorun_at < ?)`,
ageCutoff, runCutoff)
AND (last_autorun_at IS NULL OR last_autorun_at < ?)
AND NOT EXISTS (
SELECT 1 FROM expedition_invite i
WHERE i.expedition_id = e.expedition_id
AND i.invited_at > ?)`,
ageCutoff, runCutoff, inviteCutoff())
if err != nil {
return nil, err
}