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:
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
66
tests/unit/loreResolver.test.ts
Normal file
66
tests/unit/loreResolver.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user