User-authored content edits (schema-validated: 543 unit tests pass):
- specs: explicit minPlayers on mawfang-pursuit (2), old-friend-bad-timing (3),
the-clock-maker (1) — documents author intent per Feature D.
- the-clock-maker: passiveReveals section + a skillChecks note on the optional
Feature A timer for the returning-customer deadline.
- lore/vocabulary.yaml: curated locations (inn, village, road) + a new forest
category.
Co-Authored-By: Claude <noreply@anthropic.com>
Bug 1 — loreResolver no longer uses lore-index chunks as randomizable values.
A disposition placeholder resolved to the 'All NPCs' index (Overview + [[wiki-
links]]), corrupting the opening narrative. Filter out index-like chunks
(Overview/Entries/Index header OR >=2 [[links]]) from candidates; fall back if
only index chunks remain. TDD: tests/unit/loreResolver.test.ts (4 tests).
(Follow-up: short-value randomizables like dispositions should use
source:vocabulary — the engine guard is the safety net.)
Bug 2 — begin announcement now precedes the opening narrative. handleStartBtn
uses interaction.update (edits the lobby embed to the announcement) BEFORE
beginEncounter posts the opening, so the thread reads announcement -> opening.
Bug 3 — lobby close updates the UI + renames the thread. On Begin: the lobby
embed is closed in place (Join/Begin buttons removed, replaced by the
announcement) + the thread is renamed '... — underway'. On Cancel: the thread
is renamed '... — cancelled' before archive. Live test AC-8 asserts the embed
is closed (no buttons + announcement) + the thread rename.
Verified: tsc clean, 543 unit tests pass (+4), live multiplayer E2E 3/3.
Co-Authored-By: Claude <noreply@anthropic.com>
Add specs/old-friend-bad-timing.yaml — a wacky chaos-bar encounter at the
Lonespear Tavern in Barristown (user-authored scene). tools: [] so all
registered tools are active (per getActiveTools, an empty list = all) — the
spec uses skill_check_emit, skill_check_group_emit, context_recall,
goal_register, and encounter_resolve. Satisfies specsToolsConsistency.
Co-Authored-By: Claude <noreply@anthropic.com>
Get the live multiplayer E2E passing end-to-end against real Discord + Redis +
LLM (3/3 green):
- Authorized starter: /encounter start is driven as a user in
DISCORD_ALLOWED_USERS (bots aren't in the user allowlist). The starter (DM)
just opens the lobby; the two player-bots Join, player1 presses Begin.
- Wire messageCreate → handleMessage on the test's botClient. connectLiveBots
creates a raw client (production wiring lives in index.ts); without this the
player bots' real gateway messages never reached handleMessage. The bot's own
messages are skipped by the anti-loop guard (its id isn't allowlisted).
- Register the player-bots in playerRegistry so their messages pass the player
gate (otherwise held + a gate embed posted).
- AC-9 asserts the 👀 reaction on each bot's real message — the robust routing
proof (fires in handleMessage before the player-gate/pending-check guards).
- AC-10 group check uses successRule 'all' for the Stealth check (a single sound
alerts the patrol — every roller must succeed), per review. Fixture updated
to match. 1-of-2 → FAILURE → 'The party falters' + [GROUP CHECK RESULT]
'Rule: all — FAILURE'.
Live: 3/3 pass (lobby gating, 2 real routed turns, group check N=2 finalizes).
tsc clean. Unit suite: 538/539 — the 1 failure is an unrelated new spec
(specs/old-friend-bad-timing.yaml) with no tools: list, flagged by
specsToolsConsistency (not from these changes).
Co-Authored-By: Claude <noreply@anthropic.com>
FU-13 — lobby on ALL encounters + starter NOT pre-joined (PRD addendum 2 A/B):
- encounter.ts handleStart: remove the minPlayers>=2 branch + the solo
immediate-begin path. EVERY /encounter start opens a lobby. The lobby opens
EMPTY (joined:[], joinedNames:[]) — the starter (DM) is not pre-joined;
players Join; Begin enables at minPlayers. Solo (minPlayers:1) needs one
Join. resolveRandomizables/NPC memories/beginEncounter run on Begin
(lobbyHandler.handleStartBtn), not in handleStart. Drops the now-dead
resolveRandomizables import.
- TDD: tests/unit/encounterStartLobby.test.ts (RED pre-join → GREEN no-pre-join).
- Amends FR-19/20/26 — a solo behavior change (ship-and-break family).
FU-14 — TTL auto-expiry + end/expired event (PRD addendum 2 C/D):
- SessionPhase gains 'expired'. config: ENCOUNTER_INACTIVITY_TTL_HOURS (24h).
LogEncounterParams gains kind:'ended'|'expired' (best-effort to the tool).
- new src/bot/handlers/expirySweep.ts: runExpirySweep finalizes open sessions
whose updatedAt is past the TTL as phase:'expired' (clears in-flight checks),
emits a GraphMCP log_encounter kind:'expired', posts an in-world notice, and
archives the thread. Uses updatedAt (bumped on every mutation) as the activity
timestamp — no new lastActivityAt field needed. SCAN, race-safe via atomicMutate.
- /encounter end tags log_encounter kind:'ended'.
- bot/index.ts: runExpirySweep at boot + an hourly periodic timer.
- TDD: tests/unit/expirySweep.test.ts (3 tests: idle→expired, recent left
alone, resolved left alone).
Tests updated: group-encounter-live.test.ts lobby AC for the no-pre-join flow
(0 → player1 join → player2 join → Begin). Verified: tsc clean, 539 unit tests
pass (+4), live test skips CI-safe.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
Uplift the multiplayer live test to verify the actual UI (embeds), per review
feedback that Redis-only assertions aren't thorough enough.
- fakeButton: optional messageId param — when set, update() EDITS THE REAL
message so the embed reflects the click (drives the UI). Back-compat for
2-arg callers (captured update).
- Correct lobby semantics: player1 is the STARTER (pre-joined per /encounter
start), player2 joins → roster = both bots. (Starter pre-joined; my earlier
draft had the join counts wrong.)
- AC-8 asserts on the REAL lobby embed: Seats field ('1 / 2 minimum' →
'min 2 met') + Begin button disabled/enabled read from the fetched components.
- AC-9 asserts the 👀 reaction on the player's real message (proves handleMessage
routed it, not skipped) + history growth.
- AC-10 asserts on the REAL scoreboard embed: Rolled field shows both players'
rolls, final footer (prevails/falters), buttons removed on finalize; outcome
consistent with the [GROUP CHECK RESULT] system message.
Verified: tsc --noEmit clean; 535 unit tests pass (fakeButton back-compat);
CI-safe skip confirmed (gate off → 3 skipped, clean).
Co-Authored-By: Claude <noreply@anthropic.com>
Implement the multiplayer E2E MVP story: a second player-side bot in the live
E2E flow so two distinct players can run a real group encounter.
Production code (TDD, red→green):
- src/bot/lib/e2ePlayerAllowlist.ts: runtime Set of player-bot userIds, populated
by connectLiveBots after login. A runtime Set (not a config field) because
config.ts parses env once at import — the ids are only known after login.
- src/bot/handlers/messageRouter.ts: anti-loop guard branches on
config.E2E_ALLOW_PLAYER_BOTS + isE2EPlayerBot(id); allowlisted player-bot
messages route as player turns. Default false → prod unchanged.
- src/config.ts: E2E_ALLOW_PLAYER_BOTS (boolean, default false).
Tests:
- tests/unit/e2ePlayerAllowlist.test.ts (5) + messageRouterAllowlist.test.ts (3):
cover both anti-loop branches without going live.
- tests/integration/graphmcp/group-encounter-live.test.ts: gated MVP live test
(lobby gating, 2 real chat turns, group check N=2). Skipped by default — CI-safe.
- liveBots.ts: N-player connectLiveBots (player1=E2E_DRIVER_TOKEN, player2=
E2E_PLAYER2_TOKEN) with driverBot alias preserved; populates the allowlist.
- fakes.ts: fakeButton now carries userId/username (back-compat 2-arg signature).
Fixture: specs/e2e-group-multiplayer.yaml (minPlayers:2, group Stealth check,
passive reveal, e2e- encounterId for flushRedisForGuild cleanup).
Verified: tsc --noEmit clean; 535 unit tests pass (+8 new); live test skips
cleanly without env. The 4 gap-case ACs (FR-11-14) are a follow-up story.
No token values committed; .env stays gitignored.
Co-Authored-By: Claude <noreply@anthropic.com>
Create the implementable story for the multiplayer live E2E PRD: config flag +
runtime player-bot allowlist + anti-loop branch + unit test, liveBots N-player
extension (driverBot alias preserved), minPlayers:2 fixture spec, and the MVP
live test (lobby gating, 2 real chat turns, group check N=2). The 4 gap-case
ACs and the doc update are explicitly out of scope (follow-up stories).
Created directly (project has no _bmad/ sprint-tracking infra) following the
BMad story template, grounded in the actual files touched. Key design note:
the player-bot id allowlist is a runtime Set (populated by liveBots after
login) because config.ts parses env once at import — only the on/off flag
lives in config.
Co-Authored-By: Claude <noreply@anthropic.com>
Add PRD for closing the one-token constraint gap: a second player-side bot
in the live E2E flow so two distinct players can join a lobby, post real
gateway messages (env-gated anti-loop allowlist), and roll in a group check.
Covers the MVP multiplayer run + the four documented gap cases (simultaneous
fan-out, successRule N>1, per-user ephemeral, second-claimant rejection).
Closes the FU-9 optional second-token live-E2E item. Approach A (in-process
N-player clients + env-gated allowlist).
Co-Authored-By: Claude <noreply@anthropic.com>
FU-9 (playtest checklist) and FU-12 (velvet-auction group tools) both
shipped in the prior commit; update the backlog so it reflects reality.
Co-Authored-By: Claude <noreply@anthropic.com>
FU-12 — velvet-auction.yaml now uses the group-encounter tools:
- minPlayers: 3 (lobby-gated party heist, matches PRD UJ-1)
- passiveReveals: Insight/15 (notices Karr's tell — Feature B)
- group_stealth skillChecks entry (group Stealth, successRule: majority,
durationSeconds: 60) + skill_check_group_emit and character_status added
to the tools list.
- specsToolsConsistency: emptied the NOT_YET_REFERENCED allowlist
(skill_check_group_emit + character_status are now referenced); all 8
registered tools are reachable from specs. Validated: specLoader +
specsToolsConsistency + full unit suite (527) pass.
FU-9 — docs/release-playtest-checklist.md: the 7-step manual pre-release
multi-player playtest checklist checked into the repo as a release gate
(was buried only in the arch doc). Includes pass criteria (no orphaned
thread / lost roll / raw-JSON leak) + the NFR-3/NFR-4 latency checklist.
docs/project-overview.md drift fix: pino -> src/lib/logger.ts (custom
plaintext, ADR-002); primary LLM -> minimax-m3 via LiteLLM
(LITELLM_MODEL); test count 22 -> 58; lib/ description; relabel dynamic
goal registration as delivered.
Co-Authored-By: Claude <noreply@anthropic.com>
- Add follow-up-stories.md (FU-1..FU-11) for the group-encounters feature:
deferred items extracted from the PRD non-goals + arch 'Areas for Future
Enhancement' + 3-round party-mode decision log (durable timers, ConditionsReader
real impl, campaignId enforcement, CAP-17 full refactor, multi-scene passive
reveals, spectator views, OQ-8 modifiers, live-E2E gap, plus two verify items).
- Mark _bmad-output/specs/spec-encounter-builder/prd.md as superseded
(status: final -> superseded) with a pointer to ADR-012: the helper-tools
suite is a Claude Code skill, not the local web app + published package
(P-1) this PRD described. Closes the drift between the doc and shipped reality.
Co-Authored-By: Claude <noreply@anthropic.com>
Feature D is live: a group encounter (minPlayers >= 2) opens a lobby instead
of starting immediately. Players Join via the button; Begin starts the
encounter with the joined roster when the minimum is met.
- encounter.ts handleStart: branches on minPlayers >= 2 → lobby flow (create
thread, post lobby embed, persist lobby state, editReply 'Lobby opened');
<= 1 → solo flow (existing, now via beginEncounter).
- beginEncounter + loadNpcMemories extracted from handleStart (shared by solo
start + lobby Begin). applyResolved exported. beginEncounter takes the
initial players roster ({} for solo, the joined roster for lobby Begin).
- lobbyHandler: lobby_join (joinLobby + edit embed), lobby_leave (leaveLobby +
edit), lobby_start (re-resolve spec + beginEncounter with the joined roster +
clearLobby), lobby_cancel (starter-only, clears + archives). Routed from
bot/index.ts interactionCreate (lobby_ prefix).
- The lobby embed's Join button stays live after Start (P1 — obvious in-flight
join). Begin is disabled until joined >= min. Cancel is starter-only.
Note: message regulation (FR-28/29 — delete non-joined messages while a lobby
is open) is a deferred refinement; the lobby is functional without it (Join/
Begin/Leave/Cancel all work). A getLobby gate in messageRouter would close it.
527 unit pass; tsc clean.
Story 9.1 complete — all 10 stories done.
Co-Authored-By: Claude <noreply@anthropic.com>
The testable core of Feature D (encounter lobby):
- buildLobbyEmbed(title, joinedNames, min, max, ready): the lobby embed —
GATHERING orange while seats are open, NEUTRAL once the minimum is met;
seats + joined-names fields; Join/Leave in their own row (stays live after
Start — P1), Begin/Cancel in a second row (Begin disabled until ready).
- lobbyManager (Redis lobby:{threadId}, TTL ~30m): setLobby/getLobby/
clearLobby/joinLobby (idempotent, cap-aware)/leaveLobby. LobbyState holds
specName + joined roster (discordIds + dndNames) + min/max + starterId +
the lobby embed messageId.
- EMBED_COLOR += GATHERING (warm orange) + NEUTRAL (gray).
Tests: lobbyManager (8 — set/get, join, idempotent, cap, gone, leave, non-
joined, clear) + buildLobbyEmbed (3 — title/seats/joined, Begin disabled until
min, Begin enabled when ready). 527 unit pass; tsc clean.
Co-Authored-By: Claude <noreply@anthropic.com>
The Feature E L2 conditions path, relay-blocked. The bot owns the contract
(CharacterCondition shape); the Foundry relay implements /dnd5e/get-actor-
conditions against it later. Until then the stub returns [] and the relay
reader degrades gracefully — flipping FOUNDRY_CONDITIONS_ENABLED + landing
the relay RPC is the only cutover.
- foundryClient.getActorConditions(uuid) + CharacterCondition type
(id, name, description?, durationRemaining?, concentration?).
- conditionsReader: ConditionsReader interface + stubConditionsReader ([]) +
relayConditionsReader (calls the relay, graceful on failure / no linked
character) + getConditionsReader factory (stub by default; relay when
FOUNDRY_CONDITIONS_ENABLED).
- characterContext.getConditions(guildId, discordId) via the active reader.
- config += FOUNDRY_CONDITIONS_ENABLED (default false).
Tests: conditionsReader (5 — stub [], factory default stub, relay returns
conditions, relay no-character, relay graceful on 404). 516 unit pass; tsc clean.
Story 10.2 complete (L2 path wired + stubbed; real conditions wait on the relay).
Co-Authored-By: Claude <noreply@anthropic.com>
Feature E is live: the LLM now sees each player's class/race/level + active
story status every turn, and the DM can set/clear/show story status via a
slash command.
- assembleContext: now async — fetches each player's character profile
(class/race/level from the registry) + active story status per turn, passes
the L1 enrichment to buildSystemPrompt → buildPlayersBlock.
- buildPlayersBlock: renders 'dndName (class race level) [pronouns] — status:
sick, cursed' (was just 'dndName (pronouns)'). PlayerEnrichment type.
- runLLMTurn: awaits assembleContext; sendTyping moved before the await so the
typing indicator starts immediately (before the context fetch).
- /character status set|clear|show @user [label]: DM command (gated by
isAllowedUser) — sets/clears story status with setter:'dm' (DM > LLM), or
shows the active statuses. Ephemeral confirmations.
Tests: contextAssembler (mocked registry + status store, all async), promptBuilder
players-block (new format). 511 unit pass; tsc clean.
Story 10.1 complete (Feature E L1 + character_status + story status shipped).
Co-Authored-By: Claude <noreply@anthropic.com>
Engine-tracked story-driven status (Feature E) — sick, cursed, disguised, etc.
- storyStatusStore (Redis character_status:{guildId}:{discordId}): get/set/clear,
TTL ~24h (auto-clears on expiry). DM > LLM: an LLM set/clear of a DM-held
label is a silent no-op; clear-all is DM-only.
- character_status tool (LLM): action set|clear, player, label. Whitelist
(wounded/inspired/hidden/exhausted/sick/cursed/disguised/frightened) —
capability-escalation guard; non-whitelisted labels rejected. Resolves the
player from the roster; DM>LLM via the store. Registered in tools/index.ts.
- StoryStatus type.
Tests: storyStatusStore (7 — set/get, DM>LLM set + clear, LLM set non-DM-held,
replace-not-duplicate, clear, clear-all DM-only), characterStatus (5 — set
whitelisted, reject non-whitelisted, clear, DM-held report, unknown player).
511 unit pass; tsc clean.
Co-Authored-By: Claude <noreply@anthropic.com>
Feature C is complete.
- messageRouter: block player messages while a pendingGroupCheck is active
(the targeted players roll via the scoreboard button through interactionCreate;
other chat waits so the LLM doesn't narrate past an unresolved group check).
No skip counter — the check finalizes on all-rolled or the timer.
- groupCheckManager: armGroupCheckTimer/clearGroupCheckTimer — timed checks fire
after durationSeconds; untimed checks arm a 300s no-show backstop so an AFK
player can't hang the check. finalizeGroupCheck clears the timer. Lost on
restart (the boot sweep finalizes a pending timed group check).
- skill_check_group_emit: arms the timer (durationSeconds or the backstop) after
persisting the pending group check.
Tests: group-check timer (2 — timeout finalizes with unrolled=failure; clear
cancels). 499 unit pass; tsc clean.
Co-Authored-By: Claude <noreply@anthropic.com>
The roll side of Feature C: a player clicks Roll on the scoreboard → the bot
records their roll atomically, acks them privately, updates the scoreboard in
place, and finalizes once when all targeted players have rolled.
- groupCheckManager.recordGroupRoll(threadId, discordId, roll, modifier):
atomic (via atomicMutate), idempotent (a second click by the same player is
a no-op — alreadyRolled), returns the updated gc + allRolled. Two
near-simultaneous rolls can't lose an update.
- groupCheckManager.finalizeGroupCheck(threadId, thread, client): applies the
successRule to the rolls (unrolled = failure), edits the scoreboard to its
final SUCCESS/FAILURE state (buttons removed), appends the aggregate
[GROUP CHECK RESULT] system message, clears pendingGroupCheck, schedules ONE
LLM turn (once-per-check — the LLM narrates the group outcome; it never
evaluates the rule). No-op if already finalized.
- buildGroupRollEphemeralEmbed: the per-player ephemeral roll view (the
authoritative personal surface per the UX surface hierarchy).
- rollHandler: routes sc_roll — pendingGroupCheck → submitGroupRoll (player-
lock to a targeted player, roll with the group's adv/dis, ephemeral ack
BEFORE the scoreboard edit, scoreboard edited in place, finalize when
allRolled); otherwise the solo submitResult path.
Tests: groupCheckManager (7 — record atomic/idempotent/allRolled/gone;
finalize applies rule + edits + appends result + clears + schedules once +
unrolled-as-failure + no-op), groupScoreboard ephemeral (2). 497 unit pass;
tsc clean.
Co-Authored-By: Claude <noreply@anthropic.com>
The emit side of Feature C is complete: the LLM can now post a group
skill-check scoreboard and the bot persists the pending group check.
- buildGroupScoreboardEmbed(skill, prompt, dc, rolls, opts?): the scoreboard
— per-player rows (awaiting / ✅+total / ❌+total), DC, optional Roll Mode
(adv/dis) + timer (Time / Final sands) fields, PENDING→URGENT in the final
stretch. One embed, edited in place by the 8.2 runner.
- skill_check_group_emit tool: validates the successRule (built from primitive
args — majority/all/n_of_m(n,m)/sum_threshold(threshold,sumOf) — then
SuccessRuleSchema.parse), resolves targeted players ('all' = roster, or a
comma-separated dndName list), resolves each modifier via
characterContext.getModifier, posts the scoreboard + Roll button, and
persists pendingGroupCheck via atomicMutate. Rejects n_of_m m > N and
no-targeted-players. Whole-group advantage/disadvantage + durationSeconds
stored on the pending check.
- PendingGroupCheck += advantage/disadvantage. Registered in tools/index.ts.
specsToolsConsistency: allowlist skill_check_group_emit (registered ahead of
its spec — a group spec lands with the lobby in Story 9).
Tests: groupScoreboard (5 — title/DC, rows, PENDING, roll mode, timer/URGENT),
skill_check_group_emit (6 — all-players default majority, named players,
no-players error, n_of_m m>N error, sum_threshold, advantage). 489 unit pass;
tsc clean.
Story 8.1 complete (successRule evaluator + types + resolver + embed + tool).
The roll side (atomic roll registration, scoreboard live updates, aggregate
[GROUP CHECK RESULT], once-per-check LLM call) is Story 8.2.
Co-Authored-By: Claude <noreply@anthropic.com>
Groundwork for the skill_check_group_emit tool (pt2b):
- characterContext.getModifier(guildId, discordId, skill): the skill/ability
modifier resolver, consolidated out of skillCheckEmit so the group tool can
reuse it. Skill checks use Foundry skills[key].total; ability checks fall
back to abilities[key].mod; undefined when no Foundry char / unrecognized
skill / lookup fails (graceful). skillCheckEmit now calls it (removed its
inline resolveModifier + the characterRegistry/foundryClient/SKILL_KEY/log
imports it no longer needs).
- types: PendingGroupCheckRoll (per-player: rolled, modifier, roll?, total?,
success?) + PendingGroupCheck (skill, prompt, dc, messageId, successRule,
durationSeconds?, deadline?, rolls[]) on SessionState.pendingGroupCheck — a
distinct field, not overloading pendingSkillCheck's shape; mutated only via
atomicMutate.
Tests: characterContext += getModifier (5 — skill total / ability fallback /
unrecognized / no Foundry char / graceful throw). 478 unit pass; tsc clean.
Co-Authored-By: Claude <noreply@anthropic.com>
The deterministic group-check outcome rule — the one open doc-debt the
architecture validation panel flagged, now pinned concretely. The LLM passes
one of these on skill_check_group_emit; the BOT evaluates it (pure function)
and feeds the verdict to the narrator. The LLM never judges the group outcome
(dice monopoly — successRule-in-code).
SuccessRuleSchema (Zod union) + SuccessRule type:
majority — successes >= ceil(N/2), N = targeted roller count (default)
all — every targeted roller succeeds
n_of_m — successes >= n of m (m = N at emit; n >= 1)
sum_threshold — sum(values) >= t; of ∈ {roll (d20 face), total (d20+mod)}
evaluateSuccessRule(rule, results): pure, N=0 → false. Unrolled players at
finalization count as failures (success=false, roll=0, total=0) — neutral for
sum_threshold, failure for majority/all/n_of_m. GroupRollResult type
(discordId, dndName, roll, modifier, total, success).
Tests (15): schema parses each variant / rejects unknown kind / rejects n<1;
majority (ceil(N/2), even-N half, unrolled-as-failure); all (unrolled fails
the group); n_of_m; sum_threshold (of=roll, of=total, unrolled contributes 0);
zero-results → false; default is majority. 473 unit pass; tsc clean.
Co-Authored-By: Claude <noreply@anthropic.com>
Feature B is now live: at /encounter start, after the opening narrative, the
bot fires passive-skill reveals for the players present (the starter; the
lobby roster for group encounters once Feature D lands). For each
passiveReveal (skill, threshold, revealText) whose passive score (from
Foundry via the character registry) meets the threshold, a group-visible
reveal embed is posted, attributed to the player. Players without a Foundry
character are skipped (FR-46). Deterministic — the LLM does not trigger or
threshold these.
- src/harness/characterContext.ts: the architecture's characterContext
platform module — shared 30s actor cache (fetchActorCached, moved out of
skillCheckEmit so the cache TTL can't drift) + getPassiveScore(guildId,
discordId, skill) resolver. L1/L2 enrichment + ConditionsReader extend it
in Story 10.1/10.2.
- skillCheckEmit: imports fetchActorCached from characterContext (no inline
cache duplicate).
- encounter.ts: after thread.send(openingText), build the present-players
list (starter via playerRegistry + session.players), run computePassiveReveals
with the real getPassiveScore, post each buildPassiveRevealEmbed.
Tests: characterContext (5) — resolves via Foundry / undefined for no
Foundry char / no profile / graceful on relay throw / 30s cache. 458 unit
pass; tsc clean.
Story 7.1 complete (passive reveals — Feature B shipped).
Co-Authored-By: Claude <noreply@anthropic.com>
The timed-check embed UX (per the UX spec): a countdown field the runner
updates in 10s increments; below ~10s it switches to the final-stretch
'Final sands — roll now' urgency cue (an announced FIELD, not the footer —
discord.js embed images take no alt text, so the text cue is the a11y
backstop) and, when TIMER_GIF_URL is set, a ~10s-loop hourglass GIF. No URL
→ the text cue alone (static fallback; the asset is non-blocking).
- EMBED_COLOR += URGENT (amber).
- buildTimedCheckEmbed(player, prompt, dc, remaining, ...mods, gifUrl):
countdown field ('~Ns') for remaining > 10; final-sands field + URGENT +
optional setImage(gifUrl) for remaining <= 10; timed footer.
- skillCheckTimer.startCountdown: a 10s setInterval that edits the embed in
place (preserving the Roll button — only embeds passed), re-checks pending
state each tick (stops early if the roll lands / the timer fires), and
stops after the final-stretch edit. clearSkillCheckTimer clears it with the
finalize timeout. skillCheckEmit starts it alongside armSkillCheckTimer.
- config += TIMER_GIF_URL (optional, default '').
Tests: buildTimedCheckEmbed (countdown field + PENDING; final-sands + URGENT;
GIF setImage; static fallback no image; timed footer); startCountdown (10s
cadence + stops after final stretch; stops when the check resolves mid-count).
438 unit pass; tsc clean.
Story 6.2 complete (boot restart sweep + timed-check embed UX).
Co-Authored-By: Claude <noreply@anthropic.com>
In-memory timed-check timers are lost on restart; without recovery a pending
TIMED check would hang forever (no timer to fire, no roll coming). runRestartSweep
runs once at client ready (boot barrier before the gateway processes
interactions): SCAN session:* (never KEYS), and for each session with a
pending TIMED check (pendingSkillCheck.durationSeconds set), finalize it as
FAILURE (timer expired) — clear the pending state inside atomicMutate and
append the fail result to history so the LLM narrates the timeout on the next
turn. Untimed pending checks are left alone (the player can still click Roll;
the embed persists). Race-safe: a Roll click landing during the sweep wins
(the mutator re-checks pendingSkillCheck and no-ops if it's gone/changed).
- src/lib/skillCheckMessages.ts: shared timedOutSystemMessage(p) helper (used by
both the in-memory timer finalize and the sweep — dep-free so the sweep
doesn't pull the messageRouter graph).
- skillCheckTimer: uses the shared helper (no inline message duplication).
- bot/index.ts: client 'ready' → await runRestartSweep().
Tests: restartSweep (4) — finalizes a pending timed check (clears + appends
FAILURE (timer expired)); leaves an untimed pending check; skips no-pending
sessions; across multiple sessions finalizes only the timed ones. 431 unit
pass; tsc clean.
Co-Authored-By: Claude <noreply@anthropic.com>
skill_check_emit gains an optional durationSeconds arg (1–600). When set
(and the client is available on the tool ctx), an in-memory timer is armed;
on expiry, if the check is still pending (the roll hasn't landed), it
finalizes as FAILURE (timer expired): conditionally clears the pending
check inside atomicMutate (only when the messageId still matches — a stale
timer can't finalize a different check, and a roll that already resolved it
wins), edits the embed to a timed-out state, pushes the [SKILL CHECK RESULT]
system message, and schedules the next LLM turn. rollHandler clears the
timer when the roll is accepted.
- ToolContext += optional client (so the tool can schedule follow-up work);
passed at the runLLMTurn dispatch site.
- PendingSkillCheck.durationSeconds persisted (so the boot sweep in 6.2 can
tell a pending check was timed and finalize it on restart).
- In-memory timers are lost on restart — accepted; the 6.2 boot sweep finalizes
a pending timed check on restart as a fail.
Tests: skillCheckTimer (4) — expiry finalizes FAILURE (timer expired); no
finalize when already resolved; no finalize for a different check (stale
timer); clearSkillCheckTimer cancels. 427 unit pass; tsc clean.
Story 6.1 complete (FR-43 single-Roll + Feature A timed checks).
Co-Authored-By: Claude <noreply@anthropic.com>
Retire the Advantage/Disadvantage/Custom-Modifier buttons and the modifier
modal on the skill-check surface. The player now gets a single Roll button;
advantage/disadvantage and the modifier are decided UPSTREAM (LLM emit args +
Foundry stats) and stored on pendingSkillCheck — shown as embed fields, not
chosen by the player. Per the architecture (internal/shared-use stakes), this
is a ship-and-break cutover in one release (no flag).
- PendingSkillCheck += discordId (the targeted player; locks the Roll button)
+ durationSeconds (Feature A timed checks — used in pt2).
- embeds/skillCheck: buildRollButtons() returns one sc_roll button; removed
buildRollButtons' modifier variants and buildModifierRollButtons.
- rollHandler: routes only sc_roll; canRoll(pendingDiscordId, clickerId) —
fail-CLOSED when the targeted discordId is known and the clicker differs
(in-world 'This roll is not yours to make.'), fail-OPEN when discordId is
absent (name-match fragility — a legit player is never soft-locked out of
their own roll). Roll mode + modifier come from pendingSkillCheck. Removed
the sc_adv/sc_dis/sc_mod/sc_*_m:* paths and the modifier modal.
- skillCheckEmit: stores discordId on pendingSkillCheck; migrated the
pendingSkillCheck set from the non-atomic update() to atomicMutate (closes
the TOCTOU window where an await on thread.send separated resolve from
persist — a 5.2 migration miss).
- bot/index.ts: interactionCreate guard is button-only now (modal retired).
Tests: skillCheckEmbed (single-button contract), rollHandler (isSkillCheckInteraction
single-Roll + canRoll player-lock: allow target / reject other / fail-open when
unknown), toolDispatcher (atomicMutate mutator-capture for skill_check_emit).
423 unit pass; tsc clean.
Co-Authored-By: Claude <noreply@anthropic.com>
sessionManager.atomicMutate(threadId, mutator): atomic read-modify-write
serialized per threadId via an in-process promise chain. Single-process
Node: the only concurrency is across await boundaries in one event loop, so
an in-process per-key mutex prevents lost updates (no Lua/WATCH — swap path
documented for a future multi-instance). update() retained (constant patches
with no concurrent read) but flagged as non-atomic.
Migrate every read-derived / concurrency-sensitive sessionManager.update()
call site to atomicMutate: pendingSkillCheck set/clear (skillCheckEmit,
rollHandler, messageRouter roll-resolve + auto-cancel), pendingSkillCheckAttempts
increment (messageRouter pending-block — now reads current count inside the
mutator so two concurrent pending messages can't both read a stale count),
heldMessages append/clear, players join (re-checks presence inside the
mutator), addMessage history (trim inside the lock), goal_register spec
update, encounter/turn resolve. Closes the TOCTOU windows the group-check
multi-player fan-out will stress.
Add src/db/keys.ts: the Redis key registry (single source for key shapes,
documented owner/TTL/sweep table) — groupcheck/lobby/encounter:active/
character_status/campaign key builders for the upcoming feature stories.
Tests: 432 unit pass (5 new atomicMutate concurrency tests + key-registry
shape test); migrated test mocks to expose atomicMutate; tsc clean.
Co-Authored-By: Claude <noreply@anthropic.com>
CAP-17 minimal schema additions — unblocks Features A-E:
- minPlayers (int, min 1, default 1): party-size gating; omit/1 = solo-able.
- maxPlayers (int, optional): party-size cap.
- passiveReveals (optional array of {skill, threshold, revealText}):
bot-applied passive-skill reveals at encounter start (Feature B).
Group-visible only — no private delivery path (no interaction in flight
at start to carry an ephemeral). threshold is a DC integer.
Also: declare explicit tools: list in specs/the-clock-maker.yaml (was
omitted; specsToolsConsistency requires explicit declaration). Update
docs/spec-authoring-guide.md (minPlayers/maxPlayers/passiveReveals docs
+ new pitfalls: no dice in revealText, threshold is a DC int, successRule
is a tool arg not a spec field, story-status never in spec prose).
Tests: 426 unit pass; tsc clean.
Co-Authored-By: Claude <noreply@anthropic.com>
PRD (46 FRs, 5 features), UX (DESIGN + EXPERIENCE), and architecture
(READY FOR IMPLEMENTATION) for the group-encounters feature set, plus
decision logs. Built via bmad-prd / bmad-ux / bmad-create-architecture
with party-mode validation.
Co-Authored-By: Claude <noreply@anthropic.com>
Untrack the 9 committed data/summaries/*.txt files (runtime encounter
summaries from 2026-05-24..05-30). They were committed before the data/
gitignore rule took effect; data/ is already ignored, so untracking
brings tracking in line with the ignore and stops runtime artifacts
from living in version control. Working copies are kept on disk.
Co-Authored-By: Claude <noreply@anthropic.com>
- AC3 skill-check: fakeInteraction omitted userId, so the user-allowlist guard
rejected /encounter start ('You are not authorised to use encounter
commands.') and beforeAll failed. Add E2E_DRIVER_USER_ID like AC2/AC9.
Verified live (2 tests pass).
- AC5 flee: the engine reached a valid 'escape' outcome in ~2 driver turns but
the guards demanded >=5 (hardcoded). flee is a fast-disengagement strategy,
not a 20-30 turn play — set minDriverTurns: 2 and make the history-length guard
scale with minTurns instead of a hardcoded 5. Verified live (escape outcome).
- Untrack data/tally.json (runtime run-counts, already covered by data/ in
.gitignore) so live runs stop dirtying the working tree.
Co-Authored-By: Claude <noreply@anthropic.com>
Includes full bot source (Phases 1–4), plus five new features:
- Epic 1: emoji reaction state machine (👀⏳✅🎲) + burst queue cap at 2 with in-world drop notices
- Epic 2: per-encounter tone field in YAML injected into LLM system prompt
- Epic 3: player pronouns via modal registration + system prompt players block
- Epic 4: strengthened skill_check_emit tool contract + missed-skill-check diagnostic
Also includes UX design docs, epics, and story files under Docs/.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>