283 lines
9.4 KiB
TypeScript
283 lines
9.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
|
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
|
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
|
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
|
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
|
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
|
|
|
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
|
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 getSnapshot() {
|
|
return null;
|
|
},
|
|
async putSnapshot(_userId, payload) {
|
|
return payload;
|
|
},
|
|
async deleteSnapshot() {
|
|
return undefined;
|
|
},
|
|
async getSettings() {
|
|
return {
|
|
musicVolume: 0.42,
|
|
platformTheme: 'light',
|
|
};
|
|
},
|
|
async putSettings(_userId, settings) {
|
|
return settings;
|
|
},
|
|
async listCustomWorldProfiles(userId) {
|
|
return [...(profilesByUser.get(userId) ?? [])];
|
|
},
|
|
async upsertCustomWorldProfile(userId, profileId, profile) {
|
|
const current = [...(profilesByUser.get(userId) ?? [])].filter(
|
|
(item) => String(item.id ?? '') !== profileId,
|
|
);
|
|
current.unshift({
|
|
...profile,
|
|
id: profileId,
|
|
});
|
|
profilesByUser.set(userId, current);
|
|
return current;
|
|
},
|
|
async deleteCustomWorldProfile(userId, profileId) {
|
|
const current = [...(profilesByUser.get(userId) ?? [])].filter(
|
|
(item) => String(item.id ?? '') !== profileId,
|
|
);
|
|
profilesByUser.set(userId, current);
|
|
return current;
|
|
},
|
|
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: 'phase5-ready-1',
|
|
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
|
|
focusCardId: null,
|
|
selectedCardIds: [],
|
|
});
|
|
await waitForOperation(
|
|
orchestrator,
|
|
userId,
|
|
createdSession.sessionId,
|
|
message1.operation.operationId,
|
|
);
|
|
|
|
const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
|
|
clientMessageId: 'phase5-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('phase5 generate_role_assets only allows a single role and moves session into visual_refining', async () => {
|
|
const runtimeRepository = createRuntimeRepositoryStub();
|
|
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
|
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
|
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
|
});
|
|
const userId = 'user-phase5-generate-role-assets';
|
|
const session = await createObjectRefiningSession(orchestrator, userId);
|
|
const characterIds = session.draftCards
|
|
.filter((card) => card.kind === 'character')
|
|
.map((card) => card.id);
|
|
|
|
await assert.rejects(
|
|
orchestrator.executeAction(userId, session.sessionId, {
|
|
action: 'generate_role_assets',
|
|
roleIds: characterIds.slice(0, 2),
|
|
}),
|
|
);
|
|
|
|
const response = await orchestrator.executeAction(userId, session.sessionId, {
|
|
action: 'generate_role_assets',
|
|
roleIds: [characterIds[0]!],
|
|
});
|
|
const operation = await waitForOperation(
|
|
orchestrator,
|
|
userId,
|
|
session.sessionId,
|
|
response.operation.operationId,
|
|
);
|
|
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
|
|
|
|
assert.equal(operation?.status, 'completed');
|
|
assert.equal(snapshot?.stage, 'visual_refining');
|
|
assert.equal(snapshot?.focusCardId, characterIds[0]);
|
|
assert.ok(
|
|
snapshot?.messages.some(
|
|
(message) =>
|
|
message.kind === 'action_result' &&
|
|
message.text.includes('角色资产工坊'),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => {
|
|
const runtimeRepository = createRuntimeRepositoryStub();
|
|
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
|
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
|
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
|
});
|
|
const userId = 'user-phase5-sync-role-assets';
|
|
const session = await createObjectRefiningSession(orchestrator, userId);
|
|
const characterCard = session.draftCards.find((card) => card.kind === 'character');
|
|
|
|
assert.ok(characterCard);
|
|
|
|
const prepareResponse = await orchestrator.executeAction(
|
|
userId,
|
|
session.sessionId,
|
|
{
|
|
action: 'generate_role_assets',
|
|
roleIds: [characterCard!.id],
|
|
},
|
|
);
|
|
await waitForOperation(
|
|
orchestrator,
|
|
userId,
|
|
session.sessionId,
|
|
prepareResponse.operation.operationId,
|
|
);
|
|
|
|
const response = await orchestrator.executeAction(userId, session.sessionId, {
|
|
action: 'sync_role_assets',
|
|
roleId: characterCard!.id,
|
|
portraitPath: '/generated/characters/shenli-portrait.png',
|
|
generatedVisualAssetId: 'visual-shenli-1',
|
|
generatedAnimationSetId: 'animation-set-shenli-1',
|
|
animationMap: {
|
|
idle: { basePath: '/generated/characters/shenli/idle' },
|
|
run: { basePath: '/generated/characters/shenli/run' },
|
|
attack: { basePath: '/generated/characters/shenli/attack' },
|
|
hurt: { basePath: '/generated/characters/shenli/hurt' },
|
|
die: { basePath: '/generated/characters/shenli/die' },
|
|
},
|
|
});
|
|
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 syncedRole = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find(
|
|
(entry) => entry.id === characterCard!.id,
|
|
);
|
|
const syncedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id);
|
|
const syncedAssetSummary = snapshot?.assetCoverage.roleAssets.find(
|
|
(entry) => entry.roleId === characterCard!.id,
|
|
);
|
|
const latestRecord = await sessionStore.get(userId, session.sessionId);
|
|
|
|
assert.equal(operation?.status, 'completed');
|
|
assert.equal(syncedRole?.imageSrc, '/generated/characters/shenli-portrait.png');
|
|
assert.equal(syncedRole?.generatedVisualAssetId, 'visual-shenli-1');
|
|
assert.equal(syncedRole?.generatedAnimationSetId, 'animation-set-shenli-1');
|
|
assert.equal(
|
|
(syncedRole?.animationMap as Record<string, { basePath?: string }> | null)?.idle
|
|
?.basePath,
|
|
'/generated/characters/shenli/idle',
|
|
);
|
|
assert.equal(syncedAssetSummary?.status, 'complete');
|
|
assert.equal(syncedCard?.assetStatusLabel, '动作已就绪');
|
|
assert.ok(syncedCard?.subtitle.includes('动作已就绪'));
|
|
assert.ok(
|
|
snapshot?.messages.some(
|
|
(message) =>
|
|
message.kind === 'action_result' && message.text.includes('动作已就绪'),
|
|
),
|
|
);
|
|
assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2);
|
|
});
|