Merge branch 'codex/dev' into codex/backend-rewrite-spacetimedb

# Conflicts:
#	docs/technical/README.md
#	server-node/src/modules/assets/qwenSpriteRoutes.ts
#	src/components/CustomWorldResultView.test.tsx
#	src/components/CustomWorldResultView.tsx
#	src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx
#	src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx
#	src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx
#	src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx
#	src/components/rpg-entry/RpgEntryCharacterSelectView.tsx
#	src/components/rpg-entry/RpgEntryHomeView.tsx
#	src/services/apiClient.ts
#	src/tools/QwenSpriteSheetTool.tsx
This commit is contained in:
2026-04-21 20:16:01 +08:00
477 changed files with 38047 additions and 26570 deletions

View File

@@ -9,7 +9,7 @@ import type {
type NpcChatTurnCompletionDirective,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js';
import { parseLineListContent } from '../../../../packages/shared/src/llm/parsers.js';
import { prepareEventStreamResponse } from '../../http.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';

View File

@@ -4,7 +4,7 @@ import test from 'node:test';
import type {
CharacterChatSuggestionsRequest,
NpcChatTurnRequest,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js';
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,

View File

@@ -1,912 +0,0 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import http, {
type IncomingMessage,
type RequestOptions,
type ServerResponse,
} from 'node:http';
import https from 'node:https';
import path from 'node:path';
import { Router, type NextFunction, type Request, type Response } from 'express';
import type { AppConfig } from '../../config.js';
import { routeMeta } from '../../middleware/routeMeta.js';
const QWEN_SPRITE_MASTER_GENERATE_PATH = '/api/assets/qwen-sprite/master';
const QWEN_SPRITE_SHEET_GENERATE_PATH = '/api/assets/qwen-sprite/sheet';
const QWEN_SPRITE_FRAME_REPAIR_PATH = '/api/assets/qwen-sprite/frame-repair';
const QWEN_SPRITE_SAVE_PATH = '/api/assets/qwen-sprite/save';
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-2.0';
function readJsonBody(req: IncomingMessage & { body?: unknown }) {
const parsedBody = req.body;
if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) {
return Promise.resolve(parsedBody as Record<string, unknown>);
}
return new Promise<Record<string, unknown>>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('end', () => {
try {
const raw =
Buffer.concat(chunks)
.toString('utf8')
.replace(/^\uFEFF/u, '') || '{}';
resolve(JSON.parse(raw));
} catch (error) {
reject(error);
}
});
req.on('error', reject);
});
}
function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}
function isRecordValue(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.every((item) => typeof item === 'string' && item.trim().length > 0)
);
}
function resolveRuntimeEnv(config: AppConfig) {
return config.rawEnv;
}
function normalizeDashScopeBaseUrl(value: string) {
return value.replace(/\/$/u, '');
}
function extractApiErrorMessage(responseText: string, fallbackMessage: string) {
if (!responseText.trim()) {
return fallbackMessage;
}
try {
const parsed = JSON.parse(responseText) as {
code?: string;
message?: string;
error?: { message?: string };
};
if (
typeof parsed.error?.message === 'string' &&
parsed.error.message.trim()
) {
return parsed.error.message;
}
if (typeof parsed.message === 'string' && parsed.message.trim()) {
return parsed.message;
}
if (typeof parsed.code === 'string' && parsed.code.trim()) {
return `${fallbackMessage} (${parsed.code})`;
}
} catch {
// Fall through to raw text.
}
return responseText;
}
function sanitizePathSegment(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-_]+/gu, '-')
.replace(/-+/gu, '-')
.replace(/^-|-$/gu, '');
return normalized || 'asset';
}
function createTimestampId(prefix: string) {
return `${prefix}-${Date.now()}`;
}
function requestTextResponse(
urlString: string,
options: {
method?: string;
headers?: Record<string, string>;
bodyText?: string;
} = {},
) {
return new Promise<{
statusCode: number;
headers: Record<string, string | string[] | undefined>;
bodyText: string;
}>((resolve, reject) => {
const url = new URL(urlString);
const transport = url.protocol === 'https:' ? https : http;
const payload = options.bodyText;
const requestOptions: RequestOptions = {
protocol: url.protocol,
hostname: url.hostname,
port: url.port ? Number(url.port) : undefined,
path: `${url.pathname}${url.search}`,
method: options.method ?? 'GET',
headers: {
...(options.headers ?? {}),
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
};
const request = transport.request(requestOptions, (upstreamRes) => {
const chunks: Buffer[] = [];
upstreamRes.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
upstreamRes.on('end', () => {
resolve({
statusCode: upstreamRes.statusCode ?? 502,
headers: upstreamRes.headers,
bodyText: Buffer.concat(chunks).toString('utf8'),
});
});
upstreamRes.on('error', reject);
});
request.on('error', reject);
if (payload) {
request.write(payload);
}
request.end();
});
}
function requestBinaryResponse(
urlString: string,
options: {
method?: string;
headers?: Record<string, string>;
} = {},
) {
return new Promise<{
statusCode: number;
headers: Record<string, string | string[] | undefined>;
body: Buffer;
}>((resolve, reject) => {
const url = new URL(urlString);
const transport = url.protocol === 'https:' ? https : http;
const requestOptions: RequestOptions = {
protocol: url.protocol,
hostname: url.hostname,
port: url.port ? Number(url.port) : undefined,
path: `${url.pathname}${url.search}`,
method: options.method ?? 'GET',
headers: options.headers ?? {},
};
const request = transport.request(requestOptions, (upstreamRes) => {
const chunks: Buffer[] = [];
upstreamRes.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
upstreamRes.on('end', () => {
resolve({
statusCode: upstreamRes.statusCode ?? 502,
headers: upstreamRes.headers,
body: Buffer.concat(chunks),
});
});
upstreamRes.on('error', reject);
});
request.on('error', reject);
request.end();
});
}
function proxyJsonRequest(
urlString: string,
apiKey: string,
body: Record<string, unknown>,
) {
return requestTextResponse(urlString, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
bodyText: JSON.stringify(body),
});
}
function collectStringsByKey(
value: unknown,
targetKey: string,
results: string[],
) {
if (Array.isArray(value)) {
value.forEach((item) => collectStringsByKey(item, targetKey, results));
return;
}
if (!isRecordValue(value)) {
return;
}
const directValue = value[targetKey];
if (typeof directValue === 'string' && directValue.trim()) {
results.push(directValue.trim());
}
Object.values(value).forEach((nestedValue) =>
collectStringsByKey(nestedValue, targetKey, results),
);
}
function extractImageUrls(payload: Record<string, unknown>) {
const results: string[] = [];
collectStringsByKey(payload.output, 'image', results);
collectStringsByKey(payload.output, 'url', results);
return [...new Set(results)];
}
function parseDataUrl(source: string) {
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
if (!matched) {
return null;
}
const mimeType = matched[1];
const base64Payload = matched[2];
const extension = (() => {
switch (mimeType) {
case 'image/jpeg':
return 'jpg';
case 'image/webp':
return 'webp';
default:
return 'png';
}
})();
return {
buffer: Buffer.from(base64Payload, 'base64'),
extension,
};
}
async function resolveImageSourcePayload(rootDir: string, source: string) {
const parsedDataUrl = parseDataUrl(source);
if (parsedDataUrl) {
return parsedDataUrl;
}
if (!source.startsWith('/')) {
throw new Error('图像来源必须是 Data URL 或 public 目录 URL。');
}
const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, '');
const absolutePath = path.resolve(
rootDir,
'public',
...normalizedSource.split('/'),
);
const publicRoot = path.resolve(rootDir, 'public');
if (!absolutePath.startsWith(publicRoot)) {
throw new Error('图像来源路径越界。');
}
const buffer = await readFile(absolutePath);
const extension = path.extname(absolutePath).replace(/^\./u, '') || 'png';
return {
buffer,
extension,
};
}
async function resolveImageSourceAsDataUrl(rootDir: string, source: string) {
if (/^data:image\/[^;]+;base64,/u.test(source)) {
return source;
}
const payload = await resolveImageSourcePayload(rootDir, source);
const mimeType = (() => {
switch (payload.extension) {
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'webp':
return 'image/webp';
default:
return 'image/png';
}
})();
return `data:${mimeType};base64,${payload.buffer.toString('base64')}`;
}
async function writeDraftImageFile(
rootDir: string,
relativePath: string,
buffer: Buffer,
) {
const absolutePath = path.resolve(rootDir, 'public', ...relativePath.split('/'));
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, buffer);
return `/${relativePath}`;
}
async function generateQwenImages(
config: AppConfig,
input: {
kind: 'master' | 'sheet' | 'repair';
promptText: string;
negativePrompt: string;
model: string;
size: string;
promptExtend: boolean;
seed?: number;
candidateCount: number;
referenceImages: string[];
},
) {
const rootDir = config.projectRoot;
const runtimeEnv = resolveRuntimeEnv(config);
const baseUrl = normalizeDashScopeBaseUrl(
runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL,
);
const apiKey = runtimeEnv.DASHSCOPE_API_KEY || '';
if (!apiKey) {
throw new Error('服务端缺少 DASHSCOPE_API_KEY无法调用 Qwen-Image。');
}
const content = [
...(await Promise.all(
input.referenceImages
.slice(0, 3)
.map(async (image) => ({ image: await resolveImageSourceAsDataUrl(rootDir, image) })),
)),
{ text: input.promptText },
];
const requestPayload: Record<string, unknown> = {
model: input.model || DEFAULT_QWEN_IMAGE_MODEL,
input: {
messages: [
{
role: 'user',
content,
},
],
},
parameters: {
n: Math.max(1, Math.min(6, input.candidateCount)),
negative_prompt: input.negativePrompt,
prompt_extend: input.promptExtend,
watermark: false,
size: input.size,
...(typeof input.seed === 'number' && Number.isFinite(input.seed)
? { seed: input.seed }
: {}),
},
};
const response = await proxyJsonRequest(
`${baseUrl}/services/aigc/multimodal-generation/generation`,
apiKey,
requestPayload,
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(
extractApiErrorMessage(response.bodyText, 'Qwen-Image 生成失败。'),
);
}
const parsed = JSON.parse(response.bodyText) as Record<string, unknown>;
const imageUrls = extractImageUrls(parsed);
if (imageUrls.length === 0) {
throw new Error('Qwen-Image 未返回可下载的图片结果。');
}
const draftId = createTimestampId(`qwen-${input.kind}`);
const relativeDir = path.posix.join(
'generated-qwen-sprites',
'_drafts',
input.kind,
draftId,
);
const drafts = await Promise.all(
imageUrls.map(async (imageUrl, index) => {
const binaryResponse = await requestBinaryResponse(imageUrl);
if (
binaryResponse.statusCode < 200 ||
binaryResponse.statusCode >= 300
) {
throw new Error(`下载生成图片失败(${binaryResponse.statusCode})。`);
}
const imageSrc = await writeDraftImageFile(
rootDir,
path.posix.join(relativeDir, `candidate-${String(index + 1).padStart(2, '0')}.png`),
binaryResponse.body,
);
return {
id: `${draftId}-${index + 1}`,
label: `${input.kind === 'master' ? '主图' : input.kind === 'sheet' ? '精灵表' : '修帧'} ${index + 1}`,
imageSrc,
remoteUrl: imageUrl,
};
}),
);
await writeFile(
path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'job.json'),
JSON.stringify(
{
draftId,
kind: input.kind,
model: input.model,
size: input.size,
promptText: input.promptText,
negativePrompt: input.negativePrompt,
promptExtend: input.promptExtend,
seed: input.seed,
candidateCount: input.candidateCount,
referenceImageCount: input.referenceImages.length,
drafts,
createdAt: new Date().toISOString(),
},
null,
2,
) + '\n',
'utf8',
);
return {
draftId,
drafts,
model: input.model,
size: input.size,
promptText: input.promptText,
negativePrompt: input.negativePrompt,
};
}
async function handleGenerateMaster(
config: AppConfig,
req: IncomingMessage & { body?: unknown },
res: ServerResponse,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
let body: Record<string, unknown>;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
return;
}
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const negativePrompt =
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
const model =
typeof body.model === 'string' && body.model.trim()
? body.model.trim()
: DEFAULT_QWEN_IMAGE_MODEL;
const size =
typeof body.size === 'string' && body.size.trim()
? body.size.trim()
: '1024*1024';
const promptExtend = body.promptExtend !== false;
const candidateCount =
typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount)
? body.candidateCount
: 1;
const seed =
typeof body.seed === 'number' && Number.isFinite(body.seed)
? body.seed
: undefined;
const referenceImages = isStringArray(body.referenceImages)
? body.referenceImages
: [];
if (!promptText) {
sendJson(res, 400, { error: { message: 'promptText is required.' } });
return;
}
try {
const result = await generateQwenImages(config, {
kind: 'master',
promptText,
negativePrompt,
model,
size,
promptExtend,
seed,
candidateCount,
referenceImages,
});
sendJson(res, 200, {
ok: true,
...result,
});
} catch (error) {
sendJson(res, 500, {
error: {
message: error instanceof Error ? error.message : '生成主图失败。',
},
});
}
}
async function handleGenerateSheet(
config: AppConfig,
req: IncomingMessage & { body?: unknown },
res: ServerResponse,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
let body: Record<string, unknown>;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
return;
}
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const negativePrompt =
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
const model =
typeof body.model === 'string' && body.model.trim()
? body.model.trim()
: DEFAULT_QWEN_IMAGE_MODEL;
const size =
typeof body.size === 'string' && body.size.trim()
? body.size.trim()
: '1024*1024';
const promptExtend = body.promptExtend !== false;
const candidateCount =
typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount)
? body.candidateCount
: 1;
const seed =
typeof body.seed === 'number' && Number.isFinite(body.seed)
? body.seed
: undefined;
const referenceImages = isStringArray(body.referenceImages)
? body.referenceImages
: [];
if (!promptText) {
sendJson(res, 400, { error: { message: 'promptText is required.' } });
return;
}
try {
const result = await generateQwenImages(config, {
kind: 'sheet',
promptText,
negativePrompt,
model,
size,
promptExtend,
seed,
candidateCount,
referenceImages,
});
sendJson(res, 200, {
ok: true,
...result,
});
} catch (error) {
sendJson(res, 500, {
error: {
message: error instanceof Error ? error.message : '生成精灵表失败。',
},
});
}
}
async function handleRepairFrame(
config: AppConfig,
req: IncomingMessage & { body?: unknown },
res: ServerResponse,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
let body: Record<string, unknown>;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
return;
}
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const negativePrompt =
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
const model =
typeof body.model === 'string' && body.model.trim()
? body.model.trim()
: DEFAULT_QWEN_IMAGE_MODEL;
const size =
typeof body.size === 'string' && body.size.trim()
? body.size.trim()
: '512*512';
const promptExtend = body.promptExtend !== false;
const seed =
typeof body.seed === 'number' && Number.isFinite(body.seed)
? body.seed
: undefined;
const referenceImages = isStringArray(body.referenceImages)
? body.referenceImages
: [];
if (!promptText) {
sendJson(res, 400, { error: { message: 'promptText is required.' } });
return;
}
if (referenceImages.length === 0) {
sendJson(res, 400, {
error: { message: '至少需要一张参考图来修复帧。' },
});
return;
}
try {
const result = await generateQwenImages(config, {
kind: 'repair',
promptText,
negativePrompt,
model,
size,
promptExtend,
seed,
candidateCount: 1,
referenceImages,
});
sendJson(res, 200, {
ok: true,
...result,
repairedFrame: result.drafts[0] ?? null,
});
} catch (error) {
sendJson(res, 500, {
error: {
message: error instanceof Error ? error.message : '修帧失败。',
},
});
}
}
async function handleSaveAsset(
rootDir: string,
req: IncomingMessage & { body?: unknown },
res: ServerResponse,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
let body: Record<string, unknown>;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
return;
}
const assetKey =
typeof body.assetKey === 'string' ? sanitizePathSegment(body.assetKey) : '';
const actionKey =
typeof body.actionKey === 'string' ? sanitizePathSegment(body.actionKey) : '';
const masterSource =
typeof body.masterSource === 'string' ? body.masterSource.trim() : '';
const sheetSource =
typeof body.sheetSource === 'string' ? body.sheetSource.trim() : '';
const framesDataUrls = isStringArray(body.framesDataUrls)
? body.framesDataUrls
: [];
const metadata = isRecordValue(body.metadata) ? body.metadata : {};
const prompts = isRecordValue(body.prompts) ? body.prompts : {};
if (!assetKey) {
sendJson(res, 400, { error: { message: 'assetKey is required.' } });
return;
}
if (!actionKey) {
sendJson(res, 400, { error: { message: 'actionKey is required.' } });
return;
}
if (!sheetSource) {
sendJson(res, 400, { error: { message: 'sheetSource is required.' } });
return;
}
try {
const assetId = createTimestampId('qwen-sprite');
const relativeDir = path.posix.join(
'generated-qwen-sprites',
assetKey,
actionKey,
assetId,
);
const absoluteDir = path.resolve(rootDir, 'public', ...relativeDir.split('/'));
await mkdir(path.join(absoluteDir, 'frames'), { recursive: true });
let masterImagePath: string | null = null;
if (masterSource) {
const payload = await resolveImageSourcePayload(rootDir, masterSource);
masterImagePath = await writeDraftImageFile(
rootDir,
path.posix.join(relativeDir, `master.${payload.extension}`),
payload.buffer,
);
}
const sheetPayload = await resolveImageSourcePayload(rootDir, sheetSource);
const sheetImagePath = await writeDraftImageFile(
rootDir,
path.posix.join(relativeDir, `sheet.${sheetPayload.extension}`),
sheetPayload.buffer,
);
const framePaths: string[] = [];
for (let index = 0; index < framesDataUrls.length; index += 1) {
const framePayload = await resolveImageSourcePayload(
rootDir,
framesDataUrls[index] ?? '',
);
const framePath = await writeDraftImageFile(
rootDir,
path.posix.join(
relativeDir,
'frames',
`frame-${String(index + 1).padStart(2, '0')}.${framePayload.extension}`,
),
framePayload.buffer,
);
framePaths.push(framePath);
}
await writeFile(
path.join(absoluteDir, 'metadata.json'),
JSON.stringify(
{
assetId,
assetKey,
actionKey,
masterImagePath,
sheetImagePath,
framePaths,
metadata,
prompts,
createdAt: new Date().toISOString(),
},
null,
2,
) + '\n',
'utf8',
);
sendJson(res, 200, {
ok: true,
assetId,
assetDir: `/${relativeDir}`,
masterImagePath,
sheetImagePath,
framePaths,
saveMessage: '已保存到 public/generated-qwen-sprites。',
});
} catch (error) {
sendJson(res, 500, {
error: {
message: error instanceof Error ? error.message : '保存精灵表资产失败。',
},
});
}
}
function toExpressHandler(
handler: (
request: IncomingMessage & { body?: unknown },
response: ServerResponse,
) => Promise<void> | void,
) {
return (request: Request, response: Response, next: NextFunction) => {
Promise.resolve(
handler(
request as Request & IncomingMessage & { body?: unknown },
response as Response & ServerResponse,
),
).catch(next);
};
}
export function createQwenSpriteRoutes(config: AppConfig) {
const router = Router();
router.use((request, response, next) => {
if (
request.path !== '/api/assets' &&
!request.path.startsWith('/api/assets/')
) {
next();
return;
}
if (!config.assetsApiEnabled) {
response.status(403).json({
error: {
message: '资产工具接口当前未启用。',
},
});
return;
}
next();
});
router.post(
QWEN_SPRITE_MASTER_GENERATE_PATH,
routeMeta({ operation: 'assets.qwenSprite.master.generate' }),
toExpressHandler((request, response) =>
handleGenerateMaster(config, request, response),
),
);
router.post(
QWEN_SPRITE_SHEET_GENERATE_PATH,
routeMeta({ operation: 'assets.qwenSprite.sheet.generate' }),
toExpressHandler((request, response) =>
handleGenerateSheet(config, request, response),
),
);
router.post(
QWEN_SPRITE_FRAME_REPAIR_PATH,
routeMeta({ operation: 'assets.qwenSprite.frameRepair.generate' }),
toExpressHandler((request, response) =>
handleRepairFrame(config, request, response),
),
);
router.post(
QWEN_SPRITE_SAVE_PATH,
routeMeta({ operation: 'assets.qwenSprite.asset.save' }),
toExpressHandler((request, response) =>
handleSaveAsset(config.projectRoot, request, response),
),
);
return router;
}

View File

@@ -1,8 +1,10 @@
import type {
RuntimeBattlePresentation,
RuntimeStoryChoicePayload,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import type {
RuntimeStoryChoicePayload,
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js';
import {
buildInventoryUseResultText,
incrementGameRuntimeStats,
@@ -26,7 +28,7 @@ import {
getPlayerSkillCooldowns,
setEncounterNpcState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js';
type CombatActionConfig = {
actionText: string;

View File

@@ -0,0 +1,365 @@
import type {
AttributeVector,
CustomWorldNpc,
CustomWorldPlayableNpc,
RoleAttributeProfile,
WorldAttributeSchema,
WorldAttributeSlot,
WorldType,
} from '../runtimeTypes.js';
import { inferWorldTypeFromSetting } from './creatorIntentBridge.js';
import { slugify } from './normalizeShared.js';
/**
* 工作包 G
* 把 attribute schema 构建和角色属性画像编译从主 runtime compiler 中抽离,
* 让结果预览编译、世界基础 profile 归一和角色属性推导有清晰边界。
*/
const WORLD_ATTRIBUTE_SLOT_IDS = [
'axis_a',
'axis_b',
'axis_c',
'axis_d',
'axis_e',
'axis_f',
] as const;
const AXIS_KEYWORD_RULES: Array<{
slotId: string;
patterns: RegExp[];
weight: number;
}> = [
{ slotId: 'axis_a', patterns: [/||||||||||/u], weight: 16 },
{ slotId: 'axis_b', patterns: [/|||||||||/u], weight: 16 },
{ slotId: 'axis_c', patterns: [/|||||||||/u], weight: 16 },
{ slotId: 'axis_d', patterns: [/|||||||||/u], weight: 16 },
{ slotId: 'axis_e', patterns: [/|||||||||/u], weight: 16 },
{ slotId: 'axis_f', patterns: [/|||||||||/u], weight: 16 },
];
export function buildTemplateWorldAttributeSchema(
worldType: Exclude<WorldType, 'CUSTOM'>,
) {
const common = {
schemaVersion: 1,
generatedFrom:
worldType === 'XIANXIA'
? {
worldType: 'XIANXIA' as const,
worldName: '仙侠',
settingSummary: '灵潮、宗门、禁制、秘境与道途交织。',
tone: '空灵、危险、带着灾变与大道压迫。',
conflictCore: '在裂变与因果之间稳住自我与道途。',
}
: {
worldType: 'WUXIA' as const,
worldName: '武侠',
settingSummary: '江湖、门派、旧案与人情纠葛并存。',
tone: '克制、紧张、讲究局势与心气。',
conflictCore: '在人情、威压与旧案之间立住自身。',
},
};
if (worldType === 'XIANXIA') {
return {
id: 'schema:xianxia:v1',
worldId: 'XIANXIA',
schemaName: '灵界六轴',
...common,
slots: [
{
slotId: 'axis_a',
name: '道骨',
definition: '承载道压与高强度冲击的底子。',
positiveSignals: ['承压', '根基稳', '扛得住'],
negativeSignals: ['根基浅', '易溃', '承载不足'],
combatUseText: '扛住灵压、正面承受高强度对撞。',
socialUseText: '让人感到根基扎实,值得托付重事。',
explorationUseText: '承受秘境、禁制与裂隙带来的压迫。',
},
{
slotId: 'axis_b',
name: '灵行',
definition: '位移、御空、转场、抢占天时地利的能力。',
positiveSignals: ['位移', '御空', '机动'],
negativeSignals: ['迟滞', '失位', '转场慢'],
combatUseText: '抢位、御空、快速重整战场位置。',
socialUseText: '反应轻快,擅长顺势接住局面的变化。',
explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。',
},
{
slotId: 'axis_c',
name: '识海',
definition: '解析禁制、洞察因果、识破虚实的能力。',
positiveSignals: ['洞察', '解构', '看破'],
negativeSignals: ['迷失', '误判', '看不清'],
combatUseText: '识破术理、找出因果节点与破绽。',
socialUseText: '更容易辨认真话、虚言与隐藏动机。',
explorationUseText: '解读阵纹、禁制、旧史与环境异象。',
},
{
slotId: 'axis_d',
name: '劫纹',
definition: '在高危变化中强行推进、改写局势的能力。',
positiveSignals: ['强推', '决断', '逆转'],
negativeSignals: ['畏缩', '迟疑', '不敢碰变局'],
combatUseText: '在高压窗口里压上去,逼出变化与突破。',
socialUseText: '在关键谈判中拍板,推动他人表态。',
explorationUseText: '面对异变与风险时敢于推进关键节点。',
},
{
slotId: 'axis_e',
name: '心契',
definition: '与他者、器物、灵兽、誓约建立共鸣的能力。',
positiveSignals: ['共鸣', '结契', '安抚'],
negativeSignals: ['隔阂', '生硬', '难以共振'],
combatUseText: '与器物、灵兽、同伴形成协同与共鸣。',
socialUseText: '建立信任、誓约与更深层的关系连结。',
explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。',
},
{
slotId: 'axis_f',
name: '玄息',
definition: '循环灵息、稳住心神、让自身持续在线的能力。',
positiveSignals: ['稳态', '回转', '续航'],
negativeSignals: ['紊乱', '枯竭', '失衡'],
combatUseText: '维持灵息循环、拖住长线压力与消耗。',
socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。',
explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。',
},
] satisfies WorldAttributeSlot[],
} satisfies WorldAttributeSchema;
}
return {
id: 'schema:wuxia:v1',
worldId: 'WUXIA',
schemaVersion: 1,
schemaName: '江湖六脉',
generatedFrom: common.generatedFrom,
slots: [
{
slotId: 'axis_a',
name: '骨势',
definition: '扛压、顶冲、硬吃风险也不退的势头。',
positiveSignals: ['扛压', '硬桥硬马', '稳住正面'],
negativeSignals: ['虚浮', '怯退', '一碰就散'],
combatUseText: '顶住正面压力、换伤不退、撑住阵线。',
socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。',
explorationUseText: '穿越险路、硬顶机关、承受高压环境。',
},
{
slotId: 'axis_b',
name: '身法',
definition: '腾挪、抢位、换线、把握出手节奏的能力。',
positiveSignals: ['快', '轻灵', '抢位'],
negativeSignals: ['迟缓', '失位', '笨重'],
combatUseText: '切线换位、闪转腾挪、争夺先手。',
socialUseText: '应变快,擅长观察气口并顺势接话。',
explorationUseText: '攀越、潜入、追踪与复杂地形穿行。',
},
{
slotId: 'axis_c',
name: '眼脉',
definition: '看破破绽、拆招、识局、看穿人心的能力。',
positiveSignals: ['识局', '洞察', '拆招'],
negativeSignals: ['迟钝', '误判', '看不透'],
combatUseText: '抓破绽、拆套路、找出最该切入的位置。',
socialUseText: '判断弦外之音、试探真假、识别来意。',
explorationUseText: '识破机关、辨认痕迹、看懂异状。',
},
{
slotId: 'axis_d',
name: '心焰',
definition: '决断、压迫、胆气、在局面中立住自身意志的能力。',
positiveSignals: ['胆气', '决断', '压迫'],
negativeSignals: ['犹疑', '软弱', '易被动摇'],
combatUseText: '逼迫对手、强行推进、在关键时刻拍板。',
socialUseText: '立威、定调、在谈判里压住场子。',
explorationUseText: '在未知风险前保持决断,不被局势拖死。',
},
{
slotId: 'axis_e',
name: '尘缘',
definition: '与人事、情面、承诺、牵引关系打交道的能力。',
positiveSignals: ['通人情', '会安抚', '懂交换'],
negativeSignals: ['生硬', '失礼', '不近人情'],
combatUseText: '借势协同、读懂同伴与对手的关系脉络。',
socialUseText: '安抚、求助、结盟、维系承诺与信任。',
explorationUseText: '从传闻、人脉和地方关系里打开线索。',
},
{
slotId: 'axis_f',
name: '玄息',
definition: '调息、稳态、久战、把自身维持在可用状态的能力。',
positiveSignals: ['稳', '续战', '调息'],
negativeSignals: ['紊乱', '易崩', '续不上'],
combatUseText: '续战、回气、稳住节奏与状态。',
socialUseText: '遇事不乱,语气和姿态都更沉稳可信。',
explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。',
},
] satisfies WorldAttributeSlot[],
} satisfies WorldAttributeSchema;
}
export function generateWorldAttributeSchema(input: {
worldName: string;
settingText: string;
summary: string;
tone: string;
playerGoal: string;
}) {
const inferredWorldType = inferWorldTypeFromSetting(input.settingText);
const template = buildTemplateWorldAttributeSchema(
inferredWorldType === 'XIANXIA' ? 'XIANXIA' : 'WUXIA',
);
return {
...template,
id: `schema:custom:${slugify(input.worldName)}`,
worldId: `custom:${input.worldName}`,
generatedFrom: {
worldType: 'CUSTOM',
worldName: input.worldName,
settingSummary: input.summary,
tone: input.tone,
conflictCore: input.playerGoal,
},
} satisfies WorldAttributeSchema;
}
function normalizeAttributeValues(
values: AttributeVector,
slotIds: readonly string[],
targetTotal = 360,
) {
const positiveValues = slotIds.map((slotId) => Math.max(0, values[slotId] ?? 0));
const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0);
const normalized =
rawTotal > 0
? positiveValues.map((value) => (value / rawTotal) * targetTotal)
: slotIds.map(() => targetTotal / Math.max(slotIds.length, 1));
const rounded = normalized.map((value) => Math.max(0, Math.min(100, Math.round(value))));
return Object.fromEntries(
slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]),
) as AttributeVector;
}
function ensureRoleAttributeProfile(
profile: Partial<RoleAttributeProfile> | null | undefined,
schema: WorldAttributeSchema,
fallbackValues: AttributeVector,
): RoleAttributeProfile {
const slotIds = schema.slots.map((slot) => slot.slotId);
const values = normalizeAttributeValues(
{
...fallbackValues,
...(profile?.values ?? {}),
},
slotIds,
);
const sortedSlots = [...schema.slots]
.map((slot) => ({
slot,
value: values[slot.slotId] ?? 0,
}))
.sort((left, right) => right.value - left.value);
return {
schemaId: profile?.schemaId ?? schema.id,
values,
topTraits: sortedSlots.slice(0, 2).map((entry) => entry.slot.name),
hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined,
evidence:
profile?.evidence?.length
? [...profile.evidence]
: sortedSlots.slice(0, 3).map((entry) => ({
slotId: entry.slot.slotId,
reason: `${entry.slot.name}在当前画像中最突出。`,
})),
};
}
function buildDefaultAxisVector(
overrides: Partial<Record<(typeof WORLD_ATTRIBUTE_SLOT_IDS)[number], number>>,
) {
return WORLD_ATTRIBUTE_SLOT_IDS.reduce<AttributeVector>((result, slotId) => {
result[slotId] = overrides[slotId] ?? 0;
return result;
}, {});
}
function buildRoleAttributeProfileFromTexts(params: {
schema: WorldAttributeSchema;
textBlocks: Array<string | null | undefined>;
}) {
const sourceText = params.textBlocks.filter(Boolean).join(' ');
const seed = buildDefaultAxisVector({
axis_a: 58,
axis_b: 58,
axis_c: 58,
axis_d: 58,
axis_e: 58,
axis_f: 58,
});
AXIS_KEYWORD_RULES.forEach((rule) => {
const matches = rule.patterns.reduce(
(count, pattern) => count + (pattern.test(sourceText) ? 1 : 0),
0,
);
if (matches <= 0) {
return;
}
seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches;
});
return ensureRoleAttributeProfile(
{
schemaId: params.schema.id,
},
params.schema,
seed,
);
}
export function buildCustomWorldPlayableNpcAttributeProfile(
npc: CustomWorldPlayableNpc,
schema: WorldAttributeSchema,
) {
return buildRoleAttributeProfileFromTexts({
schema,
textBlocks: [
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...(npc.relationshipHooks ?? []),
...(npc.tags ?? []),
],
});
}
export function buildCustomWorldStoryNpcAttributeProfile(
npc: CustomWorldNpc,
schema: WorldAttributeSchema,
) {
return buildRoleAttributeProfileFromTexts({
schema,
textBlocks: [
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...(npc.relationshipHooks ?? []),
...(npc.tags ?? []),
],
});
}

View File

@@ -0,0 +1,410 @@
import type {
CustomWorldGenerationFramework,
CustomWorldProfile,
} from '../runtimeTypes.js';
import {
buildCustomWorldPlayableNpcAttributeProfile,
buildCustomWorldStoryNpcAttributeProfile,
generateWorldAttributeSchema,
} from './buildAttributeSchema.js';
import {
buildWorldName,
inferWorldTypeFromSetting,
normalizeWorldType,
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
resolveCustomWorldRuntimeIntentBridge,
} from './creatorIntentBridge.js';
import {
buildFallbackCustomWorldCampScene,
normalizeCampOutline,
normalizeCampScene,
} from './normalizeCamp.js';
import {
buildCustomWorldRawProfileLandmarksFromFramework,
normalizeLandmarkOutlineList,
normalizeLandmarks,
} from './normalizeLandmark.js';
import {
buildCustomWorldRawProfileRolesFromFramework,
normalizeCustomWorldGenerationFrameworkRoles,
normalizePlayableNpcList,
normalizeStoryNpcList,
} from './normalizeRole.js';
import {
buildDefaultCustomWorldCover,
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
normalizeCustomWorldCover,
normalizeItemList,
normalizeTags,
PLAYABLE_TEMPLATE_CHARACTER_IDS,
slugify,
toRecordArray,
toText,
} from './normalizeShared.js';
import { normalizeSceneChapterBlueprints } from './normalizeSceneChapter.js';
/**
* 工作包 G
* 让 runtime profile 真正由“主编译入口 + 目录化 normalize/build 子模块”组成。
*/
function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
const templateWorldType = inferWorldTypeFromSetting(settingText);
const name = buildWorldName(settingText, templateWorldType);
const subtitle = '前路未明';
const summary = settingText.trim()
? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。`
: '一个仍待展开的独立世界正在成形。';
const tone = '未知、紧绷、仍在展开';
const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事';
const camp = buildFallbackCustomWorldCampScene({
name,
summary,
tone,
playerGoal,
settingText: settingText.trim(),
});
return {
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
settingText: settingText.trim(),
name,
subtitle,
summary,
tone,
playerGoal,
cover: buildDefaultCustomWorldCover([]),
templateWorldType,
compatibilityTemplateWorldType: templateWorldType,
majorFactions: [],
coreConflicts: [summary],
attributeSchema: generateWorldAttributeSchema({
worldName: name,
settingText: settingText.trim(),
summary,
tone,
playerGoal,
}),
playableNpcs: [],
storyNpcs: [],
items: [],
camp,
landmarks: [],
themePack: null,
storyGraph: null,
creatorIntent: null,
anchorPack: null,
lockState: normalizeCustomWorldLockState(null),
generationMode: 'full',
generationStatus: 'complete',
ownedSettingLayers: null,
scenarioPackId: null,
campaignPackId: null,
};
}
export function normalizeCustomWorldGenerationFramework(
raw: unknown,
settingText: string,
): CustomWorldGenerationFramework {
const fallback = buildBaseCustomWorldProfile(settingText);
if (!raw || typeof raw !== 'object') {
return {
settingText: fallback.settingText,
name: fallback.name,
subtitle: fallback.subtitle,
summary: fallback.summary,
tone: fallback.tone,
playerGoal: fallback.playerGoal,
templateWorldType: fallback.templateWorldType,
compatibilityTemplateWorldType:
fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType,
majorFactions: [],
coreConflicts: [fallback.summary],
camp: {
name: fallback.camp?.name ?? '归舍',
description: fallback.camp?.description ?? '',
dangerLevel: fallback.camp?.dangerLevel ?? 'low',
},
playableNpcs: [],
storyNpcs: [],
landmarks: [],
};
}
const item = raw as Record<string, unknown>;
const roleState = normalizeCustomWorldGenerationFrameworkRoles({
raw: item,
fallback,
settingText,
});
return {
settingText: settingText.trim(),
name: roleState.name,
subtitle: toText(item.subtitle) || fallback.subtitle,
summary: toText(item.summary) || fallback.summary,
tone: toText(item.tone) || fallback.tone,
playerGoal: toText(item.playerGoal) || fallback.playerGoal,
templateWorldType: roleState.templateWorldType,
compatibilityTemplateWorldType: roleState.templateWorldType,
majorFactions: normalizeTags(item.majorFactions, []),
coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]),
camp: {
name: normalizeCampOutline(item.camp, roleState.campFallbackProfile).name,
description: normalizeCampOutline(item.camp, roleState.campFallbackProfile)
.description,
dangerLevel: normalizeCampOutline(item.camp, roleState.campFallbackProfile)
.dangerLevel,
},
playableNpcs: roleState.playableNpcs,
storyNpcs: roleState.storyNpcs,
landmarks: normalizeLandmarkOutlineList(item.landmarks),
};
}
export function buildCustomWorldRawProfileFromFramework(
framework: CustomWorldGenerationFramework,
) {
return {
name: framework.name,
subtitle: framework.subtitle,
summary: framework.summary,
tone: framework.tone,
playerGoal: framework.playerGoal,
templateWorldType: framework.templateWorldType,
compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType,
majorFactions: framework.majorFactions,
coreConflicts: framework.coreConflicts,
camp: {
name: framework.camp.name,
description: framework.camp.description,
dangerLevel: framework.camp.dangerLevel,
},
...buildCustomWorldRawProfileRolesFromFramework(framework),
landmarks: buildCustomWorldRawProfileLandmarksFromFramework(framework),
};
}
function pickCyclic<T>(items: readonly T[], index: number, label: string): T {
const item = items[index % items.length];
if (item === undefined) {
throw new Error(`Missing ${label}`);
}
return item;
}
export function normalizeCustomWorldProfile(
raw: unknown,
settingText: string,
): CustomWorldProfile {
const fallback = buildBaseCustomWorldProfile(settingText);
if (!raw || typeof raw !== 'object') {
return fallback;
}
const item = raw as Record<string, unknown>;
const worldSignalText = [
settingText,
toText(item.subtitle),
toText(item.summary),
toText(item.tone),
toText(item.playerGoal),
].join(' ');
const templateWorldType = normalizeWorldType(
item.templateWorldType,
worldSignalText,
);
const name =
toText(item.name) || buildWorldName(settingText, templateWorldType);
const summary = toText(item.summary) || fallback.summary;
const tone = toText(item.tone) || fallback.tone;
const playerGoal = toText(item.playerGoal) || fallback.playerGoal;
const generatedAttributeSchema = generateWorldAttributeSchema({
worldName: name,
settingText: settingText.trim(),
summary,
tone,
playerGoal,
});
const playableNpcs = normalizePlayableNpcList(item.playableNpcs);
const storyNpcs = normalizeStoryNpcList(item.storyNpcs);
const landmarkDrafts = toRecordArray(item.landmarks);
const camp = normalizeCampScene(item.camp, {
name,
summary,
tone,
playerGoal,
settingText: settingText.trim(),
});
const runtimeBridge = resolveCustomWorldRuntimeIntentBridge(item);
return {
id:
toText(item.id) || `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
settingText: settingText.trim(),
name,
subtitle: toText(item.subtitle) || fallback.subtitle,
summary,
tone,
playerGoal,
cover: normalizeCustomWorldCover(item.cover, playableNpcs),
templateWorldType,
compatibilityTemplateWorldType: templateWorldType,
majorFactions: normalizeTags(item.majorFactions, []),
coreConflicts: normalizeTags(item.coreConflicts, [summary]),
attributeSchema:
item.attributeSchema && typeof item.attributeSchema === 'object'
? generatedAttributeSchema
: generatedAttributeSchema,
playableNpcs,
storyNpcs,
items: normalizeItemList(item.items),
camp,
landmarks: normalizeLandmarks({
landmarks: landmarkDrafts,
storyNpcs,
}),
themePack:
item.themePack && typeof item.themePack === 'object'
? (item.themePack as CustomWorldProfile['themePack'])
: null,
storyGraph:
item.storyGraph && typeof item.storyGraph === 'object'
? (item.storyGraph as CustomWorldProfile['storyGraph'])
: null,
anchorContent:
item.anchorContent && typeof item.anchorContent === 'object'
? (item.anchorContent as Record<string, unknown>)
: null,
creatorIntent: runtimeBridge.creatorIntent,
anchorPack: runtimeBridge.anchorPack,
lockState: runtimeBridge.lockState,
generationMode:
item.generationMode === 'fast' || item.generationMode === 'full'
? item.generationMode
: fallback.generationMode,
generationStatus:
item.generationStatus === 'key_only' || item.generationStatus === 'complete'
? item.generationStatus
: fallback.generationStatus,
ownedSettingLayers:
item.ownedSettingLayers && typeof item.ownedSettingLayers === 'object'
? (item.ownedSettingLayers as Record<string, unknown>)
: null,
knowledgeFacts:
Array.isArray(item.knowledgeFacts)
? (item.knowledgeFacts as Array<Record<string, unknown>>)
: null,
threadContracts:
Array.isArray(item.threadContracts)
? (item.threadContracts as Array<Record<string, unknown>>)
: null,
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
item.sceneChapterBlueprints,
),
scenarioPackId: toText(item.scenarioPackId) || null,
campaignPackId: toText(item.campaignPackId) || null,
};
}
export function buildCompiledCustomWorldProfile(
raw: unknown,
settingText: string,
): CustomWorldProfile {
const profile = normalizeCustomWorldProfile(raw, settingText);
const playableNpcs = profile.playableNpcs.map((npc, index) => {
const templateCharacterId =
npc.templateCharacterId ??
pickCyclic(
PLAYABLE_TEMPLATE_CHARACTER_IDS,
index,
'playable template character id',
);
return {
...npc,
templateCharacterId,
attributeProfile:
npc.attributeProfile ??
buildCustomWorldPlayableNpcAttributeProfile(
{
...npc,
templateCharacterId,
},
profile.attributeSchema,
),
};
});
const storyNpcs = profile.storyNpcs.map((npc) => ({
...npc,
attributeProfile:
npc.attributeProfile ??
buildCustomWorldStoryNpcAttributeProfile(npc, profile.attributeSchema),
}));
return {
...profile,
playableNpcs,
storyNpcs,
scenarioPackId:
profile.scenarioPackId ?? `scenario-pack:${slugify(profile.name)}`,
campaignPackId:
profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`,
};
}
function countUniqueNames(items: Array<{ name: string }>) {
return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size;
}
export function validateGeneratedCustomWorldProfile(
profile: CustomWorldProfile,
) {
const playableCount = countUniqueNames(profile.playableNpcs);
const landmarkCount = countUniqueNames(profile.landmarks);
if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) {
throw new Error(
`自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`,
);
}
if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) {
throw new Error(
`自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`,
);
}
const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id));
const validLandmarkIds = new Set(
profile.landmarks.map((landmark) => landmark.id),
);
profile.landmarks.forEach((landmark) => {
const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)];
if (uniqueSceneNpcIds.length < 3) {
throw new Error(
`场景「${landmark.name}」至少需要关联 3 个场景角色,当前仅有 ${uniqueSceneNpcIds.length} 个。`,
);
}
if (uniqueSceneNpcIds.some((npcId) => !validStoryNpcIds.has(npcId))) {
throw new Error(`场景「${landmark.name}」引用了不存在的场景角色。`);
}
if (landmark.connections.length === 0) {
throw new Error(`场景「${landmark.name}」缺少可用的场景连接关系。`);
}
if (
landmark.connections.some(
(connection) =>
connection.targetLandmarkId === landmark.id ||
!validLandmarkIds.has(connection.targetLandmarkId),
)
) {
throw new Error(`场景「${landmark.name}」存在无效的目标场景连接。`);
}
});
}

View File

@@ -0,0 +1,82 @@
import {
buildCustomWorldAnchorPackFromIntent,
deriveCustomWorldLockStateFromIntent,
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
} from '../creatorIntentRuntime.js';
import type {
CustomWorldCreatorIntent,
CustomWorldProfile,
WorldType,
} from '../runtimeTypes.js';
import { toText } from './normalizeShared.js';
/**
* 工作包 G
* 统一 runtime profile 对 creator intent、anchor pack 和 lock state 的桥接入口,
* 避免主编译器继续直接拼装这些兼容字段。
*/
export function inferWorldTypeFromSetting(settingText: string): WorldType {
return /[]/u.test(settingText)
? 'XIANXIA'
: 'WUXIA';
}
export function normalizeWorldType(value: unknown, sourceText: string): WorldType {
const worldType = toText(value).toUpperCase();
if (worldType === 'WUXIA' || worldType === 'XIANXIA') {
return worldType;
}
return inferWorldTypeFromSetting(sourceText);
}
export function buildSeedPhrase(settingText: string, fallback: string) {
const compact = settingText.replace(/\s+/g, '').trim();
return compact ? compact.slice(0, 10) : fallback;
}
export function buildWorldName(settingText: string, worldType: WorldType) {
const seed = buildSeedPhrase(settingText, '新旅');
const suffix = worldType === 'XIANXIA' ? '境' : '域';
return `${seed}${suffix}`;
}
export {
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
};
export function buildEmptyCustomWorldRuntimeBridge() {
return {
creatorIntent: null,
anchorPack: null,
lockState: normalizeCustomWorldLockState(null),
} satisfies {
creatorIntent: CustomWorldCreatorIntent | null;
anchorPack: CustomWorldProfile['anchorPack'];
lockState: CustomWorldProfile['lockState'];
};
}
export function resolveCustomWorldRuntimeIntentBridge(
raw: Record<string, unknown>,
) {
const creatorIntent = normalizeCustomWorldCreatorIntent(raw.creatorIntent);
return {
creatorIntent,
anchorPack:
raw.anchorPack && typeof raw.anchorPack === 'object'
? (raw.anchorPack as CustomWorldProfile['anchorPack'])
: buildCustomWorldAnchorPackFromIntent(creatorIntent),
lockState:
raw.lockState && typeof raw.lockState === 'object'
? normalizeCustomWorldLockState(raw.lockState)
: deriveCustomWorldLockStateFromIntent(creatorIntent),
} satisfies {
creatorIntent: CustomWorldCreatorIntent | null;
anchorPack: CustomWorldProfile['anchorPack'];
lockState: CustomWorldProfile['lockState'];
};
}

View File

@@ -0,0 +1,13 @@
/**
* 工作包 G
* custom world runtime profile 的主入口统一收口到目录化模块。
* 主编译逻辑和各类 normalize/build 子模块已经物理拆分,旧 compiler 文件只保留兼容转发。
*/
export * from './buildAttributeSchema.js';
export * from './buildCompiledProfile.js';
export * from './creatorIntentBridge.js';
export * from './normalizeCamp.js';
export * from './normalizeLandmark.js';
export * from './normalizeRole.js';
export * from './normalizeSceneChapter.js';
export * from './normalizeShared.js';

View File

@@ -0,0 +1,178 @@
import type {
CustomWorldCampScene,
CustomWorldGenerationCampOutline,
} from '../runtimeTypes.js';
import {
clampText,
toRecordArray,
toStringArray,
toText,
} from './normalizeShared.js';
/**
* 工作包 G
* 营地 fallback、outline 归一和 runtime 场景归一单独收口,
* 避免主编译器继续混合 UI 展示语义和营地领域默认值。
*/
export type CustomWorldCampFallbackProfile = {
name: string;
summary: string;
tone: string;
playerGoal: string;
settingText: string;
};
function detectCustomWorldThemeMode(profile: {
settingText: string;
summary: string;
tone: string;
playerGoal: string;
}) {
const source = [
profile.settingText,
profile.summary,
profile.tone,
profile.playerGoal,
].join(' ');
if (/[齿]/u.test(source)) return 'machina';
if (/[]/u.test(source)) return 'tide';
if (/[线]/u.test(source)) return 'rift';
if (/[]/u.test(source)) return 'arcane';
if (/[]/u.test(source)) return 'martial';
return 'mythic';
}
function sanitizeCampSeed(name: string) {
const normalized = name.trim().replace(/\s+/g, '');
if (!normalized) {
return '';
}
const stripped = normalized.replace(
/(|||||||||)$/u,
'',
);
const seed = stripped || normalized;
return seed.slice(0, Math.min(seed.length, 4));
}
function buildFallbackCampName(profile: CustomWorldCampFallbackProfile) {
const seed = sanitizeCampSeed(profile.name) || '归途';
const themeMode = detectCustomWorldThemeMode(profile);
const suffixByMode = {
mythic: '归舍',
martial: '归舍',
arcane: '栖居',
machina: '整备居',
tide: '潮居',
rift: '界隙居所',
} as const;
return `${seed}${suffixByMode[themeMode]}`;
}
export function buildFallbackCustomWorldCampScene(
profile: CustomWorldCampFallbackProfile,
): CustomWorldCampScene {
const fallbackName = buildFallbackCampName(profile);
const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗';
const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索';
const themeMode = detectCustomWorldThemeMode(profile);
const descriptionByMode = {
mythic: `${fallbackName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`,
martial: `${fallbackName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`,
arcane: `${fallbackName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`,
machina: `${fallbackName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`,
tide: `${fallbackName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`,
rift: `${fallbackName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`,
} as const;
return {
id: 'custom-scene-camp',
name: fallbackName,
description: descriptionByMode[themeMode],
dangerLevel: 'low',
sceneNpcIds: [],
connections: [],
narrativeResidues: null,
};
}
export function normalizeCampOutline(
value: unknown,
fallbackProfile: CustomWorldCampFallbackProfile,
) {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
return {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) ||
toText(connection.position) ||
'forward',
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkName),
} satisfies CustomWorldGenerationCampOutline & {
id: string;
visualDescription?: string;
imageSrc?: string;
sceneNpcIds: string[];
connections: Array<{
targetLandmarkName: string;
relativePosition: string;
summary: string;
}>;
};
}
export function normalizeCampScene(
value: unknown,
fallbackProfile: CustomWorldCampFallbackProfile,
): CustomWorldCampScene {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
return {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
relativePosition:
toText(connection.relativePosition) || toText(connection.position) || 'forward',
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkId),
narrativeResidues: null,
};
}

View File

@@ -0,0 +1,151 @@
import type {
CustomWorldGenerationFramework,
CustomWorldGenerationLandmarkOutline,
CustomWorldNpc,
} from '../runtimeTypes.js';
import {
clampText,
createEntryId,
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
toRecordArray,
toStringArray,
toText,
} from './normalizeShared.js';
/**
* 工作包 G
* 世界地点 outline/runtime 归一独立收口,避免地点网络解析继续和角色、场景章节逻辑混在一个文件里。
*/
export function normalizeLandmarkOutlineList(value: unknown) {
return toRecordArray(value)
.map((item) => {
const name = toText(item.name);
return {
name,
description:
toText(item.description) ||
clampText(`${name}暗藏新的局势变化。`, 40),
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || 'medium',
sceneNpcNames: [
...toStringArray(item.sceneNpcNames),
...toStringArray(item.npcs, 'name'),
...toStringArray(item.sceneNpcs, 'name'),
...toStringArray(item.npcNames),
],
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) ||
toText(connection.position) ||
'forward',
summary:
toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkName),
} satisfies CustomWorldGenerationLandmarkOutline;
})
.filter((entry) => entry.name)
.slice(0, MIN_CUSTOM_WORLD_LANDMARK_COUNT);
}
export function normalizeCustomWorldGenerationLandmarkOutlineBatch(raw: unknown) {
const item =
raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
return normalizeLandmarkOutlineList(item.landmarks);
}
export function buildCustomWorldRawProfileLandmarksFromFramework(
framework: CustomWorldGenerationFramework,
) {
return framework.landmarks.map((landmark) => ({
name: landmark.name,
description: landmark.description,
visualDescription: landmark.visualDescription,
dangerLevel: landmark.dangerLevel,
sceneNpcNames: [...landmark.sceneNpcNames],
connections: landmark.connections.map((connection) => ({
targetLandmarkName: connection.targetLandmarkName,
relativePosition: connection.relativePosition,
summary: connection.summary,
})),
}));
}
export function normalizeLandmarks(params: {
landmarks: Array<Record<string, unknown>>;
storyNpcs: CustomWorldNpc[];
}) {
const storyNpcIdByName = new Map(
params.storyNpcs.map((npc) => [npc.name.trim(), npc.id] as const),
);
const landmarkEntries = params.landmarks
.map((item, index) => ({
id: toText(item.id) || createEntryId('landmark', toText(item.name), index),
name: toText(item.name),
description: toText(item.description),
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || 'medium',
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
sceneNpcNames: [
...toStringArray(item.sceneNpcNames),
...toStringArray(item.npcs, 'name'),
...toStringArray(item.sceneNpcs, 'name'),
...toStringArray(item.npcNames),
],
connections: toRecordArray(item.connections).map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) || toText(connection.position),
summary: toText(connection.summary) || toText(connection.description),
})),
}))
.filter((entry) => entry.name);
const landmarkIdByName = new Map(
landmarkEntries.map((landmark) => [landmark.name.trim(), landmark.id] as const),
);
return landmarkEntries.map((landmark) => {
const resolvedSceneNpcIds = [
...new Set(
[
...landmark.sceneNpcIds,
...landmark.sceneNpcNames
.map((name) => storyNpcIdByName.get(name.trim()) ?? '')
.filter(Boolean),
].filter(Boolean),
),
];
return {
id: landmark.id,
name: landmark.name,
description: landmark.description,
visualDescription: landmark.visualDescription,
dangerLevel: landmark.dangerLevel,
imageSrc: landmark.imageSrc,
sceneNpcIds: resolvedSceneNpcIds,
connections: landmark.connections
.map((connection) => ({
targetLandmarkId:
connection.targetLandmarkId ||
landmarkIdByName.get(connection.targetLandmarkName.trim()) ||
'',
relativePosition: connection.relativePosition || 'forward',
summary: connection.summary,
}))
.filter((connection) => connection.targetLandmarkId),
};
});
}

View File

@@ -0,0 +1,541 @@
import type {
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldGenerationFramework,
CustomWorldGenerationRoleBatchType,
CustomWorldGenerationRoleOutline,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldRoleInitialItem,
CustomWorldRoleProfile,
CustomWorldRoleSkill,
} from '../runtimeTypes.js';
import {
buildWorldName,
normalizeWorldType,
} from './creatorIntentBridge.js';
import {
clampCustomWorldAffinity,
clampText,
createEntryId,
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
normalizeInitialAffinity,
normalizeRarity,
normalizeRoleItemCategory,
normalizeTags,
toRecordArray,
toText,
} from './normalizeShared.js';
/**
* 工作包 G
* 把角色相关的 outline/runtime 归一、背景揭示、初始技能与物品 fallback 统一收口,
* 让主编译器只负责装配,不继续内嵌角色画像细节。
*/
const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [15, 30, 60, 90] as const;
const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY = 60;
const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3;
const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [
'表层来意',
'旧事裂痕',
'隐藏执念',
'最终底牌',
] as const;
type CustomWorldRoleFallbackSource = Pick<
CustomWorldRoleProfile,
| 'name'
| 'title'
| 'role'
| 'description'
| 'backstory'
| 'personality'
| 'motivation'
| 'combatStyle'
| 'relationshipHooks'
| 'tags'
>;
function splitNarrativeSentences(text: string) {
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) {
return [];
}
const matches = normalized.match(/[^!?]+[!?]?/gu);
return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean);
}
function buildFallbackBackstoryReveal(
source: CustomWorldRoleFallbackSource,
): CharacterBackstoryRevealConfig {
const normalizedBackstory =
source.backstory.trim() || `${source.name}对自己的过去始终有所保留。`;
const backstorySentences = splitNarrativeSentences(normalizedBackstory);
const backstoryLead = backstorySentences[0] ?? normalizedBackstory;
const backstoryDetail =
backstorySentences.slice(0, 2).join('') || normalizedBackstory;
const publicSummary =
source.description.trim() || clampText(normalizedBackstory, 42);
const fallbackContents = [
source.description.trim() || backstoryLead,
backstoryDetail,
source.motivation.trim()
? `${source.name}真正挂念的,是:${source.motivation.trim()}`
: `${source.name}的选择与“${clampText(backstoryLead, 24)}”直接相关。`,
source.personality.trim()
? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}`
: `${source.name}仍把最深的筹码藏在过去之中。`,
];
return {
publicSummary,
privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map(
(affinityRequired, index) =>
({
id: createEntryId(
'backstory-chapter',
`${source.name}-${CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? index + 1}`,
index,
),
title:
CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ??
`背景片段${index + 1}`,
affinityRequired,
teaser: clampText(
fallbackContents[index] ?? normalizedBackstory,
22,
),
content: clampText(
fallbackContents[index] ?? normalizedBackstory,
72,
),
contextSnippet: clampText(
`${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`,
48,
),
}) satisfies CharacterBackstoryChapter,
),
};
}
function normalizeBackstoryReveal(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const fallback = buildFallbackBackstoryReveal(fallbackSource);
if (!value || typeof value !== 'object') {
return fallback;
}
const item = value as Record<string, unknown>;
const rawChapters = toRecordArray(item.chapters);
return {
publicSummary: toText(item.publicSummary) || fallback.publicSummary,
privateChatUnlockAffinity:
typeof item.privateChatUnlockAffinity === 'number' &&
Number.isFinite(item.privateChatUnlockAffinity)
? clampCustomWorldAffinity(item.privateChatUnlockAffinity)
: fallback.privateChatUnlockAffinity,
chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map(
(defaultAffinity, index) => {
const fallbackChapter = fallback.chapters[index];
const rawChapter = rawChapters[index];
return {
id:
(rawChapter && toText(rawChapter.id)) ||
fallbackChapter?.id ||
`backstory-chapter-${index + 1}`,
title:
(rawChapter && toText(rawChapter.title)) ||
fallbackChapter?.title ||
`背景片段${index + 1}`,
affinityRequired:
fallbackChapter?.affinityRequired ?? defaultAffinity,
teaser:
(rawChapter && toText(rawChapter.teaser)) ||
fallbackChapter?.teaser ||
'',
content:
(rawChapter && toText(rawChapter.content)) ||
fallbackChapter?.content ||
'',
contextSnippet:
(rawChapter && toText(rawChapter.contextSnippet)) ||
fallbackChapter?.contextSnippet ||
'',
} satisfies CharacterBackstoryChapter;
},
),
} satisfies CharacterBackstoryRevealConfig;
}
function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
const skillNameSeed = source.title || source.role || source.name || '角色';
const skillSummarySeed =
source.combatStyle || source.description || `${source.name}善于把握局势。`;
const motivationSeed =
source.motivation || source.personality || source.backstory;
return [
{
id: createEntryId('role-skill', `${skillNameSeed}-起手`, 0),
name: `${skillNameSeed}起手`,
summary: clampText(skillSummarySeed, 36),
style: '起手压制',
},
{
id: createEntryId('role-skill', `${skillNameSeed}-机动`, 1),
name: `${skillNameSeed}变招`,
summary: clampText(
source.personality || `${source.name}习惯在试探中寻找破绽。`,
36,
),
style: '机动周旋',
},
{
id: createEntryId('role-skill', `${skillNameSeed}-底牌`, 2),
name: `${skillNameSeed}底牌`,
summary: clampText(
motivationSeed || `${source.name}会在关键时刻亮出压箱手段。`,
36,
),
style: '爆发终结',
},
] satisfies CustomWorldRoleSkill[];
}
function normalizeRoleSkillList(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const normalized = toRecordArray(value)
.map((item, index) => {
const name = toText(item.name);
const summary = toText(item.summary) || toText(item.description);
const style = toText(item.style) || toText(item.category) || '常用';
return {
id: createEntryId('role-skill', name || style, index),
name,
summary,
style,
} satisfies CustomWorldRoleSkill;
})
.filter((entry) => entry.name)
.slice(0, DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT);
return normalized.length > 0
? normalized
: buildFallbackRoleSkills(fallbackSource);
}
function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
const itemNameSeed = source.title || source.role || source.name || '角色';
return [
{
id: createEntryId('role-item', `${itemNameSeed}-1`, 0),
name: `${itemNameSeed}常备武具`,
category: '武器',
quantity: 1,
rarity: 'rare',
description: clampText(
source.combatStyle || `${source.name}随身携带的主要作战物件。`,
36,
),
tags: normalizeTags(source.tags, ['战斗', '随身']),
},
{
id: createEntryId('role-item', `${itemNameSeed}-2`, 1),
name: `${itemNameSeed}补给包`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: clampText(
source.personality || `${source.name}为了长期行动准备的基础补给。`,
36,
),
tags: normalizeTags(source.relationshipHooks, ['补给', '行动']),
},
{
id: createEntryId('role-item', `${itemNameSeed}-3`, 2),
name: `${itemNameSeed}私人物件`,
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: clampText(
source.backstory ||
source.motivation ||
`${source.name}不愿随意交出的信物。`,
36,
),
tags: normalizeTags(
[...source.tags, ...source.relationshipHooks],
['信物', '线索'],
),
},
] satisfies CustomWorldRoleInitialItem[];
}
function normalizeRoleInitialItemList(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const normalized = toRecordArray(value)
.map((item, index) => {
const name = toText(item.name);
return {
id: createEntryId('role-item', name, index),
name,
category: normalizeRoleItemCategory(item.category),
quantity:
typeof item.quantity === 'number' && Number.isFinite(item.quantity)
? Math.max(1, Math.min(99, Math.round(item.quantity)))
: 1,
rarity: normalizeRarity(item.rarity, 'rare'),
description: toText(item.description),
tags: normalizeTags(item.tags),
} satisfies CustomWorldRoleInitialItem;
})
.filter((entry) => entry.name)
.slice(0, DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT);
return normalized.length > 0
? normalized
: buildFallbackRoleInitialItems(fallbackSource);
}
function normalizeRoleOutlineList(
value: unknown,
options: {
titleFallback: string;
defaultAffinity: number;
maxCount?: number;
},
) {
const normalized = toRecordArray(value)
.map((item) => {
const name = toText(item.name);
const title =
toText(item.title) || toText(item.role) || options.titleFallback;
const role = toText(item.role) || title;
const relationshipHooks = normalizeTags(
item.relationshipHooks,
normalizeTags(item.tags),
);
return {
name,
title,
role,
description:
toText(item.description) ||
clampText(`${name || title}在世界中以${role}身份活动。`, 36),
visualDescription: toText(item.visualDescription) || undefined,
actionDescription: toText(item.actionDescription) || undefined,
sceneVisualDescription: toText(item.sceneVisualDescription) || undefined,
initialAffinity: normalizeInitialAffinity(
item.initialAffinity,
options.defaultAffinity,
),
relationshipHooks,
tags: normalizeTags(item.tags, relationshipHooks),
} satisfies CustomWorldGenerationRoleOutline;
})
.filter((entry) => entry.name);
return typeof options.maxCount === 'number'
? normalized.slice(0, options.maxCount)
: normalized;
}
export function normalizeCustomWorldGenerationRoleOutlineBatch(
raw: unknown,
roleType: CustomWorldGenerationRoleBatchType,
) {
const item =
raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs';
return normalizeRoleOutlineList(item[key], {
titleFallback: '未定称号',
defaultAffinity:
roleType === 'playable'
? DEFAULT_PLAYABLE_INITIAL_AFFINITY
: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
});
}
export function normalizeCustomWorldGenerationFrameworkRoles(params: {
raw: Record<string, unknown>;
fallback: CustomWorldProfile;
settingText: string;
}) {
const worldSignalText = [
params.settingText,
toText(params.raw.subtitle),
toText(params.raw.summary),
toText(params.raw.tone),
toText(params.raw.playerGoal),
].join(' ');
const templateWorldType = normalizeWorldType(
params.raw.templateWorldType,
worldSignalText,
);
const name =
toText(params.raw.name) || buildWorldName(params.settingText, templateWorldType);
return {
name,
templateWorldType,
playableNpcs: normalizeRoleOutlineList(params.raw.playableNpcs, {
titleFallback: '未定称号',
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
maxCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
}),
storyNpcs: normalizeRoleOutlineList(params.raw.storyNpcs, {
titleFallback: '未定称号',
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
maxCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
}),
campFallbackProfile: {
name,
summary: toText(params.raw.summary) || params.fallback.summary,
tone: toText(params.raw.tone) || params.fallback.tone,
playerGoal: toText(params.raw.playerGoal) || params.fallback.playerGoal,
settingText: params.settingText.trim(),
},
};
}
export function buildCustomWorldRawProfileRolesFromFramework(
framework: CustomWorldGenerationFramework,
) {
return {
playableNpcs: framework.playableNpcs.map((npc) => ({
name: npc.name,
title: npc.title,
role: npc.role,
description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
})),
storyNpcs: framework.storyNpcs.map((npc) => ({
name: npc.name,
title: npc.title,
role: npc.role,
description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
})),
};
}
function normalizeRoleProfile(
item: Record<string, unknown>,
index: number,
options: {
idPrefix: 'playable-npc' | 'story-npc';
titleFallback: string;
defaultAffinity: number;
},
) {
const name = toText(item.name);
const title =
toText(item.title) || toText(item.role) || options.titleFallback;
const role = toText(item.role) || title;
const relationshipHooks = normalizeTags(
item.relationshipHooks,
normalizeTags(item.tags),
);
const normalizedRole = {
id: toText(item.id) || createEntryId(options.idPrefix, name, index),
name,
title,
role,
description: toText(item.description),
visualDescription: toText(item.visualDescription) || undefined,
actionDescription: toText(item.actionDescription) || undefined,
sceneVisualDescription: toText(item.sceneVisualDescription) || undefined,
backstory: toText(item.backstory),
personality: toText(item.personality),
motivation: toText(item.motivation) || toText(item.description),
combatStyle: toText(item.combatStyle),
initialAffinity: normalizeInitialAffinity(
item.initialAffinity,
options.defaultAffinity,
),
relationshipHooks,
tags: normalizeTags(item.tags, relationshipHooks),
};
return {
...normalizedRole,
backstoryReveal: normalizeBackstoryReveal(
item.backstoryReveal,
normalizedRole,
),
skills: normalizeRoleSkillList(item.skills, normalizedRole),
initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole),
imageSrc: toText(item.imageSrc) || undefined,
generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined,
generatedAnimationSetId:
toText(item.generatedAnimationSetId) || undefined,
animationMap:
item.animationMap && typeof item.animationMap === 'object'
? (item.animationMap as Record<string, unknown>)
: undefined,
narrativeProfile:
item.narrativeProfile && typeof item.narrativeProfile === 'object'
? (item.narrativeProfile as CustomWorldRoleProfile['narrativeProfile'])
: null,
};
}
export function normalizePlayableNpcList(value: unknown) {
return toRecordArray(value)
.map((item, index) => ({
...normalizeRoleProfile(item, index, {
idPrefix: 'playable-npc',
titleFallback: '未定称号',
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
}),
templateCharacterId: toText(item.templateCharacterId) || undefined,
}))
.filter((entry) => entry.name)
.slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT);
}
export function normalizeStoryNpcList(value: unknown) {
return toRecordArray(value)
.map(
(item, index) =>
({
...normalizeRoleProfile(item, index, {
idPrefix: 'story-npc',
titleFallback: '未定称号',
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
}),
visual:
item.visual && typeof item.visual === 'object'
? (item.visual as Record<string, unknown>)
: undefined,
}) satisfies CustomWorldNpc,
)
.filter((entry) => entry.name);
}

View File

@@ -0,0 +1,123 @@
import type { SceneActBlueprint, SceneChapterBlueprint } from '../runtimeTypes.js';
import { createEntryId, toRecordArray, toStringArray, toText } from './normalizeShared.js';
/**
* 工作包 G
* 分幕与场景章节 blueprint 归一独立收口,让结果预览编译层只消费稳定的章节结构。
*/
const SCENE_ACT_STAGES = new Set([
'opening',
'expansion',
'turning_point',
'climax',
'aftermath',
]);
const SCENE_ACT_ADVANCE_RULES = new Set([
'after_primary_contact',
'after_active_step_complete',
'after_chapter_resolution',
]);
function normalizeSceneActStageCoverage(value: unknown) {
const stageCoverage = Array.isArray(value)
? value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry): entry is SceneActBlueprint['stageCoverage'][number] =>
SCENE_ACT_STAGES.has(entry as never),
)
: [];
return [...new Set(stageCoverage)];
}
function normalizeSceneActBlueprint(
value: unknown,
index: number,
sceneId: string,
): SceneActBlueprint | null {
const item =
value && typeof value === 'object'
? (value as Record<string, unknown>)
: null;
if (!item) {
return null;
}
const encounterNpcIds = toStringArray(item.encounterNpcIds);
const stageCoverage = normalizeSceneActStageCoverage(item.stageCoverage);
const advanceRule = toText(item.advanceRule);
const title = toText(item.title);
const summary = toText(item.summary);
if (!title && !summary && encounterNpcIds.length === 0) {
return null;
}
return {
id:
toText(item.id) ||
createEntryId(`saved-scene-act-${sceneId}`, title || sceneId, index),
sceneId,
title: title || `${index + 1}`,
summary: summary || title || `围绕${sceneId}继续推进`,
stageCoverage:
stageCoverage.length > 0
? stageCoverage
: index === 0
? ['opening']
: ['climax', 'aftermath'],
backgroundImageSrc: toText(item.backgroundImageSrc) || undefined,
backgroundAssetId: toText(item.backgroundAssetId) || undefined,
encounterNpcIds,
primaryNpcId: toText(item.primaryNpcId) || encounterNpcIds[0] || '',
linkedThreadIds: toStringArray(item.linkedThreadIds),
advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never)
? (advanceRule as SceneActBlueprint['advanceRule'])
: 'after_active_step_complete',
actGoal: toText(item.actGoal),
transitionHook: toText(item.transitionHook),
};
}
export function normalizeSceneChapterBlueprints(value: unknown) {
if (!Array.isArray(value)) {
return null;
}
const normalized = value
.filter(
(entry): entry is Record<string, unknown> =>
Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry),
)
.map((entry, index) => {
const sceneId = toText(entry.sceneId);
if (!sceneId) {
return null;
}
const acts = Array.isArray(entry.acts)
? entry.acts
.map((act, actIndex) =>
normalizeSceneActBlueprint(act, actIndex, sceneId),
)
.filter((act): act is SceneActBlueprint => Boolean(act))
: [];
return {
id:
toText(entry.id) ||
createEntryId('saved-scene-chapter', sceneId, index),
sceneId,
title: toText(entry.title) || toText(entry.sceneName) || sceneId,
summary: toText(entry.summary),
linkedThreadIds: toStringArray(entry.linkedThreadIds),
linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds),
acts,
} satisfies SceneChapterBlueprint;
})
.filter((entry): entry is SceneChapterBlueprint => Boolean(entry));
return normalized.length > 0 ? normalized : null;
}

View File

@@ -0,0 +1,248 @@
import type {
CustomWorldCoverProfile,
CustomWorldCoverSourceType,
CustomWorldItem,
CustomWorldPlayableNpc,
} from '../runtimeTypes.js';
/**
* 工作包 G
* 把 runtime profile 编译流程里的通用常量、基础文本归一和通用小型归一逻辑收口到共享模块,
* 让角色、地点、场景章节和主编译入口都不再重复维护这些基础能力。
*/
const MIN_CUSTOM_WORLD_AFFINITY = -40;
const MAX_CUSTOM_WORLD_AFFINITY = 90;
const CUSTOM_WORLD_RARITIES = [
'common',
'uncommon',
'rare',
'epic',
'legendary',
] as const;
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = [
'武器',
'护甲',
'饰品',
'消耗品',
'材料',
'稀有品',
'专属物品',
'专属物',
] as const;
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10;
export const MIN_CUSTOM_WORLD_STORY_NPC_COUNT = Math.max(
0,
MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
);
export const PLAYABLE_TEMPLATE_CHARACTER_IDS = [
'sword-princess',
'archer-hero',
'girl-hero',
'punch-hero',
'fighter-4',
] as const;
export function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
export function toFiniteInteger(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? Math.round(value)
: undefined;
}
export function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function toRecordArray(value: unknown) {
return Array.isArray(value)
? (value.filter((item) => item && typeof item === 'object') as Array<
Record<string, unknown>
>)
: [];
}
export function toStringArray(value: unknown, nestedKey?: string) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => {
if (typeof item === 'string') {
return item.trim();
}
if (nestedKey && item && typeof item === 'object') {
return toText((item as Record<string, unknown>)[nestedKey]);
}
return '';
})
.filter(Boolean);
}
export function normalizeTags(value: unknown, fallbackTags: string[] = []) {
const tags = Array.isArray(value)
? value.map((item) => toText(item)).filter(Boolean)
: [];
return [
...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)),
].slice(0, 5);
}
export function clampText(value: string, maxLength: number) {
const normalized = value.trim().replace(/\s+/g, ' ');
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
export function slugify(value: string) {
const ascii = value
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
return ascii ? ascii.slice(0, 24) : 'entry';
}
export function createEntryId(prefix: string, label: string, index: number) {
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
}
export function clampCustomWorldAffinity(value: number) {
return Math.max(
MIN_CUSTOM_WORLD_AFFINITY,
Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)),
);
}
export function normalizeInitialAffinity(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value)
? clampCustomWorldAffinity(value)
: fallback;
}
export function normalizeRarity(
value: unknown,
fallback: (typeof CUSTOM_WORLD_RARITIES)[number] = 'rare',
) {
const rarity = toText(value).toLowerCase();
return CUSTOM_WORLD_RARITIES.includes(
rarity as (typeof CUSTOM_WORLD_RARITIES)[number],
)
? (rarity as (typeof CUSTOM_WORLD_RARITIES)[number])
: fallback;
}
export function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
const category = toText(value);
if (
(CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category)
) {
return category === '专属物' ? '专属物品' : category;
}
if (/||||||/u.test(category)) return '';
if (/||||/u.test(category)) return '';
if (/|||||/u.test(category)) return '';
if (/||||/u.test(category)) return '';
if (/|||||/u.test(category)) return '';
if (/|||||/u.test(category)) return '';
if (/||||/u.test(category)) return '';
return fallback;
}
export function normalizeCustomWorldCoverCharacterRoleIds(
value: unknown,
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
) {
const availableIds = new Set(
playableNpcs.map((entry) => entry.id.trim()).filter(Boolean),
);
const selectedIds = Array.isArray(value)
? [
...new Set(
value
.map((entry) => toText(entry))
.filter((entry) => entry && availableIds.has(entry)),
),
].slice(0, 3)
: [];
if (selectedIds.length > 0) {
return selectedIds;
}
return playableNpcs
.map((entry) => entry.id.trim())
.filter(Boolean)
.slice(0, 3);
}
export function buildDefaultCustomWorldCover(
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
): CustomWorldCoverProfile {
return {
sourceType: 'default' as const,
imageSrc: null,
characterRoleIds: normalizeCustomWorldCoverCharacterRoleIds(
undefined,
playableNpcs,
),
};
}
export function normalizeCustomWorldCover(
value: unknown,
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
): CustomWorldCoverProfile {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return buildDefaultCustomWorldCover(playableNpcs);
}
const item = value as Record<string, unknown>;
const sourceType: CustomWorldCoverSourceType =
item.sourceType === 'uploaded' || item.sourceType === 'generated'
? item.sourceType
: 'default';
const imageSrc = toText(item.imageSrc) || null;
if (sourceType !== 'default' && imageSrc) {
return {
sourceType,
imageSrc,
characterRoleIds: [],
};
}
return buildDefaultCustomWorldCover(playableNpcs);
}
export function normalizeItemList(value: unknown) {
return toRecordArray(value)
.map((item, index) => {
const name = toText(item.name);
const category = toText(item.category);
return {
id: toText(item.id) || createEntryId('item', name, index),
name,
category,
rarity: normalizeRarity(item.rarity, 'rare'),
description: toText(item.description),
tags: normalizeTags(item.tags),
} satisfies CustomWorldItem;
})
.filter((entry) => entry.name && entry.category);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
export * from './inventoryMutationService.js';

View File

@@ -1,7 +1,7 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
getPlayerBuildDamageBreakdown,
@@ -19,8 +19,8 @@ import {
} from './inventoryMutationService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
const SUPPORTED_INVENTORY_STORY_FUNCTION_IDS = new Set<string>([
'equipment_equip',

View File

@@ -1,7 +1,7 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
addInventoryItems,
@@ -23,8 +23,8 @@ import {
} from '../../bridges/legacyNpcTask6Bridge.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
const SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS = new Set<string>([
'npc_gift',

View File

@@ -1,6 +1,10 @@
import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/story.js';
import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict } from '../../errors.js';
import { resolveHostileBattleProfile } from '../progression/hostileProgressionService.js';
import {
applyStoryChoiceToStanceProfile,
} from './npcTask6Primitives.js';
import { markNpcFirstMeaningfulContactResolved } from '../runtime/runtimeNpcStatePrimitives.js';
import {
MAX_TASK5_COMPANIONS,
getEncounterNpcState,
@@ -8,7 +12,7 @@ import {
type RuntimeEncounter,
type RuntimeNpcState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js';
type JsonRecord = Record<string, unknown>;
@@ -57,6 +61,158 @@ function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function buildRecruitedCompanion(
session: RuntimeSession,
encounter: RuntimeEncounter,
npcState: RuntimeNpcState,
) {
const rawCompanionSource = isRecord(session.rawGameState.currentEncounter)
? session.rawGameState.currentEncounter
: {};
const maxHp = Math.max(
1,
Math.round(
typeof rawCompanionSource.maxHp === 'number' &&
Number.isFinite(rawCompanionSource.maxHp)
? rawCompanionSource.maxHp
: 180,
),
);
const maxMana = Math.max(
1,
Math.round(
typeof rawCompanionSource.maxMana === 'number' &&
Number.isFinite(rawCompanionSource.maxMana)
? rawCompanionSource.maxMana
: 999,
),
);
const skillCooldowns = Object.fromEntries(
Object.entries(
isRecord(rawCompanionSource.skillCooldowns)
? rawCompanionSource.skillCooldowns
: {},
).map(([skillId, turns]) => [
skillId,
typeof turns === 'number' && Number.isFinite(turns)
? Math.max(0, Math.round(turns))
: 0,
]),
);
return {
npcId: encounter.id,
characterId: encounter.characterId ?? '',
joinedAtAffinity: npcState.affinity,
hp: maxHp,
maxHp,
mana: maxMana,
maxMana,
skillCooldowns,
animationState: readString(rawCompanionSource.animationState) || 'idle',
actionMode: readString(rawCompanionSource.actionMode) || 'idle',
offsetX:
typeof rawCompanionSource.offsetX === 'number' &&
Number.isFinite(rawCompanionSource.offsetX)
? rawCompanionSource.offsetX
: 0,
offsetY:
typeof rawCompanionSource.offsetY === 'number' &&
Number.isFinite(rawCompanionSource.offsetY)
? rawCompanionSource.offsetY
: 0,
transitionMs:
typeof rawCompanionSource.transitionMs === 'number' &&
Number.isFinite(rawCompanionSource.transitionMs)
? Math.max(0, Math.round(rawCompanionSource.transitionMs))
: 0,
};
}
function upsertCompanion(
list: RuntimeSession['companions'],
companion: RuntimeSession['companions'][number],
) {
const next = [...list];
const existingIndex = next.findIndex((item) => item.npcId === companion.npcId);
if (existingIndex >= 0) {
next[existingIndex] = companion;
return next;
}
next.push(companion);
return next;
}
function removeCompanion(
list: RuntimeSession['companions'],
npcId: string,
) {
return list.filter((item) => item.npcId !== npcId);
}
function normalizeRoster(
roster: RuntimeSession['roster'],
activeCompanions: RuntimeSession['companions'],
) {
const activeIds = new Set(activeCompanions.map((companion) => companion.npcId));
return roster.filter((companion) => !activeIds.has(companion.npcId));
}
function recruitCompanionToParty(params: {
session: RuntimeSession;
companion: RuntimeSession['companions'][number];
releaseNpcId?: string | null;
}) {
const nextRosterWithoutRecruit = removeCompanion(
params.session.roster,
params.companion.npcId,
);
if (
!params.releaseNpcId &&
params.session.companions.length < MAX_TASK5_COMPANIONS
) {
return {
companions: [...params.session.companions, params.companion],
roster: nextRosterWithoutRecruit,
releasedCompanion: null,
};
}
if (!params.releaseNpcId) {
throw conflict('队伍已满时必须明确指定一名离队同伴');
}
const replaceIndex = params.session.companions.findIndex(
(item) => item.npcId === params.releaseNpcId,
);
if (replaceIndex < 0) {
throw conflict('指定的离队同伴不存在,无法完成换队招募');
}
const releasedCompanion = params.session.companions[replaceIndex];
if (!releasedCompanion) {
throw conflict('指定的离队同伴不存在,无法完成换队招募');
}
const nextCompanions = [...params.session.companions];
nextCompanions[replaceIndex] = params.companion;
return {
companions: nextCompanions,
roster: normalizeRoster(
upsertCompanion(nextRosterWithoutRecruit, releasedCompanion),
nextCompanions,
),
releasedCompanion,
};
}
function buildBattleTarget(
encounter: RuntimeEncounter,
rawGameState: JsonRecord,
@@ -92,6 +248,7 @@ function buildBattleTarget(
export function resolveNpcInteraction(
session: RuntimeSession,
functionId: string,
payload?: JsonRecord,
): NpcInteractionResolution {
const encounter = requireNpcEncounter(session);
const npcState = requireNpcState(session, encounter);
@@ -179,20 +336,29 @@ export function resolveNpcInteraction(
if (npcState.affinity < 60) {
throw conflict('当前关系还没达到招募阈值,暂时不能邀请入队');
}
if (session.companions.length >= MAX_TASK5_COMPANIONS) {
throw conflict('队伍已满任务5首轮后端接口暂不处理换队逻辑');
}
setEncounterNpcState(session, {
...npcState,
const releaseNpcId = readString(payload?.releaseNpcId) || null;
const recruitedCompanion = buildRecruitedCompanion(
session,
encounter,
npcState,
);
const recruitmentResult = recruitCompanionToParty({
session,
companion: recruitedCompanion,
releaseNpcId,
});
const nextNpcState = {
...markNpcFirstMeaningfulContactResolved(npcState),
recruited: true,
firstMeaningfulContactResolved: true,
});
session.companions.push({
npcId: encounter.id,
characterId: encounter.characterId ?? '',
joinedAtAffinity: npcState.affinity,
});
stanceProfile: applyStoryChoiceToStanceProfile(
npcState.stanceProfile,
'npc_recruit',
{ recruited: true },
),
};
setEncounterNpcState(session, nextNpcState);
session.companions = recruitmentResult.companions;
session.roster = recruitmentResult.roster;
session.currentEncounter = null;
session.npcInteractionActive = false;
session.currentNpcBattleMode = null;
@@ -202,7 +368,9 @@ export function resolveNpcInteraction(
return {
actionText: `邀请${encounter.npcName}加入队伍`,
resultText: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`,
resultText: recruitmentResult.releasedCompanion
? `${encounter.npcName}接受了你的邀请,你先让一名当前同行暂时离队,把位置腾给了新的同行者。`
: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`,
patches: [
{
type: 'status_changed',

View File

@@ -1,2 +0,0 @@
export * from './questProgressionService.js';
export { generateQuestForNpcEncounter } from '../../services/questService.js';

View File

@@ -1,12 +1,12 @@
import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/story.js';
import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import {
applyQuestSignal,
normalizeQuestEntries,
} from './questProgressionService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
type JsonRecord = Record<string, unknown>;
type RuntimeGameState = {

View File

@@ -1,7 +1,8 @@
import type {
RuntimeStoryOptionView,
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import {
buildExperienceGrantResultText,
grantPlayerExperience,
@@ -25,10 +26,13 @@ import {
} from './questTask6Bridge.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set<string>([
'npc_chat_quest_offer_abandon',
'npc_chat_quest_offer_replace',
'npc_chat_quest_offer_view',
'npc_quest_accept',
'npc_quest_turn_in',
]);
@@ -37,6 +41,9 @@ type QuestStoryResolution = {
actionText: string;
resultText: string;
patches: RuntimeStoryPatch[];
storyText?: string;
presentationOptions?: RuntimeStoryOptionView[];
savedCurrentStory?: JsonRecord;
};
type JsonRecord = Record<string, unknown>;
@@ -140,6 +147,144 @@ function readPendingQuestOffer(
return quest as RuntimeQuestLogEntry;
}
function readPendingQuestOfferContext(
currentStory: unknown,
npcKey: string,
) {
if (!isObject(currentStory)) {
return null;
}
const npcChatState = isObject(currentStory.npcChatState)
? currentStory.npcChatState
: null;
const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer)
? npcChatState.pendingQuestOffer
: null;
const quest = readPendingQuestOffer(currentStory, npcKey);
if (!quest) {
return null;
}
const dialogue = Array.isArray(currentStory.dialogue)
? currentStory.dialogue
.filter((entry) => isObject(entry))
.map((entry) => ({ ...entry }))
: [];
const turnCount =
typeof npcChatState?.turnCount === 'number' &&
Number.isFinite(npcChatState.turnCount)
? Math.max(0, Math.round(npcChatState.turnCount))
: 0;
const customInputPlaceholder =
readString(npcChatState?.customInputPlaceholder) || '输入你想对 TA 说的话';
return {
dialogue,
turnCount,
customInputPlaceholder,
quest,
introText: readString(pendingQuestOffer?.introText),
};
}
function buildNpcChatOption(
encounter: RuntimeEncounter,
actionText: string,
) {
return {
functionId: 'npc_chat',
actionText,
text: actionText,
detailText: '',
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} satisfies JsonRecord;
}
function buildPendingQuestOfferOptions(encounter: RuntimeEncounter) {
const npcId = encounter.id ?? encounter.npcName;
const buildOption = (
functionId:
| 'npc_chat_quest_offer_view'
| 'npc_chat_quest_offer_replace'
| 'npc_chat_quest_offer_abandon',
actionText: string,
action: 'quest_offer_view' | 'quest_offer_replace' | 'quest_offer_abandon',
) =>
({
functionId,
actionText,
text: actionText,
detailText: '',
interaction: {
kind: 'npc',
npcId,
action,
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
runtimePayload:
functionId === 'npc_chat_quest_offer_view'
? { npcChatQuestOfferAction: 'view' }
: functionId === 'npc_chat_quest_offer_replace'
? { npcChatQuestOfferAction: 'replace' }
: { npcChatQuestOfferAction: 'abandon' },
}) satisfies JsonRecord;
return [
buildOption('npc_chat_quest_offer_view', '查看任务', 'quest_offer_view'),
buildOption(
'npc_chat_quest_offer_replace',
'更换任务',
'quest_offer_replace',
),
buildOption(
'npc_chat_quest_offer_abandon',
'放弃任务',
'quest_offer_abandon',
),
];
}
function buildPostQuestOfferChatOptions(encounter: RuntimeEncounter) {
return [
'那先继续聊聊你刚才没说完的部分',
'除了委托,你对眼前局势还有什么判断',
'先把这附近真正危险的地方说清楚',
].map((actionText) => buildNpcChatOption(encounter, actionText));
}
function buildQuestOfferDialogueText(
encounter: RuntimeEncounter,
quest: RuntimeQuestLogEntry,
) {
const summaryText = readString(quest.summary) || readString(quest.description);
return `${encounter.npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${
summaryText
? `如果你愿意,我想把这件事正式交给你:${summaryText}`
: '如果你愿意,我想把眼前这件事正式交给你。'
}`;
}
function ensureEncounterQuestContext(session: RuntimeSession) {
const state = session.rawGameState as unknown as RuntimeGameState;
const encounter = getNpcEncounter(session, state);
@@ -225,6 +370,171 @@ function resolveQuestAcceptAction(
};
}
function resolveQuestOfferViewAction(
session: RuntimeSession,
currentStory?: unknown,
): QuestStoryResolution {
const { encounter, npcKey } = ensureEncounterQuestContext(session);
const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey);
if (!pendingOffer) {
throw conflict('当前没有待处理的委托可查看。');
}
return {
actionText: `查看${encounter.npcName}提出的委托`,
resultText: readString(pendingOffer.introText) || buildQuestOfferDialogueText(encounter, pendingOffer.quest),
patches: [],
};
}
function resolveQuestOfferReplaceAction(
session: RuntimeSession,
currentStory?: unknown,
): QuestStoryResolution {
const { state, encounter, npcKey } = ensureEncounterQuestContext(session);
const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey);
if (!pendingOffer) {
throw conflict('当前没有待处理的委托可更换。');
}
const nextQuest = buildQuestForEncounter({
issuerNpcId: npcKey,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
context: {
worldType: state.worldType,
recentStoryMoments: Array.isArray(state.storyHistory)
? state.storyHistory.slice(-6)
: [],
playerCharacter: state.playerCharacter ?? null,
playerProgression: state.playerProgression ?? null,
},
currentQuests: (Array.isArray(state.quests) ? state.quests : []).map((item) => ({
id: item.id,
issuerNpcId: item.issuerNpcId,
status: item.status,
})),
});
if (!nextQuest) {
throw conflict('当前没有更合适的委托可供更换。');
}
const dialogue = [
...pendingOffer.dialogue,
{
speaker: 'player',
text: '能不能换一份更适合眼下局势的委托?',
},
{
speaker: 'npc',
speakerName: encounter.npcName,
text: buildQuestOfferDialogueText(encounter, nextQuest),
},
];
return {
actionText: `${encounter.npcName}更换委托`,
resultText: buildQuestOfferDialogueText(encounter, nextQuest),
storyText: buildQuestOfferDialogueText(encounter, nextQuest),
savedCurrentStory: {
text: dialogue
.map((entry) => readString(entry.text))
.filter(Boolean)
.join('\n'),
options: buildPendingQuestOfferOptions(encounter),
displayMode: 'dialogue',
dialogue,
streaming: false,
npcChatState: {
npcId: npcKey,
npcName: encounter.npcName,
turnCount: pendingOffer.turnCount,
customInputPlaceholder: pendingOffer.customInputPlaceholder,
pendingQuestOffer: {
quest: nextQuest,
},
},
},
presentationOptions: buildPendingQuestOfferOptions(encounter).map((option) => ({
functionId: readString(option.functionId),
actionText: readString(option.actionText),
detailText: '',
scope: 'npc',
interaction: isObject(option.interaction)
? (option.interaction as RuntimeStoryOptionView['interaction'])
: undefined,
payload: isObject(option.runtimePayload)
? (option.runtimePayload as Record<string, unknown>)
: undefined,
})),
patches: [],
};
}
function resolveQuestOfferAbandonAction(
session: RuntimeSession,
currentStory?: unknown,
): QuestStoryResolution {
const { encounter, npcKey } = ensureEncounterQuestContext(session);
const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey);
if (!pendingOffer) {
throw conflict('当前没有待处理的委托可放弃。');
}
const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`;
const dialogue = [
...pendingOffer.dialogue,
{
speaker: 'player',
text: '这件事我先不接,咱们还是先聊别的。',
},
{
speaker: 'npc',
speakerName: encounter.npcName,
text: npcReply,
},
];
return {
actionText: `暂不接受${encounter.npcName}的委托`,
resultText: npcReply,
storyText: npcReply,
savedCurrentStory: {
text: dialogue
.map((entry) => readString(entry.text))
.filter(Boolean)
.join('\n'),
options: buildPostQuestOfferChatOptions(encounter),
displayMode: 'dialogue',
dialogue,
streaming: false,
npcChatState: {
npcId: npcKey,
npcName: encounter.npcName,
turnCount: pendingOffer.turnCount,
customInputPlaceholder: pendingOffer.customInputPlaceholder,
pendingQuestOffer: null,
},
},
presentationOptions: buildPostQuestOfferChatOptions(encounter).map((option) => ({
functionId: readString(option.functionId),
actionText: readString(option.actionText),
detailText: '',
scope: 'npc',
interaction: isObject(option.interaction)
? (option.interaction as RuntimeStoryOptionView['interaction'])
: undefined,
payload: isObject(option.runtimePayload)
? (option.runtimePayload as Record<string, unknown>)
: undefined,
})),
patches: [],
};
}
function resolveQuestTurnInAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
@@ -311,6 +621,12 @@ export function resolveQuestStoryAction(
} = {},
): QuestStoryResolution {
switch (request.action.functionId) {
case 'npc_chat_quest_offer_view':
return resolveQuestOfferViewAction(session, options.currentStory);
case 'npc_chat_quest_offer_replace':
return resolveQuestOfferReplaceAction(session, options.currentStory);
case 'npc_chat_quest_offer_abandon':
return resolveQuestOfferAbandonAction(session, options.currentStory);
case 'npc_quest_accept':
return resolveQuestAcceptAction(session, options.currentStory);
case 'npc_quest_turn_in':

View File

@@ -4,7 +4,7 @@ import {
QUEST_OBJECTIVE_KINDS,
QUEST_REWARD_THEMES,
QUEST_URGENCY_LEVELS,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
import {
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,

View File

@@ -0,0 +1,14 @@
import {
buildAvailableOptions,
buildLegacyCurrentStory,
buildRuntimeViewModel,
} from './RpgRuntimeSessionDomain.js';
/**
* RPG runtime option / view model 编译入口。
* 工作包 G 后所有可见 option 与 view model 都从新域目录输出。
*/
export { buildAvailableOptions, buildRuntimeViewModel };
export const buildRpgRuntimeAvailableOptions = buildAvailableOptions;
export const buildRpgRuntimeViewModel = buildRuntimeViewModel;
export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory;

View File

@@ -3,9 +3,13 @@ import test from 'node:test';
import {
buildAvailableOptions,
} from './RpgRuntimeOptionCompiler.js';
import {
buildLegacyCurrentStory,
} from './RpgRuntimeStoryPresentationCompiler.js';
import {
loadRuntimeSession,
} from './runtimeSession.ts';
} from './RpgRuntimeSessionLoader.js';
function createNpcSnapshot() {
return {

View File

@@ -1,3 +1,7 @@
/**
* RPG runtime session
* G `runtimeSession.ts`
*/
import type {
RuntimeStoryChoicePayload,
RuntimeStoryEncounterViewModel,
@@ -5,9 +9,9 @@ import type {
RuntimeStoryOptionView,
RuntimeStoryViewModel,
Task5RuntimeOptionScope,
} from '../../../../packages/shared/src/contracts/story.js';
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js';
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js';
import type { RpgRuntimeSavedSnapshot } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js';
import {
normalizeRuntimeEntityLevelProfile,
type RuntimeEntityLevelProfile,
@@ -75,6 +79,16 @@ export type RuntimeCompanion = {
npcId: string;
characterId: string;
joinedAtAffinity: number;
hp: number;
maxHp: number;
mana: number;
maxMana: number;
skillCooldowns: Record<string, number>;
animationState?: string;
actionMode?: string;
offsetX?: number;
offsetY?: number;
transitionMs?: number;
};
type RuntimePlayerAttributes = {
@@ -146,6 +160,7 @@ export type RuntimeSession = {
playerMaxMana: number;
npcStates: Record<string, RuntimeNpcState>;
companions: RuntimeCompanion[];
roster: RuntimeCompanion[];
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
};
@@ -511,6 +526,53 @@ function normalizeCompanion(value: unknown): RuntimeCompanion | null {
npcId,
characterId: readString(rawCompanion.characterId),
joinedAtAffinity: Math.round(readNumber(rawCompanion.joinedAtAffinity, 0)),
hp: Math.max(
0,
Math.round(
readNumber(
rawCompanion.hp,
readNumber(rawCompanion.maxHp, 1),
),
),
),
maxHp: Math.max(1, Math.round(readNumber(rawCompanion.maxHp, 1))),
mana: Math.max(
0,
Math.round(
readNumber(
rawCompanion.mana,
readNumber(rawCompanion.maxMana, 1),
),
),
),
maxMana: Math.max(1, Math.round(readNumber(rawCompanion.maxMana, 1))),
skillCooldowns: Object.fromEntries(
Object.entries(
isObject(rawCompanion.skillCooldowns)
? rawCompanion.skillCooldowns
: {},
).map(([skillId, turns]) => [
skillId,
Math.max(0, Math.round(readNumber(turns, 0))),
]),
),
animationState: readString(rawCompanion.animationState) || undefined,
actionMode: readString(rawCompanion.actionMode) || undefined,
offsetX:
typeof rawCompanion.offsetX === 'number' &&
Number.isFinite(rawCompanion.offsetX)
? rawCompanion.offsetX
: undefined,
offsetY:
typeof rawCompanion.offsetY === 'number' &&
Number.isFinite(rawCompanion.offsetY)
? rawCompanion.offsetY
: undefined,
transitionMs:
typeof rawCompanion.transitionMs === 'number' &&
Number.isFinite(rawCompanion.transitionMs)
? Math.max(0, Math.round(rawCompanion.transitionMs))
: undefined,
};
}
@@ -531,6 +593,15 @@ function normalizeCompanions(value: unknown) {
.filter((entry): entry is RuntimeCompanion => Boolean(entry));
}
function normalizeRoster(
roster: RuntimeCompanion[],
companions: RuntimeCompanion[],
) {
const activeNpcIds = new Set(companions.map((companion) => companion.npcId));
return roster.filter((companion) => !activeNpcIds.has(companion.npcId));
}
function normalizeHostileNpcs(value: unknown) {
return readArray(value)
.map((entry) => normalizeHostileNpc(entry))
@@ -738,6 +809,21 @@ function buildOptionInteraction(
npc_spar: { kind: 'npc', npcId, action: 'spar' },
npc_trade: { kind: 'npc', npcId, action: 'trade' },
npc_gift: { kind: 'npc', npcId, action: 'gift' },
npc_chat_quest_offer_view: {
kind: 'npc',
npcId,
action: 'quest_offer_view',
},
npc_chat_quest_offer_replace: {
kind: 'npc',
npcId,
action: 'quest_offer_replace',
},
npc_chat_quest_offer_abandon: {
kind: 'npc',
npcId,
action: 'quest_offer_abandon',
},
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
};
@@ -929,7 +1015,7 @@ export function getEncounterKey(encounter: RuntimeEncounter) {
}
export function loadRuntimeSession(
snapshot: SavedSnapshot,
snapshot: RpgRuntimeSavedSnapshot,
requestedSessionId: string,
): RuntimeSession {
const rawGameState = isObject(snapshot.gameState)
@@ -967,6 +1053,10 @@ export function loadRuntimeSession(
),
npcStates: normalizeNpcStates(rawGameState.npcStates),
companions: normalizeCompanions(rawGameState.companions),
roster: normalizeRoster(
normalizeCompanions(rawGameState.roster),
normalizeCompanions(rawGameState.companions),
),
currentNpcBattleMode:
rawGameState.currentNpcBattleMode === 'fight' ||
rawGameState.currentNpcBattleMode === 'spar'
@@ -1170,16 +1260,7 @@ export function buildAvailableOptions(session: RuntimeSession) {
if (npcState && !npcState.recruited && npcState.affinity >= 60) {
options.push(
buildOptionView(
session,
'npc_recruit',
session.companions.length >= MAX_TASK5_COMPANIONS
? {
disabled: true,
reason: '队伍已满任务5首轮后端接口暂不处理换队逻辑。',
}
: {},
),
buildOptionView(session, 'npc_recruit'),
);
}
@@ -1313,6 +1394,7 @@ export function syncRawGameState(session: RuntimeSession) {
session.rawGameState.playerMaxMana = session.playerMaxMana;
session.rawGameState.npcStates = cloneJson(session.npcStates);
session.rawGameState.companions = cloneJson(session.companions);
session.rawGameState.roster = cloneJson(session.roster);
session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode;
session.rawGameState.currentNpcBattleOutcome =
session.currentNpcBattleOutcome;
@@ -1352,6 +1434,7 @@ export function replaceRuntimeSessionRawGameState(
session.playerMaxMana = refreshed.playerMaxMana;
session.npcStates = refreshed.npcStates;
session.companions = refreshed.companions;
session.roster = refreshed.roster;
session.currentNpcBattleMode = refreshed.currentNpcBattleMode;
session.currentNpcBattleOutcome = refreshed.currentNpcBattleOutcome;
}

View File

@@ -0,0 +1,13 @@
import {
loadRuntimeSession,
type RuntimeSession,
} from './RpgRuntimeSessionDomain.js';
export type { RuntimeSession };
/**
* RPG runtime session loader 的主入口。
* 工作包 G 把旧 `runtimeSession.ts` 的真实实现迁入新域后,这里负责承接稳定命名。
*/
export { loadRuntimeSession };
export const loadRpgRuntimeSession = loadRuntimeSession;

View File

@@ -0,0 +1,29 @@
/**
* RPG runtime session 原子能力导出。
* 这里集中输出运行时动作链直接依赖的 session 原语,避免再次回到旧热点文件取用。
*/
export {
appendStoryHistory,
getEncounterKey,
getEncounterNpcState,
getPlayerCharacter,
getPlayerSkillCooldowns,
isCombatFunctionId,
isNpcFunctionId,
isStoryFunctionId,
isTask5FunctionId,
isTask6RuntimeFunctionId,
MAX_TASK5_COMPANIONS,
setEncounterNpcState,
syncRawGameState,
TASK6_DEFERRED_FUNCTION_IDS,
} from './RpgRuntimeSessionDomain.js';
export type {
RuntimeCompanion,
RuntimeEncounter,
RuntimeHostileNpc,
RuntimeNpcState,
RuntimeSession,
RuntimeStoryHistoryEntry,
} from './RpgRuntimeSessionDomain.js';

View File

@@ -0,0 +1,13 @@
import {
replaceRuntimeSessionRawGameState,
syncRawGameState,
} from './RpgRuntimeSessionDomain.js';
/**
* RPG runtime snapshot 同步入口。
* 工作包 G 后 rawGameState 的回写与替换都统一从新域目录输出。
*/
export { replaceRuntimeSessionRawGameState, syncRawGameState };
export const syncRpgRuntimeSnapshot = syncRawGameState;
export const replaceRpgRuntimeSessionRawGameState =
replaceRuntimeSessionRawGameState;

View File

@@ -1,12 +1,17 @@
/**
* RPG runtime story /
* G RPG runtime story
*/
import type {
RuntimeBattlePresentation,
RuntimeStoryActionRequest,
RuntimeStoryActionResponse,
RuntimeStoryOptionView,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
RuntimeStoryStateRequest,
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict, invalidRequest } from '../../errors.js';
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js';
import type { RpgRuntimeSnapshotRepositoryPort } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
import {
buildStrictNpcChatDialoguePrompt,
@@ -38,27 +43,35 @@ import {
resolveTreasureStoryAction,
} from '../runtime-item/treasureStoryActionService.js';
import {
appendStoryHistory,
buildAvailableOptions,
buildLegacyCurrentStory,
buildRuntimeViewModel,
} from './RpgRuntimeOptionCompiler.js';
import {
appendStoryHistory,
getEncounterNpcState,
isCombatFunctionId,
isNpcFunctionId,
isStoryFunctionId,
isTask5FunctionId,
loadRuntimeSession,
type RuntimeSession,
setEncounterNpcState,
syncRawGameState,
TASK6_DEFERRED_FUNCTION_IDS,
} from './runtimeSession.js';
} from './RpgRuntimeSessionPrimitives.js';
import {
buildLegacyCurrentStory,
} from './RpgRuntimeStoryPresentationCompiler.js';
import {
loadRuntimeSession,
type RuntimeSession,
} from './RpgRuntimeSessionLoader.js';
type StoryResolution = {
actionText: string;
resultText: string;
patches: RuntimeStoryPatch[];
storyText?: string;
presentationOptions?: RuntimeStoryOptionView[];
savedCurrentStory?: JsonRecord;
battle?: RuntimeBattlePresentation | null;
toast?: string | null;
};
@@ -604,6 +617,53 @@ function readSavedStoryText(currentStory: unknown) {
return '';
}
function normalizeIncomingSnapshot(snapshot: unknown) {
if (!isObject(snapshot)) {
return null;
}
const gameState = 'gameState' in snapshot ? snapshot.gameState : null;
const bottomTab = readString(snapshot.bottomTab) || 'adventure';
const currentStory = 'currentStory' in snapshot ? snapshot.currentStory : null;
const savedAt = readString(snapshot.savedAt) || new Date().toISOString();
if (!gameState || !isObject(gameState)) {
return null;
}
return normalizeSavedSnapshotPayload({
savedAt,
bottomTab,
gameState,
currentStory: currentStory ?? null,
});
}
async function resolveSnapshotForRequest(params: {
snapshotRepository: RpgRuntimeSnapshotRepositoryPort;
userId: string;
snapshot?: unknown;
}) {
const incomingSnapshot = normalizeIncomingSnapshot(params.snapshot);
if (incomingSnapshot) {
return hydrateSavedSnapshot(
await params.snapshotRepository.putSnapshot(
params.userId,
incomingSnapshot,
),
)!;
}
const persistedSnapshot = await params.snapshotRepository.getSnapshot(
params.userId,
);
if (!persistedSnapshot) {
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
}
return hydrateSavedSnapshot(persistedSnapshot)!;
}
function buildFallbackStoryText(session: RuntimeSession) {
if (session.inBattle && session.sceneHostileNpcs.length > 0) {
return `眼前的冲突还没结束,${session.sceneHostileNpcs[0]!.name}仍在逼你立刻做出下一步判断。`;
@@ -855,16 +915,16 @@ function resolveStoryFlowAction(
}
export async function resolveRuntimeStoryAction(params: {
runtimeRepository: RuntimeRepositoryPort;
snapshotRepository: RpgRuntimeSnapshotRepositoryPort;
llmClient?: UpstreamLlmClient;
userId: string;
request: RuntimeStoryActionRequest;
}) {
const snapshot = await params.runtimeRepository.getSnapshot(params.userId);
if (!snapshot) {
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
}
const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!;
const hydratedSnapshot = await resolveSnapshotForRequest({
snapshotRepository: params.snapshotRepository,
userId: params.userId,
snapshot: params.request.snapshot,
});
const functionId =
typeof params.request.action.functionId === 'string'
@@ -924,7 +984,13 @@ export async function resolveRuntimeStoryAction(params: {
: undefined,
});
} else if (isNpcFunctionId(functionId)) {
resolution = resolveNpcInteraction(session, functionId);
resolution = resolveNpcInteraction(
session,
functionId,
isObject(params.request.action.payload)
? params.request.action.payload
: undefined,
);
} else if (isSupportedInventoryStoryFunctionId(functionId)) {
resolution = resolveInventoryStoryAction(session, params.request);
} else if (isSupportedNpcInventoryStoryFunctionId(functionId)) {
@@ -968,6 +1034,12 @@ export async function resolveRuntimeStoryAction(params: {
storyText,
options,
);
if (resolution.presentationOptions?.length) {
options = resolution.presentationOptions;
}
if (resolution.savedCurrentStory) {
savedCurrentStory = resolution.savedCurrentStory;
}
const pendingQuestAcceptedCurrentStory =
functionId === 'npc_quest_accept'
? buildPendingQuestAcceptedCurrentStory({
@@ -1023,7 +1095,7 @@ export async function resolveRuntimeStoryAction(params: {
appendStoryHistory(session, actionText, historyResultText);
syncRawGameState(session);
const persistedSnapshot = await params.runtimeRepository.putSnapshot(
const persistedSnapshot = await params.snapshotRepository.putSnapshot(
params.userId,
normalizeSavedSnapshotPayload({
savedAt: new Date().toISOString(),
@@ -1058,17 +1130,28 @@ export async function resolveRuntimeStoryAction(params: {
}
export async function getRuntimeStoryState(params: {
runtimeRepository: RuntimeRepositoryPort;
snapshotRepository: RpgRuntimeSnapshotRepositoryPort;
userId: string;
sessionId: string;
clientVersion?: number;
snapshot?: RuntimeStoryStateRequest['snapshot'];
}) {
const snapshot = await params.runtimeRepository.getSnapshot(params.userId);
if (!snapshot) {
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
}
const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!;
const hydratedSnapshot = await resolveSnapshotForRequest({
snapshotRepository: params.snapshotRepository,
userId: params.userId,
snapshot: params.snapshot,
});
const session = loadRuntimeSession(hydratedSnapshot, params.sessionId);
if (
typeof params.clientVersion === 'number' &&
params.clientVersion !== session.runtimeVersion
) {
throw conflict('运行时版本已变化,请先同步最新快照后再读取状态', {
clientVersion: params.clientVersion,
serverVersion: session.runtimeVersion,
});
}
ensureNpcInventorySessionState(session);
const options = buildAvailableOptions(session);
const storyText =

View File

@@ -0,0 +1,10 @@
import {
resolveRuntimeStoryAction,
} from './RpgRuntimeStoryActionDomain.js';
/**
* RPG runtime story 动作服务入口。
* 工作包 G 后 runtime action 主链直接落到新域实现,不再继续桥接旧热点文件。
*/
export { resolveRuntimeStoryAction };
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;

View File

@@ -0,0 +1,8 @@
import { buildLegacyCurrentStory } from './RpgRuntimeSessionDomain.js';
/**
* RPG runtime story 展示兼容编译器。
* 当前仍复用 legacy currentStory 投影规则,但真实落点已经切到新域目录。
*/
export { buildLegacyCurrentStory };
export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory;

View File

@@ -0,0 +1,8 @@
import { getRuntimeStoryState } from './RpgRuntimeStoryActionDomain.js';
/**
* RPG runtime story 状态读取入口。
* 工作包 G 先把状态读取从旧热点文件迁到新域命名,再保持动作与状态的物理落点分离。
*/
export { getRuntimeStoryState };
export const getRpgRuntimeStoryState = getRuntimeStoryState;

View File

@@ -1,2 +0,0 @@
export * from './runtimeItemResolutionService.js';
export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js';

View File

@@ -1,7 +1,7 @@
import {
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
RUNTIME_ITEM_TONE_VALUES,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
import {
buildRuntimeItemIntentPromptText,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,

View File

@@ -1,96 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildLooseRuntimeItemGenerationContext,
buildQuestRuntimeItemGenerationContext,
} from '../../bridges/legacyRuntimeItemResolutionBridge.js';
import {
resolveDirectedReward,
resolveRuntimeInventoryStock,
} from './runtimeItemResolutionService.js';
const TEST_WUXIA_WORLD = 'WUXIA' as Parameters<
typeof buildLooseRuntimeItemGenerationContext
>[0]['worldType'];
const TEST_XIANXIA_WORLD = 'XIANXIA' as NonNullable<
Parameters<typeof buildQuestRuntimeItemGenerationContext>[0]['context']['worldType']
>;
test('resolveDirectedReward returns flattened runtime reward items on the server side', () => {
const context = buildLooseRuntimeItemGenerationContext({
worldType: TEST_WUXIA_WORLD,
scene: {
id: 'scene-ruins',
name: '断碑古道',
description: '碎碑与旧誓散落在路旁。',
treasureHints: ['残匣', '旧祭火'],
},
encounter: {
id: 'treasure-altar',
kind: 'treasure',
npcName: '断誓秘匣',
npcDescription: '匣盖上留着未熄的旧印。',
npcAvatar: '',
context: '古道祭坛',
},
playerCharacterId: 'hero',
playerBuildTags: ['快剑', '追击'],
generationChannel: 'treasure',
});
const result = resolveDirectedReward(context, {
seedKey: 'task6:treasure',
fixedKinds: ['relic', 'consumable'],
fixedPermanence: ['permanent', 'timed'],
itemCount: 2,
});
assert.equal(result.items.length, 2);
assert.equal(
result.reward.primaryItem?.runtimeMetadata?.generationChannel,
'treasure',
);
assert.equal(result.items[0]?.id, result.reward.primaryItem?.id);
assert.ok(result.reward.primaryItem?.description?.includes('构筑'));
});
test('resolveRuntimeInventoryStock builds quest-flavored stock without browser fallback', () => {
const context = buildQuestRuntimeItemGenerationContext({
context: {
worldType: TEST_XIANXIA_WORLD,
currentSceneId: 'scene-cloud',
currentSceneName: '云阙旧渡',
currentSceneDescription: '旧渡口残留着灵潮和巡守痕迹。',
issuerNpcId: 'npc-issuer',
issuerNpcName: '巡守使',
issuerNpcContext: '巡守',
issuerAffinity: 24,
recentStoryMoments: [],
playerCharacter: null,
},
issuerNpcId: 'npc-issuer',
issuerNpcName: '巡守使',
roleText: '巡守',
scene: {
id: 'scene-cloud',
name: '云阙旧渡',
description: '旧渡口残留着灵潮和巡守痕迹。',
treasureHints: ['旧印'],
},
});
const items = resolveRuntimeInventoryStock(context, {
seedKey: 'task6:quest',
fixedKinds: ['equipment', 'consumable'],
fixedPermanence: ['permanent', 'timed'],
itemCount: 2,
});
assert.equal(items.length, 2);
assert.equal(
items.every((item) => item.runtimeMetadata?.generationChannel === 'quest_reward'),
true,
);
assert.equal(items.some((item) => Boolean(item.buildProfile || item.useProfile)), true);
});

View File

@@ -1,39 +0,0 @@
import {
buildDirectedRuntimeReward,
buildRuntimeInventoryStock,
flattenDirectedRuntimeRewardItems,
} from '../../bridges/legacyRuntimeItemResolutionBridge.js';
export type RuntimeItemGenerationContext = Parameters<
typeof buildDirectedRuntimeReward
>[0];
export type RuntimeRewardOptions = Parameters<
typeof buildDirectedRuntimeReward
>[1];
export type DirectedRuntimeReward = ReturnType<typeof buildDirectedRuntimeReward>;
export type ResolvedRuntimeRewardItem = ReturnType<
typeof buildRuntimeInventoryStock
>[number];
export type RuntimeRewardResolution = {
reward: DirectedRuntimeReward;
items: ResolvedRuntimeRewardItem[];
};
export function resolveDirectedReward(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): RuntimeRewardResolution {
const reward = buildDirectedRuntimeReward(context, options);
return {
reward,
items: flattenDirectedRuntimeRewardItems(reward),
};
}
export function resolveRuntimeInventoryStock(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): ResolvedRuntimeRewardItem[] {
return buildRuntimeInventoryStock(context, options);
}

View File

@@ -1,7 +1,7 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
addInventoryItems,
@@ -14,8 +14,8 @@ import {
import { buildBuildToast } from '../inventory/inventoryStoryActionService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
const SUPPORTED_TREASURE_STORY_FUNCTION_IDS = new Set<string>([
'treasure_inspect',

File diff suppressed because it is too large Load Diff

View File

@@ -1,74 +0,0 @@
import { Router } from 'express';
import { z } from 'zod';
import type { RuntimeStoryActionRequest } from '../../../../packages/shared/src/contracts/story.js';
import type { AppContext } from '../../context.js';
import { badRequest } from '../../errors.js';
import { asyncHandler, sendApiResponse } from '../../http.js';
import { requireJwtAuth } from '../../middleware/auth.js';
import { routeMeta } from '../../middleware/routeMeta.js';
import {
getRuntimeStoryState,
resolveRuntimeStoryAction,
} from './storyActionService.js';
const actionPayloadSchema = z.record(z.string(), z.unknown());
const runtimeStoryActionSchema = z.object({
sessionId: z.string().trim().min(1),
clientVersion: z.number().int().min(0).optional(),
action: z.object({
type: z.literal('story_choice'),
functionId: z.string().trim().min(1),
targetId: z.string().trim().optional(),
payload: actionPayloadSchema.optional().default({}),
}),
});
export function createStoryActionRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.use(requireAuth);
router.post(
'/actions/resolve',
routeMeta({ operation: 'runtime.story.actions.resolve' }),
asyncHandler(async (request, response) => {
const payload = runtimeStoryActionSchema.parse(
request.body,
) as RuntimeStoryActionRequest;
sendApiResponse(
response,
await resolveRuntimeStoryAction({
runtimeRepository: context.runtimeRepository,
llmClient: context.llmClient,
userId: request.userId!,
request: payload,
}),
);
}),
);
router.get(
'/state/:sessionId',
routeMeta({ operation: 'runtime.story.state.get' }),
asyncHandler(async (request, response) => {
const sessionId = request.params.sessionId?.trim() || '';
if (!sessionId) {
throw badRequest('sessionId is required');
}
sendApiResponse(
response,
await getRuntimeStoryState({
runtimeRepository: context.runtimeRepository,
userId: request.userId!,
sessionId,
}),
);
}),
);
return router;
}