diff --git a/lore/vocabulary.yaml b/lore/vocabulary.yaml index 1f992e9..b638b18 100644 --- a/lore/vocabulary.yaml +++ b/lore/vocabulary.yaml @@ -141,7 +141,7 @@ locations: - Bridgefoot Square inn: - - The Broken Compass + - The Willowing Pig - The Crooked Lantern - The Mended Drum - The Dusty Heel @@ -151,22 +151,21 @@ locations: - The Ember & Stone village: - - Cinder Ford - - Greymoss Crossing - - Tallow's End - - Ashwick - - Breckfen - - Moldvale - - Harrow's Gate - - Dunford - - Saltmere - - Edgemarsh + - Mardsville + - Barristown + - Veldra's Maw + - Mardonar City + - Wildwood Forest road: - - the Pilgrim's Switchback - - the Ridge Road - - the Goat Track - - the Sheercliff Path - - the High Drove - - the Ironway - - the Mossback Trail + - the King's Road + - the Vampires Trail + - 3 Heads Crossing + - Gloaming Pass + + forest: + - Wildwood Forest + - The Whispering Woods + - The Gloaming Thicket + - The Shadowgrove + - The Eldertree Vale diff --git a/specs/mawfang-pursuit.yaml b/specs/mawfang-pursuit.yaml index 5074cff..fe07239 100644 --- a/specs/mawfang-pursuit.yaml +++ b/specs/mawfang-pursuit.yaml @@ -1,5 +1,10 @@ encounterId: "mawfang-pursuit-001" title: "Unwelcome Guests" + +# Group encounters (Feature D) — solo scene, explicit for documentation. +# Default would be 1; explicit value documents the author's intent. +minPlayers: 2 + tone: "tense" setting: diff --git a/specs/old-friend-bad-timing.yaml b/specs/old-friend-bad-timing.yaml index 298b859..a777399 100644 --- a/specs/old-friend-bad-timing.yaml +++ b/specs/old-friend-bad-timing.yaml @@ -25,6 +25,10 @@ encounterId: "old-friend-bad-timing" title: "Old Friend, Bad Timing" +# Group encounters (Feature D) — solo scene, explicit for documentation. +# Default would be 1; explicit value documents the author's intent. +minPlayers: 3 + setting: location: "Lonespear Tavern — Barristown's harbor quarter" mood: > diff --git a/specs/the-clock-maker.yaml b/specs/the-clock-maker.yaml index b8ab24e..42febd7 100644 --- a/specs/the-clock-maker.yaml +++ b/specs/the-clock-maker.yaml @@ -18,6 +18,10 @@ title: "The Clock Maker" # tone — LLM READS (narration flavor) + BOT ENFORCES (drop-notice string). tone: "grim" +# Group encounters (Feature D) — solo scene, explicit for documentation. +# Default would be 1; explicit value documents the author's intent. +minPlayers: 1 + # setting — LLM READS. Three strings grounding the scene. setting: # location — where the scene happens. One line. @@ -147,6 +151,38 @@ skillChecks: it alone — the party must find the right action first; this is the resolution roll once they have. + # The returning customer's 15-minute deadline (see Hale / persona) is a + # narrative urgency cue by default. If the LLM wants it as a real + # engine-driven timer (Feature A), it may emit skill_check_emit with + # durationSeconds: 900 against calm_returning_customer — the timeout + # path then becomes bot-enforced auto-fail, not a narration-only beat. + # Spec-author opt-in; not required. + +# Passive skill reveals (Feature B) — bot-applied at encounter start, +# group-visible, attributed to the qualifying player. threshold is a passive +# DC (integer); revealText is outcome prose only — no dice results (the bot +# owns dice). Thresholds gate roughly half the party, consistent with the PRD +# Feature B tone for the clock-maker's "something is wrong" entry point. +passiveReveals: + - skill: "Perception" + threshold: 13 + revealText: > + Notices the clocks in the shop are not keeping the same time. Three of + them are five minutes apart, and a fourth has stopped — but its second + hand is still moving. + + - skill: "Insight" + threshold: 14 + revealText: > + Reads something rehearsed in the clock-maker's warmth. He is performing + friendliness the way a stage magician performs ease with the deck. + + - skill: "Investigation" + threshold: 12 + revealText: > + Spots a thin line of dust on the shelf where a clock once sat. The empty + place is the size of a pocket-watch. The shop keeps no receipts. + # randomizable — BOT ENFORCES. Fields that vary per run. Name draws bind to # npc.nameKey (fresh names each run); cursed_ware seeds the specific ware. # source: vocabulary + category route name draws to the vocabulary namespace. @@ -180,6 +216,9 @@ tools: - goal_register # LLM emits to add an off-rails goal mid-encounter. - foundry_lookup # LLM emits to surface a linked player's live Foundry stats. - foundry_reward # LLM emits to award XP/items via the Foundry relay. + # NOTE: skill_check_group_emit (Feature C) is intentionally absent. The + # clock-maker is a solo scene (minPlayers: 1); there is no group to check + # against. The timed-check opt-in on break_curse_note uses the solo emit. # dmNotes — LLM READS. Author framing for the DM's intent (stakes, feel, # escalation). Not rules the LLM mechanically follows. @@ -196,5 +235,21 @@ dmNotes: > return a lethal clock; use them to break the dithering and raise the stakes. Their fifteen-minute deadline is urgency to narrate, not a real-time timer. + NEW CAPABILITIES (group-encounters feature set, 2026-06-20): + The opening beat of the clock-maker scene is a "something is wrong" + pre-roll moment; lean on the three passiveReveals above (Perception, + Insight, Investigation) to surface clues in the opening lines without + waiting for the LLM-emitted checks. Bot applies them automatically at + encounter start, group-visible, attributed to the qualifying player — + the LLM should weave their revealText into the scene as it unfolds, + not announce them as a list. + + For the returning customer's deadline: the original framing is "urgency + to narrate, not a real-time timer," and that remains the default. The + LLM may, at its discretion, opt into the new timed-skill-check path + (durationSeconds: 900 on calm_returning_customer) if the table wants + the deadline to be engine-enforced rather than narrative-pace. The + spec does not require it; it gives the LLM permission. + # xpReward — BOT ENFORCES. Flat XP to all participants on resolution. xpReward: 50 \ No newline at end of file diff --git a/src/bot/handlers/lobbyHandler.ts b/src/bot/handlers/lobbyHandler.ts index 62a15d2..7568e58 100644 --- a/src/bot/handlers/lobbyHandler.ts +++ b/src/bot/handlers/lobbyHandler.ts @@ -91,8 +91,13 @@ async function handleStartBtn(interaction: ButtonInteraction, channel: RollChann } await clearLobby(threadId); + // Close the lobby UI: update the lobby embed in place to the begin + // announcement (drops the Join/Begin buttons so they don't stay stale) BEFORE + // the opening narrative, so the thread reads announcement → opening. + await interaction.update({ content: '🗡️ The gathering has set out — the encounter begins!', embeds: [], components: [] }).catch(() => null); + // Rename the thread to signal the lobby is closed + the encounter is underway. + if (channel.isThread()) await (channel as ThreadChannel).setName(`⚔️ ${resolvedSpec.title} — underway`).catch(() => null); await beginEncounter(channel as ThreadChannel, resolvedSpec, resolvedContext, npcMemories, lobby.guildId, lobby.specName, players); - await interaction.reply({ content: '🗡️ The gathering has set out — the encounter begins!' }).catch(() => null); } async function handleCancel(interaction: ButtonInteraction, channel: RollChannel, threadId: string): Promise { @@ -106,6 +111,7 @@ async function handleCancel(interaction: ButtonInteraction, channel: RollChannel return; } await clearLobby(threadId); + if (channel.isThread()) await (channel as ThreadChannel).setName(`⚔️ ${lobby.title} — cancelled`).catch(() => null); await interaction.update({ content: '❌ The gathering was cancelled.', embeds: [], components: [] }).catch(() => null); if (channel.isThread()) await channel.setArchived(true).catch(() => null); } \ No newline at end of file diff --git a/src/graphmcp/loreResolver.ts b/src/graphmcp/loreResolver.ts index 849ffad..c5e5ba9 100644 --- a/src/graphmcp/loreResolver.ts +++ b/src/graphmcp/loreResolver.ts @@ -13,6 +13,19 @@ import type { RandomizableItem } from '../types/index.js'; * Returns a flat Record ready to store in session state * and inject into the system prompt. */ + +// Lore-index chunks (the "All NPCs" overview, category lists, etc.) are +// encyclopedia navigation, not usable VALUES for a prose placeholder. They leak +// "[[wiki-link]]" lists + "Overview/Entries" headers into interpolated text +// (Bug 1: a disposition placeholder resolved to the NPC index). Skip them and +// prefer a real content chunk, or fall back. +function isIndexLike(content: string): boolean { + const head = content.trimStart().slice(0, 64).toLowerCase(); + if (/^(overview|entries|index|contents|categories)\b/.test(head)) return true; + const linkCount = (content.match(/\[\[[^\]]+\]\]/g) ?? []).length; + return linkCount >= 2; // a value chunk rarely has 2+ wiki-links; an index does +} + export async function resolveRandomizables( items: RandomizableItem[], ): Promise> { @@ -30,7 +43,8 @@ export async function resolveRandomizables( try { const result = await semanticSearch(item.query, config.GRAPHMCP_NPC_MEMORY_LIMIT); const candidates = (result.chunks ?? []) - .filter(c => c.score > config.GRAPHMCP_SCORE_THRESHOLD); + .filter(c => c.score > config.GRAPHMCP_SCORE_THRESHOLD) + .filter(c => !isIndexLike(c.content)); if (candidates.length > 0) { // Pick randomly so repeated starts of the same spec feel different diff --git a/tests/integration/graphmcp/group-encounter-live.test.ts b/tests/integration/graphmcp/group-encounter-live.test.ts index 4749801..0e11784 100644 --- a/tests/integration/graphmcp/group-encounter-live.test.ts +++ b/tests/integration/graphmcp/group-encounter-live.test.ts @@ -169,9 +169,11 @@ describe.skipIf(!runE2E)('Multiplayer live E2E — MVP (2 player-bots, driving t expect(seatsField(lobbyMsg2), 'seats met').toContain('min 2 met'); expect(beginDisabled(lobbyMsg2), 'Begin enabled at min').toBe(false); - // Begin the encounter from the lobby. + // Begin the encounter from the lobby. Pass the lobby messageId so + // interaction.update EDITS THE REAL lobby embed (Bug 3a: it must be closed + // in place — no stale Join/Begin buttons — and show the begin announcement). await handleLobbyInteraction( - fakeButton(thread!, 'lobby_start', player1Id, 'Player1').interaction, + fakeButton(thread!, 'lobby_start', player1Id, 'Player1', lobby0!.messageId).interaction, bots.botClient, ); const session = await waitFor( @@ -185,6 +187,19 @@ describe.skipIf(!runE2E)('Multiplayer live E2E — MVP (2 player-bots, driving t expect(Object.keys(session!.players), 'both player-bots are in the roster').toEqual( expect.arrayContaining([player1Id, player2Id]), ); + // Bug 3a: the lobby embed is closed in place — buttons removed + announcement. + const closedLobby = await thread!.messages.fetch(lobby0!.messageId!); + expect((closedLobby.components ?? []).length, 'lobby buttons removed on Begin').toBe(0); + expect(closedLobby.content, 'lobby embed replaced by the begin announcement').toContain('gathering has set out'); + // Bug 3b: the thread is renamed to indicate the lobby is closed. + const renamed = await waitFor( + async () => { + const t = await bots.channel.threads.fetch(threadId!).catch(() => null); + return t && t.name.includes('underway') ? t : null; + }, + { timeoutMs: 15_000, intervalMs: 500 }, + ); + expect(renamed!.name, 'thread renamed on Begin').toContain('underway'); }, 180_000); // AC-9 — 2 real chat turns routed through handleMessage (👀 reaction = routed) ── diff --git a/tests/unit/loreResolver.test.ts b/tests/unit/loreResolver.test.ts new file mode 100644 index 0000000..fa9446d --- /dev/null +++ b/tests/unit/loreResolver.test.ts @@ -0,0 +1,66 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +const { mockSemanticSearch } = vi.hoisted(() => ({ mockSemanticSearch: vi.fn() })); +vi.mock('../../src/graphmcp/client.js', () => ({ + semanticSearch: mockSemanticSearch, + queryAsNPC: vi.fn(), + formatNPCMemory: vi.fn(), + logEncounter: vi.fn().mockResolvedValue({}), +})); + +const { mockVocab } = vi.hoisted(() => ({ mockVocab: vi.fn() })); +vi.mock('../../src/graphmcp/vocabularyResolver.js', () => ({ + sampleFromVocabulary: mockVocab, +})); + +vi.mock('../../src/config.js', () => ({ + config: { GRAPHMCP_NPC_MEMORY_LIMIT: 5, GRAPHMCP_SCORE_THRESHOLD: 0.68 }, +})); + +import { resolveRandomizables } from '../../src/graphmcp/loreResolver.js'; + +beforeEach(() => { + mockSemanticSearch.mockReset(); + mockVocab.mockReset(); +}); + +describe('resolveRandomizables — skips lore-index chunks (Bug 1)', () => { + it('does NOT use a lore-index chunk (Overview + [[links]]) as a value; picks a normal chunk', async () => { + mockSemanticSearch.mockResolvedValue({ + chunks: [ + { + content: + 'Overview All named characters in the Land of Mardonar. Entries Rulers & Nobility - [[King Mardonarious]] — ruler of the land - [[Prince of Mardonia]] — heir', + score: 0.95, + }, + { content: 'drunk, and looking for the people who cheated him at dice three weeks back', score: 0.7 }, + ], + }); + const out = await resolveRandomizables([{ key: 'disp', query: 'a reason', fallback: 'drunk' }]); + expect(out.disp).toContain('drunk'); + expect(out.disp, 'no wiki-link index text leaks into the value').not.toContain('[['); + expect(out.disp).not.toMatch(/^Overview/); + }); + + it('falls back when only index-like chunks are returned', async () => { + mockSemanticSearch.mockResolvedValue({ + chunks: [{ content: 'Entries - [[A]] - [[B]] - [[C]]', score: 0.9 }], + }); + const out = await resolveRandomizables([{ key: 'disp', query: 'x', fallback: 'sober' }]); + expect(out.disp).toBe('sober'); + }); + + it('uses a normal lore chunk (a relic description) as a value', async () => { + mockSemanticSearch.mockResolvedValue({ + chunks: [{ content: 'the Crimson Relic of Emberloc, a bloodstone carved with ancestral battle runes', score: 0.8 }], + }); + const out = await resolveRandomizables([{ key: 'item', query: 'a relic', fallback: 'a stone' }]); + expect(out.item).toContain('Crimson Relic'); + }); + + it('falls back when semanticSearch returns nothing above threshold', async () => { + mockSemanticSearch.mockResolvedValue({ chunks: [{ content: 'x', score: 0.2 }] }); + const out = await resolveRandomizables([{ key: 'k', query: 'q', fallback: 'fb' }]); + expect(out.k).toBe('fb'); + }); +}); \ No newline at end of file