Files
Genarrative/server-node/src/services/customWorldAgentPhase4.test.ts

288 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
import type {
CustomWorldSessionCapability,
CustomWorldWorkSummaryCapability,
} from './runtimeCapabilities.js';
function createRuntimeRepositoryStub(): CustomWorldSessionCapability &
CustomWorldWorkSummaryCapability {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
>();
const profilesByUser = new Map<string, Record<string, unknown>[]>();
const getSessionBucket = (userId: string) => {
const existing = sessionsByUser.get(userId);
if (existing) {
return existing;
}
const nextBucket = new Map<string, CustomWorldSessionRecord>();
sessionsByUser.set(userId, nextBucket);
return nextBucket;
};
return {
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},
async getCustomWorldSession(userId, sessionId) {
return getSessionBucket(userId).get(sessionId) ?? null;
},
async upsertCustomWorldSession(userId, sessionId, session) {
getSessionBucket(userId).set(
sessionId,
JSON.parse(JSON.stringify(session)),
);
return JSON.parse(JSON.stringify(session));
},
};
}
async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
sessionId: string,
operationId: string,
) {
for (let attempt = 0; attempt < 60; attempt += 1) {
const operation = await orchestrator.getOperation(
userId,
sessionId,
operationId,
);
if (operation?.status === 'completed' || operation?.status === 'failed') {
return operation;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error('operation did not finish in time');
}
async function createObjectRefiningSession(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
) {
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase4-ready-1',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message1.operation.operationId,
);
const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase4-ready-2',
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message2.operation.operationId,
);
const foundationOperation = await orchestrator.executeAction(
userId,
createdSession.sessionId,
{
action: 'draft_foundation',
},
);
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
foundationOperation.operation.operationId,
);
return (await orchestrator.getSessionSnapshot(
userId,
createdSession.sessionId,
))!;
}
test('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-edit';
const session = await createObjectRefiningSession(orchestrator, userId);
const characterCard = session.draftCards.find((card) => card.kind === 'character');
assert.ok(characterCard);
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'update_draft_card',
cardId: characterCard!.id,
sections: [
{
sectionId: 'publicMask',
value: '表面上仍是守灯会里最懂旧航道的人。',
},
{
sectionId: 'relationToPlayer',
value: '和玩家共享一段无法轻易翻篇的旧灯塔往事。',
},
{
sectionId: 'summary',
value: '他像旧友,也像最早知道航道秘密的人。',
},
],
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
const editedCharacter = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find(
(entry) => entry.id === characterCard!.id,
);
const editedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id);
assert.equal(operation?.status, 'completed');
assert.equal(
editedCharacter?.publicMask,
'表面上仍是守灯会里最懂旧航道的人。',
);
assert.equal(
editedCharacter?.relationToPlayer,
'和玩家共享一段无法轻易翻篇的旧灯塔往事。',
);
assert.equal(editedCard?.summary, '他像旧友,也像最早知道航道秘密的人。');
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' && message.text.includes('已更新'),
),
);
});
test('phase4 generate_characters appends story npcs and updates work summary counts', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-characters';
const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
const baselineCharacterCount = [
...new Set(
[...baselineProfile.playableNpcs, ...baselineProfile.storyNpcs].map(
(entry) => entry.id,
),
),
].length;
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'generate_characters',
count: 2,
promptText: '补两位更贴近旧航道线的边缘角色。',
anchorCardIds: [session.draftCards.find((card) => card.kind === 'thread')!.id],
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!;
const nextCharacterCount = [
...new Set(
[...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id),
),
].length;
const workItems = await listCustomWorldWorkSummaries(userId, {
runtimeRepository,
customWorldAgentSessions: sessionStore,
});
const draftItem = workItems.find((item) => item.sessionId === session.sessionId);
assert.equal(operation?.status, 'completed');
assert.ok(profile.storyNpcs.length >= 2);
assert.ok(nextCharacterCount >= baselineCharacterCount + 2);
assert.ok(snapshot?.draftCards.filter((card) => card.kind === 'character').length);
assert.ok(snapshot?.focusCardId);
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' && message.text.includes('新角色'),
),
);
assert.ok((draftItem?.playableNpcCount ?? 0) >= baselineCharacterCount + 2);
});
test('phase4 generate_landmarks appends new landmark cards and checkpoints', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-landmarks';
const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
const baselineLandmarkCount = baselineProfile.landmarks.length;
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'generate_landmarks',
count: 2,
promptText: '补两个适合藏旧航道秘密的地点。',
anchorCardIds: [session.draftCards.find((card) => card.kind === 'character')!.id],
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!;
const latestSessionRecord = await sessionStore.get(userId, session.sessionId);
assert.equal(operation?.status, 'completed');
assert.ok(profile.landmarks.length >= baselineLandmarkCount + 2);
assert.ok(
snapshot?.draftCards.filter((card) => card.kind === 'landmark').length,
);
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' && message.text.includes('新地点'),
),
);
assert.ok((latestSessionRecord?.checkpoints.length ?? 0) >= 2);
});