26 Commits

Author SHA1 Message Date
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
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
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
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
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
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