docs(prd): addendum 2 — lobby on all encounters + end/expiry event

Two requirements the group-encounters PRD missed (raised post-ship):
- A/B: Lobby on ALL encounters (solo too) + starter NOT pre-joined (lobby
  opens empty; the DM creates it, players Join, Begin at min). Amends
  FR-19/FR-20/FR-26 — a solo behavior change (ship-and-break family).
- C/D: TTL auto-expiry + event — inactivity TTL finalizes a session as
  phase:'expired' with a GraphMCP log_encounter kind:'expired' event + an
  in-world Discord notice + thread archive; /encounter end tags kind:'ended'.

Backlog: FU-13 (A+B), FU-14 (C+D) with acceptance + open questions
(OQ-A1 default TTL, OQ-A2 clear in-flight checks, OQ-A3 periodic vs boot sweep).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kaysser Kayyali
2026-06-22 21:05:04 +00:00
parent 2dda9b4847
commit 465b4c80ba
2 changed files with 119 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
# Addendum 2 — Lobby on All Encounters + Encounter End/Expiry Event
**Date:** 2026-06-22
**Amends:** the Group Encounters PRD (`prd.md`) — FR-19, FR-20, FR-26, and the
`phase: open | resolved` model. These are requirements the PRD missed, raised
after the feature set shipped.
---
## A. Lobby on ALL encounters (amends FR-19/FR-20)
**Today:** `/encounter start` opens a lobby **only** when `minPlayers >= 2`
(FR-20); solo encounters (`minPlayers <= 1`) begin immediately with implicit
join (FR-19).
**New requirement:** **every** `/encounter start` opens a lobby, including solo.
The `minPlayers` gate still controls when **Begin** enables, but there is no
"begin immediately" path — the lobby is the single entry point for all
encounters.
- Solo (`minPlayers: 1`): lobby opens → 0 joined → Begin disabled → one player
Joins → 1 joined (min met) → Begin enabled → begin.
- Group (`minPlayers >= 2`): unchanged gating, just no longer a separate branch.
## B. Starter is NOT pre-joined (amends FR-26)
**Today:** `/encounter start` pre-joins the starter (`joined: [interaction.user.id]`,
`joinedNames: [starterName]`).
**New requirement:** the starter (the DM, "usually me") is **not** added to the
roster. The lobby opens **empty** (`joined: []`, `joinedNames: []`). The starter
creates the lobby; players Join; **Begin** is pressable by any joined player
once the minimum is met (FR-22 unchanged). The starter's `starterId` is still
recorded (for Cancel authorization, FR-23) but they are not a participant.
**Impact on existing specs/behavior:** solo play now requires the single player
to Join + Begin (one extra click) rather than implicit-join-on-chat. This is an
intentional behavior change to shipped behavior (call it out in the changelog +
the release-playtest checklist, same family as FR-43 ship-and-break).
## C. TTL auto-expiry + event (new)
**New requirement:** encounters **auto-expire** after an inactivity TTL — no
player message and no LLM turn for a configurable period (default TBD, e.g. 24h,
env-configurable `ENCOUNTER_INACTIVITY_TTL_HOURS`). On expiry:
1. `session.phase` transitions to a new **`expired`** state (alongside
`open` / `resolved`).
2. A **GraphMCP `log_encounter` event** is emitted with `kind: "expired"`
(distinct from `kind: "ended"` below), recording the encounter + participants
+ a summary like "Encounter expired due to inactivity."
3. A **Discord notice** is posted in the thread (in-world voice: e.g. *"The
moment passes unresolved, and the road moves on…"*) — not a raw system string.
4. The thread is **archived**.
## D. End vs expired — the two events
- **`/encounter end`** (existing) → `phase: resolved`, GraphMCP event
`kind: "ended"` (the DM explicitly ended it). Today `log_encounter` fires on
end but does not tag `kind`; add the `kind` field so end vs expired are
distinguishable in the graph.
- **Auto-expiry** (new) → `phase: expired`, GraphMCP event `kind: "expired"`.
Both post a Discord notice + archive the thread.
---
## Implementation notes (for the stories)
- **A + B** live in `src/bot/commands/encounter.ts` `handleStart`: remove the
`if (spec.minPlayers >= 2)` branch (always lobby); change `setLobby` to
`joined: []`, `joinedNames: []` (drop the starter pre-join). `lobbyHandler`
is unchanged (Join/Begin already work on the joined list). Update the lobby
embed initial state (0 joined) + the live E2E test's lobby AC.
- **C + D** need: a `lastActivityAt` on `SessionState` (bumped on each message +
LLM turn); a periodic sweep (or extension of `restartSweep`) that finalizes
sessions past the TTL as `expired`; a `phase: 'expired'` literal in the type;
a `kind` field on `log_encounter`; the in-world expiry notice (centralize in
`src/lib/systemStrings.ts` if it exists — see FU-11). Boot sweep should also
catch expired-by-inactivity on restart.
- **Follow-up stories:** FU-13 (A + B), FU-14 (C + D) — see `follow-up-stories.md`.
## Open questions
- **OQ-A1** Default inactivity TTL value (24h? 12h to match session TTL?).
- **OQ-A2** Does auto-expiry also clear an in-flight `pendingGroupCheck` /
`pendingSkillCheck` (finalize as expired-failure)? Recommend yes.
- **OQ-A3** Should the expiry sweep run on a periodic timer (in-process) or only
at boot + on activity? Periodic is more responsive; boot-only is simpler.

View File

@@ -121,6 +121,37 @@ deferred, accepted as ship-and-break trade-offs, or left open. The feature set
---
## FU-13 — Lobby on ALL encounters + starter NOT pre-joined
- **status:** backlog (PRD addendum 2, items A + B) · **confidence: high**
- **Source:** addendum-2026-06-22-lobby-all-and-expiry.md (amends FR-19/FR-20/FR-26).
- **Today:** `/encounter start` lobbies only for `minPlayers >= 2` and pre-joins the starter (`joined: [interaction.user.id]`).
- **Change:**
- Every `/encounter start` opens a lobby (remove the `minPlayers >= 2` branch in `handleStart`). Solo (`minPlayers: 1`) also lobbies.
- The starter is NOT pre-joined — lobby opens empty (`joined: []`, `joinedNames: []`). `starterId` is still recorded (for Cancel). Players Join; Begin at min.
- **Acceptance:**
- Solo spec (`minPlayers: 1`): `/encounter start` → lobby (0 joined, Begin disabled) → one Join → Begin enabled → begin.
- Group spec: unchanged gating, lobby flow only.
- The live multiplayer E2E test's lobby AC is updated (no pre-join: 0 → player1 join → player2 join → Begin).
- Changelog + release-playtest checklist note the solo behavior change (ship-and-break family).
- **Depends on:** none. Touches `src/bot/commands/encounter.ts` + the lobby live test.
## FU-14 — TTL auto-expiry + end/expired event
- **status:** backlog (PRD addendum 2, items C + D) · **confidence: high**
- **Source:** addendum-2026-06-22-lobby-all-and-expiry.md.
- **Today:** encounters end only via `/encounter end` (`phase: resolved`); no inactivity expiry; `log_encounter` has no `kind`.
- **Change:**
- `SessionState.lastActivityAt` (bumped on each message + LLM turn).
- Inactivity TTL (env `ENCOUNTER_INACTIVITY_TTL_HOURS`, default TBD — OQ-A1).
- A sweep finalizes sessions past the TTL as `phase: 'expired'` (new phase literal); clears any in-flight `pendingGroupCheck`/`pendingSkillCheck` as expired-failure (OQ-A2).
- On expiry: GraphMCP `log_encounter` with `kind: "expired"`; an in-world Discord notice (centralized system string); thread archived.
- `/encounter end` tags `log_encounter` `kind: "ended"` (so end vs expired are distinguishable).
- Boot sweep also catches expired-by-inactivity on restart.
- **Acceptance:**
- An idle session past the TTL is finalized as `expired` with a GraphMCP `kind: "expired"` event + a Discord notice + archived thread.
- `/encounter end` emits `kind: "ended"`.
- A unit test covers the expiry sweep (clock-injected TTL) + the kind tagging.
- **Open:** OQ-A1 (default TTL), OQ-A2 (clear in-flight checks on expiry — recommend yes), OQ-A3 (periodic timer vs boot-only sweep).
## Not deferred (intentionally closed — do not re-open)
- **FR-43 deprecation window / `SKILL_CHECK_SURFACE` flag (FR-49/FR-50):** RETIRED by the round-3 party-mode decision (Mary's trim). Ship-and-break in one release, no flag, plain system notice for old buttons, pinned Discord message. This is *done by design*, not a follow-up.
- **Straggler deadline as a spec field:** retracted → became a DM "resolve with current rolls" UX affordance (FR-47), not a spec field.