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>
This commit is contained in:
Kaysser Kayyali
2026-06-22 22:09:42 +00:00
parent 83180555a5
commit fdf1d705d1
4 changed files with 105 additions and 4 deletions

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