48 Commits

Author SHA1 Message Date
be00ec2ba5 merge: lobby close UI + loreResolver index guard + spec/vocabulary edits
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 29s
2026-06-22 22:13:02 +00:00
Kaysser Kayyali
3f426de6a4 content: spec/vocabulary edits — minPlayers docs, vocabulary locations, clock-maker passive reveals
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>
2026-06-22 22:13:02 +00:00
Kaysser Kayyali
fdf1d705d1 fix: lobby close UI + loreResolver skips index chunks (3 live-run bugs)
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>
2026-06-22 22:09:42 +00:00
83180555a5 merge: multiplayer live E2E + FU-13 lobby-on-all + FU-14 TTL auto-expiry
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 31s
2026-06-22 21:54:59 +00:00
Kaysser Kayyali
b5234ca972 spec: old-friend-bad-timing (chaos-bar encounter) + tools: [] (all-active)
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>
2026-06-22 21:54:59 +00:00
Kaysser Kayyali
8ee406957a test(e2e): live multiplayer E2E green — drive the real UI, all-rule Stealth
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>
2026-06-22 21:43:26 +00:00
Kaysser Kayyali
663dc85762 feat: FU-13 lobby on all encounters + FU-14 TTL auto-expiry
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>
2026-06-22 21:27:13 +00:00
Kaysser Kayyali
465b4c80ba 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>
2026-06-22 21:05:04 +00:00
Kaysser Kayyali
2dda9b4847 test(e2e): multiplayer live test drives the real embed UI, not just Redis
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>
2026-06-22 20:39:33 +00:00
Kaysser Kayyali
fcea0a30bc feat(e2e): multiplayer live E2E MVP — second player bot + anti-loop allowlist
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>
2026-06-22 20:15:19 +00:00
ba3b2deecb merge: story — multiplayer E2E MVP vertical slice 2026-06-22 19:56:18 +00:00
Kaysser Kayyali
cb61481af5 story: multiplayer E2E MVP vertical slice (2-player-bot live run)
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>
2026-06-22 19:52:22 +00:00
60ffc9c661 merge: PRD — multiplayer live E2E via a second player bot 2026-06-22 19:48:07 +00:00
Kaysser Kayyali
09e8a1aae2 prd: multiplayer live E2E via a second player bot
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>
2026-06-22 16:51:02 +00:00
4c520fd567 merge: follow-up backlog status update (FU-9 done, FU-12 logged) 2026-06-22 16:03:56 +00:00
Kaysser Kayyali
839d303b3e docs: mark FU-9 done + log FU-12 completed in follow-up backlog
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>
2026-06-22 16:03:56 +00:00
d05b381b49 merge: group-encounters follow-through (FU-12 spec, FU-9 playtest gate, PRD status, docs drift) 2026-06-22 16:02:00 +00:00
Kaysser Kayyali
37a1a3d421 feat(specs): velvet-auction exercises group tools; FU-9 playtest gate; docs drift fix
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>
2026-06-22 16:02:00 +00:00
Kaysser Kayyali
c549aaa49f docs: group-encounters follow-up backlog + mark helper-tools PRD superseded
- 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>
2026-06-22 15:46:53 +00:00
0028f96349 merge: group-encounters feature set (Features A-E + FR-43)
19 commits: schema additions, atomicMutate infra, timed checks + single-Roll,
boot sweep, passive reveals, group checks (emit + roll + outcome), lobby,
story status + L1 enrichment, conditions stub. 527 unit tests pass, tsc clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 03:10:49 +00:00
b69fecfe11 feat(lobby): /encounter start branching + lobby button routing (Story 9.1 pt2)
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>
2026-06-21 03:08:49 +00:00
c78d40dd3c feat(lobby): lobby embed + lobbyManager Redis state (Story 9.1 pt1)
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>
2026-06-21 03:04:44 +00:00
8aea3982a9 feat(conditions): ConditionsReader stub + relay reader + getActorConditions (Story 10.2)
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>
2026-06-21 03:02:43 +00:00
9e4197fa4b feat(story-status): L1 prompt enrichment + /character status command (Story 10.1 pt2)
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>
2026-06-21 03:00:15 +00:00
848f9e2dcb feat(story-status): story-status store + character_status tool (Story 10.1 pt1)
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>
2026-06-21 02:52:06 +00:00
244f5bfc39 feat(group-check): gate migration + timed group timer (Story 8.2 pt2)
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>
2026-06-21 02:49:34 +00:00
ac9573340d feat(group-check): atomic roll registration + finalize + rollHandler routing (Story 8.2 pt1)
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>
2026-06-21 02:40:22 +00:00
cf06687a2c feat(group-check): skill_check_group_emit tool + scoreboard embed (Story 8.1 pt2b)
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>
2026-06-21 02:24:24 +00:00
736ca374b8 feat(group-check): characterContext.getModifier + PendingGroupCheck types (Story 8.1 pt2a)
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>
2026-06-21 02:08:28 +00:00
b374a4f90c feat(group-check): successRule pure-fn evaluator + Zod contract (Story 8.1 pt1)
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>
2026-06-21 01:52:38 +00:00
cf4f3ccf32 feat(passive): wire passive reveals into /encounter start + characterContext module (Story 7.1 pt2)
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>
2026-06-21 01:38:29 +00:00
7b171b4c82 feat(passive): passive-score resolver + reveal runner + reveal embed (Story 7.1 pt1)
The testable core of Feature B (passive skill reveals), ahead of the
/encounter start wiring (pt2):

- src/harness/skillKeys.ts: the skill-name→Foundry-key map (moved out of
  skillCheckEmit so it's shared) + resolvePassiveScore(actor, skill) — uses
  Foundry's passive for Perception/Investigation, computes 10 + skill.total
  for other skills, 10 + ability.mod for raw ability checks, undefined when
  unrecognized/data absent (caller skips — FR-46).
- src/harness/passiveReveals.ts: computePassiveReveals(reveals, players,
  getPassive) — for each passiveReveal and present player, fires a reveal
  (attributed to the player, group-visible) when the passive meets the
  threshold; skips unresolvable passives; getPassive injected for testability.
- src/bot/embeds/passiveReveal.ts: buildPassiveRevealEmbed (👁️ title, NOTICE
  purple, in-world 'keen eye catches what others miss' + revealText).
- EMBED_COLOR += NOTICE (0x9B59B6). skillCheckEmit imports SKILL_KEY from
  skillKeys (no more inline duplicate).

Tests: skillKeys (6 — Foundry passive / case-insensitive / 10+total / ability
fallback / unrecognized / thin actor), passiveReveals (6 — threshold met / all
qualifiers / below threshold / unresolvable skipped / multiple reveals / empty),
passiveRevealEmbed (3 — title+👁️ / revealText / NOTICE). 453 unit pass; tsc clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 01:22:49 +00:00
6239c2103a feat(skill-check): timed-check embed — 10s countdown + hourglass GIF + Final sands cue (Story 6.2 pt2)
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>
2026-06-21 01:11:29 +00:00
326ce4265a feat(skill-check): boot restart sweep — finalize pending timed checks on restart (Story 6.2 pt1)
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>
2026-06-21 00:57:16 +00:00
d49dfbae16 feat(skill-check): Feature A timed checks — durationSeconds + in-memory timer + timeout finalize (Story 6.1 pt2)
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>
2026-06-21 00:39:34 +00:00
8f8335802e feat(skill-check): FR-43 single player-locked Roll button (ship-and-break) — Story 6.1 pt1
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>
2026-06-21 00:26:56 +00:00
86d4354b51 feat(session): atomicMutate (per-threadId mutex) + Redis key registry (Story 5.2)
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>
2026-06-21 00:09:51 +00:00
9f401692c8 feat(spec): add minPlayers/maxPlayers/passiveReveals to EncounterSpecSchema (Story 5.1)
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>
2026-06-20 23:21:58 +00:00
b6c7c87f60 docs: group-encounters PRD + UX + architecture (finalized 2026-06-20)
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>
2026-06-20 23:21:58 +00:00
b884a13d98 fix wizard for new stories
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 28s
2026-06-20 06:52:19 +00:00
Kaysser Kayyali
d19278cd11 chore: untrack historical encounter summaries
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>
2026-06-20 04:39:55 +00:00
Kaysser Kayyali
810d43a45b fix: live E2E test bugs + untrack runtime tally
- 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>
2026-06-20 04:34:15 +00:00
59152bcc51 feat: remove ai generator, and update core vision. move specs to another repo, to iterate on them via a tool
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 28s
2026-06-20 04:20:47 +00:00
10e0f22598 feat: integration testing
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 30s
2026-06-20 00:32:18 +00:00
fbd991a2b0 feat: docs pass, test fixes, advanced review
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 28s
2026-06-19 16:15:06 +00:00
e2c92e854f Add unit tests for LLM clients, persona loader, and XP/Foundry rewards
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 2m13s
Expands the unit test suite from 320 to 380 tests (+60) and adds a
Gitea Actions CI workflow. Closes all six follow-up recommendations
from the test-architecture validation report.

New tests (tests/unit/):
  - ollamaClient.test.ts          — Ollama SDK wrapper, options passthrough
  - litellmClient.test.ts         — OpenAI SDK wrapper, model fallback
  - personaLoader.test.ts         — Zod validation + cache invalidation
  - foundryReward.test.ts         — Tool plugin: lookup, errors, partial grants
  - xpAwarder.test.ts             — Bulk XP awards + per-player skip reasons
  - redisErrorPath.test.ts        — Singleton error handler does not crash
  - messageRouterRunLLMTurn.test.ts — 18 cases for the runtime heart:
    narrative-only path, tool dispatch, filter correction, retry loop
    guard, missed-skill-check heuristic, typing indicator interval,
    LLM error fallback, archive on resolve.

Coverage (line %):
  - harness/litellmClient.ts      0 → 100
  - harness/ollamaClient.ts       0 → 100
  - harness/tools/foundryReward.ts 0 → 100
  - session/xpAwarder.ts          0 → 100
  - persona/loader.ts             0 → 100
  - db/redis.ts                   0 → 100
  - bot/handlers/messageRouter.ts 0 → 39.86 (runLLMTurn now covered)

Tooling:
  - package.json: + test:coverage, test:watch scripts
  - devDep: @vitest/coverage-v8@^3.1.0
  - tests/README.md: conventions, anti-patterns, template map
  - .gitignore: exclude coverage/
  - .gitea/workflows/test.yml: Node 22, npm cache, tsc --noEmit gate

Documentation (from earlier /bmad-document-project run, now committed):
  - docs/index.md
  - docs/project-overview.md
  - docs/architecture.md
  - docs/deployment-guide.md
  - docs/api-contracts.md
  - docs/data-models.md
  - docs/source-tree-analysis.md
  - docs/component-inventory.md
  - docs/development-guide.md
  - _bmad-output/test-artifacts/automate-validation-report.md

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-19 05:59:13 +00:00
f406800cc5 more events 2026-06-19 04:50:13 +00:00
9dc6e8e1a3 Initial commit — Mardonar encounter engine with UX improvements
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>
2026-05-30 04:51:21 +00:00