merge: lobby close UI + loreResolver index guard + spec/vocabulary edits
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 29s

This commit is contained in:
2026-06-22 22:13:02 +00:00
8 changed files with 186 additions and 22 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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: >

View File

@@ -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

View File

@@ -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<void> {
@@ -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);
}

View File

@@ -13,6 +13,19 @@ import type { RandomizableItem } from '../types/index.js';
* Returns a flat Record<key, resolvedValue> 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<Record<string, string>> {
@@ -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

View File

@@ -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) ──

View File

@@ -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');
});
});