This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,52 @@ import express from 'express';
import pinoHttp from 'pino-http';
import type { AppContext } from './context.js';
import { buildApiLogContext, withRouteMeta } from './http.js';
import { errorHandler } from './middleware/errorHandler.js';
import { requestIdMiddleware } from './middleware/requestId.js';
import { responseEnvelopeMiddleware } from './middleware/responseEnvelope.js';
import { createCharacterAssetRoutes } from './modules/assets/characterAssetRoutes.js';
import { createQwenSpriteRoutes } from './modules/assets/qwenSpriteRoutes.js';
import { createEditorRoutes } from './modules/editor/editorRoutes.js';
import { createStoryActionRoutes } from './modules/story/storyActionRoutes.js';
import { createAuthRoutes } from './routes/authRoutes.js';
import { createRuntimeRoutes } from './routes/runtimeRoutes.js';
import { notFound } from './errors.js';
function matchesRoutePrefix(
request: express.Request,
prefixes: readonly string[],
) {
const requestPath = request.path || request.originalUrl || request.url || '/';
return prefixes.some((prefix) => {
const normalizedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
return (
requestPath === normalizedPrefix ||
requestPath.startsWith(`${normalizedPrefix}/`)
);
});
}
function scopeToPrefixes(
prefixes: readonly string[],
handler: express.RequestHandler,
): express.RequestHandler {
return (request, response, next) => {
if (!matchesRoutePrefix(request, prefixes)) {
next();
return;
}
handler(request, response, next);
};
}
export function createApp(context: AppContext) {
const app = express();
const createHttpLogger = pinoHttp as unknown as (options: Record<string, unknown>) => express.RequestHandler;
const createHttpLogger = pinoHttp as unknown as (
options: Record<string, unknown>,
) => express.RequestHandler;
app.disable('x-powered-by');
@@ -17,18 +55,14 @@ export function createApp(context: AppContext) {
app.use(
createHttpLogger({
logger: context.logger,
genReqId: (request) => request.requestId,
customProps: (request: express.Request) => ({
request_id: request.requestId,
user_id: request.userId ?? null,
}),
genReqId: (request: express.Request) => request.requestId,
customSuccessObject: (
request: express.Request,
response: express.Response,
baseObject: Record<string, unknown> & { responseTime?: number },
) => ({
...baseObject,
request_id: request.requestId,
...buildApiLogContext(request, response),
user_id: request.userId ?? null,
method: request.method,
path: request.url,
@@ -42,7 +76,7 @@ export function createApp(context: AppContext) {
baseObject: Record<string, unknown> & { responseTime?: number },
) => ({
...baseObject,
request_id: request.requestId,
...buildApiLogContext(request, response),
user_id: request.userId ?? null,
method: request.method,
path: request.url,
@@ -53,17 +87,67 @@ export function createApp(context: AppContext) {
}),
);
app.use(express.json({ limit: '10mb' }));
app.use(responseEnvelopeMiddleware);
app.get('/healthz', (_request, response) => {
response.json({
ok: true,
service: 'genarrative-node-server',
});
app.get(
'/healthz',
withRouteMeta({ operation: 'health.check' }),
(_request, response) => {
response.json({
ok: true,
service: 'genarrative-node-server',
});
},
);
app.use(
scopeToPrefixes(
['/api/editor'],
withRouteMeta({ routeVersion: '2026-04-08', operation: 'editor.api' }),
),
);
app.use(scopeToPrefixes(['/api/editor'], createEditorRoutes(context.config)));
app.use(
scopeToPrefixes(
['/api/assets'],
withRouteMeta({ routeVersion: '2026-04-08', operation: 'assets.api' }),
),
);
app.use(
scopeToPrefixes(['/api/assets'], createCharacterAssetRoutes(context.config)),
);
app.use(
scopeToPrefixes(
['/api/assets/qwen-sprite'],
withRouteMeta({ routeVersion: '2026-04-08', operation: 'assets.qwen' }),
),
);
app.use(
scopeToPrefixes(
['/api/assets/qwen-sprite'],
createQwenSpriteRoutes(context.config),
),
);
app.use(
'/api/auth',
withRouteMeta({ routeVersion: '2026-04-08' }),
createAuthRoutes(context),
);
app.use(
'/api/runtime/story',
withRouteMeta({ routeVersion: '2026-04-08' }),
createStoryActionRoutes(context),
);
app.use(
'/api',
withRouteMeta({ routeVersion: '2026-04-08' }),
createRuntimeRoutes(context),
);
app.use((request, _response, next) => {
next(notFound(`接口不存在:${request.method} ${request.originalUrl}`));
});
app.use('/api/auth', createAuthRoutes(context));
app.use('/api', createRuntimeRoutes(context));
app.use(errorHandler);
return app;
}

View File

@@ -0,0 +1,15 @@
import type { Request } from 'express';
export type AuthRequestContext = {
clientType: string;
userAgent: string | null;
ip: string | null;
};
export function buildAuthRequestContext(request: Request): AuthRequestContext {
return {
clientType: 'browser',
userAgent: request.header('user-agent')?.trim() || null,
ip: request.ip || null,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
import { badRequest } from '../errors.js';
export type NormalizedPhoneNumber = {
countryCode: string;
nationalNumber: string;
e164: string;
maskedNationalNumber: string;
};
function stripPhoneInput(input: string) {
return input.replace(/[^\d+]/gu, '').trim();
}
export function maskNationalPhoneNumber(phoneNumber: string) {
if (phoneNumber.length < 7) {
return phoneNumber;
}
return `${phoneNumber.slice(0, 3)}****${phoneNumber.slice(-4)}`;
}
export function normalizeMainlandChinaPhoneNumber(
phoneInput: string,
): NormalizedPhoneNumber {
const trimmed = stripPhoneInput(phoneInput);
if (!trimmed) {
throw badRequest('请输入手机号');
}
let nationalNumber = trimmed;
if (nationalNumber.startsWith('+86')) {
nationalNumber = nationalNumber.slice(3);
} else if (nationalNumber.startsWith('86') && nationalNumber.length === 13) {
nationalNumber = nationalNumber.slice(2);
}
if (!/^1\d{10}$/u.test(nationalNumber)) {
throw badRequest('请输入正确的中国大陆手机号');
}
return {
countryCode: '86',
nationalNumber,
e164: `+86${nationalNumber}`,
maskedNationalNumber: maskNationalPhoneNumber(nationalNumber),
};
}
export function validateSmsVerifyCode(verifyCode: string) {
const normalizedVerifyCode = verifyCode.trim();
if (!/^[A-Za-z0-9]{4,8}$/u.test(normalizedVerifyCode)) {
throw badRequest('请输入正确的验证码');
}
return normalizedVerifyCode;
}

View File

@@ -0,0 +1,96 @@
import crypto from 'node:crypto';
import type { Request, Response } from 'express';
import type { AppConfig } from '../config.js';
export type RefreshSessionRequestContext = {
clientType: string;
userAgent: string | null;
ip: string | null;
};
function buildCookieParts(
config: AppConfig,
value: string,
options: {
maxAgeSeconds: number;
},
) {
const parts = [
`${config.authSession.refreshCookieName}=${encodeURIComponent(value)}`,
`Path=${config.authSession.refreshCookiePath}`,
'HttpOnly',
`SameSite=${config.authSession.refreshCookieSameSite}`,
`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`,
];
if (config.authSession.refreshCookieSecure) {
parts.push('Secure');
}
return parts.join('; ');
}
export function hashRefreshSessionToken(token: string) {
return crypto.createHash('sha256').update(token).digest('hex');
}
export function createRefreshSessionToken() {
return crypto.randomBytes(32).toString('base64url');
}
export function setRefreshSessionCookie(
response: Response,
config: AppConfig,
token: string,
maxAgeSeconds: number,
) {
response.setHeader(
'Set-Cookie',
buildCookieParts(config, token, {
maxAgeSeconds,
}),
);
}
export function clearRefreshSessionCookie(response: Response, config: AppConfig) {
response.setHeader(
'Set-Cookie',
buildCookieParts(config, '', {
maxAgeSeconds: 0,
}),
);
}
export function readRefreshSessionToken(request: Request, config: AppConfig) {
const cookieHeader = request.header('cookie')?.trim() || '';
if (!cookieHeader) {
return '';
}
const cookieEntries = cookieHeader.split(';');
for (const entry of cookieEntries) {
const [rawName, ...valueParts] = entry.split('=');
const name = rawName?.trim();
if (name !== config.authSession.refreshCookieName) {
continue;
}
const rawValue = valueParts.join('=').trim();
return rawValue ? decodeURIComponent(rawValue) : '';
}
return '';
}
export function buildRefreshSessionRequestContext(
request: Request,
): RefreshSessionRequestContext {
const userAgent = request.header('user-agent')?.trim() || null;
return {
clientType: 'browser',
userAgent,
ip: request.ip || null,
};
}

View File

@@ -1,8 +1,24 @@
import crypto from 'node:crypto';
import { jwtVerify, SignJWT } from 'jose';
import type { AppConfig } from '../config.js';
import { unauthorized } from '../errors.js';
if (!globalThis.crypto?.subtle) {
Object.assign(globalThis, {
crypto: crypto.webcrypto,
});
}
if (typeof globalThis.structuredClone !== 'function') {
Object.assign(globalThis, {
structuredClone<T>(value: T) {
return JSON.parse(JSON.stringify(value)) as T;
},
});
}
export type AccessTokenClaims = {
userId: string;
tokenVersion: number;
@@ -21,6 +37,7 @@ export async function signAccessToken(
.setSubject(claims.userId)
.setIssuer(config.jwtIssuer)
.setIssuedAt()
.setExpirationTime(config.jwtExpiresIn)
.sign(getSecret(config));
}

View File

@@ -0,0 +1,6 @@
// Temporary bridge for legacy pure build calculation logic from src/**.
export { getEquipmentBonuses } from '../modules/runtime/runtimeEquipmentModule.js';
export {
getPlayerBuildDamageBreakdown,
resolvePlayerOutgoingDamageResult,
} from '../modules/runtime/runtimeBuildModule.js';

View File

@@ -0,0 +1,25 @@
// Temporary bridge for legacy pure inventory/build mutation logic from src/**.
export { appendBuildBuffs } from '../modules/runtime/runtimeBuildModule.js';
export {
applyEquipmentLoadoutToState,
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
} from '../modules/runtime/runtimeEquipmentModule.js';
export {
buildForgeSuccessText,
executeDismantleItem,
executeForgeRecipe,
executeReforgeItem,
getForgeRecipeViews,
getReforgeCostView,
} from '../modules/runtime/runtimeForgeModule.js';
export {
buildInventoryUseResultText,
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../modules/runtime/runtimeInventoryEffectsModule.js';
export {
addInventoryItems,
incrementGameRuntimeStats,
removeInventoryItem,
} from '../modules/runtime/runtimeStatePrimitives.js';

View File

@@ -0,0 +1,26 @@
// Temporary bridge for legacy pure NPC inventory/task6 logic from src/**.
export { buildRelationState } from '../modules/runtime/runtimeStatePrimitives.js';
export {
formatCurrency,
getNpcBuybackPrice,
getNpcPurchasePrice,
} from '../modules/runtime/runtimeEconomyPrimitives.js';
export {
applyStoryChoiceToStanceProfile,
buildInitialNpcState,
buildNpcGiftCommitActionText,
buildNpcGiftResultText,
buildNpcTradeTransactionActionText,
buildNpcTradeTransactionResultText,
getGiftCandidates,
syncNpcTradeInventory,
} from '../modules/npc/npcTask6Primitives.js';
export {
markNpcFirstMeaningfulContactResolved,
normalizeNpcPersistentState,
} from '../modules/runtime/runtimeNpcStatePrimitives.js';
export { appendStoryEngineCarrierMemory } from '../modules/runtime/runtimeNarrativeMemory.js';
export {
addInventoryItems,
removeInventoryItem,
} from '../modules/runtime/runtimeStatePrimitives.js';

View File

@@ -0,0 +1,15 @@
// Temporary bridge for legacy pure quest progression logic from src/**.
export {
acceptQuest,
buildQuestAcceptResultText,
buildQuestForEncounter,
buildQuestTurnInResultText,
applyQuestProgressSignal,
getQuestForIssuer,
buildChapterQuestForScene,
findQuestById,
isQuestReadyToClaim,
markQuestCompletionNotified,
markQuestTurnedIn,
normalizeQuestLogEntries,
} from '../modules/quest/runtimeQuestModule.js';

View File

@@ -0,0 +1,9 @@
// Temporary bridge for legacy pure quest runtime composition from src/**.
export {
buildFallbackQuestIntent,
compileQuestIntentToQuest,
evaluateQuestOpportunity,
buildQuestIntentPrompt,
buildQuestGenerationContextFromState,
QUEST_INTENT_SYSTEM_PROMPT,
} from '../modules/quest/runtimeQuestModule.js';

View File

@@ -0,0 +1,6 @@
// Temporary bridge for legacy pure runtime item composition from src/**.
export {
buildRuntimeItemAiIntent,
buildRuntimeItemIntentPrompt,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
} from '../modules/runtime-item/runtimeItemModule.js';

View File

@@ -0,0 +1,8 @@
// Temporary bridge for legacy pure runtime item resolution logic from src/**.
export {
buildLooseRuntimeItemGenerationContext,
buildQuestRuntimeItemGenerationContext,
buildDirectedRuntimeReward,
buildRuntimeInventoryStock,
flattenDirectedRuntimeRewardItems,
} from '../modules/runtime-item/runtimeItemModule.js';

View File

@@ -0,0 +1,3 @@
// Temporary bridge for legacy pure treasure/runtime item logic from src/**.
export { buildTreasureResultText } from '../modules/runtime/runtimeTreasureTexts.js';
export { resolveTreasureReward } from '../modules/runtime-item/runtimeTreasureModule.js';

View File

@@ -7,9 +7,12 @@ export type AppConfig = {
publicDir: string;
logsDir: string;
dataDir: string;
sqlitePath: string;
rawEnv: Record<string, string>;
databaseUrl: string;
serverAddr: string;
logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent';
editorApiEnabled: boolean;
assetsApiEnabled: boolean;
jwtSecret: string;
jwtExpiresIn: string;
jwtIssuer: string;
@@ -24,6 +27,59 @@ export type AppConfig = {
imageModel: string;
requestTimeoutMs: number;
};
smsAuth: {
enabled: boolean;
provider: 'aliyun' | 'mock';
endpoint: string;
accessKeyId: string;
accessKeySecret: string;
signName: string;
templateCode: string;
templateParamKey: string;
countryCode: string;
schemeName: string;
codeLength: number;
codeType: number;
validTimeSeconds: number;
intervalSeconds: number;
duplicatePolicy: number;
caseAuthPolicy: number;
returnVerifyCode: boolean;
mockVerifyCode: string;
maxSendPerPhonePerDay: number;
maxSendPerIpPerHour: number;
maxVerifyFailuresPerPhonePerHour: number;
maxVerifyFailuresPerIpPerHour: number;
captchaTtlSeconds: number;
captchaTriggerVerifyFailuresPerPhone: number;
captchaTriggerVerifyFailuresPerIp: number;
blockPhoneFailureThreshold: number;
blockIpFailureThreshold: number;
blockPhoneDurationMinutes: number;
blockIpDurationMinutes: number;
};
wechatAuth: {
enabled: boolean;
provider: 'wechat' | 'mock';
appId: string;
appSecret: string;
authorizeEndpoint: string;
accessTokenEndpoint: string;
userInfoEndpoint: string;
callbackPath: string;
defaultRedirectPath: string;
mockUserId: string;
mockUnionId: string;
mockDisplayName: string;
mockAvatarUrl: string;
};
authSession: {
refreshCookieName: string;
refreshSessionTtlDays: number;
refreshCookieSecure: boolean;
refreshCookieSameSite: 'Lax' | 'Strict' | 'None';
refreshCookiePath: string;
};
};
type LoadConfigOptions = {
@@ -101,11 +157,80 @@ function readPositiveInt(
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
}
function readIntInRange(
env: Record<string, string | undefined>,
key: string,
fallback: number,
options: {
min: number;
max: number;
},
) {
const parsed = Number(env[key]);
if (!Number.isFinite(parsed)) {
return fallback;
}
const rounded = Math.round(parsed);
if (rounded < options.min || rounded > options.max) {
return fallback;
}
return rounded;
}
function readBoolean(
env: Record<string, string | undefined>,
key: string,
fallback: boolean,
) {
const value = env[key]?.trim().toLowerCase();
if (!value) {
return fallback;
}
if (value === '1' || value === 'true' || value === 'yes' || value === 'on') {
return true;
}
if (value === '0' || value === 'false' || value === 'no' || value === 'off') {
return false;
}
return fallback;
}
export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot();
const env = readMergedEnv(projectRoot, options.env ?? process.env);
const logsDir = path.join(projectRoot, 'server-node', 'logs');
const dataDir = path.join(projectRoot, 'server-node', 'data');
const defaultEditorApiEnabled = readString(env, 'NODE_ENV', 'development') !== 'production';
const editorApiEnabled = readBoolean(
env,
'EDITOR_API_ENABLED',
defaultEditorApiEnabled,
);
const smsProvider = readString(
env,
'SMS_AUTH_PROVIDER',
readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'aliyun',
) as AppConfig['smsAuth']['provider'];
const smsAccessKeyId = readString(env, 'ALIYUN_SMS_ACCESS_KEY_ID', '');
const smsAccessKeySecret = readString(env, 'ALIYUN_SMS_ACCESS_KEY_SECRET', '');
const defaultSmsEnabled =
smsProvider === 'mock' || Boolean(smsAccessKeyId && smsAccessKeySecret);
const wechatProvider = readString(
env,
'WECHAT_AUTH_PROVIDER',
readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'wechat',
) as AppConfig['wechatAuth']['provider'];
const wechatAppId = readString(env, 'WECHAT_APP_ID', '');
const wechatAppSecret = readString(env, 'WECHAT_APP_SECRET', '');
const defaultWechatEnabled =
wechatProvider === 'mock' || Boolean(wechatAppId && wechatAppSecret);
const refreshSameSite = readString(
env,
'AUTH_REFRESH_COOKIE_SAME_SITE',
'Lax',
);
return {
nodeEnv: readString(env, 'NODE_ENV', 'development'),
@@ -113,15 +238,26 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
publicDir: path.join(projectRoot, 'public'),
logsDir,
dataDir,
sqlitePath: readString(
rawEnv: Object.fromEntries(
Object.entries(env).flatMap(([key, value]) =>
typeof value === 'string' ? [[key, value]] : [],
),
),
databaseUrl: readString(
env,
'SQLITE_PATH',
path.join(dataDir, 'genarrative.sqlite'),
'DATABASE_URL',
'postgresql://postgres:postgres@127.0.0.1:5432/genarrative',
),
serverAddr: readString(env, 'NODE_SERVER_ADDR', ':8081'),
logLevel: readString(env, 'LOG_LEVEL', 'info') as AppConfig['logLevel'],
editorApiEnabled,
assetsApiEnabled: readBoolean(
env,
'ASSETS_API_ENABLED',
editorApiEnabled,
),
jwtSecret: readString(env, 'JWT_SECRET', 'genarrative-dev-secret'),
jwtExpiresIn: readString(env, 'JWT_EXPIRES_IN', '7d'),
jwtExpiresIn: readString(env, 'JWT_EXPIRES_IN', '2h'),
jwtIssuer: readString(env, 'JWT_ISSUER', 'genarrative-server-node'),
llm: {
baseUrl: readString(
@@ -158,5 +294,177 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
150000,
),
},
smsAuth: {
enabled: readBoolean(env, 'SMS_AUTH_ENABLED', defaultSmsEnabled),
provider: smsProvider,
endpoint: readString(
env,
'ALIYUN_SMS_ENDPOINT',
'dypnsapi.aliyuncs.com',
),
accessKeyId: smsAccessKeyId,
accessKeySecret: smsAccessKeySecret,
signName: readString(
env,
'ALIYUN_SMS_SIGN_NAME',
'速通互联验证码',
),
templateCode: readString(env, 'ALIYUN_SMS_TEMPLATE_CODE', '100001'),
templateParamKey: readString(
env,
'ALIYUN_SMS_TEMPLATE_PARAM_KEY',
'code',
),
countryCode: readString(env, 'ALIYUN_SMS_COUNTRY_CODE', '86'),
schemeName: readString(env, 'ALIYUN_SMS_SCHEME_NAME', ''),
codeLength: readIntInRange(env, 'ALIYUN_SMS_CODE_LENGTH', 6, {
min: 4,
max: 8,
}),
codeType: readIntInRange(env, 'ALIYUN_SMS_CODE_TYPE', 1, {
min: 1,
max: 7,
}),
validTimeSeconds: readPositiveInt(
env,
'ALIYUN_SMS_VALID_TIME_SECONDS',
300,
),
intervalSeconds: readPositiveInt(
env,
'ALIYUN_SMS_INTERVAL_SECONDS',
60,
),
duplicatePolicy: readIntInRange(
env,
'ALIYUN_SMS_DUPLICATE_POLICY',
1,
{ min: 1, max: 2 },
),
caseAuthPolicy: readIntInRange(
env,
'ALIYUN_SMS_CASE_AUTH_POLICY',
1,
{ min: 1, max: 2 },
),
returnVerifyCode: readBoolean(
env,
'ALIYUN_SMS_RETURN_VERIFY_CODE',
false,
),
mockVerifyCode: readString(env, 'SMS_AUTH_MOCK_VERIFY_CODE', '123456'),
maxSendPerPhonePerDay: readPositiveInt(
env,
'SMS_AUTH_MAX_SEND_PER_PHONE_PER_DAY',
20,
),
maxSendPerIpPerHour: readPositiveInt(
env,
'SMS_AUTH_MAX_SEND_PER_IP_PER_HOUR',
30,
),
maxVerifyFailuresPerPhonePerHour: readPositiveInt(
env,
'SMS_AUTH_MAX_VERIFY_FAILURES_PER_PHONE_PER_HOUR',
12,
),
maxVerifyFailuresPerIpPerHour: readPositiveInt(
env,
'SMS_AUTH_MAX_VERIFY_FAILURES_PER_IP_PER_HOUR',
24,
),
captchaTtlSeconds: readPositiveInt(
env,
'SMS_AUTH_CAPTCHA_TTL_SECONDS',
180,
),
captchaTriggerVerifyFailuresPerPhone: readPositiveInt(
env,
'SMS_AUTH_CAPTCHA_TRIGGER_VERIFY_FAILURES_PER_PHONE',
3,
),
captchaTriggerVerifyFailuresPerIp: readPositiveInt(
env,
'SMS_AUTH_CAPTCHA_TRIGGER_VERIFY_FAILURES_PER_IP',
5,
),
blockPhoneFailureThreshold: readPositiveInt(
env,
'SMS_AUTH_BLOCK_PHONE_FAILURE_THRESHOLD',
6,
),
blockIpFailureThreshold: readPositiveInt(
env,
'SMS_AUTH_BLOCK_IP_FAILURE_THRESHOLD',
10,
),
blockPhoneDurationMinutes: readPositiveInt(
env,
'SMS_AUTH_BLOCK_PHONE_DURATION_MINUTES',
30,
),
blockIpDurationMinutes: readPositiveInt(
env,
'SMS_AUTH_BLOCK_IP_DURATION_MINUTES',
30,
),
},
wechatAuth: {
enabled: readBoolean(env, 'WECHAT_AUTH_ENABLED', defaultWechatEnabled),
provider: wechatProvider,
appId: wechatAppId,
appSecret: wechatAppSecret,
authorizeEndpoint: readString(
env,
'WECHAT_AUTHORIZE_ENDPOINT',
'https://open.weixin.qq.com/connect/qrconnect',
),
accessTokenEndpoint: readString(
env,
'WECHAT_ACCESS_TOKEN_ENDPOINT',
'https://api.weixin.qq.com/sns/oauth2/access_token',
),
userInfoEndpoint: readString(
env,
'WECHAT_USER_INFO_ENDPOINT',
'https://api.weixin.qq.com/sns/userinfo',
),
callbackPath: readString(
env,
'WECHAT_CALLBACK_PATH',
'/api/auth/wechat/callback',
),
defaultRedirectPath: readString(env, 'WECHAT_REDIRECT_PATH', '/'),
mockUserId: readString(env, 'WECHAT_MOCK_USER_ID', 'mock_wechat_user'),
mockUnionId: readString(env, 'WECHAT_MOCK_UNION_ID', 'mock_wechat_union'),
mockDisplayName: readString(env, 'WECHAT_MOCK_DISPLAY_NAME', '微信旅人'),
mockAvatarUrl: readString(env, 'WECHAT_MOCK_AVATAR_URL', ''),
},
authSession: {
refreshCookieName: readString(
env,
'AUTH_REFRESH_COOKIE_NAME',
'genarrative_refresh_session',
),
refreshSessionTtlDays: readPositiveInt(
env,
'AUTH_REFRESH_SESSION_TTL_DAYS',
30,
),
refreshCookieSecure: readBoolean(
env,
'AUTH_REFRESH_COOKIE_SECURE',
readString(env, 'NODE_ENV', 'development') === 'production',
),
refreshCookieSameSite:
refreshSameSite === 'None' || refreshSameSite === 'Strict'
? (refreshSameSite as AppConfig['authSession']['refreshCookieSameSite'])
: 'Lax',
refreshCookiePath: readString(
env,
'AUTH_REFRESH_COOKIE_PATH',
'/api/auth',
),
},
};
}

View File

@@ -2,17 +2,35 @@ import type { Logger } from 'pino';
import type { AppConfig } from './config.js';
import type { AppDatabase } from './db.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js';
import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js';
import { RuntimeRepository } from './repositories/runtimeRepository.js';
import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js';
import { UserRepository } from './repositories/userRepository.js';
import { UserSessionRepository } from './repositories/userSessionRepository.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
import { UpstreamLlmClient } from './services/llmClient.js';
import type { SmsVerificationService } from './services/smsVerificationService.js';
import type { WechatAuthService } from './services/wechatAuthService.js';
import { WechatAuthStateStore } from './services/wechatAuthStateStore.js';
export type AppContext = {
config: AppConfig;
logger: Logger;
db: AppDatabase;
userRepository: UserRepository;
authIdentityRepository: AuthIdentityRepository;
authAuditLogRepository: AuthAuditLogRepository;
authRiskBlockRepository: AuthRiskBlockRepository;
smsAuthEventRepository: SmsAuthEventRepository;
userSessionRepository: UserSessionRepository;
runtimeRepository: RuntimeRepository;
llmClient: UpstreamLlmClient;
customWorldSessions: CustomWorldSessionStore;
smsVerificationService: SmsVerificationService;
wechatAuthService: WechatAuthService;
wechatAuthStates: WechatAuthStateStore;
captchaChallenges: CaptchaChallengeStore;
};

163
server-node/src/db.test.ts Normal file
View File

@@ -0,0 +1,163 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import type { AppConfig } from './config.js';
import { createDatabase, listAppliedMigrations } from './db.js';
function createTestConfig(databaseUrl: string): AppConfig {
const projectRoot = path.resolve(process.cwd(), '..');
return {
nodeEnv: 'test',
projectRoot,
publicDir: path.join(projectRoot, 'public'),
logsDir: path.join(projectRoot, 'server-node', 'logs'),
dataDir: path.join(projectRoot, 'server-node', 'data'),
rawEnv: {},
databaseUrl,
serverAddr: ':0',
logLevel: 'silent',
editorApiEnabled: true,
assetsApiEnabled: true,
jwtSecret: 'test-secret',
jwtExpiresIn: '7d',
jwtIssuer: 'genarrative-server-node-test',
llm: {
baseUrl: 'https://example.invalid',
apiKey: '',
model: 'test-model',
},
dashScope: {
baseUrl: 'https://example.invalid',
apiKey: '',
imageModel: 'test-image-model',
requestTimeoutMs: 1000,
},
smsAuth: {
enabled: true,
provider: 'mock',
endpoint: 'dypnsapi.aliyuncs.com',
accessKeyId: '',
accessKeySecret: '',
signName: 'Test Sign',
templateCode: '100001',
templateParamKey: 'code',
countryCode: '86',
schemeName: '',
codeLength: 6,
codeType: 1,
validTimeSeconds: 300,
intervalSeconds: 60,
duplicatePolicy: 1,
caseAuthPolicy: 1,
returnVerifyCode: false,
mockVerifyCode: '123456',
maxSendPerPhonePerDay: 20,
maxSendPerIpPerHour: 30,
maxVerifyFailuresPerPhonePerHour: 12,
maxVerifyFailuresPerIpPerHour: 24,
captchaTtlSeconds: 180,
captchaTriggerVerifyFailuresPerPhone: 3,
captchaTriggerVerifyFailuresPerIp: 5,
blockPhoneFailureThreshold: 6,
blockIpFailureThreshold: 10,
blockPhoneDurationMinutes: 30,
blockIpDurationMinutes: 30,
},
wechatAuth: {
enabled: true,
provider: 'mock',
appId: '',
appSecret: '',
authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect',
accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token',
userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo',
callbackPath: '/api/auth/wechat/callback',
defaultRedirectPath: '/',
mockUserId: 'mock_wechat_user',
mockUnionId: 'mock_wechat_union',
mockDisplayName: '微信旅人',
mockAvatarUrl: '',
},
authSession: {
refreshCookieName: 'genarrative_refresh_session',
refreshSessionTtlDays: 30,
refreshCookieSecure: false,
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/api/auth',
},
};
}
test('createDatabase applies runtime baseline migrations for pg-mem', async () => {
const db = await createDatabase(
createTestConfig('pg-mem://genarrative-db-test'),
);
try {
const migrations = await listAppliedMigrations(db);
assert.deepEqual(
migrations.map((migration) => migration.id),
[
'20260408_001_runtime_postgres_baseline',
'20260408_002_allow_null_current_story_snapshot',
'20260409_003_phone_auth_user_extensions',
'20260409_004_auth_identities_and_account_status',
'20260409_005_user_sessions',
'20260409_006_auth_audit_logs',
'20260409_007_sms_auth_events',
'20260409_008_auth_risk_blocks',
],
);
const tablesResult = await db.query<{ tableName: string }>(
`SELECT table_name AS "tableName"
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN (
'schema_migrations',
'users',
'auth_identities',
'auth_audit_logs',
'auth_risk_blocks',
'sms_auth_events',
'user_sessions',
'save_snapshots',
'runtime_settings',
'custom_world_profiles'
)
ORDER BY table_name`,
);
assert.deepEqual(
tablesResult.rows.map((row) => row.tableName),
[
'auth_audit_logs',
'auth_identities',
'auth_risk_blocks',
'custom_world_profiles',
'runtime_settings',
'save_snapshots',
'schema_migrations',
'sms_auth_events',
'user_sessions',
'users',
],
);
} finally {
await db.close();
}
});
test('createDatabase rejects non-postgresql database urls', async () => {
await assert.rejects(
() =>
createDatabase(
createTestConfig(
'mysql://root:root@127.0.0.1:3306/genarrative',
),
),
/DATABASE_URL PostgreSQL pg-mem /u,
);
});

View File

@@ -1,57 +1,168 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { Pool, type QueryResult, type QueryResultRow } from 'pg';
import type { AppConfig } from './config.js';
import { databaseMigrations } from './db/migrations.js';
const schemaSql = `
CREATE TABLE IF NOT EXISTS users (
const migrationTableSql = `
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
token_version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS save_snapshots (
user_id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
saved_at TEXT NOT NULL,
bottom_tab TEXT NOT NULL,
game_state_json TEXT NOT NULL,
current_story_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS runtime_settings (
user_id TEXT PRIMARY KEY,
music_volume REAL NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS custom_world_profiles (
user_id TEXT NOT NULL,
profile_id TEXT NOT NULL,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, profile_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
name TEXT NOT NULL,
applied_at TEXT NOT NULL
)
`;
export type AppDatabase = Database.Database;
type MigrationRow = QueryResultRow & {
id: string;
name: string;
appliedAt: string;
};
export function createDatabase(config: AppConfig) {
const sqliteDir = path.dirname(config.sqlitePath);
fs.mkdirSync(sqliteDir, { recursive: true });
export type AppDatabase = {
query<TResult extends QueryResultRow = QueryResultRow>(
text: string,
params?: readonly unknown[],
): Promise<QueryResult<TResult>>;
close(): Promise<void>;
};
const db = new Database(config.sqlitePath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(schemaSql);
type QueryablePool = Pick<Pool, 'query' | 'end'>;
function wrapPool(pool: QueryablePool): AppDatabase {
return {
query<TResult extends QueryResultRow = QueryResultRow>(
text: string,
params: readonly unknown[] = [],
) {
return pool.query<TResult>(text, [...params]);
},
async close() {
await pool.end();
},
};
}
function validateDatabaseUrl(databaseUrl: string) {
const trimmed = databaseUrl.trim();
if (!trimmed) {
throw new Error('DATABASE_URL 不能为空');
}
if (trimmed.startsWith('pg-mem://')) {
return;
}
let protocol = '';
try {
protocol = new URL(trimmed).protocol;
} catch {
throw new Error(
'DATABASE_URL 只支持 PostgreSQL 连接串或 pg-mem 测试连接',
);
}
if (protocol !== 'postgresql:' && protocol !== 'postgres:') {
throw new Error(
'DATABASE_URL 只支持 PostgreSQL 连接串或 pg-mem 测试连接',
);
}
}
export function summarizeDatabaseTarget(databaseUrl: string) {
const trimmed = databaseUrl.trim();
if (!trimmed) {
return '[missing]';
}
if (trimmed.startsWith('pg-mem://')) {
return trimmed;
}
try {
const url = new URL(trimmed);
const databaseName = url.pathname.replace(/^\/+/u, '') || 'postgres';
const portSuffix = url.port ? `:${url.port}` : '';
return `${url.protocol}//${url.hostname}${portSuffix}/${databaseName}`;
} catch {
return '[configured]';
}
}
async function ensureMigrationTable(db: AppDatabase) {
await db.query(migrationTableSql);
}
export async function listAppliedMigrations(db: AppDatabase) {
await ensureMigrationTable(db);
const result = await db.query<MigrationRow>(
`SELECT id, name, applied_at AS "appliedAt"
FROM schema_migrations
ORDER BY id`,
);
return result.rows.map((row) => ({
id: row.id,
name: row.name,
appliedAt: row.appliedAt,
}));
}
async function runMigrations(db: AppDatabase) {
await ensureMigrationTable(db);
const appliedMigrations = new Set(
(await listAppliedMigrations(db)).map((migration) => migration.id),
);
for (const migration of databaseMigrations) {
if (appliedMigrations.has(migration.id)) {
continue;
}
await db.query('BEGIN');
try {
for (const statement of migration.statements) {
await db.query(statement);
}
await db.query(
`INSERT INTO schema_migrations (id, name, applied_at)
VALUES ($1, $2, $3)`,
[migration.id, migration.name, new Date().toISOString()],
);
await db.query('COMMIT');
} catch (error) {
await db.query('ROLLBACK');
throw new Error(
`failed to apply database migration ${migration.id}: ${error instanceof Error ? error.message : 'unknown error'}`,
);
}
}
}
async function createInMemoryDatabase() {
const { newDb } = await import('pg-mem');
const memoryDb = newDb({
autoCreateForeignKeyIndices: true,
noAstCoverageCheck: true,
});
const adapter = memoryDb.adapters.createPg();
const pool = new adapter.Pool() as unknown as QueryablePool;
const db = wrapPool(pool);
await runMigrations(db);
return db;
}
export async function createDatabase(config: AppConfig) {
validateDatabaseUrl(config.databaseUrl);
if (config.databaseUrl.startsWith('pg-mem://')) {
return createInMemoryDatabase();
}
const pool = new Pool({
connectionString: config.databaseUrl,
});
const db = wrapPool(pool);
await db.query('SELECT 1');
await runMigrations(db);
return db;
}

View File

@@ -0,0 +1,192 @@
export type DatabaseMigration = {
id: string;
name: string;
statements: readonly string[];
};
export const databaseMigrations: readonly DatabaseMigration[] = [
{
id: '20260408_001_runtime_postgres_baseline',
name: 'runtime postgres baseline',
statements: [
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
token_version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS save_snapshots (
user_id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
saved_at TEXT NOT NULL,
bottom_tab TEXT NOT NULL,
game_state_json JSONB NOT NULL,
current_story_json JSONB NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS runtime_settings (
user_id TEXT PRIMARY KEY,
music_volume REAL NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS custom_world_profiles (
user_id TEXT NOT NULL,
profile_id TEXT NOT NULL,
payload_json JSONB NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, profile_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS custom_world_profiles_user_updated_idx
ON custom_world_profiles (user_id, updated_at DESC)`,
],
},
{
id: '20260408_002_allow_null_current_story_snapshot',
name: 'allow null current story snapshot',
statements: [
`ALTER TABLE save_snapshots
ALTER COLUMN current_story_json DROP NOT NULL`,
],
},
{
id: '20260409_003_phone_auth_user_extensions',
name: 'phone auth user extensions',
statements: [
`ALTER TABLE users
ADD COLUMN IF NOT EXISTS display_name TEXT`,
`UPDATE users
SET display_name = COALESCE(
CASE
WHEN display_name = '' THEN NULL
ELSE display_name
END,
username,
'玩家'
)
WHERE display_name IS NULL OR display_name = ''`,
`ALTER TABLE users
ALTER COLUMN display_name SET NOT NULL`,
`ALTER TABLE users
ADD COLUMN IF NOT EXISTS login_provider TEXT NOT NULL DEFAULT 'password'`,
`ALTER TABLE users
ADD COLUMN IF NOT EXISTS phone_number TEXT`,
`ALTER TABLE users
ADD COLUMN IF NOT EXISTS phone_verified_at TEXT`,
`CREATE UNIQUE INDEX IF NOT EXISTS users_phone_number_unique_idx
ON users (phone_number)`,
],
},
{
id: '20260409_004_auth_identities_and_account_status',
name: 'auth identities and account status',
statements: [
`ALTER TABLE users
ADD COLUMN IF NOT EXISTS account_status TEXT NOT NULL DEFAULT 'active'`,
`CREATE TABLE IF NOT EXISTS auth_identities (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
provider_uid TEXT NOT NULL,
provider_unionid TEXT,
display_name TEXT,
avatar_url TEXT,
is_verified BOOLEAN NOT NULL DEFAULT TRUE,
meta_json JSONB,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS auth_identities_provider_uid_unique_idx
ON auth_identities (provider, provider_uid)`,
`CREATE UNIQUE INDEX IF NOT EXISTS auth_identities_provider_unionid_unique_idx
ON auth_identities (provider, provider_unionid)
WHERE provider_unionid IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS auth_identities_user_idx
ON auth_identities (user_id, provider)`,
],
},
{
id: '20260409_005_user_sessions',
name: 'user sessions',
statements: [
`CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
refresh_token_hash TEXT NOT NULL UNIQUE,
client_type TEXT NOT NULL,
user_agent TEXT,
ip TEXT,
expires_at TEXT NOT NULL,
revoked_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS user_sessions_user_idx
ON user_sessions (user_id, expires_at DESC)`,
],
},
{
id: '20260409_006_auth_audit_logs',
name: 'auth audit logs',
statements: [
`CREATE TABLE IF NOT EXISTS auth_audit_logs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
event_type TEXT NOT NULL,
detail TEXT NOT NULL,
ip TEXT,
user_agent TEXT,
meta_json JSONB,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS auth_audit_logs_user_created_idx
ON auth_audit_logs (user_id, created_at DESC)`,
],
},
{
id: '20260409_007_sms_auth_events',
name: 'sms auth events',
statements: [
`CREATE TABLE IF NOT EXISTS sms_auth_events (
id TEXT PRIMARY KEY,
phone_number TEXT NOT NULL,
scene TEXT NOT NULL,
action TEXT NOT NULL,
success BOOLEAN NOT NULL,
ip TEXT,
user_agent TEXT,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS sms_auth_events_phone_created_idx
ON sms_auth_events (phone_number, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS sms_auth_events_ip_created_idx
ON sms_auth_events (ip, created_at DESC)`,
],
},
{
id: '20260409_008_auth_risk_blocks',
name: 'auth risk blocks',
statements: [
`CREATE TABLE IF NOT EXISTS auth_risk_blocks (
id TEXT PRIMARY KEY,
scope_type TEXT NOT NULL,
scope_key TEXT NOT NULL,
reason TEXT NOT NULL,
expires_at TEXT NOT NULL,
lifted_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS auth_risk_blocks_scope_idx
ON auth_risk_blocks (scope_type, scope_key, expires_at DESC)`,
],
},
];

View File

@@ -1,35 +1,173 @@
export class HttpError extends Error {
statusCode: number;
expose: boolean;
import { ZodError } from 'zod';
constructor(statusCode: number, message: string, expose = true) {
super(message);
this.name = 'HttpError';
this.statusCode = statusCode;
this.expose = expose;
type HttpErrorOptions = {
code?: string;
details?: unknown;
expose?: boolean;
};
type JsonBodyParseError = SyntaxError & {
status?: number;
type?: string;
};
export function resolveHttpErrorCode(statusCode: number) {
switch (statusCode) {
case 400:
return 'BAD_REQUEST';
case 401:
return 'UNAUTHORIZED';
case 429:
return 'TOO_MANY_REQUESTS';
case 403:
return 'FORBIDDEN';
case 404:
return 'NOT_FOUND';
case 409:
return 'CONFLICT';
case 502:
return 'UPSTREAM_ERROR';
default:
return 'INTERNAL_SERVER_ERROR';
}
}
export function badRequest(message: string) {
return new HttpError(400, message);
export function resolveHttpErrorMessage(statusCode: number) {
switch (statusCode) {
case 400:
return '请求参数不合法';
case 401:
return '未授权访问';
case 429:
return '请求过于频繁';
case 403:
return '禁止访问';
case 404:
return '资源不存在';
case 409:
return '请求冲突';
case 502:
return '上游服务请求失败';
default:
return '服务器内部错误';
}
}
function isJsonBodyParseError(error: unknown): error is JsonBodyParseError {
return (
error instanceof SyntaxError &&
typeof error === 'object' &&
'status' in error &&
'type' in error &&
(error.status === 400 || error.type === 'entity.parse.failed')
);
}
function serializeZodIssues(error: ZodError) {
return error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
code: issue.code,
}));
}
export class HttpError extends Error {
statusCode: number;
expose: boolean;
code: string;
details?: unknown;
constructor(
statusCode: number,
message: string,
options: HttpErrorOptions = {},
) {
super(message);
this.name = 'HttpError';
this.statusCode = statusCode;
this.expose = options.expose ?? statusCode < 500;
this.code = options.code ?? resolveHttpErrorCode(statusCode);
this.details = options.details;
}
}
export function badRequest(message: string, details?: unknown) {
return new HttpError(400, message, {
code: 'BAD_REQUEST',
details,
});
}
export function invalidRequest(message = '请求参数不合法', details?: unknown) {
return new HttpError(400, message, {
code: 'INVALID_REQUEST',
details,
});
}
export function unauthorized(message = '未授权访问') {
return new HttpError(401, message);
return new HttpError(401, message, {
code: 'UNAUTHORIZED',
});
}
export function forbidden(message = '禁止访问') {
return new HttpError(403, message);
return new HttpError(403, message, {
code: 'FORBIDDEN',
});
}
export function tooManyRequests(message = '请求过于频繁', details?: unknown) {
return new HttpError(429, message, {
code: 'TOO_MANY_REQUESTS',
details,
});
}
export function captchaRequired(message = '需要完成人机校验', details?: unknown) {
return new HttpError(403, message, {
code: 'CAPTCHA_REQUIRED',
details,
});
}
export function notFound(message = '资源不存在') {
return new HttpError(404, message);
return new HttpError(404, message, {
code: 'NOT_FOUND',
});
}
export function conflict(message: string) {
return new HttpError(409, message);
export function conflict(message: string, details?: unknown) {
return new HttpError(409, message, {
code: 'CONFLICT',
details,
});
}
export function upstreamError(message: string) {
return new HttpError(502, message);
export function upstreamError(message: string, details?: unknown) {
return new HttpError(502, message, {
code: 'UPSTREAM_ERROR',
details,
});
}
export function toHttpError(error: unknown) {
if (error instanceof HttpError) {
return error;
}
if (error instanceof ZodError) {
return invalidRequest('请求参数不合法', {
issues: serializeZodIssues(error),
});
}
if (isJsonBodyParseError(error)) {
return badRequest('JSON 请求体格式错误');
}
return new HttpError(500, '服务器内部错误', {
code: 'INTERNAL_SERVER_ERROR',
expose: false,
});
}

View File

@@ -1,5 +1,299 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import {
API_VERSION,
createApiError,
createApiSuccess,
parseApiErrorMessage,
} from '../../packages/shared/src/http.js';
import { resolveHttpErrorCode, resolveHttpErrorMessage } from './errors.js';
export const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
export const API_VERSION_HEADER = 'x-api-version';
export const ROUTE_VERSION_HEADER = 'x-route-version';
export const RESPONSE_TIME_HEADER = 'x-response-time-ms';
const DEFAULT_API_VERSION = API_VERSION;
const DEFAULT_ROUTE_VERSION = DEFAULT_API_VERSION;
export type ApiRouteMeta = {
operation?: string;
routeVersion?: string;
};
export type ApiResponseMeta = {
requestId: string;
apiVersion: string;
routeVersion: string;
operation: string | null;
latencyMs: number;
timestamp: string;
};
export type ApiSuccessEnvelope<T> = {
ok: true;
data: T;
error: null;
meta: ApiResponseMeta;
};
type LegacyApiErrorBody = {
error?: {
code?: string;
message?: string;
details?: unknown;
} | null;
message?: string;
code?: string;
details?: unknown;
meta?: unknown;
};
function readRouteMeta(response: Response): ApiRouteMeta {
const routeMeta = response.locals.apiRouteMeta;
if (!routeMeta || typeof routeMeta !== 'object') {
return {};
}
return routeMeta as ApiRouteMeta;
}
function inferOperation(request: Request) {
if (request.originalUrl) {
return `${request.method} ${request.originalUrl}`;
}
if (request.route?.path) {
return `${request.method} ${request.baseUrl}${request.route.path}`;
}
return `${request.method} ${request.originalUrl || request.url}`;
}
export function setRouteMeta(response: Response, meta: ApiRouteMeta) {
response.locals.apiRouteMeta = {
...readRouteMeta(response),
...meta,
};
}
export function withRouteMeta(meta: ApiRouteMeta): RequestHandler {
return (_request, response, next) => {
setRouteMeta(response, meta);
next();
};
}
export function wantsApiEnvelope(request: Request) {
const value =
request.header(API_RESPONSE_ENVELOPE_HEADER)?.trim().toLowerCase() || '';
return (
value === '1' || value === 'true' || value === 'v1' || value === 'envelope'
);
}
export function buildApiResponseMeta(
request: Request,
response: Response,
): ApiResponseMeta {
const routeMeta = readRouteMeta(response);
return {
requestId: request.requestId,
apiVersion: DEFAULT_API_VERSION,
routeVersion: routeMeta.routeVersion || DEFAULT_ROUTE_VERSION,
operation: routeMeta.operation || inferOperation(request),
latencyMs: Math.max(0, Date.now() - request.requestStartedAt),
timestamp: new Date().toISOString(),
};
}
export function applyApiResponseHeaders(request: Request, response: Response) {
const meta = buildApiResponseMeta(request, response);
response.setHeader('X-Request-Id', meta.requestId);
response.setHeader(API_VERSION_HEADER, meta.apiVersion);
response.setHeader(ROUTE_VERSION_HEADER, meta.routeVersion);
response.setHeader(RESPONSE_TIME_HEADER, String(meta.latencyMs));
return meta;
}
function buildSharedApiMeta(meta: ApiResponseMeta) {
return {
requestId: meta.requestId,
apiVersion: meta.apiVersion,
routeVersion: meta.routeVersion,
operation: meta.operation,
latencyMs: meta.latencyMs,
timestamp: meta.timestamp,
};
}
export function buildApiLogContext(request: Request, response: Response) {
const meta = buildApiResponseMeta(request, response);
return {
request_id: meta.requestId,
api_version: meta.apiVersion,
route_version: meta.routeVersion,
operation: meta.operation,
};
}
export function toApiSuccessBody<T>(
request: Request,
response: Response,
data: T,
): T | ApiSuccessEnvelope<T> {
const meta = applyApiResponseHeaders(request, response);
if (!wantsApiEnvelope(request)) {
return data;
}
return createApiSuccess(data, buildSharedApiMeta(meta));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function isStandardApiSuccessEnvelope(
body: unknown,
): body is ApiSuccessEnvelope<unknown> {
return (
isRecord(body) &&
body.ok === true &&
'data' in body &&
'error' in body &&
body.error === null &&
isRecord(body.meta) &&
typeof body.meta.apiVersion === 'string'
);
}
export function isStandardApiErrorResponse(body: unknown) {
if (
!isRecord(body) ||
!isRecord(body.meta) ||
typeof body.meta.apiVersion !== 'string'
) {
return false;
}
if (body.ok === false) {
return (
body.data === null &&
isRecord(body.error) &&
typeof body.error.code === 'string' &&
typeof body.error.message === 'string'
);
}
return (
'error' in body &&
isRecord(body.error) &&
typeof body.error.code === 'string' &&
typeof body.error.message === 'string'
);
}
function normalizeLegacyApiErrorBody(body: unknown, statusCode: number) {
const legacyBody = isRecord(body) ? (body as LegacyApiErrorBody) : {};
const nestedError = isRecord(legacyBody.error) ? legacyBody.error : null;
const code =
(typeof nestedError?.code === 'string' && nestedError.code.trim()) ||
(typeof legacyBody.code === 'string' && legacyBody.code.trim()) ||
resolveHttpErrorCode(statusCode);
const message =
(typeof nestedError?.message === 'string' && nestedError.message.trim()) ||
(typeof legacyBody.message === 'string' && legacyBody.message.trim()) ||
resolveHttpErrorMessage(statusCode);
const details = nestedError?.details ?? legacyBody.details;
return {
code,
message,
...(details !== undefined ? { details } : {}),
};
}
export function toApiErrorBody(
request: Request,
response: Response,
body: unknown,
) {
const meta = applyApiResponseHeaders(request, response);
const error = normalizeLegacyApiErrorBody(body, response.statusCode || 500);
if (wantsApiEnvelope(request)) {
return createApiError(error, buildSharedApiMeta(meta));
}
return {
error,
meta,
};
}
export function sendApiResponse<T>(
response: Response,
data: T,
statusCode = 200,
) {
response.status(statusCode);
response.json(data);
}
export function prepareApiResponse(
request: Request,
response: Response,
options: {
statusCode?: number;
headers?: Record<string, string>;
routeMeta?: ApiRouteMeta;
} = {},
) {
if (options.routeMeta) {
setRouteMeta(response, options.routeMeta);
}
if (typeof options.statusCode === 'number') {
response.status(options.statusCode);
}
const meta = applyApiResponseHeaders(request, response);
for (const [name, value] of Object.entries(options.headers ?? {})) {
response.setHeader(name, value);
}
return meta;
}
export function prepareEventStreamResponse(
request: Request,
response: Response,
options: {
statusCode?: number;
routeMeta?: ApiRouteMeta;
headers?: Record<string, string>;
} = {},
) {
return prepareApiResponse(request, response, {
statusCode: options.statusCode ?? 200,
routeMeta: options.routeMeta,
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
...(options.headers ?? {}),
},
});
}
export function asyncHandler(
handler: (
request: Request,
@@ -16,31 +310,7 @@ export function extractApiErrorMessage(
rawText: string,
fallbackMessage: string,
) {
if (!rawText.trim()) {
return fallbackMessage;
}
try {
const parsed = JSON.parse(rawText) as {
error?: { message?: string };
message?: string;
code?: string;
};
if (typeof parsed.error?.message === 'string' && parsed.error.message.trim()) {
return parsed.error.message.trim();
}
if (typeof parsed.message === 'string' && parsed.message.trim()) {
return parsed.message.trim();
}
if (typeof parsed.code === 'string' && parsed.code.trim()) {
return `${fallbackMessage}${parsed.code.trim()}`;
}
} catch {
// Ignore malformed json responses.
}
return rawText.trim() || fallbackMessage;
return parseApiErrorMessage(rawText, fallbackMessage);
}
export function jsonClone<T>(value: T): T {

View File

@@ -22,10 +22,13 @@ export function requireJwtAuth(config: AppConfig, userRepository: UserRepository
}
const claims = await verifyAccessToken(token, config);
const user = userRepository.findById(claims.userId);
const user = await userRepository.findById(claims.userId);
if (!user) {
throw unauthorized('用户不存在');
}
if (user.accountStatus === 'disabled') {
throw unauthorized('账号已被禁用');
}
if (user.tokenVersion !== claims.tokenVersion) {
throw unauthorized('登录状态已失效,请重新登录');
}

View File

@@ -1,27 +1,54 @@
import type { ErrorRequestHandler } from 'express';
import { HttpError } from '../errors.js';
import { toHttpError } from '../errors.js';
import {
applyApiResponseHeaders,
buildApiLogContext,
wantsApiEnvelope,
} from '../http.js';
export const errorHandler: ErrorRequestHandler = (error, request, response, _next) => {
const statusCode =
error instanceof HttpError ? error.statusCode : 500;
const message =
error instanceof HttpError
? error.message
: '服务器内部错误';
export const errorHandler: ErrorRequestHandler = (
error,
request,
response,
_next,
) => {
const normalizedError = toHttpError(error);
const meta = applyApiResponseHeaders(request, response);
request.log?.error(
{
err: error,
request_id: request.requestId,
...buildApiLogContext(request, response),
user_id: request.userId ?? null,
status: normalizedError.statusCode,
error_code: normalizedError.code,
},
'request failed',
);
response.status(statusCode).json({
error: {
message,
},
response.status(normalizedError.statusCode);
const errorPayload = {
code: normalizedError.code,
message: normalizedError.message,
...(normalizedError.expose && normalizedError.details !== undefined
? { details: normalizedError.details }
: {}),
};
if (wantsApiEnvelope(request)) {
response.json({
ok: false,
data: null,
error: errorPayload,
meta,
});
return;
}
response.json({
error: errorPayload,
meta,
});
};

View File

@@ -2,7 +2,15 @@ import crypto from 'node:crypto';
import type { RequestHandler } from 'express';
export const requestIdMiddleware: RequestHandler = (request, _response, next) => {
request.requestId = request.header('x-request-id')?.trim() || crypto.randomUUID();
export const requestIdMiddleware: RequestHandler = (
request,
response,
next,
) => {
const requestId =
request.header('x-request-id')?.trim() || crypto.randomUUID();
request.requestId = requestId;
request.requestStartedAt = Date.now();
response.setHeader('x-request-id', requestId);
next();
};

View File

@@ -0,0 +1,49 @@
import type { RequestHandler, Response } from 'express';
import {
applyApiResponseHeaders,
isStandardApiErrorResponse,
isStandardApiSuccessEnvelope,
toApiErrorBody,
toApiSuccessBody,
} from '../http.js';
function isLegacyApiErrorBody(body: unknown) {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return false;
}
return (
('error' in body || 'message' in body || 'code' in body) &&
!('meta' in body && 'ok' in body)
);
}
function patchJsonResponse(response: Response) {
const originalJson = response.json.bind(response);
response.json = ((body: unknown) => {
if (
isStandardApiSuccessEnvelope(body) ||
isStandardApiErrorResponse(body)
) {
applyApiResponseHeaders(response.req, response);
return originalJson(body);
}
if (response.statusCode >= 400 || isLegacyApiErrorBody(body)) {
return originalJson(toApiErrorBody(response.req, response, body));
}
return originalJson(toApiSuccessBody(response.req, response, body));
}) as Response['json'];
}
export const responseEnvelopeMiddleware: RequestHandler = (
_request,
response,
next,
) => {
patchJsonResponse(response);
next();
};

View File

@@ -0,0 +1,10 @@
import type { RequestHandler } from 'express';
import { setRouteMeta, type ApiRouteMeta } from '../http.js';
export function routeMeta(meta: ApiRouteMeta): RequestHandler {
return (_request, response, next) => {
setRouteMeta(response, meta);
next();
};
}

View File

@@ -0,0 +1,30 @@
import { loadConfig } from './config.js';
import {
createDatabase,
listAppliedMigrations,
summarizeDatabaseTarget,
} from './db.js';
async function main() {
const config = loadConfig();
const db = await createDatabase(config);
try {
const migrations = await listAppliedMigrations(db);
console.log(
`[db:migrate] database=${summarizeDatabaseTarget(config.databaseUrl)}`,
);
console.log(`[db:migrate] applied migrations=${migrations.length}`);
for (const migration of migrations) {
console.log(`[db:migrate] ${migration.id} ${migration.name}`);
}
} finally {
await db.close();
}
}
void main().catch((error) => {
console.error('[db:migrate] failed', error);
process.exit(1);
});

View File

@@ -0,0 +1,90 @@
import type { Request, Response } from 'express';
import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcRecruitDialogueRequest,
} from '../../../../packages/shared/src/contracts/story.js';
import {
buildCharacterPanelChatPrompt,
buildCharacterPanelChatSuggestionPrompt,
buildCharacterPanelChatSummaryPrompt,
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
buildNpcRecruitDialoguePrompt,
buildStrictNpcChatDialoguePrompt,
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
} from './chatPromptBuilders.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
export async function generateCharacterChatSuggestionsFromOrchestrator(
llmClient: UpstreamLlmClient,
payload: CharacterChatSuggestionsRequest,
) {
return llmClient.requestMessageContent({
systemPrompt: CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
userPrompt: buildCharacterPanelChatSuggestionPrompt(payload),
});
}
export async function generateCharacterChatSummaryFromOrchestrator(
llmClient: UpstreamLlmClient,
payload: CharacterChatSummaryRequest,
) {
return llmClient.requestMessageContent({
systemPrompt: CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
userPrompt: buildCharacterPanelChatSummaryPrompt(payload),
});
}
export async function streamCharacterChatReplyFromOrchestrator(
llmClient: UpstreamLlmClient,
params: {
request: Request;
response: Response;
payload: CharacterChatReplyRequest;
},
) {
await llmClient.forwardSseText({
request: params.request,
response: params.response,
systemPrompt: CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
userPrompt: buildCharacterPanelChatPrompt(params.payload),
});
}
export async function streamNpcChatDialogueFromOrchestrator(
llmClient: UpstreamLlmClient,
params: {
request: Request;
response: Response;
payload: NpcChatDialogueRequest;
},
) {
await llmClient.forwardSseText({
request: params.request,
response: params.response,
systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
userPrompt: buildStrictNpcChatDialoguePrompt(params.payload),
});
}
export async function streamNpcRecruitDialogueFromOrchestrator(
llmClient: UpstreamLlmClient,
params: {
request: Request;
response: Response;
payload: NpcRecruitDialogueRequest;
},
) {
await llmClient.forwardSseText({
request: params.request,
response: params.response,
systemPrompt: NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
userPrompt: buildNpcRecruitDialoguePrompt(params.payload),
});
}

View File

@@ -0,0 +1,372 @@
import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcRecruitDialogueRequest,
} from '../../../../packages/shared/src/contracts/story.js';
type JsonRecord = Record<string, unknown>;
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
只回复这名角色此刻会对玩家说的话。
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
只输出纯文本,共 3 行,每行一条。
不要加编号、项目符号、Markdown 或额外说明。
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
只输出一段简洁文字。
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。
你只能输出纯中文对话正文不能输出解释、代码、markdown、JSON 或额外说明。
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 这段内容只是聊天,不是做决定。
- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。
- 禁止把情报直接写成对玩家的指令。
- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`;
export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。
你只能输出纯中文对话正文不能输出解释、代码、markdown、JSON 或额外说明。
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 这段对话的目标是把“邀请对方入队”自然谈成。
- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。
- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。
- 最后一行必须由对方明确答应加入队伍。`;
function asRecord(value: unknown): JsonRecord | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as JsonRecord)
: null;
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readStringArray(value: unknown) {
return Array.isArray(value)
? value
.map((item) => readString(item))
.filter((item): item is string => Boolean(item))
: [];
}
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '武侠';
case 'XIANXIA':
return '仙侠';
case 'CUSTOM':
return '自定义世界';
default:
return worldType || '未知世界';
}
}
function describeStats(label: string, record: JsonRecord | null) {
const hp = readNumber(record?.hp);
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
const mana = readNumber(record?.mana);
const maxMana = Math.max(1, readNumber(record?.maxMana, mana));
return `${label}生命 ${hp}/${maxHp},灵力 ${mana}/${maxMana}`;
}
function describeCharacter(label: string, value: unknown) {
const record = asRecord(value);
const name = readString(record?.name) ?? '未知角色';
const title = readString(record?.title) ?? '未知称号';
const description = readString(record?.description) ?? '暂无额外描述';
const personality = readString(record?.personality) ?? '性格信息未显式提供';
return [
`${label}姓名:${name}`,
`${label}称号:${title}`,
`${label}描述:${description}`,
`${label}性格:${personality}`,
].join('\n');
}
function describeStoryHistory(history: unknown) {
if (!Array.isArray(history) || history.length === 0) {
return '近期剧情:暂无。';
}
const lines = history
.slice(-4)
.map((item) => readString(asRecord(item)?.text))
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['近期剧情:', ...lines.map((line) => `- ${line}`)].join('\n')
: '近期剧情:暂无。';
}
function describeConversationHistory(history: unknown) {
if (!Array.isArray(history) || history.length === 0) {
return '聊天记录:暂无。';
}
const lines = history
.slice(-12)
.map((item) => {
const record = asRecord(item);
const speaker = readString(record?.speaker) === 'player' ? '玩家' : '角色';
const text = readString(record?.text);
return text ? `- ${speaker}${text}` : null;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['聊天记录:', ...lines].join('\n')
: '聊天记录:暂无。';
}
function describeSceneContext(context: unknown) {
const record = asRecord(context);
const sceneName = readString(record?.sceneName) ?? '当前区域';
const sceneDescription =
readString(record?.sceneDescription) ?? '周围气氛仍未完全安定。';
const inBattle = record?.inBattle === true ? '战斗中' : '非战斗';
const customWorldProfile = asRecord(record?.customWorldProfile);
const customWorldName = readString(customWorldProfile?.name);
const customWorldSummary = readString(customWorldProfile?.summary);
return [
`世界补充:${customWorldName ?? '无'}`,
customWorldSummary ? `世界摘要:${customWorldSummary}` : null,
`场景:${sceneName}`,
`场景描述:${sceneDescription}`,
`当前状态:${inBattle}`,
describeStats('玩家', record),
]
.filter(Boolean)
.join('\n');
}
function describeTargetStatus(status: unknown) {
const record = asRecord(status);
const roleLabel = readString(record?.roleLabel) ?? '同行角色';
const affinity = record?.affinity;
return [
`对方身份:${roleLabel}`,
describeStats('对方', record),
typeof affinity === 'number' ? `当前好感:${affinity}` : null,
]
.filter(Boolean)
.join('\n');
}
function describeEncounter(encounter: unknown) {
const record = asRecord(encounter);
const npcName = readString(record?.npcName) ?? '眼前角色';
const contextText =
readString(record?.context) ??
readString(record?.npcDescription) ??
'你们正在当前遭遇里继续对话。';
return {
npcName,
block: [`当前对象:${npcName}`, `对象背景:${contextText}`].join('\n'),
};
}
function describeMonsters(monsters: unknown) {
if (!Array.isArray(monsters) || monsters.length === 0) {
return '当前敌对目标:无。';
}
const lines = monsters
.slice(0, 4)
.map((item) => {
const record = asRecord(item);
const name =
readString(record?.name) ??
readString(record?.npcName) ??
readString(record?.id);
const hp = readNumber(record?.hp);
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
return name ? `- ${name}(生命 ${hp}/${maxHp}` : null;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['当前敌对目标:', ...lines].join('\n')
: '当前敌对目标:无。';
}
function describeTargetCharacterName(payload: {
targetCharacter?: unknown;
encounter?: unknown;
}) {
return (
readString(asRecord(payload.targetCharacter)?.name) ??
readString(asRecord(payload.encounter)?.npcName) ??
'对方'
);
}
export function buildCharacterPanelChatPrompt(
payload: CharacterChatReplyRequest,
) {
const targetName = describeTargetCharacterName(payload);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.conversationSummary
? `之前聊天摘要:${payload.conversationSummary}`
: '之前聊天摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
`玩家刚刚对 ${targetName} 说:${payload.playerMessage}`,
`现在请以 ${targetName} 的身份,直接回复玩家。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildCharacterPanelChatSuggestionPrompt(
payload: CharacterChatSuggestionsRequest,
) {
const targetName = describeTargetCharacterName(payload);
const latestCharacterReply = Array.isArray(payload.conversationHistory)
? [...payload.conversationHistory]
.reverse()
.map((item) => asRecord(item))
.find((record) => readString(record?.speaker) === 'character')
: null;
const latestReplyText = readString(latestCharacterReply?.text);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.conversationSummary
? `之前聊天摘要:${payload.conversationSummary}`
: '之前聊天摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
latestReplyText
? `角色刚刚的回复:${latestReplyText}`
: `玩家正准备与 ${targetName} 开始一段新的私聊。`,
`请围绕当前气氛,为玩家生成 3 条可以直接发送给 ${targetName} 的简短回复候选。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildCharacterPanelChatSummaryPrompt(
payload: CharacterChatSummaryRequest,
) {
const targetName = describeTargetCharacterName(payload);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.previousSummary
? `旧摘要:${payload.previousSummary}`
: '旧摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
`请把玩家与 ${targetName} 的旧摘要和最新聊天整理成一段更新后的关系摘要。`,
]
.filter(Boolean)
.join('\n\n');
}
function buildNpcDialoguePromptBase(
payload: NpcChatDialogueRequest | NpcRecruitDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.character),
encounter.block,
describeMonsters(payload.monsters),
describeStoryHistory(payload.history),
]
.filter(Boolean)
.join('\n\n');
}
export function buildStrictNpcChatDialoguePrompt(
payload: NpcChatDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
const context = asRecord(payload.context);
const openingCampBackground = readString(context?.openingCampBackground);
const openingCampDialogue = readString(context?.openingCampDialogue);
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
return [
buildNpcDialoguePromptBase(payload),
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
allowedTopics.length > 0
? `当前更适合谈的内容:${allowedTopics.join('、')}`
: null,
blockedTopics.length > 0
? `当前避免直接说破:${blockedTopics.join('、')}`
: null,
`当前聊天主题:${payload.topic}`,
payload.resultSummary
? `这段聊天希望带来的变化:${payload.resultSummary}`
: '这段聊天要让气氛、情报或关系出现一层新的变化。',
`请围绕“${payload.topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounter.npcName}:”开头。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcRecruitDialoguePrompt(
payload: NpcRecruitDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
return [
buildNpcDialoguePromptBase(payload),
`玩家邀请:${payload.invitationText}`,
payload.recruitSummary
? `招募补充条件:${payload.recruitSummary}`
: '这轮对话已经具备自然邀请对方入队的条件。',
'这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。',
`最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`,
]
.filter(Boolean)
.join('\n\n');
}

View File

@@ -0,0 +1,461 @@
import type {
CustomWorldGenerationProgress,
GenerateCustomWorldProfileInput,
} from '../../../../packages/shared/src/contracts/runtime.js';
type GeneratedProfile = Record<string, unknown>;
const PLAYABLE_ROLE_TEMPLATES = [
{ title: '断桥行者', role: '游历剑客', style: '快剑追击', tags: ['快剑', '突进', '追击'] },
{ title: '听风客', role: '远行弓手', style: '远射游击', tags: ['远射', '游击', '风行'] },
{ title: '守夜人', role: '前列护卫', style: '守御护体', tags: ['守御', '护体', '先锋'] },
{ title: '观火者', role: '术式使', style: '法修过载', tags: ['法修', '过载', '法力'] },
{ title: '逐潮者', role: '浪客拳师', style: '重击压制', tags: ['重击', '爆发', '压制'] },
] as const;
const STORY_ROLE_TEMPLATES = [
{ role: '沿街商贩', danger: 'low', tags: ['交易', '情报'] },
{ role: '巡路探子', danger: 'medium', tags: ['巡守', '警觉'] },
{ role: '旧案见证人', danger: 'medium', tags: ['旧案', '隐情'] },
{ role: '守桥武人', danger: 'high', tags: ['守御', '敌意'] },
{ role: '异变潜伏者', danger: 'high', tags: ['异变', '威胁'] },
] as const;
const LANDMARK_TEMPLATES = [
'断桥口',
'旧市桥廊',
'潮痕渡口',
'灰塔前庭',
'沉钟小巷',
'碑下荒庭',
'雾潮栈道',
'封灯码头',
'裂潮前哨',
'残照高台',
] as const;
function nowMs() {
return Date.now();
}
function inferWorldType(settingText: string) {
return /||||||/u.test(settingText)
? 'XIANXIA'
: 'WUXIA';
}
function seedText(input: GenerateCustomWorldProfileInput) {
return input.settingText.trim().replace(/\s+/g, ' ');
}
function slugify(value: string) {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'entry';
}
function buildAttributeSchema(worldType: 'WUXIA' | 'XIANXIA') {
return {
id: `schema:${worldType.toLowerCase()}:default`,
worldId: `world:${worldType.toLowerCase()}`,
schemaVersion: 1,
generatedFrom: {
worldType,
worldName: worldType === 'XIANXIA' ? '云海异境' : '裂潮边城',
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的修行世界' : '旧桥与边城交错的武侠世界',
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、江湖余震',
conflictCore: '旧秩序与新威胁正在同时逼近',
},
schemaName: worldType === 'XIANXIA' ? '灵潮六轴' : '边城六轴',
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: '决定发现隐藏真相的能力',
},
],
};
}
function buildBackstoryReveal(name: string) {
return {
publicSummary: `${name}在表面上只露出一层足以自保的说辞。`,
privateChatUnlockAffinity: 60,
chapters: [
{
id: `${slugify(name)}-surface`,
title: '表层来意',
affinityRequired: 15,
teaser: `${name}对你仍留着一层试探。`,
content: `${name}先承认自己并非偶然出现在这里,而是被同一场异动推到了前线。`,
contextSnippet: `${name}的真正来意还没有完全摊开。`,
},
{
id: `${slugify(name)}-scar`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `${name}提到过一次不愿重说的旧伤。`,
content: `${name}曾在上一轮风暴里失去过重要的人,因此对类似局势格外警觉。`,
contextSnippet: `${name}和旧案之间存在未平的裂痕。`,
},
{
id: `${slugify(name)}-hidden`,
title: '隐藏执念',
affinityRequired: 60,
teaser: `${name}其实一直在盯着更深一层的线索。`,
content: `${name}真正想追索的不是眼前纷乱本身,而是它背后那只一直没露面的手。`,
contextSnippet: `${name}的行动始终绕着一条更深的暗线。`,
},
{
id: `${slugify(name)}-final`,
title: '最终底牌',
affinityRequired: 90,
teaser: `${name}手里一直留着最后一道底牌。`,
content: `${name}早就为最坏结局准备了最后的应对,但不到绝境绝不会轻易亮出。`,
contextSnippet: `${name}仍保留着能改写局面的最后筹码。`,
},
],
};
}
function buildSkills(name: string) {
return [
{
id: `${slugify(name)}-skill-1`,
name: `${name}起手`,
summary: '先用短促动作压住眼前节奏。',
style: '起手压制',
},
{
id: `${slugify(name)}-skill-2`,
name: `${name}变招`,
summary: '在试探后迅速换位改势。',
style: '机动周旋',
},
{
id: `${slugify(name)}-skill-3`,
name: `${name}底牌`,
summary: '在局势逼紧时打出保留手段。',
style: '爆发终结',
},
];
}
function buildInitialItems(name: string) {
return [
{
id: `${slugify(name)}-item-1`,
name: `${name}常备武具`,
category: '武器',
quantity: 1,
rarity: 'rare',
description: '随身不离手的主战物件。',
tags: ['战斗', '随身'],
},
{
id: `${slugify(name)}-item-2`,
name: `${name}补给包`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '为了久战和撤离准备的基础补给。',
tags: ['补给', '行动'],
},
{
id: `${slugify(name)}-item-3`,
name: `${name}私人物件`,
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '不愿轻易交出的旧信物。',
tags: ['信物', '线索'],
},
];
}
function buildPlayableNpcs(seed: string) {
return PLAYABLE_ROLE_TEMPLATES.map((template, index) => {
const name = `${seed.slice(0, 2) || '裂潮'}${['岚', '砺', '遥', '烛', '澜'][index]}`;
return {
id: `playable-npc-${index + 1}`,
name,
title: template.title,
role: template.role,
description: `${name}习惯先观察再出手,对局势变化反应极快。`,
backstory: `${name}长期在风暴边缘活动,对眼前这场失衡局势并不陌生。`,
personality: '谨慎、沉稳、保留余地',
motivation: '想先查清是谁把局势推到这一步。',
combatStyle: template.style,
initialAffinity: 18 + index * 4,
relationshipHooks: ['共同求生', '交换情报'],
tags: [...template.tags],
backstoryReveal: buildBackstoryReveal(name),
skills: buildSkills(name),
initialItems: buildInitialItems(name),
templateCharacterId: ['sword-princess', 'archer-hero', 'girl-hero', 'punch-hero', 'fighter-4'][index],
};
});
}
function buildStoryNpcs(seed: string) {
return Array.from({ length: 25 }, (_, index) => {
const template = STORY_ROLE_TEMPLATES[index % STORY_ROLE_TEMPLATES.length]!;
const name = `${seed.slice(0, 2) || '裂潮'}${['青', '玄', '沉', '洛', '霁'][index % 5]}${index + 1}`;
return {
id: `story-npc-${index + 1}`,
name,
title: `${index + 1}位见证者`,
role: template.role,
description: `${name}始终在观察这场异动会把谁先逼到台前。`,
backstory: `${name}和这片地界的旧事牵连很深,只是还没有把来历说透。`,
personality: '警觉、克制、善于藏话',
motivation: '想确认这轮动荡背后真正的引线。',
combatStyle: template.danger === 'high' ? '先压后断' : '先试后动',
initialAffinity: template.danger === 'high' ? -12 : 6 + (index % 3) * 6,
relationshipHooks: ['旧案牵连', '局势试探'],
tags: [...template.tags],
backstoryReveal: buildBackstoryReveal(name),
skills: buildSkills(name),
initialItems: buildInitialItems(name),
};
});
}
function buildLandmarks(seed: string, storyNpcIds: string[]) {
return LANDMARK_TEMPLATES.map((baseName, index, all) => {
const name = `${seed.slice(0, 2) || '裂潮'}${baseName}`;
return {
id: `landmark-${index + 1}`,
name,
description: `${name}附近同时压着旧痕、异动与尚未收束的危险。`,
dangerLevel: index < 3 ? 'medium' : index < 7 ? 'high' : 'extreme',
sceneNpcIds: [
storyNpcIds[index % storyNpcIds.length],
storyNpcIds[(index + 7) % storyNpcIds.length],
storyNpcIds[(index + 13) % storyNpcIds.length],
],
connections: [
{
targetLandmarkId: `landmark-${((index + 1) % all.length) + 1}`,
relativePosition: 'forward',
summary: '沿着当前道路继续前推就能抵达。',
},
{
targetLandmarkId: `landmark-${((index + all.length - 1) % all.length) + 1}`,
relativePosition: 'back',
summary: '沿原路回撤可以折返到上一处节点。',
},
],
};
});
}
function buildProgress(
phaseId: string,
phaseLabel: string,
phaseDetail: string,
overallProgress: number,
activeStepIndex: number,
startedAt: number,
): CustomWorldGenerationProgress {
const steps = [
{ id: 'framework', label: '世界框架', detail: '整理世界基础骨架。', status: overallProgress >= 0.25 ? 'completed' : phaseId === 'framework' ? 'active' : 'pending', completed: overallProgress >= 0.25 ? 1 : 0, total: 1 },
{ id: 'roles', label: '角色群像', detail: '生成可玩与场景角色。', status: overallProgress >= 0.6 ? 'completed' : phaseId === 'roles' ? 'active' : 'pending', completed: overallProgress >= 0.6 ? 1 : 0, total: 1 },
{ id: 'landmarks', label: '场景网络', detail: '生成地标与连接关系。', status: overallProgress >= 0.85 ? 'completed' : phaseId === 'landmarks' ? 'active' : 'pending', completed: overallProgress >= 0.85 ? 1 : 0, total: 1 },
{ id: 'finalize', label: '最终归档', detail: '整理最终世界资料。', status: overallProgress >= 1 ? 'completed' : phaseId === 'finalize' ? 'active' : 'pending', completed: overallProgress >= 1 ? 1 : 0, total: 1 },
] as CustomWorldGenerationProgress['steps'];
return {
phaseId,
phaseLabel,
phaseDetail,
overallProgress,
completedWeight: Math.round(overallProgress * 100),
totalWeight: 100,
elapsedMs: nowMs() - startedAt,
estimatedRemainingMs: overallProgress >= 1 ? 0 : Math.max(1000, Math.round((1 - overallProgress) * 4000)),
activeStepIndex,
steps,
};
}
function inferMajorFactions(seed: string) {
return [
`${seed.slice(0, 2) || '裂潮'}守桥司`,
`${seed.slice(0, 2) || '裂潮'}旧案会`,
`${seed.slice(0, 2) || '裂潮'}商旅盟`,
];
}
function inferCoreConflicts(seedText: string) {
const core = seedText.slice(0, 24) || '旧秩序与新威胁的失衡';
return [
`围绕“${core}”的旧秩序正在松动。`,
'各方都在争夺谁来解释眼前的异变。',
'真正推动局势的人始终没有完全现身。',
];
}
function buildDeterministicProfile(input: GenerateCustomWorldProfileInput) {
const setting = seedText(input);
const worldType = inferWorldType(setting);
const seed = setting.replace(/\s+/g, '').slice(0, 6) || (worldType === 'XIANXIA' ? '云潮' : '裂潮');
const playableNpcs = buildPlayableNpcs(seed);
const storyNpcs = buildStoryNpcs(seed);
const landmarks = buildLandmarks(
seed,
storyNpcs.map((npc) => npc.id),
);
return {
id: `custom-world-${Date.now().toString(36)}-${slugify(seed)}`,
settingText: setting,
name: worldType === 'XIANXIA' ? `${seed}灵境` : `${seed}边城`,
subtitle: '前路未明',
summary: `这个世界围绕“${setting.slice(0, 28)}”展开,旧秩序与新威胁正在同时逼近。`,
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、江湖余震',
playerGoal: '查清眼前局势的关键矛盾,并守住仍值得相信的人与事',
templateWorldType: worldType,
majorFactions: inferMajorFactions(seed),
coreConflicts: inferCoreConflicts(setting),
attributeSchema: buildAttributeSchema(worldType),
playableNpcs,
storyNpcs,
items: [],
camp: {
name: worldType === 'XIANXIA' ? `${seed}归云舍` : `${seed}归桥居`,
description: '这是玩家开局时暂时安身、整理情报与调整队伍的位置。',
dangerLevel: 'low',
},
landmarks,
themePack: null,
storyGraph: null,
knowledgeFacts: [],
threadContracts: [],
creatorIntent: input.creatorIntent ?? null,
anchorPack: null,
lockState: null,
ownedSettingLayers: null,
generationMode: input.generationMode ?? 'full',
generationStatus: input.generationMode === 'fast' ? 'key_only' : 'complete',
scenarioPackId: null,
campaignPackId: null,
} satisfies GeneratedProfile;
}
export async function generateCustomWorldProfileFromOrchestrator(
input: GenerateCustomWorldProfileInput,
options: {
onProgress?: (progress: CustomWorldGenerationProgress) => void;
signal?: AbortSignal;
} = {},
) {
if (options.signal?.aborted) {
throw new Error('世界生成已中断。');
}
const startedAt = nowMs();
options.onProgress?.(
buildProgress(
'framework',
'世界框架',
'正在整理世界基础设定与主矛盾。',
0.2,
0,
startedAt,
),
);
options.onProgress?.(
buildProgress(
'roles',
'角色群像',
'正在生成可扮演角色与场景角色骨架。',
0.55,
1,
startedAt,
),
);
options.onProgress?.(
buildProgress(
'landmarks',
'场景网络',
'正在生成地标与场景连接关系。',
0.82,
2,
startedAt,
),
);
const profile = buildDeterministicProfile(input);
options.onProgress?.(
buildProgress(
'finalize',
'最终归档',
`世界“${String(profile.name)}”已完成归档。`,
1,
3,
startedAt,
),
);
return profile;
}

View File

@@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type {
CharacterChatSuggestionsRequest,
} from '../../../../packages/shared/src/contracts/story.js';
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js';
import { SYSTEM_PROMPT } from './storyPromptBuilders.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,
} from './chatOrchestrator.js';
import { generateInitialStoryFromOrchestrator } from './storyOrchestrator.js';
type TestStoryContext = Parameters<typeof generateInitialStoryFromOrchestrator>[4];
type TestStoryOption = Awaited<
ReturnType<typeof generateInitialStoryFromOrchestrator>
>['options'][number];
const TEST_WORLD = 'WUXIA' as Parameters<
typeof generateInitialStoryFromOrchestrator
>[1];
type TestCharacter = Parameters<typeof generateInitialStoryFromOrchestrator>[2];
function createTestCharacter(overrides: Partial<TestCharacter> = {}) {
return {
...createTestPlayerCharacter<TestCharacter>(),
...overrides,
};
}
function createStoryContext(): TestStoryContext {
return {
playerHp: 120,
playerMaxHp: 120,
playerMana: 40,
playerMaxMana: 40,
inBattle: false,
playerX: 320,
playerFacing: 'right',
playerAnimation: 'idle',
skillCooldowns: {},
sceneId: 'inn_room',
sceneName: '客栈内室',
sceneDescription: '昏黄灯火照着刚刚停下脚步的木桌。',
pendingSceneEncounter: false,
};
}
function createAvailableOptions(context: TestStoryContext) {
void context;
return [
{
functionId: 'idle_explore_forward',
actionText: '继续向前探索前路',
text: '继续向前探索前路',
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
{
functionId: 'idle_observe_signs',
actionText: '停步观察附近的风吹草动',
text: '停步观察附近的风吹草动',
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
] as TestStoryOption[];
}
test('story orchestrator repairs mixed-language narrative on the server side', async () => {
const context = createStoryContext();
const availableOptions = createAvailableOptions(context);
const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = [];
const llmClient = {
requestMessageContent: async ({
systemPrompt,
userPrompt,
}: {
systemPrompt: string;
userPrompt: string;
}) => {
capturedPrompts.push({ systemPrompt, userPrompt });
if (capturedPrompts.length === 1) {
return JSON.stringify({
storyText: 'The room falls quiet for a moment.',
encounter: null,
options: availableOptions.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
})),
});
}
return JSON.stringify({
storyText: '房间里短暂安静了一瞬,你能听见灯火轻轻噼啪作响。',
encounter: null,
options: availableOptions.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
})),
});
},
} as const;
const response = await generateInitialStoryFromOrchestrator(
llmClient as never,
TEST_WORLD,
createTestCharacter(),
[],
context,
{
availableOptions,
},
);
assert.equal(capturedPrompts.length, 2);
assert.equal(capturedPrompts[0]?.systemPrompt, SYSTEM_PROMPT);
assert.match(capturedPrompts[0]?.userPrompt ?? '', //u);
assert.equal(
response.storyText,
'房间里短暂安静了一瞬,你能听见灯火轻轻噼啪作响。',
);
assert.deepEqual(
response.options.map((option) => option.functionId),
availableOptions.map((option) => option.functionId),
);
});
test('chat orchestrator builds character suggestion prompts on the server side', async () => {
const payload = {
worldType: TEST_WORLD,
playerCharacter: createTestCharacter(),
targetCharacter: createTestCharacter({
id: 'test-companion',
name: '测试同伴',
title: '听风客',
}),
storyHistory: [],
context: createStoryContext(),
conversationHistory: [
{ speaker: 'player', text: '刚才那阵风是不是也不太对劲?' },
{ speaker: 'character', text: '像是有人故意把门帘掀起来了一样。' },
],
conversationSummary: '两人刚在客栈里察觉到不寻常的动静。',
targetStatus: {
roleLabel: '同行角色',
hp: 95,
maxHp: 120,
mana: 28,
maxMana: 40,
affinity: 18,
},
} satisfies CharacterChatSuggestionsRequest;
const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = [];
const llmClient = {
requestMessageContent: async ({
systemPrompt,
userPrompt,
}: {
systemPrompt: string;
userPrompt: string;
}) => {
capturedPrompts.push({ systemPrompt, userPrompt });
return '先别急,我们再听一轮。\n你刚才看见谁动门帘了吗\n要不我先去门边探一眼。';
},
} as const;
const text = await generateCharacterChatSuggestionsFromOrchestrator(
llmClient as never,
payload,
);
assert.equal(text.split('\n').length, 3);
assert.equal(
capturedPrompts[0]?.systemPrompt,
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
);
assert.match(capturedPrompts[0]?.userPrompt ?? '', //u);
assert.match(capturedPrompts[0]?.userPrompt ?? '', //u);
assert.match(capturedPrompts[0]?.userPrompt ?? '', new RegExp(payload.targetCharacter.name, 'u'));
});

View File

@@ -0,0 +1,615 @@
import { hasMixedNarrativeLanguage } from '../../../../packages/shared/src/llm/narrativeLanguage.js';
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
import { buildUserPrompt, SYSTEM_PROMPT } from './storyPromptBuilders.js';
type JsonRecord = Record<string, unknown>;
type PromptWorldType = string;
type PromptCharacter = JsonRecord;
type PromptMonster = JsonRecord;
type PromptMonsters = PromptMonster[];
type PromptStoryMoment = JsonRecord;
type PromptHistory = PromptStoryMoment[];
type PromptContext = JsonRecord;
type PromptStoryOption = {
functionId: string;
actionText: string;
text?: string;
detailText?: string;
priority?: number;
visuals: {
playerAnimation: 'idle' | 'attack' | 'run' | 'hurt' | 'jump' | 'dash';
playerMoveMeters: number;
playerOffsetY: number;
playerFacing: 'left' | 'right';
scrollWorld: boolean;
monsterChanges: Array<{
id: string;
action: string;
animation: 'idle' | 'move' | 'attack';
moveMeters?: number;
yOffset?: number;
}>;
};
interaction?: {
kind: 'npc' | 'treasure';
npcId?: string;
action?: string;
};
skillProbabilities?: Record<string, number>;
goalAffordance?: {
goalId: string;
relation: 'advance' | 'support' | 'detour';
label: string;
} | null;
};
type PromptAvailableOptions = PromptStoryOption[];
type PromptOptionCatalog = PromptStoryOption[];
type StoryRequestOptions = {
availableOptions?: PromptAvailableOptions;
optionCatalog?: PromptOptionCatalog;
};
type SceneEncounterResult =
| { kind: 'none' }
| { kind: 'npc'; npcId?: string }
| { kind: 'treasure'; treasureText?: string };
type AIResponse = {
storyText: string;
options: PromptStoryOption[];
encounter?: SceneEncounterResult;
};
type RawOptionItem = {
functionId: string;
actionText?: string;
};
const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
你会收到一个已经解析过的剧情 JSON 对象。
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
const DEFAULT_VISUALS = {
playerAnimation: 'idle' as const,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
};
const STATIC_FALLBACK_OPTION_MAP: Record<
string,
Partial<PromptStoryOption> & { actionText: string }
> = {
battle_all_in_crush: { actionText: '正面强压敌人' },
battle_escape_breakout: { actionText: '先脱离眼前追杀' },
battle_feint_step: { actionText: '借假动作切进身位' },
battle_finisher_window: { actionText: '抓住破绽补上终结一击' },
battle_guard_break: { actionText: '重击破开对手架势' },
battle_probe_pressure: { actionText: '稳扎稳打继续试探' },
battle_recover_breath: { actionText: '边守边调息稳住节奏' },
idle_call_out: { actionText: '朝前方主动出声试探' },
idle_explore_forward: { actionText: '继续向前探索前路' },
idle_observe_signs: { actionText: '停步观察附近的风吹草动' },
idle_rest_focus: { actionText: '原地调息整理状态' },
idle_travel_next_scene: { actionText: '前往相邻场景' },
npc_chat: {
actionText: '继续交谈',
interaction: { kind: 'npc', action: 'chat' },
},
npc_help: {
actionText: '请求援手',
interaction: { kind: 'npc', action: 'help' },
},
npc_fight: {
actionText: '直接开战',
interaction: { kind: 'npc', action: 'fight' },
},
npc_leave: {
actionText: '先拉开距离',
interaction: { kind: 'npc', action: 'leave' },
},
npc_preview_talk: {
actionText: '先试着接一句话',
interaction: { kind: 'npc', action: 'chat' },
},
npc_recruit: {
actionText: '正式邀请同行',
interaction: { kind: 'npc', action: 'recruit' },
},
npc_spar: {
actionText: '点到为止地切磋',
interaction: { kind: 'npc', action: 'spar' },
},
npc_trade: {
actionText: '看看能交换什么',
interaction: { kind: 'npc', action: 'trade' },
},
npc_gift: {
actionText: '送上一份礼物',
interaction: { kind: 'npc', action: 'gift' },
},
npc_quest_accept: {
actionText: '接下这份委托',
interaction: { kind: 'npc', action: 'quest_accept' },
},
npc_quest_turn_in: {
actionText: '交付已经完成的委托',
interaction: { kind: 'npc', action: 'quest_turn_in' },
},
treasure_inspect: {
actionText: '仔细检查',
interaction: { kind: 'treasure', action: 'inspect' },
},
treasure_leave: {
actionText: '先记下位置',
interaction: { kind: 'treasure', action: 'leave' },
},
treasure_secure: {
actionText: '直接收取',
interaction: { kind: 'treasure', action: 'secure' },
},
};
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function inferNpcId(context: PromptContext, encounter?: SceneEncounterResult) {
if (encounter?.kind === 'npc' && encounter.npcId) {
return encounter.npcId;
}
return readString(context.encounterId) || readString(context.encounterName);
}
function createGenericOption(params: {
functionId: string;
actionText?: string;
context: PromptContext;
encounter?: SceneEncounterResult;
}) {
const functionId = params.functionId;
const preset = STATIC_FALLBACK_OPTION_MAP[functionId];
const npcId = inferNpcId(params.context, params.encounter);
const interaction =
preset?.interaction?.kind === 'npc' && npcId
? {
...preset.interaction,
npcId,
}
: preset?.interaction;
return {
functionId,
actionText: readString(params.actionText) || preset?.actionText || functionId,
text: readString(params.actionText) || preset?.actionText || functionId,
visuals: DEFAULT_VISUALS,
interaction,
} satisfies PromptStoryOption;
}
function cloneStoryOption(option: PromptStoryOption): PromptStoryOption {
return {
...option,
visuals: {
...DEFAULT_VISUALS,
...option.visuals,
monsterChanges: option.visuals?.monsterChanges?.map((change) => ({
...change,
})) ?? [],
},
interaction: option.interaction ? { ...option.interaction } : undefined,
skillProbabilities: option.skillProbabilities
? { ...option.skillProbabilities }
: undefined,
goalAffordance: option.goalAffordance ? { ...option.goalAffordance } : option.goalAffordance,
};
}
function normalizeEncounterResult(
raw: unknown,
context: PromptContext,
): SceneEncounterResult | undefined {
if (!context.pendingSceneEncounter) {
return undefined;
}
if (!raw || typeof raw !== 'object') {
return { kind: 'none' };
}
const item = raw as Record<string, unknown>;
const kind = readString(item.kind);
if (kind === 'npc' || kind === 'monster') {
return {
kind: 'npc',
npcId: readString(item.npcId) || readString(context.encounterId) || undefined,
};
}
if (kind === 'treasure') {
return {
kind: 'treasure',
treasureText: readString(item.treasureText) || undefined,
};
}
return { kind: 'none' };
}
function resolveSafeGeneratedActionText(actionText: string | undefined) {
const trimmed = actionText?.trim();
if (!trimmed || hasMixedNarrativeLanguage(trimmed)) {
return undefined;
}
return trimmed;
}
function resolveOptionsFromProvidedOptions(
items: RawOptionItem[],
availableOptions: PromptAvailableOptions,
) {
if (items.length === 0) {
return availableOptions.map(cloneStoryOption);
}
const optionBuckets = new Map<string, PromptStoryOption[]>();
const consumedOptions = new Set<PromptStoryOption>();
availableOptions.forEach((option) => {
const bucket = optionBuckets.get(option.functionId) ?? [];
bucket.push(option);
optionBuckets.set(option.functionId, bucket);
});
const resolved: PromptStoryOption[] = [];
items.forEach((item) => {
const bucket = optionBuckets.get(item.functionId);
const matchedOption = bucket?.shift();
if (!matchedOption) {
return;
}
consumedOptions.add(matchedOption);
const rewrittenText = resolveSafeGeneratedActionText(item.actionText);
resolved.push({
...cloneStoryOption(matchedOption),
actionText: rewrittenText || matchedOption.actionText,
text: rewrittenText || matchedOption.text || matchedOption.actionText,
});
});
if (resolved.length === availableOptions.length) {
return resolved;
}
const remainingOptions = availableOptions.filter(
(option) => !consumedOptions.has(option),
);
return [...resolved, ...remainingOptions.map(cloneStoryOption)];
}
function resolveOptionsFromOptionCatalog(
items: RawOptionItem[],
optionCatalog: PromptOptionCatalog,
context: PromptContext,
encounter?: SceneEncounterResult,
) {
if (items.length === 0) {
return optionCatalog.map(cloneStoryOption);
}
const optionBuckets = new Map<string, PromptStoryOption[]>();
optionCatalog.forEach((option) => {
const bucket = optionBuckets.get(option.functionId) ?? [];
bucket.push(option);
optionBuckets.set(option.functionId, bucket);
});
return items.map((item) => {
const bucket = optionBuckets.get(item.functionId);
const matchedOption = bucket?.shift();
if (!matchedOption) {
return createGenericOption({
functionId: item.functionId,
actionText: item.actionText,
context,
encounter,
});
}
const rewrittenText = resolveSafeGeneratedActionText(item.actionText);
return {
...cloneStoryOption(matchedOption),
actionText: rewrittenText || matchedOption.actionText,
text: rewrittenText || matchedOption.text || matchedOption.actionText,
};
});
}
function getFallbackFunctionIds(context: PromptContext, encounter?: SceneEncounterResult) {
if (context.inBattle === true) {
return [
'battle_probe_pressure',
'battle_guard_break',
'battle_recover_breath',
'battle_feint_step',
'battle_finisher_window',
'battle_escape_breakout',
];
}
if (encounter?.kind === 'npc') {
return [
'npc_chat',
'npc_help',
'npc_trade',
'npc_gift',
'npc_recruit',
'npc_leave',
];
}
if (encounter?.kind === 'treasure') {
return ['treasure_inspect', 'treasure_secure', 'treasure_leave'];
}
return [
'idle_explore_forward',
'idle_call_out',
'idle_observe_signs',
'idle_rest_focus',
'idle_travel_next_scene',
'idle_explore_forward',
];
}
function getFallbackOptions(
context: PromptContext,
encounter?: SceneEncounterResult,
) {
return getFallbackFunctionIds(context, encounter).map((functionId, index) =>
createGenericOption({
functionId: functionId === 'idle_explore_forward' && index > 0 ? `idle_explore_forward` : functionId,
context,
encounter,
}),
);
}
function buildStoryLanguageRepairPrompt(response: AIResponse) {
return [
'请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。',
'只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。',
JSON.stringify(
{
storyText: response.storyText,
encounter: response.encounter ?? null,
options: response.options.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
})),
},
null,
2,
),
].join('\n\n');
}
function needsStoryLanguageRepair(response: AIResponse) {
return hasMixedNarrativeLanguage(response.storyText);
}
function buildStoryLanguageFallbackText(context: PromptContext) {
if (context.inBattle === true) {
return '敌意仍压在眼前,战斗局势还没有真正松开。';
}
if (readString(context.encounterName)) {
return `${readString(context.encounterName)}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`;
}
return `${readString(context.sceneName) || '眼前区域'}里的气氛又有了新的变化,你需要继续判断下一步。`;
}
function finalizeStoryNarrativeLanguage(
response: AIResponse,
context: PromptContext,
): AIResponse {
if (!needsStoryLanguageRepair(response)) {
return response;
}
return {
...response,
storyText: buildStoryLanguageFallbackText(context),
};
}
function normalizeResponse(
raw: unknown,
context: PromptContext,
requestOptions: StoryRequestOptions = {},
): AIResponse {
const parsedEncounter = normalizeEncounterResult(
(raw as Record<string, unknown> | null)?.encounter,
context,
);
const fallbackOptions =
requestOptions.availableOptions?.map(cloneStoryOption) ??
requestOptions.optionCatalog?.map(cloneStoryOption) ??
getFallbackOptions(context, parsedEncounter);
if (!raw || typeof raw !== 'object') {
return {
storyText:
context.inBattle === true
? '前方敌意仍在持续逼近,局势只允许继续交锋或抽身脱离。'
: '周围暂时平静下来,你可以继续探索或前往别处。',
options: fallbackOptions,
encounter: parsedEncounter,
};
}
const data = raw as Record<string, unknown>;
const rawOptions = Array.isArray(data.options) ? data.options : [];
const optionItems = rawOptions
.map((option) => {
if (!option || typeof option !== 'object') {
return null;
}
const item = option as Record<string, unknown>;
const functionId = readString(item.functionId);
if (!functionId) {
return null;
}
return {
functionId,
actionText: readString(item.actionText) || undefined,
} satisfies RawOptionItem;
})
.filter(Boolean) as RawOptionItem[];
const options = requestOptions.availableOptions
? resolveOptionsFromProvidedOptions(optionItems, requestOptions.availableOptions)
: requestOptions.optionCatalog
? resolveOptionsFromOptionCatalog(
optionItems,
requestOptions.optionCatalog,
context,
parsedEncounter,
)
: optionItems.length > 0
? optionItems.map((item) =>
createGenericOption({
functionId: item.functionId,
actionText: item.actionText,
context,
encounter: parsedEncounter,
}),
)
: fallbackOptions;
return {
storyText:
readString(data.storyText) ||
(context.inBattle === true
? '敌人仍在前方压迫而来,战斗还没有结束。'
: '前路重新安静下来,可以继续决定接下来的探索方向。'),
options: options.length > 0 ? options : fallbackOptions,
encounter: parsedEncounter,
};
}
async function repairStoryNarrativeLanguage(
llmClient: UpstreamLlmClient,
response: AIResponse,
context: PromptContext,
requestOptions: StoryRequestOptions,
) {
if (!needsStoryLanguageRepair(response)) {
return finalizeStoryNarrativeLanguage(response, context);
}
try {
const repairedContent = await llmClient.requestMessageContent({
systemPrompt: STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT,
userPrompt: buildStoryLanguageRepairPrompt(response),
});
const repairedResponse = normalizeResponse(
parseJsonResponseText(repairedContent),
context,
requestOptions,
);
return finalizeStoryNarrativeLanguage(repairedResponse, context);
} catch (error) {
llmClient.logger.warn(
{
err: error,
},
'story narrative language repair failed',
);
return finalizeStoryNarrativeLanguage(response, context);
}
}
async function requestStoryCompletion(
llmClient: UpstreamLlmClient,
params: {
worldType: PromptWorldType;
character: PromptCharacter;
monsters: PromptMonsters;
history: PromptHistory;
choice?: string;
context: PromptContext;
requestOptions?: StoryRequestOptions;
},
) {
const content = await llmClient.requestMessageContent({
systemPrompt: SYSTEM_PROMPT,
userPrompt: buildUserPrompt({
worldType: params.worldType,
character: params.character,
monsters: params.monsters,
history: params.history,
context: params.context,
choice: params.choice,
requestOptions: params.requestOptions,
}),
});
const response = normalizeResponse(
parseJsonResponseText(content),
params.context,
params.requestOptions,
);
return repairStoryNarrativeLanguage(
llmClient,
response,
params.context,
params.requestOptions ?? {},
);
}
export async function generateInitialStoryFromOrchestrator(
llmClient: UpstreamLlmClient,
worldType: PromptWorldType,
character: PromptCharacter,
monsters: PromptMonsters,
context: PromptContext,
requestOptions: StoryRequestOptions = {},
) {
return requestStoryCompletion(llmClient, {
worldType,
character,
monsters,
history: [],
context,
requestOptions,
});
}
export async function generateNextStoryFromOrchestrator(
llmClient: UpstreamLlmClient,
worldType: PromptWorldType,
character: PromptCharacter,
monsters: PromptMonsters,
history: PromptHistory,
choice: string,
context: PromptContext,
requestOptions: StoryRequestOptions = {},
) {
return requestStoryCompletion(llmClient, {
worldType,
character,
monsters,
history,
choice,
context,
requestOptions,
});
}

View File

@@ -0,0 +1,163 @@
type JsonRecord = Record<string, unknown>;
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '武侠';
case 'XIANXIA':
return '仙侠';
case 'CUSTOM':
return '自定义世界';
default:
return worldType || '未知世界';
}
}
function describeCharacter(character: JsonRecord) {
return [
`主角:${readString(character.name) ?? '未知角色'}`,
`称号:${readString(character.title) ?? '未知称号'}`,
`描述:${readString(character.description) ?? '暂无'}`,
`性格:${readString(character.personality) ?? '未显式提供'}`,
].join('\n');
}
function describeMonsters(monsters: JsonRecord[]) {
if (monsters.length <= 0) {
return '当前敌对目标:无。';
}
return [
'当前敌对目标:',
...monsters.slice(0, 4).map((monster) => {
const name = readString(monster.name) ?? readString(monster.id) ?? '未知目标';
const hp = readNumber(monster.hp);
const maxHp = Math.max(1, readNumber(monster.maxHp, hp));
return `- ${name}(生命 ${hp}/${maxHp}`;
}),
].join('\n');
}
function describeStoryHistory(history: JsonRecord[]) {
if (history.length <= 0) {
return '近期剧情:暂无。';
}
return [
'近期剧情:',
...history.slice(-4).map((moment) => `- ${readString(moment.text) ?? '(空白)'}`),
].join('\n');
}
function describeRequestOptions(options: {
availableOptions?: Array<Record<string, unknown>>;
optionCatalog?: Array<Record<string, unknown>>;
}) {
const available = options.availableOptions ?? [];
const catalog = options.optionCatalog ?? [];
if (available.length > 0) {
return [
'固定可选项列表:',
...available.map((option, index) => {
const functionId = readString(option.functionId) ?? 'unknown';
const actionText =
readString(option.actionText) ??
readString(option.text) ??
'未提供文案';
return `- 第 ${index + 1} 项 / ${functionId}${actionText}`;
}),
'必须保持数量不变functionId 不变,可以重写 actionText。'.trim(),
].join('\n');
}
if (catalog.length > 0) {
return [
'当前局面可调用的交互选项目录:',
...catalog.map((option, index) => {
const functionId = readString(option.functionId) ?? 'unknown';
const actionText =
readString(option.actionText) ??
readString(option.text) ??
'未提供文案';
return `- 第 ${index + 1} 项 / ${functionId}${actionText}`;
}),
'functionId 只能从上面目录里选择。'.trim(),
].join('\n');
}
return '当前没有固定目录,请根据局势生成合理选项。';
}
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象不能输出解释、markdown 或代码块。
输出格式必须严格符合:
{
"storyText": "剧情文本",
"encounter": null,
"options": [
{
"functionId": "预定义功能ID",
"actionText": "选项显示文本"
}
]
}
严格规则:
- 所有文本必须是中文。
- 如果提示语给出了固定可选项或可选目录,必须遵守给定的 functionId 范围。
- storyText 必须直接承接当前场景、最近剧情和玩家刚刚的动作。
- options 只允许输出 functionId 和 actionText。
- 如果当前不是“继续推进后下一刻会遇到什么”的场景encounter 必须保持为 null。`;
export function buildUserPrompt(params: {
worldType: string;
character: JsonRecord;
monsters: JsonRecord[];
history: JsonRecord[];
context: JsonRecord;
choice?: string;
requestOptions?: {
availableOptions?: Array<Record<string, unknown>>;
optionCatalog?: Array<Record<string, unknown>>;
};
}) {
const sceneName = readString(params.context.sceneName) ?? '当前区域';
const sceneDescription = readString(params.context.sceneDescription) ?? '暂无场景附加描述。';
const encounterName = readString(params.context.encounterName);
const playerHp = readNumber(params.context.playerHp);
const playerMaxHp = Math.max(1, readNumber(params.context.playerMaxHp, playerHp));
const playerMana = readNumber(params.context.playerMana);
const playerMaxMana = Math.max(1, readNumber(params.context.playerMaxMana, playerMana));
const inBattle = params.context.inBattle === true ? '战斗中' : '非战斗';
const pendingSceneEncounter =
params.context.pendingSceneEncounter === true ? '是' : '否';
return [
`世界:${describeWorld(params.worldType)}`,
`场景:${sceneName}`,
`场景描述:${sceneDescription}`,
encounterName ? `当前面前对象:${encounterName}` : null,
`当前状态:${inBattle}`,
`玩家生命:${playerHp}/${playerMaxHp}`,
`玩家灵力:${playerMana}/${playerMaxMana}`,
`是否需要判断下一刻遭遇:${pendingSceneEncounter}`,
describeCharacter(params.character),
describeMonsters(params.monsters),
describeStoryHistory(params.history),
params.choice ? `玩家刚刚选择:${params.choice}` : '玩家刚进入当前局面。',
describeRequestOptions(params.requestOptions ?? {}),
params.context.pendingSceneEncounter === true
? '如果这一轮明确是在判断继续推进后下一刻会遇到什么,可以填写 encounter否则 encounter 必须为 null。'
: '当前这一步不是新的遭遇生成流程encounter 必须为 null。',
]
.filter(Boolean)
.join('\n\n');
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,907 @@
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';
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.use(
QWEN_SPRITE_MASTER_GENERATE_PATH,
toExpressHandler((request, response) =>
handleGenerateMaster(config, request, response),
),
);
router.use(
QWEN_SPRITE_SHEET_GENERATE_PATH,
toExpressHandler((request, response) =>
handleGenerateSheet(config, request, response),
),
);
router.use(
QWEN_SPRITE_FRAME_REPAIR_PATH,
toExpressHandler((request, response) =>
handleRepairFrame(config, request, response),
),
);
router.use(
QWEN_SPRITE_SAVE_PATH,
toExpressHandler((request, response) =>
handleSaveAsset(config.projectRoot, request, response),
),
);
return router;
}

View File

@@ -0,0 +1,272 @@
import type {
RuntimeBattlePresentation,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import { conflict } from '../../errors.js';
import {
getEncounterNpcState,
setEncounterNpcState,
type RuntimeSession,
} from '../story/runtimeSession.js';
type CombatActionConfig = {
actionText: string;
manaCost: number;
baseDamage: number;
counterMultiplier: number;
heal?: number;
manaRestore?: number;
};
export type CombatResolution = {
actionText: string;
resultText: string;
battle: RuntimeBattlePresentation;
patches: RuntimeStoryPatch[];
storyText?: string;
};
const COMBAT_ACTIONS: Record<string, CombatActionConfig> = {
battle_all_in_crush: {
actionText: '正面强压',
manaCost: 14,
baseDamage: 22,
counterMultiplier: 1.25,
},
battle_feint_step: {
actionText: '虚晃切步',
manaCost: 8,
baseDamage: 16,
counterMultiplier: 0.7,
},
battle_finisher_window: {
actionText: '抓破绽终结',
manaCost: 10,
baseDamage: 18,
counterMultiplier: 0.9,
},
battle_guard_break: {
actionText: '破架重击',
manaCost: 9,
baseDamage: 17,
counterMultiplier: 0.95,
},
battle_probe_pressure: {
actionText: '稳步试探',
manaCost: 5,
baseDamage: 12,
counterMultiplier: 0.8,
},
battle_recover_breath: {
actionText: '边守边调息',
manaCost: 0,
baseDamage: 0,
counterMultiplier: 0.55,
heal: 12,
manaRestore: 9,
},
};
function getAliveTarget(session: RuntimeSession) {
return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null;
}
function applySparAffinityReward(session: RuntimeSession) {
const npcState = getEncounterNpcState(session);
const encounter = session.currentEncounter;
if (!npcState || !encounter || encounter.kind !== 'npc') {
return null;
}
const nextAffinity = npcState.affinity + 3;
setEncounterNpcState(session, {
...npcState,
affinity: nextAffinity,
});
return {
npcId: encounter.id,
previousAffinity: npcState.affinity,
nextAffinity,
} satisfies Extract<RuntimeStoryPatch, { type: 'npc_affinity_changed' }>;
}
function clampPlayerVitals(session: RuntimeSession) {
session.playerHp = Math.max(0, Math.min(session.playerHp, session.playerMaxHp));
session.playerMana = Math.max(
0,
Math.min(session.playerMana, session.playerMaxMana),
);
}
function finishBattle(
session: RuntimeSession,
outcome: RuntimeBattlePresentation['outcome'],
) {
session.inBattle = false;
session.sceneHostileNpcs = [];
session.currentNpcBattleMode = null;
session.currentNpcBattleOutcome =
outcome === 'spar_complete'
? 'spar_complete'
: outcome === 'victory'
? 'fight_victory'
: null;
if (outcome === 'victory' || outcome === 'escaped') {
session.currentEncounter = null;
session.npcInteractionActive = false;
return;
}
if (session.currentEncounter?.kind === 'npc') {
session.npcInteractionActive = true;
}
}
export function resolveCombatAction(
session: RuntimeSession,
functionId: string,
): CombatResolution {
const target = getAliveTarget(session);
if (!session.inBattle || !target) {
throw conflict('当前不在可结算战斗态,不能执行该战斗动作');
}
if (functionId === 'battle_escape_breakout') {
finishBattle(session, 'escaped');
return {
actionText: '强行脱离战斗',
resultText: `你抓住空当摆脱了${target.name}的压制,先把这轮正面冲突拉开了。`,
battle: {
targetId: target.id,
targetName: target.name,
outcome: 'escaped',
},
patches: [
{
type: 'battle_resolved',
functionId,
targetId: target.id,
outcome: 'escaped',
},
{
type: 'status_changed',
inBattle: session.inBattle,
npcInteractionActive: session.npcInteractionActive,
currentNpcBattleMode: session.currentNpcBattleMode,
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
},
{
type: 'encounter_changed',
encounterId: session.currentEncounter?.id ?? null,
},
],
};
}
const action = COMBAT_ACTIONS[functionId];
if (!action) {
throw conflict(`暂不支持的战斗动作:${functionId}`);
}
if (action.manaCost > session.playerMana) {
throw conflict('当前灵力不足,无法执行这个战斗动作');
}
const isSpar = session.currentNpcBattleMode === 'spar';
const targetHpRatio = target.hp / Math.max(target.maxHp, 1);
const damageBonus =
functionId === 'battle_finisher_window' && targetHpRatio <= 0.4 ? 8 : 0;
const damageDealt = isSpar ? 1 : action.baseDamage + damageBonus;
session.playerMana -= action.manaCost;
session.playerHp += action.heal ?? 0;
session.playerMana += action.manaRestore ?? 0;
clampPlayerVitals(session);
target.hp = Math.max(isSpar ? 1 : 0, target.hp - damageDealt);
const patches: RuntimeStoryPatch[] = [];
let resultText = '';
let outcome: RuntimeBattlePresentation['outcome'] = 'ongoing';
let damageTaken = 0;
if ((isSpar && target.hp <= 1) || (!isSpar && target.hp <= 0)) {
if (isSpar) {
const affinityPatch = applySparAffinityReward(session);
finishBattle(session, 'spar_complete');
if (affinityPatch) {
patches.push(affinityPatch);
}
outcome = 'spar_complete';
resultText = `你和${target.name}这轮过招已经分出高下,对方也承认了你的身手。`;
} else {
finishBattle(session, 'victory');
outcome = 'victory';
resultText = `你彻底压垮了${target.name}的节奏,这场战斗已经收口。`;
}
} else {
const baseCounter = isSpar
? 1
: Math.max(4, Math.round(target.maxHp * 0.16 * action.counterMultiplier));
damageTaken = baseCounter;
session.playerHp = Math.max(isSpar ? 1 : 0, session.playerHp - damageTaken);
if (isSpar && session.playerHp <= 1) {
const affinityPatch = applySparAffinityReward(session);
finishBattle(session, 'spar_complete');
if (affinityPatch) {
patches.push(affinityPatch);
}
outcome = 'spar_complete';
resultText = `${target.name}也逼到了你的极限,这场切磋点到为止,双方都默认收手。`;
} else if (!isSpar && session.playerHp <= 0) {
session.playerHp = 0;
session.inBattle = false;
session.sceneHostileNpcs = [];
session.currentNpcBattleMode = null;
session.npcInteractionActive = false;
session.currentEncounter = null;
outcome = 'escaped';
resultText = `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`;
} else {
resultText = `${action.actionText}命中了${target.name},但对方仍然顶住并回敬了一轮压力。`;
}
}
patches.push(
{
type: 'battle_resolved',
functionId,
targetId: target.id,
damageDealt,
damageTaken,
outcome,
},
{
type: 'status_changed',
inBattle: session.inBattle,
npcInteractionActive: session.npcInteractionActive,
currentNpcBattleMode: session.currentNpcBattleMode,
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
},
{
type: 'encounter_changed',
encounterId: session.currentEncounter?.id ?? null,
},
);
return {
actionText: action.actionText,
resultText,
battle: {
targetId: target.id,
targetName: target.name,
damageDealt,
damageTaken,
outcome,
},
patches,
};
}

View File

@@ -0,0 +1,141 @@
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { Router } from 'express';
import type { AppConfig } from '../../config.js';
import { badRequest, notFound } from '../../errors.js';
import { asyncHandler } from '../../http.js';
const EDITOR_JSON_RESOURCE_FILES = {
'item-overrides': 'src/data/itemOverrides.json',
'npc-visual-overrides': 'src/data/npcVisualOverrides.json',
'npc-layout-config': 'src/data/npcLayoutConfig.json',
'character-overrides': 'src/data/characterOverrides.json',
'monster-overrides': 'src/data/monsterOverrides.json',
'scene-overrides': 'src/data/sceneOverrides.json',
'scene-npc-overrides': 'src/data/sceneNpcOverrides.json',
'state-function-overrides': 'src/data/stateFunctionOverrides.json',
} as const;
type EditorJsonResourceId = keyof typeof EDITOR_JSON_RESOURCE_FILES;
function isEditorJsonPayload(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function resolveEditorJsonFile(
config: AppConfig,
resourceId: string,
) {
const relativePath =
EDITOR_JSON_RESOURCE_FILES[
resourceId as EditorJsonResourceId
];
if (!relativePath) {
throw notFound('未知的编辑器资源。');
}
return path.resolve(config.projectRoot, relativePath);
}
async function readEditorJsonFile(filePath: string) {
try {
const content = await readFile(filePath, 'utf8');
return JSON.parse(content) as Record<string, unknown>;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
throw error;
}
}
async function collectPngAssetPaths(
rootDir: string,
relativeDir = 'Icons',
): Promise<string[]> {
const entries = await readdir(rootDir, { withFileTypes: true });
const collected: string[] = [];
for (const entry of entries) {
const absolutePath = path.join(rootDir, entry.name);
const relativePath = `${relativeDir}/${entry.name}`.replace(/\\/g, '/');
if (entry.isDirectory()) {
collected.push(
...(await collectPngAssetPaths(absolutePath, relativePath)),
);
continue;
}
if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) {
collected.push(relativePath);
}
}
return collected.sort((left, right) => left.localeCompare(right));
}
export function createEditorRoutes(config: AppConfig) {
const router = Router();
router.use((request, response, next) => {
if (
request.path !== '/api/editor' &&
!request.path.startsWith('/api/editor/')
) {
next();
return;
}
if (!config.editorApiEnabled) {
response.status(403).json({
error: {
message: '编辑器接口当前未启用。',
},
});
return;
}
next();
});
router.get(
'/api/editor/catalog/items',
asyncHandler(async (_request, response) => {
response.json({
assetPaths: await collectPngAssetPaths(
path.resolve(config.projectRoot, 'public/Icons'),
),
});
}),
);
router.get(
'/api/editor/json/:resourceId',
asyncHandler(async (request, response) => {
const filePath = resolveEditorJsonFile(config, request.params.resourceId);
response.json(await readEditorJsonFile(filePath));
}),
);
router.post(
'/api/editor/json/:resourceId',
asyncHandler(async (request, response) => {
if (!isEditorJsonPayload(request.body)) {
throw badRequest('编辑器保存请求必须是 JSON 对象。');
}
const filePath = resolveEditorJsonFile(config, request.params.resourceId);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(
filePath,
JSON.stringify(request.body, null, 2) + '\n',
'utf8',
);
response.json({ ok: true });
}),
);
return router;
}

View File

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

View File

@@ -0,0 +1,230 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
import {
craftForgeRecipe,
equipInventoryItem,
useInventoryItem,
type RuntimeGameState,
type RuntimeInventoryItem,
} from './inventoryMutationService.js';
const TEST_WORLD = 'WUXIA' as RuntimeGameState['worldType'];
const TEST_IDLE_ANIMATION = 'idle' as RuntimeGameState['animationState'];
function requireCharacter() {
return createTestPlayerCharacter<
NonNullable<RuntimeGameState['playerCharacter']>
>();
}
function buildItem(
overrides: Partial<RuntimeInventoryItem> &
Pick<RuntimeInventoryItem, 'id' | 'category' | 'name'>,
): RuntimeInventoryItem {
return {
quantity: 1,
rarity: 'common',
tags: [],
...overrides,
};
}
function createState(overrides: Partial<RuntimeGameState> = {}): RuntimeGameState {
return {
worldType: TEST_WORLD,
customWorldProfile: null,
playerCharacter: requireCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'test-scene',
storyHistory: [],
characterChats: {},
animationState: TEST_IDLE_ANIMATION,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'melee',
scrollWorld: false,
inBattle: false,
playerHp: 64,
playerMaxHp: 100,
playerMana: 18,
playerMaxMana: 60,
playerSkillCooldowns: {
slash: 2,
},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 120,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} satisfies RuntimeGameState;
}
test('useInventoryItem applies recovery, cooldown推进 and buff mutation', () => {
const state = createState({
playerInventory: [
buildItem({
id: 'focus-tonic',
category: '消耗品',
name: '凝神灵液',
rarity: 'rare',
tags: ['healing', 'mana'],
useProfile: {
hpRestore: 22,
manaRestore: 16,
cooldownReduction: 1,
buildBuffs: [
{
id: 'focus-tonic:buff',
sourceType: 'item',
sourceId: 'focus-tonic',
name: '凝神增益',
tags: ['快剑'],
durationTurns: 2,
},
],
},
}),
],
});
const result = useInventoryItem(state, 'focus-tonic');
assert.equal(result.ok, true);
if (!result.ok) {
return;
}
assert.equal(result.mutation, 'use');
assert.equal(result.nextState.playerHp, 86);
assert.equal(result.nextState.playerMana, 34);
assert.equal(result.nextState.playerSkillCooldowns.slash, 1);
assert.equal(result.nextState.playerInventory.length, 0);
assert.equal(result.nextState.runtimeStats.itemsUsed, 1);
assert.equal(result.nextState.activeBuildBuffs[0]?.id, 'focus-tonic:buff');
});
test('equipInventoryItem swaps loadout and returns replaced gear to inventory', () => {
const oldWeapon = buildItem({
id: 'starter-blade',
category: '武器',
name: '旧佩剑',
rarity: 'common',
tags: ['weapon', '快剑'],
equipmentSlotId: 'weapon',
statProfile: {
outgoingDamageBonus: 0.04,
},
buildProfile: {
role: '快剑',
tags: ['快剑'],
synergy: ['快剑'],
forgeRank: 0,
},
});
const nextWeapon = buildItem({
id: 'storm-blade',
category: '武器',
name: '逐风短剑',
rarity: 'rare',
tags: ['weapon', '快剑', '突进'],
equipmentSlotId: 'weapon',
statProfile: {
outgoingDamageBonus: 0.12,
},
buildProfile: {
role: '快剑',
tags: ['快剑', '突进'],
synergy: ['快剑', '突进'],
forgeRank: 0,
},
});
const state = createState({
playerInventory: [nextWeapon],
playerEquipment: {
weapon: oldWeapon,
armor: null,
relic: null,
},
});
const result = equipInventoryItem(state, 'storm-blade');
assert.equal(result.ok, true);
if (!result.ok) {
return;
}
assert.equal(result.mutation, 'equip');
assert.equal(result.slot, 'weapon');
assert.equal(result.nextState.playerEquipment.weapon?.name, '逐风短剑');
assert.equal(
result.nextState.playerInventory.some((item) => item.id === 'starter-blade'),
true,
);
assert.equal(
result.nextState.playerInventory.some((item) => item.id === 'storm-blade'),
false,
);
});
test('craftForgeRecipe consumes materials and produces forged output on the server side', () => {
const state = createState({
playerCurrency: 40,
playerInventory: [
buildItem({
id: 'scrap-iron',
category: '材料',
name: '残铁碎片',
quantity: 3,
rarity: 'common',
tags: ['material'],
}),
],
});
const result = craftForgeRecipe(state, 'synthesis-refined-ingot');
assert.equal(result.ok, true);
if (!result.ok) {
return;
}
assert.equal(result.mutation, 'craft');
assert.equal(result.nextState.playerCurrency, 22);
assert.equal(result.createdItem?.name, '精炼锭材');
assert.equal(
result.nextState.playerInventory.some((item) => item.name === '精炼锭材'),
true,
);
assert.equal(
result.nextState.playerInventory.some((item) => item.id === 'scrap-iron'),
false,
);
});

View File

@@ -0,0 +1,458 @@
import {
addInventoryItems,
appendBuildBuffs,
applyEquipmentLoadoutToState,
buildForgeSuccessText,
buildInventoryUseResultText,
executeDismantleItem,
executeForgeRecipe,
executeReforgeItem,
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
getForgeRecipeViews,
getReforgeCostView,
incrementGameRuntimeStats,
isInventoryItemUsable,
removeInventoryItem,
resolveInventoryItemUseEffect,
} from '../../bridges/legacyInventoryRuntimeBridge.js';
export type RuntimeGameState = Parameters<
typeof applyEquipmentLoadoutToState
>[0];
export type RuntimeInventoryItem = Parameters<
typeof getEquipmentSlotFromItem
>[0];
export type RuntimeEquipmentSlotId = Exclude<
ReturnType<typeof getEquipmentSlotFromItem>,
null
>;
export type RuntimeInventoryUseEffect = Exclude<
ReturnType<typeof resolveInventoryItemUseEffect>,
null
>;
export type RuntimeForgeRecipeView = ReturnType<
typeof getForgeRecipeViews
>[number];
export type RuntimeReforgeCostView = ReturnType<typeof getReforgeCostView>;
type InventoryMutationKind =
| 'use'
| 'equip'
| 'unequip'
| 'craft'
| 'dismantle'
| 'reforge';
type InventoryMutationFailureCode =
| 'missing_player_character'
| 'battle_locked'
| 'item_not_found'
| 'item_not_usable'
| 'item_not_equippable'
| 'slot_empty'
| 'recipe_not_available'
| 'mutation_not_available';
export type InventoryMutationFailure = {
ok: false;
code: InventoryMutationFailureCode;
message: string;
};
export type InventoryMutationSuccess = {
ok: true;
mutation: InventoryMutationKind;
nextState: RuntimeGameState;
actionText: string;
detailText: string;
item?: RuntimeInventoryItem;
slot?: RuntimeEquipmentSlotId;
replacedItem?: RuntimeInventoryItem | null;
createdItem?: RuntimeInventoryItem | null;
outputs?: RuntimeInventoryItem[];
effect?: RuntimeInventoryUseEffect;
reforgeCost?: RuntimeReforgeCostView;
};
export type InventoryMutationResult =
| InventoryMutationFailure
| InventoryMutationSuccess;
function createFailure(
code: InventoryMutationFailureCode,
message: string,
): InventoryMutationFailure {
return {
ok: false,
code,
message,
};
}
function tickCooldownMap(
cooldowns: RuntimeGameState['playerSkillCooldowns'],
turns: number,
) {
let nextCooldowns = cooldowns;
const totalTurns = Math.max(0, Math.floor(turns));
for (let index = 0; index < totalTurns; index += 1) {
nextCooldowns = Object.fromEntries(
Object.entries(nextCooldowns).map(([skillId, value]) => [
skillId,
Math.max(0, Math.floor(value) - 1),
]),
);
}
return nextCooldowns;
}
function normalizeEquippedItem(item: RuntimeInventoryItem): RuntimeInventoryItem {
return {
...item,
quantity: 1,
};
}
function buildEquipResultText(
item: RuntimeInventoryItem,
slot: RuntimeEquipmentSlotId,
replacedItem?: RuntimeInventoryItem | null,
) {
return replacedItem
? `你将${replacedItem.name}${getEquipmentSlotLabel(slot)}位上换下,改为装备${item.name}`
: `你将${item.name}装备在${getEquipmentSlotLabel(slot)}位上。`;
}
function buildUnequipResultText(item: RuntimeInventoryItem) {
return `你卸下了${item.name},暂时收回背包。`;
}
export function getForgeRecipeCatalog(
state: RuntimeGameState,
): RuntimeForgeRecipeView[] {
return getForgeRecipeViews(
state.playerInventory,
state.playerCurrency,
state.worldType,
);
}
export function useInventoryItem(
state: RuntimeGameState,
itemId: string,
): InventoryMutationResult {
const playerCharacter = state.playerCharacter;
if (!playerCharacter) {
return createFailure(
'missing_player_character',
'缺少玩家角色,无法使用背包物品。',
);
}
const item = state.playerInventory.find((candidate) => candidate.id === itemId);
if (!item || item.quantity <= 0) {
return createFailure('item_not_found', '未找到可使用的背包物品。');
}
if (!isInventoryItemUsable(item)) {
return createFailure('item_not_usable', `${item.name} 当前不可直接使用。`);
}
const effect = resolveInventoryItemUseEffect(item, playerCharacter);
if (
!effect ||
(effect.hpRestore ?? 0) <= 0 &&
(effect.manaRestore ?? 0) <= 0 &&
(effect.cooldownReduction ?? 0) <= 0 &&
(effect.buildBuffs?.length ?? 0) <= 0
) {
return createFailure(
'item_not_usable',
`${item.name} 当前没有可结算的使用效果。`,
);
}
const nextState = {
...state,
playerHp: Math.min(state.playerMaxHp, state.playerHp + effect.hpRestore),
playerMana: Math.min(
state.playerMaxMana,
state.playerMana + effect.manaRestore,
),
playerSkillCooldowns: tickCooldownMap(
state.playerSkillCooldowns,
effect.cooldownReduction,
),
activeBuildBuffs: appendBuildBuffs(
state.activeBuildBuffs,
effect.buildBuffs,
),
playerInventory: removeInventoryItem(state.playerInventory, item.id, 1),
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, {
itemsUsed: 1,
}),
} satisfies RuntimeGameState;
return {
ok: true,
mutation: 'use',
nextState,
actionText: `使用${item.name}`,
detailText: buildInventoryUseResultText(item, effect),
item,
effect,
};
}
export function equipInventoryItem(
state: RuntimeGameState,
itemId: string,
): InventoryMutationResult {
if (!state.playerCharacter) {
return createFailure(
'missing_player_character',
'缺少玩家角色,无法调整装备。',
);
}
if (state.inBattle) {
return createFailure('battle_locked', '战斗中无法调整装备。');
}
const item = state.playerInventory.find((candidate) => candidate.id === itemId);
if (!item || item.quantity <= 0) {
return createFailure('item_not_found', '背包里没有这件装备。');
}
const slot = getEquipmentSlotFromItem(item);
if (!slot) {
return createFailure('item_not_equippable', `${item.name} 不是可装备物品。`);
}
const replacedItem = state.playerEquipment[slot];
const nextEquipment = {
...state.playerEquipment,
[slot]: normalizeEquippedItem(item),
};
let nextInventory = removeInventoryItem(state.playerInventory, item.id, 1);
if (replacedItem) {
nextInventory = addInventoryItems(nextInventory, [replacedItem]);
}
const nextState = applyEquipmentLoadoutToState(
{
...state,
playerInventory: nextInventory,
},
nextEquipment,
);
return {
ok: true,
mutation: 'equip',
nextState,
actionText: `装备${item.name}`,
detailText: buildEquipResultText(item, slot, replacedItem),
item,
slot,
replacedItem,
};
}
export function unequipInventoryItem(
state: RuntimeGameState,
slot: RuntimeEquipmentSlotId,
): InventoryMutationResult {
if (!state.playerCharacter) {
return createFailure(
'missing_player_character',
'缺少玩家角色,无法卸下装备。',
);
}
if (state.inBattle) {
return createFailure('battle_locked', '战斗中无法卸下装备。');
}
const equippedItem = state.playerEquipment[slot];
if (!equippedItem) {
return createFailure('slot_empty', `${getEquipmentSlotLabel(slot)}位当前没有装备。`);
}
const nextEquipment = {
...state.playerEquipment,
[slot]: null,
};
const nextState = applyEquipmentLoadoutToState(
{
...state,
playerInventory: addInventoryItems(state.playerInventory, [equippedItem]),
},
nextEquipment,
);
return {
ok: true,
mutation: 'unequip',
nextState,
actionText: `卸下${equippedItem.name}`,
detailText: buildUnequipResultText(equippedItem),
item: equippedItem,
slot,
};
}
export function craftForgeRecipe(
state: RuntimeGameState,
recipeId: string,
): InventoryMutationResult {
if (!state.playerCharacter) {
return createFailure(
'missing_player_character',
'缺少玩家角色,无法执行锻造配方。',
);
}
if (state.inBattle) {
return createFailure('battle_locked', '战斗中无法使用工坊。');
}
const recipe = getForgeRecipeCatalog(state).find(
(candidate) => candidate.id === recipeId,
);
if (!recipe) {
return createFailure('recipe_not_available', '未找到目标锻造配方。');
}
const result = executeForgeRecipe(
state.playerInventory,
recipeId,
state.worldType,
state.playerCurrency,
);
if (!result) {
return createFailure(
'mutation_not_available',
`${recipe.name} 当前材料或货币不足。`,
);
}
return {
ok: true,
mutation: 'craft',
nextState: {
...state,
playerCurrency: result.currency,
playerInventory: result.inventory,
},
actionText: `制作${result.createdItem.name}`,
detailText: buildForgeSuccessText('craft', {
recipeName: recipe.name,
createdItemName: result.createdItem.name,
currencyText: recipe.currencyText,
}),
createdItem: result.createdItem,
};
}
export function dismantleInventoryItem(
state: RuntimeGameState,
itemId: string,
): InventoryMutationResult {
if (!state.playerCharacter) {
return createFailure(
'missing_player_character',
'缺少玩家角色,无法执行拆解。',
);
}
if (state.inBattle) {
return createFailure('battle_locked', '战斗中无法执行拆解。');
}
const item = state.playerInventory.find((candidate) => candidate.id === itemId);
if (!item || item.quantity <= 0) {
return createFailure('item_not_found', '未找到可拆解的物品。');
}
const result = executeDismantleItem(state.playerInventory, itemId);
if (!result) {
return createFailure(
'mutation_not_available',
`${item.name} 当前不支持拆解。`,
);
}
return {
ok: true,
mutation: 'dismantle',
nextState: {
...state,
playerInventory: result.inventory,
},
actionText: `拆解${item.name}`,
detailText: buildForgeSuccessText('dismantle', {
sourceItemName: item.name,
outputNames: result.outputs.map((output) => output.name),
}),
item,
outputs: result.outputs,
};
}
export function reforgeInventoryItem(
state: RuntimeGameState,
itemId: string,
): InventoryMutationResult {
if (!state.playerCharacter) {
return createFailure(
'missing_player_character',
'缺少玩家角色,无法执行重铸。',
);
}
if (state.inBattle) {
return createFailure('battle_locked', '战斗中无法执行重铸。');
}
const item = state.playerInventory.find((candidate) => candidate.id === itemId);
if (!item || item.quantity <= 0) {
return createFailure('item_not_found', '未找到可重铸的物品。');
}
const reforgeCost = getReforgeCostView(item, state.worldType);
const result = executeReforgeItem(
state.playerInventory,
itemId,
state.playerCurrency,
);
if (!result) {
return createFailure(
'mutation_not_available',
`${item.name} 当前不满足重铸条件。`,
);
}
return {
ok: true,
mutation: 'reforge',
nextState: {
...state,
playerCurrency: Math.max(0, state.playerCurrency - result.currencyCost),
playerInventory: result.inventory,
},
actionText: `重铸${item.name}`,
detailText: buildForgeSuccessText('reforge', {
sourceItemName: item.name,
createdItemName: result.reforgedItem.name,
currencyText: reforgeCost.currencyText,
}),
item,
createdItem: result.reforgedItem,
reforgeCost,
};
}

View File

@@ -0,0 +1,197 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
calculatePlayerBuildSnapshot,
type RuntimeGameState as BuildRuntimeGameState,
} from '../build/buildCalculationService.js';
import {
craftForgeRecipe,
dismantleInventoryItem,
equipInventoryItem,
reforgeInventoryItem,
unequipInventoryItem,
useInventoryItem,
type InventoryMutationFailure,
type InventoryMutationSuccess,
type RuntimeGameState as InventoryRuntimeGameState,
} from './inventoryMutationService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
const SUPPORTED_INVENTORY_STORY_FUNCTION_IDS = new Set<string>([
'equipment_equip',
'equipment_unequip',
'forge_craft',
'forge_dismantle',
'forge_reforge',
'inventory_use',
]);
type InventoryStoryResolution = {
actionText: string;
resultText: string;
patches: RuntimeStoryPatch[];
toast?: string | null;
};
type JsonRecord = Record<string, unknown>;
function isObject(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readPayload(request: RuntimeStoryActionRequest) {
return isObject(request.action.payload) ? request.action.payload : {};
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function readItemId(request: RuntimeStoryActionRequest) {
const payload = readPayload(request);
return (
readString(payload.itemId) ||
readString(payload.targetId) ||
readString(request.action.targetId)
);
}
function readRecipeId(request: RuntimeStoryActionRequest) {
const payload = readPayload(request);
return (
readString(payload.recipeId) ||
readString(payload.targetId) ||
readString(request.action.targetId)
);
}
function readEquipmentSlotId(request: RuntimeStoryActionRequest) {
const payload = readPayload(request);
const slotId =
readString(payload.slotId) || readString(request.action.targetId);
if (slotId === 'weapon' || slotId === 'armor' || slotId === 'relic') {
return slotId;
}
return '';
}
function refreshSessionFromGameState(
session: RuntimeSession,
nextGameState: InventoryMutationSuccess['nextState'],
) {
replaceRuntimeSessionRawGameState(
session,
nextGameState as unknown as JsonRecord,
);
}
export function buildBuildToast(
nextState: InventoryMutationSuccess['nextState'],
) {
const snapshot = calculatePlayerBuildSnapshot(
nextState as BuildRuntimeGameState,
);
if (!snapshot.ok) {
return null;
}
const buildMultiplier =
snapshot.value.buildBreakdown.buildDamageMultiplier.toFixed(2);
return `当前 Build 倍率 x${buildMultiplier}`;
}
function throwMutationFailure(error: InventoryMutationFailure): never {
switch (error.code) {
case 'item_not_equippable':
case 'recipe_not_available':
throw invalidRequest(error.message);
default:
throw conflict(error.message);
}
}
function resolveMutation(
request: RuntimeStoryActionRequest,
state: InventoryRuntimeGameState,
) {
switch (request.action.functionId) {
case 'inventory_use': {
const itemId = readItemId(request);
if (!itemId) {
throw invalidRequest('inventory_use 缺少 itemId');
}
return useInventoryItem(state, itemId);
}
case 'equipment_equip': {
const itemId = readItemId(request);
if (!itemId) {
throw invalidRequest('equipment_equip 缺少 itemId');
}
return equipInventoryItem(state, itemId);
}
case 'equipment_unequip': {
const slotId = readEquipmentSlotId(request);
if (!slotId) {
throw invalidRequest('equipment_unequip 缺少合法 slotId');
}
return unequipInventoryItem(state, slotId);
}
case 'forge_craft': {
const recipeId = readRecipeId(request);
if (!recipeId) {
throw invalidRequest('forge_craft 缺少 recipeId');
}
return craftForgeRecipe(state, recipeId);
}
case 'forge_dismantle': {
const itemId = readItemId(request);
if (!itemId) {
throw invalidRequest('forge_dismantle 缺少 itemId');
}
return dismantleInventoryItem(state, itemId);
}
case 'forge_reforge': {
const itemId = readItemId(request);
if (!itemId) {
throw invalidRequest('forge_reforge 缺少 itemId');
}
return reforgeInventoryItem(state, itemId);
}
default:
throw invalidRequest(`暂不支持的 Inventory 动作:${request.action.functionId}`);
}
}
export function isSupportedInventoryStoryFunctionId(functionId: string) {
return SUPPORTED_INVENTORY_STORY_FUNCTION_IDS.has(functionId);
}
export function resolveInventoryStoryAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
): InventoryStoryResolution {
const mutation = resolveMutation(
request,
session.rawGameState as InventoryRuntimeGameState,
);
if (!mutation.ok) {
throwMutationFailure(mutation);
}
refreshSessionFromGameState(session, mutation.nextState);
return {
actionText: mutation.actionText,
resultText: mutation.detailText,
patches: [],
toast: buildBuildToast(mutation.nextState),
};
}

View File

@@ -0,0 +1,386 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
addInventoryItems,
appendStoryEngineCarrierMemory,
applyStoryChoiceToStanceProfile,
buildInitialNpcState,
buildNpcGiftCommitActionText,
buildNpcGiftResultText,
buildNpcTradeTransactionActionText,
buildNpcTradeTransactionResultText,
buildRelationState,
getGiftCandidates,
getNpcBuybackPrice,
getNpcPurchasePrice,
markNpcFirstMeaningfulContactResolved,
normalizeNpcPersistentState,
removeInventoryItem,
syncNpcTradeInventory,
} from '../../bridges/legacyNpcTask6Bridge.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
const SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS = new Set<string>([
'npc_gift',
'npc_trade',
]);
type NpcInventoryStoryResolution = {
actionText: string;
resultText: string;
patches: RuntimeStoryPatch[];
};
type JsonRecord = Record<string, unknown>;
type RuntimeInventoryItem = Parameters<typeof addInventoryItems>[1][number];
type RuntimeGameState = Parameters<typeof syncNpcTradeInventory>[0];
type RuntimeEncounter = Parameters<typeof buildInitialNpcState>[0];
function isObject(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readPayload(request: RuntimeStoryActionRequest) {
return isObject(request.action.payload) ? request.action.payload : {};
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function readPositiveInteger(value: unknown, fallback = 1) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback;
}
return Math.max(1, Math.floor(value));
}
function cloneInventoryItemForOwner(
item: RuntimeInventoryItem,
owner: 'player' | 'npc',
quantity = 1,
): RuntimeInventoryItem {
const preserveIdentity = Boolean(
item.runtimeMetadata ||
item.buildProfile ||
item.equipmentSlotId ||
item.statProfile ||
item.attributeResonance,
);
return {
...item,
id: preserveIdentity
? `${owner}:${item.id}:${quantity}`
: `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`,
quantity,
runtimeMetadata: item.runtimeMetadata
? {
...item.runtimeMetadata,
seedKey: `${item.runtimeMetadata.seedKey}:${owner}`,
}
: item.runtimeMetadata,
};
}
function getNpcEncounterKey(encounter: RuntimeEncounter) {
return encounter.id?.trim() || encounter.npcName;
}
function getNpcEncounter(
session: RuntimeSession,
state: RuntimeGameState,
): RuntimeEncounter | null {
const rawEncounter = state.currentEncounter;
if (!rawEncounter || rawEncounter.kind !== 'npc') {
return null;
}
return {
npcAvatar: '',
hostile: false,
...rawEncounter,
id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName,
} satisfies RuntimeEncounter;
}
export function ensureNpcInventorySessionState(session: RuntimeSession) {
const state = session.rawGameState as unknown as RuntimeGameState;
const encounter = getNpcEncounter(session, state);
if (!encounter) {
return;
}
const npcKey = getNpcEncounterKey(encounter);
const baseNpcState =
state.npcStates?.[npcKey] ??
buildInitialNpcState(encounter, state.worldType, state);
const normalizedNpcState = normalizeNpcPersistentState(baseNpcState);
const syncedNpcState = syncNpcTradeInventory(state, encounter, normalizedNpcState);
const nextState = {
...state,
npcStates: {
...(state.npcStates ?? {}),
[npcKey]: syncedNpcState,
},
} satisfies RuntimeGameState;
replaceRuntimeSessionRawGameState(
session,
nextState as unknown as JsonRecord,
);
}
function getCurrentNpcState(session: RuntimeSession) {
const state = session.rawGameState as unknown as RuntimeGameState;
const encounter = getNpcEncounter(session, state);
if (!encounter) {
throw conflict('当前不在可结算的 NPC 交互态,无法执行交易或赠礼。');
}
const npcKey = getNpcEncounterKey(encounter);
const npcState = state.npcStates?.[npcKey];
if (!npcState) {
throw conflict('当前 NPC 状态不存在,无法继续结算。');
}
return {
state,
encounter,
npcKey,
npcState,
};
}
function resolveTradeMode(request: RuntimeStoryActionRequest) {
const mode = readString(readPayload(request).mode);
if (mode === 'buy' || mode === 'sell') {
return mode;
}
throw invalidRequest('npc_trade 缺少合法 mode需为 buy 或 sell');
}
function readTradeItemId(request: RuntimeStoryActionRequest) {
const payload = readPayload(request);
return (
readString(payload.itemId) ||
readString(payload.selectedNpcItemId) ||
readString(payload.selectedPlayerItemId) ||
readString(request.action.targetId)
);
}
function readTradeQuantity(request: RuntimeStoryActionRequest) {
return readPositiveInteger(readPayload(request).quantity, 1);
}
function resolveNpcTradeAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
): NpcInventoryStoryResolution {
ensureNpcInventorySessionState(session);
const { state, encounter, npcKey, npcState } = getCurrentNpcState(session);
const mode = resolveTradeMode(request);
const itemId = readTradeItemId(request);
const quantity = readTradeQuantity(request);
if (!itemId) {
throw invalidRequest('npc_trade 缺少 itemId');
}
if (mode === 'buy') {
const npcItem = npcState.inventory.find((item) => item.id === itemId);
if (!npcItem || npcItem.quantity < quantity) {
throw conflict('目标商品不存在或库存不足。');
}
const totalPrice = getNpcPurchasePrice(npcItem, npcState.affinity) * quantity;
if (state.playerCurrency < totalPrice) {
throw conflict('当前钱币不足,无法完成购买。');
}
const acquiredItem = cloneInventoryItemForOwner(npcItem, 'player', quantity);
let nextState = {
...state,
playerCurrency: state.playerCurrency - totalPrice,
playerInventory: addInventoryItems(state.playerInventory, [acquiredItem]),
npcStates: {
...state.npcStates,
[npcKey]: {
...markNpcFirstMeaningfulContactResolved(npcState),
inventory: removeInventoryItem(npcState.inventory, npcItem.id, quantity),
},
},
} satisfies RuntimeGameState;
nextState = appendStoryEngineCarrierMemory(nextState, [acquiredItem]);
replaceRuntimeSessionRawGameState(
session,
nextState as unknown as JsonRecord,
);
return {
actionText: buildNpcTradeTransactionActionText({
encounter,
mode: 'buy',
item: npcItem,
quantity,
}),
resultText: buildNpcTradeTransactionResultText({
encounter,
mode: 'buy',
item: npcItem,
quantity,
totalPrice,
worldType: state.worldType,
}),
patches: [],
};
}
const playerItem = state.playerInventory.find((item) => item.id === itemId);
if (!playerItem || playerItem.quantity < quantity) {
throw conflict('背包里没有足够数量的目标物品。');
}
const totalPrice = getNpcBuybackPrice(playerItem, npcState.affinity) * quantity;
const soldItem = cloneInventoryItemForOwner(playerItem, 'npc', quantity);
const nextState = {
...state,
playerCurrency: state.playerCurrency + totalPrice,
playerInventory: removeInventoryItem(state.playerInventory, playerItem.id, quantity),
npcStates: {
...state.npcStates,
[npcKey]: {
...markNpcFirstMeaningfulContactResolved(npcState),
inventory: addInventoryItems(npcState.inventory, [soldItem]),
},
},
} satisfies RuntimeGameState;
replaceRuntimeSessionRawGameState(
session,
nextState as unknown as JsonRecord,
);
return {
actionText: buildNpcTradeTransactionActionText({
encounter,
mode: 'sell',
item: playerItem,
quantity,
}),
resultText: buildNpcTradeTransactionResultText({
encounter,
mode: 'sell',
item: playerItem,
quantity,
totalPrice,
worldType: state.worldType,
}),
patches: [],
};
}
function resolveNpcGiftAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
): NpcInventoryStoryResolution {
ensureNpcInventorySessionState(session);
const { state, encounter, npcKey, npcState } = getCurrentNpcState(session);
const itemId =
readString(readPayload(request).itemId) || readString(request.action.targetId);
if (!itemId) {
throw invalidRequest('npc_gift 缺少 itemId');
}
const giftItem = state.playerInventory.find((item) => item.id === itemId);
if (!giftItem || giftItem.quantity <= 0) {
throw conflict('背包里没有这件可赠送的物品。');
}
const giftCandidate = getGiftCandidates(state.playerInventory, encounter, {
worldType: state.worldType,
customWorldProfile: state.customWorldProfile,
}).find((candidate) => candidate.item.id === giftItem.id);
const affinityGain = giftCandidate?.affinityGain ?? 0;
const attributeSummary = giftCandidate?.attributeInsight?.reasonText ?? undefined;
const nextAffinity = npcState.affinity + affinityGain;
const nextNpcState = {
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
giftsGiven: (npcState.giftsGiven ?? 0) + 1,
stanceProfile: applyStoryChoiceToStanceProfile(
npcState.stanceProfile,
'npc_gift',
{ affinityGain },
),
inventory: addInventoryItems(npcState.inventory, [
cloneInventoryItemForOwner(giftItem, 'npc'),
]),
};
const nextState = {
...state,
playerInventory: removeInventoryItem(state.playerInventory, giftItem.id, 1),
npcStates: {
...state.npcStates,
[npcKey]: nextNpcState,
},
} satisfies RuntimeGameState;
replaceRuntimeSessionRawGameState(
session,
nextState as unknown as JsonRecord,
);
return {
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
resultText: buildNpcGiftResultText(
encounter,
giftItem,
affinityGain,
nextAffinity,
attributeSummary,
),
patches: [
{
type: 'npc_affinity_changed',
npcId: npcKey,
previousAffinity: npcState.affinity,
nextAffinity,
},
],
};
}
export function isSupportedNpcInventoryStoryFunctionId(functionId: string) {
return SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS.has(functionId);
}
export function resolveNpcInventoryStoryAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
): NpcInventoryStoryResolution {
switch (request.action.functionId) {
case 'npc_trade':
return resolveNpcTradeAction(session, request);
case 'npc_gift':
return resolveNpcGiftAction(session, request);
default:
throw invalidRequest(
`暂不支持的 NPC Inventory 动作:${request.action.functionId}`,
);
}
}

View File

@@ -0,0 +1,261 @@
import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/story.js';
import { conflict } from '../../errors.js';
import {
MAX_TASK5_COMPANIONS,
getEncounterNpcState,
setEncounterNpcState,
type RuntimeEncounter,
type RuntimeNpcState,
type RuntimeSession,
} from '../story/runtimeSession.js';
export type NpcInteractionResolution = {
actionText: string;
resultText: string;
patches: RuntimeStoryPatch[];
storyText?: string;
toast?: string | null;
};
function requireNpcEncounter(session: RuntimeSession) {
if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') {
throw conflict('当前没有可结算的 NPC 交互对象');
}
return session.currentEncounter;
}
function requireNpcState(
session: RuntimeSession,
encounter: RuntimeEncounter,
): RuntimeNpcState {
const npcState = getEncounterNpcState(session);
if (!npcState) {
throw conflict(`未找到 ${encounter.npcName} 的运行时关系状态`);
}
return npcState;
}
function buildAffinityPatch(
encounter: RuntimeEncounter,
previousAffinity: number,
nextAffinity: number,
) {
return {
type: 'npc_affinity_changed',
npcId: encounter.id,
previousAffinity,
nextAffinity,
} satisfies RuntimeStoryPatch;
}
function buildBattleTarget(
encounter: RuntimeEncounter,
npcState: RuntimeNpcState,
mode: 'fight' | 'spar',
) {
const maxHp =
mode === 'spar'
? 8
: Math.max(32, 24 + Math.max(0, Math.round(npcState.affinity * 0.35)));
return {
id: encounter.id,
name: encounter.npcName,
hp: maxHp,
maxHp,
description: encounter.npcDescription,
};
}
export function resolveNpcInteraction(
session: RuntimeSession,
functionId: string,
): NpcInteractionResolution {
const encounter = requireNpcEncounter(session);
const npcState = requireNpcState(session, encounter);
switch (functionId) {
case 'npc_preview_talk': {
session.npcInteractionActive = true;
return {
actionText: `转向${encounter.npcName}`,
resultText: `你把注意力真正收回到${encounter.npcName}身上,接下来可以围绕这名角色做正式交互了。`,
patches: [
{
type: 'status_changed',
inBattle: session.inBattle,
npcInteractionActive: session.npcInteractionActive,
currentNpcBattleMode: session.currentNpcBattleMode,
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
},
],
};
}
case 'npc_chat': {
session.npcInteractionActive = true;
const affinityGain = Math.max(2, 6 - npcState.chattedCount);
const nextAffinity = npcState.affinity + affinityGain;
setEncounterNpcState(session, {
...npcState,
affinity: nextAffinity,
chattedCount: npcState.chattedCount + 1,
firstMeaningfulContactResolved: true,
});
return {
actionText: `继续和${encounter.npcName}交谈`,
resultText: `${encounter.npcName}愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 ${affinityGain} 点。`,
patches: [
buildAffinityPatch(encounter, npcState.affinity, nextAffinity),
{
type: 'status_changed',
inBattle: session.inBattle,
npcInteractionActive: session.npcInteractionActive,
currentNpcBattleMode: session.currentNpcBattleMode,
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
},
],
};
}
case 'npc_help': {
if (npcState.helpUsed) {
throw conflict('当前 NPC 的一次性援手已经用完了');
}
const previousAffinity = npcState.affinity;
const nextAffinity = previousAffinity + 4;
session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 10);
session.playerMana = Math.min(session.playerMaxMana, session.playerMana + 8);
setEncounterNpcState(session, {
...npcState,
affinity: nextAffinity,
helpUsed: true,
});
return {
actionText: `${encounter.npcName}请求援手`,
resultText: `${encounter.npcName}给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。`,
patches: [
buildAffinityPatch(encounter, previousAffinity, nextAffinity),
{
type: 'status_changed',
inBattle: session.inBattle,
npcInteractionActive: session.npcInteractionActive,
currentNpcBattleMode: session.currentNpcBattleMode,
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
},
],
};
}
case 'npc_recruit': {
if (npcState.recruited) {
throw conflict('当前 NPC 已经处于已招募状态');
}
if (npcState.affinity < 60) {
throw conflict('当前关系还没达到招募阈值,暂时不能邀请入队');
}
if (session.companions.length >= MAX_TASK5_COMPANIONS) {
throw conflict('队伍已满任务5首轮后端接口暂不处理换队逻辑');
}
setEncounterNpcState(session, {
...npcState,
recruited: true,
firstMeaningfulContactResolved: true,
});
session.companions.push({
npcId: encounter.id,
characterId: encounter.characterId ?? '',
joinedAtAffinity: npcState.affinity,
});
session.currentEncounter = null;
session.npcInteractionActive = false;
session.currentNpcBattleMode = null;
session.currentNpcBattleOutcome = null;
session.inBattle = false;
session.sceneHostileNpcs = [];
return {
actionText: `邀请${encounter.npcName}加入队伍`,
resultText: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`,
patches: [
{
type: 'status_changed',
inBattle: session.inBattle,
npcInteractionActive: session.npcInteractionActive,
currentNpcBattleMode: session.currentNpcBattleMode,
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
},
{
type: 'encounter_changed',
encounterId: null,
},
],
};
}
case 'npc_fight':
case 'npc_spar': {
session.npcInteractionActive = false;
session.inBattle = true;
session.currentNpcBattleMode = functionId === 'npc_spar' ? 'spar' : 'fight';
session.currentNpcBattleOutcome = null;
session.sceneHostileNpcs = [
buildBattleTarget(
encounter,
npcState,
functionId === 'npc_spar' ? 'spar' : 'fight',
),
];
return {
actionText:
functionId === 'npc_spar'
? `${encounter.npcName}点到为止切磋`
: `${encounter.npcName}正面开战`,
resultText:
functionId === 'npc_spar'
? `${encounter.npcName}摆开架势,准备和你来一场点到为止的切磋。`
: `${encounter.npcName}已经不再保留余地,当前冲突正式转入战斗结算。`,
patches: [
{
type: 'status_changed',
inBattle: session.inBattle,
npcInteractionActive: session.npcInteractionActive,
currentNpcBattleMode: session.currentNpcBattleMode,
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
},
],
};
}
case 'npc_leave': {
session.currentEncounter = null;
session.npcInteractionActive = false;
session.currentNpcBattleMode = null;
session.currentNpcBattleOutcome = null;
session.sceneHostileNpcs = [];
session.inBattle = false;
return {
actionText: `离开${encounter.npcName}`,
resultText: `你暂时没有继续和${encounter.npcName}纠缠,把注意力重新拉回了前路。`,
patches: [
{
type: 'status_changed',
inBattle: session.inBattle,
npcInteractionActive: session.npcInteractionActive,
currentNpcBattleMode: session.currentNpcBattleMode,
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
},
{
type: 'encounter_changed',
encounterId: null,
},
],
};
}
default:
throw conflict(`暂不支持的 NPC 动作:${functionId}`);
}
}

View File

@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
import {
buildInitialNpcState,
getGiftCandidates,
syncNpcTradeInventory,
} from './npcTask6Primitives.js';
function createState(overrides: Record<string, unknown> = {}) {
return {
worldType: 'WUXIA',
customWorldProfile: null,
currentScenePreset: {
id: 'market-street',
name: '桥市',
description: '桥下的临时市集还没有散。',
treasureHints: [],
},
storyHistory: [
{
text: '你刚从桥口撤下来,正准备补足补给。',
},
],
playerCharacter: createTestPlayerCharacter<{ id: string }>(),
playerEquipment: {
weapon: {
tags: ['weapon', '快剑'],
buildProfile: {
role: '快剑',
tags: ['快剑', '突进'],
},
},
armor: null,
relic: null,
},
activeBuildBuffs: [
{
tags: ['续战'],
},
],
...overrides,
};
}
test('buildInitialNpcState generates deterministic trade stock for runtime role npc', () => {
const state = createState();
const npcState = buildInitialNpcState(
{
id: 'npc_vendor_01',
npcName: '桥市货郎',
npcDescription: '背着木箱沿街兜售补给的行脚货郎。',
context: '沿街商贩',
},
'WUXIA',
state,
);
assert.equal(npcState.affinity, 6);
assert.equal(typeof npcState.tradeStockSignature, 'string');
assert.ok((npcState.tradeStockSignature ?? '').includes('npc_vendor_01'));
assert.ok(npcState.inventory.length > 0);
assert.ok(
npcState.inventory.every(
(item) => item.runtimeMetadata?.generationChannel === 'npc_trade',
),
);
});
test('syncNpcTradeInventory keeps non-trade items while refreshing generated stock', () => {
const state = createState({
activeBuildBuffs: [{ tags: ['爆发'] }],
});
const nextState = syncNpcTradeInventory(
state,
{
id: 'npc_vendor_02',
npcName: '药铺掌柜',
npcDescription: '一边记账一边看着药炉火候。',
context: '药商',
},
{
affinity: 14,
helpUsed: false,
chattedCount: 0,
giftsGiven: 1,
inventory: [
{
id: 'gift-token',
category: '信物',
name: '旧铜铃',
quantity: 1,
rarity: 'rare',
tags: ['relic'],
runtimeMetadata: {
generationChannel: 'npc_gift',
},
},
],
recruited: false,
tradeStockSignature: 'outdated-signature',
firstMeaningfulContactResolved: true,
knownAttributeRumors: [],
revealedFacts: [],
seenBackstoryChapterIds: [],
},
);
assert.ok(nextState.inventory.some((item) => item.id === 'gift-token'));
assert.ok(
nextState.inventory.some(
(item) => item.runtimeMetadata?.generationChannel === 'npc_trade',
),
);
assert.notEqual(nextState.tradeStockSignature, 'outdated-signature');
});
test('getGiftCandidates prefers gifts that match npc role tags', () => {
const candidates = getGiftCandidates(
[
{
id: 'mana-herb',
category: '材料',
name: '暖息草',
quantity: 1,
rarity: 'rare',
tags: ['material', 'mana'],
},
{
id: 'plain-stone',
category: '材料',
name: '碎石',
quantity: 1,
rarity: 'common',
tags: ['material'],
},
],
{
id: 'npc_vendor_03',
npcName: '药行掌柜',
npcDescription: '对药性和回气补给都很熟。',
context: '药商',
},
);
assert.equal(candidates[0]?.item.id, 'mana-herb');
assert.ok((candidates[0]?.affinityGain ?? 0) > (candidates[1]?.affinityGain ?? 0));
assert.match(candidates[0]?.attributeInsight?.reasonText ?? '', /|/u);
});

View File

@@ -0,0 +1,411 @@
import { formatCurrency } from '../runtime/runtimeEconomyPrimitives.js';
import { buildRelationState, sortInventoryItems } from '../runtime/runtimeStatePrimitives.js';
import {
buildLooseRuntimeItemGenerationContext,
buildRuntimeInventoryStock,
} from '../runtime-item/runtimeItemModule.js';
import { normalizeNpcPersistentState } from '../runtime/runtimeNpcStatePrimitives.js';
type RuntimeInventoryItem = {
id: string;
category: string;
name: string;
quantity: number;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
tags: string[];
runtimeMetadata?: {
generationChannel?: string;
} | null;
};
type RuntimeEncounter = {
id?: string;
npcName: string;
context: string;
characterId?: string | null;
monsterPresetId?: string | null;
initialAffinity?: number;
};
type RuntimeNpcState = {
affinity: number;
helpUsed: boolean;
chattedCount: number;
giftsGiven: number;
inventory: RuntimeInventoryItem[];
recruited: boolean;
relationState?: ReturnType<typeof buildRelationState>;
revealedFacts?: string[];
knownAttributeRumors?: string[];
firstMeaningfulContactResolved?: boolean;
seenBackstoryChapterIds?: string[];
tradeStockSignature?: string | null;
stanceProfile?: {
trust?: number;
warmth?: number;
ideologicalFit?: number;
fearOrGuard?: number;
loyalty?: number;
currentConflictTag?: string | null;
recentApprovals?: string[];
recentDisapprovals?: string[];
} | null;
};
function clampStanceMetric(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
function buildInitialStanceProfile(
affinity: number,
options: {
recruited?: boolean;
hostile?: boolean;
roleText?: string | null;
} = {},
) {
const recruitedBonus = options.recruited ? 14 : 0;
const hostilePenalty = options.hostile ? 18 : 0;
const roleText = options.roleText ?? '';
const currentConflictTag =
/||/u.test(roleText)
? '旧案'
: /||/u.test(roleText)
? '守线'
: /||/u.test(roleText)
? '交易'
: null;
return {
trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus - hostilePenalty),
warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus),
ideologicalFit: clampStanceMetric(48 + affinity * 0.25),
fearOrGuard: clampStanceMetric(62 - affinity * 0.55 + hostilePenalty),
loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)),
currentConflictTag,
recentApprovals: [],
recentDisapprovals: [],
};
}
function getRarityScore(rarity: RuntimeInventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return 5;
case 'epic':
return 4;
case 'rare':
return 3;
case 'uncommon':
return 2;
default:
return 1;
}
}
function describeAffinityShift(affinityGain: number) {
if (affinityGain >= 12) return '态度一下子软化了许多';
if (affinityGain >= 8) return '态度明显和缓下来';
if (affinityGain >= 5) return '态度比先前亲近了一些';
return '态度略微放松了些';
}
function describeNpcAffinityInWords(
affinity: number,
options: { recruited?: boolean } = {},
) {
if (options.recruited) {
return '已经把你视为并肩而行的同伴,交流时天然站在你这一边。';
}
if (affinity >= 90) return '对你高度信赖,言谈间明显亲近,几乎已经把你当成自己人。';
if (affinity >= 60) return '对你已经建立起稳固信任,愿意进一步合作。';
if (affinity >= 30) return '对你的态度明显友善了许多,也更愿意正常交流。';
if (affinity >= 15) return '戒备开始松动,愿意试探性地配合你的节奏。';
if (affinity >= 0) return '仍保持明显距离,只会给出谨慎而有限的回应。';
return '关系已经降到冰点,对你几乎不再保留善意。';
}
function isRuntimeTradeDrivenRoleNpc(encounter: RuntimeEncounter) {
return !encounter.characterId && !encounter.monsterPresetId;
}
export function applyStoryChoiceToStanceProfile(
stanceProfile: RuntimeNpcState['stanceProfile'],
action: 'npc_chat' | 'npc_help' | 'npc_gift' | 'npc_recruit' | 'npc_quest_accept',
options: {
affinityGain?: number;
recruited?: boolean;
} = {},
) {
const base =
stanceProfile ??
buildInitialStanceProfile(0, {
recruited: options.recruited,
});
const affinityGain = options.affinityGain ?? 0;
const approvalNotes = [...(base.recentApprovals ?? [])];
const disapprovalNotes = [...(base.recentDisapprovals ?? [])];
const applyApproval = (note: string) => {
approvalNotes.push(note);
while (approvalNotes.length > 3) approvalNotes.shift();
};
const applyDisapproval = (note: string) => {
disapprovalNotes.push(note);
while (disapprovalNotes.length > 3) disapprovalNotes.shift();
};
const next = {
...base,
trust: base.trust ?? 40,
warmth: base.warmth ?? 35,
ideologicalFit: base.ideologicalFit ?? 45,
fearOrGuard: base.fearOrGuard ?? 55,
loyalty: base.loyalty ?? 20,
};
switch (action) {
case 'npc_chat':
next.trust += 6 + affinityGain * 2;
next.warmth += 4 + affinityGain * 2;
next.fearOrGuard -= 5 + affinityGain;
if (affinityGain >= 0) {
applyApproval('你愿意先从眼前局势和试探开始说话。');
} else {
applyDisapproval('这轮交流没能真正对上节奏。');
}
break;
case 'npc_help':
next.trust += 12;
next.warmth += 6;
next.fearOrGuard -= 8;
applyApproval('你在对方需要的时候搭了手。');
break;
case 'npc_gift':
next.trust += 6 + affinityGain;
next.warmth += 10 + affinityGain * 2;
next.fearOrGuard -= 4;
applyApproval('你给出的东西回应了对方眼下的处境。');
break;
case 'npc_recruit':
next.trust += 8;
next.warmth += 6;
next.loyalty += 18;
next.fearOrGuard -= 10;
applyApproval('你正式把对方纳入了同行关系。');
break;
case 'npc_quest_accept':
next.trust += 7;
next.ideologicalFit += 5;
next.loyalty += 4;
applyApproval('你接住了对方主动交出来的事。');
break;
}
return {
...next,
trust: clampStanceMetric(next.trust),
warmth: clampStanceMetric(next.warmth),
ideologicalFit: clampStanceMetric(next.ideologicalFit),
fearOrGuard: clampStanceMetric(next.fearOrGuard),
loyalty: clampStanceMetric(next.loyalty),
recentApprovals: approvalNotes,
recentDisapprovals: disapprovalNotes,
};
}
export function buildInitialNpcState(
encounter: RuntimeEncounter,
worldType: string | null | undefined,
state?: {
currentScenePreset?: {
id: string;
name: string;
description?: string;
treasureHints?: string[];
} | null;
playerCharacter?: {
id: string;
} | null;
} | null,
) {
const initialAffinity =
encounter.initialAffinity ??
(encounter.monsterPresetId ? -40 : encounter.characterId ? 18 : 6);
const baseState = normalizeNpcPersistentState({
affinity: initialAffinity,
relationState: buildRelationState(initialAffinity),
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [] as RuntimeInventoryItem[],
tradeStockSignature: null,
recruited: false,
revealedFacts: [],
knownAttributeRumors: [],
firstMeaningfulContactResolved: false,
seenBackstoryChapterIds: [],
stanceProfile: buildInitialStanceProfile(initialAffinity, {
recruited: false,
hostile: Boolean(encounter.monsterPresetId) || initialAffinity < 0,
roleText: encounter.context,
}),
});
if (state && isRuntimeTradeDrivenRoleNpc(encounter)) {
return syncNpcTradeInventory(
{
worldType,
currentScenePreset: state.currentScenePreset ?? null,
playerCharacter: state.playerCharacter ?? null,
},
encounter,
baseState,
);
}
return baseState;
}
export function getGiftCandidates(
playerInventory: RuntimeInventoryItem[],
_encounter: RuntimeEncounter,
) {
return [...playerInventory]
.filter((item) => item.quantity > 0)
.map((item) => ({
item,
affinityGain:
Math.min(
24,
4 +
getRarityScore(item.rarity) * 3 +
(item.tags.includes('mana') ? 3 : 0) +
(item.tags.includes('healing') ? 3 : 0),
),
attributeInsight: {
reasonText: item.tags.includes('mana')
? '这份礼物明显更适合对方当前的回气与补给需求。'
: item.tags.includes('healing')
? '这份礼物更像是在照顾对方眼下的补给处境。'
: '这份礼物至少表达了你愿意先拿出诚意。',
},
}))
.sort((left, right) => {
const diff = right.affinityGain - left.affinityGain;
if (diff !== 0) return diff;
return getRarityScore(right.item.rarity) - getRarityScore(left.item.rarity);
});
}
export function buildNpcGiftResultText(
encounter: RuntimeEncounter,
item: RuntimeInventoryItem,
affinityGain: number,
nextAffinity: number,
attributeSummary?: string,
) {
const summaryText = attributeSummary ? `你感到:${attributeSummary}` : '';
return `${encounter.npcName}收下了${item.name}${describeAffinityShift(affinityGain)}${describeNpcAffinityInWords(nextAffinity)}${summaryText}`;
}
export function buildNpcGiftCommitActionText(
encounter: RuntimeEncounter,
item: RuntimeInventoryItem,
) {
return `${item.name}赠给${encounter.npcName}`;
}
export function buildNpcTradeTransactionResultText(params: {
encounter: RuntimeEncounter;
mode: 'buy' | 'sell';
item: RuntimeInventoryItem;
quantity: number;
totalPrice: number;
worldType: string | null | undefined;
}) {
const quantityText =
params.quantity > 1 ? `${params.item.name} x${params.quantity}` : params.item.name;
if (params.mode === 'sell') {
return `${params.encounter.npcName}收下了${quantityText},付给你${formatCurrency(params.totalPrice, params.worldType)}`;
}
return `${params.encounter.npcName}收下了${formatCurrency(params.totalPrice, params.worldType)},把${quantityText}卖给了你。`;
}
export function buildNpcTradeTransactionActionText(params: {
encounter: RuntimeEncounter;
mode: 'buy' | 'sell';
item: RuntimeInventoryItem;
quantity: number;
}) {
const quantityText =
params.quantity > 1 ? `${params.item.name} x${params.quantity}` : params.item.name;
if (params.mode === 'sell') {
return `${quantityText}卖给${params.encounter.npcName}`;
}
return `${params.encounter.npcName}手里买下${quantityText}`;
}
export function syncNpcTradeInventory(
state: {
worldType: string | null | undefined;
currentScenePreset?: {
id: string;
name: string;
description?: string;
treasureHints?: string[];
} | null;
playerCharacter?: {
id: string;
} | null;
},
encounter: RuntimeEncounter,
npcState: RuntimeNpcState,
) {
if (!isRuntimeTradeDrivenRoleNpc(encounter)) {
return npcState;
}
const tradeStockSignature = `${encounter.id ?? encounter.npcName}:${state.currentScenePreset?.id ?? 'scene'}:${state.worldType ?? 'world'}`;
if (npcState.tradeStockSignature === tradeStockSignature) {
return npcState;
}
const runtimeStock = buildRuntimeInventoryStock(
buildLooseRuntimeItemGenerationContext({
worldType: state.worldType,
scene: state.currentScenePreset ?? null,
encounter: {
...encounter,
kind: 'npc',
npcDescription: encounter.context,
npcAvatar: '',
context: encounter.context,
},
playerCharacterId: state.playerCharacter?.id ?? 'npc-trade-preview',
generationChannel: 'npc_trade',
}),
{
seedKey: `npc-trade:${encounter.id ?? encounter.npcName}`,
itemCount: 4,
fixedKinds: ['consumable', 'material', 'relic', 'equipment'],
fixedPermanence: ['timed', 'resource', 'permanent', 'permanent'],
} as Parameters<typeof buildRuntimeInventoryStock>[1],
);
const preservedInventory = npcState.tradeStockSignature
? npcState.inventory.filter(
(item) => item.runtimeMetadata?.generationChannel !== 'npc_trade',
)
: [];
return normalizeNpcPersistentState({
...npcState,
inventory: sortInventoryItems([...preservedInventory, ...runtimeStock]),
tradeStockSignature,
});
}

View File

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

View File

@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildQuestForEncounter } from '../../bridges/legacyQuestProgressBridge.js';
import {
acknowledgeQuestCompletion,
applyQuestSignal,
turnInQuest,
} from './questProgressionService.js';
const TEST_WORLD = 'WUXIA' as Parameters<typeof buildQuestForEncounter>[0]['worldType'];
const TEST_SCENE = {
id: 'forest_path',
name: 'Forest Path',
description: 'A narrow trail with fresh claw marks.',
npcs: [
{
id: 'hostile-wolf-alpha',
name: '狼王',
description: 'A hostile wolf alpha.',
avatar: '狼',
role: '敌对角色',
monsterPresetId: 'wolf_alpha',
initialAffinity: -40,
hostile: true,
},
],
treasureHints: [],
};
function createQuest() {
const quest = buildQuestForEncounter({
issuerNpcId: 'npc_scout',
issuerNpcName: 'Scout Lin',
roleText: 'tracker',
scene: TEST_SCENE,
worldType: TEST_WORLD,
currentQuests: [],
});
assert.ok(quest);
return quest;
}
test('applyQuestSignal advances quest steps on the server side', () => {
const quest = createQuest();
const result = applyQuestSignal([quest], {
kind: 'hostile_npc_defeated',
sceneId: TEST_SCENE.id,
hostileNpcId: 'wolf_alpha',
});
assert.equal(result.updatedQuestIds.length, 1);
assert.equal(result.updatedQuestIds[0], quest.id);
assert.equal(result.updatedQuests[0]?.objective.kind, 'talk_to_npc');
assert.equal(result.updatedQuests[0]?.status, 'active');
});
test('turnInQuest rejects unfinished quests before reward-ready state', () => {
const quest = createQuest();
const result = turnInQuest([quest], quest.id);
assert.equal(result.ok, false);
if (result.ok) {
return;
}
assert.equal(result.code, 'quest_not_ready_to_turn_in');
});
test('turnInQuest marks ready quests as turned in after signal progression', () => {
const quest = createQuest();
const afterBattle = applyQuestSignal([quest], {
kind: 'hostile_npc_defeated',
sceneId: TEST_SCENE.id,
hostileNpcId: 'wolf_alpha',
});
const afterTalk = applyQuestSignal(afterBattle.nextQuests, {
kind: 'npc_talk_completed',
npcId: 'npc_scout',
});
const turnInResult = turnInQuest(afterTalk.nextQuests, quest.id);
assert.equal(turnInResult.ok, true);
if (!turnInResult.ok) {
return;
}
assert.equal(turnInResult.updatedQuests[0]?.status, 'turned_in');
assert.equal(turnInResult.updatedQuests[0]?.completionNotified, true);
});
test('acknowledgeQuestCompletion updates completion notification flag independently', () => {
const quest = createQuest();
const result = acknowledgeQuestCompletion([quest], quest.id);
assert.equal(result.ok, true);
if (!result.ok) {
return;
}
assert.equal(result.updatedQuests[0]?.completionNotified, true);
});

View File

@@ -0,0 +1,213 @@
import {
applyQuestProgressSignal,
normalizeQuestLogEntries,
} from '../../bridges/legacyQuestProgressBridge.js';
export type QuestLogEntry = Parameters<typeof normalizeQuestLogEntries>[0][number];
export type QuestProgressSignal = Parameters<typeof applyQuestProgressSignal>[1];
type QuestMutationFailureCode = 'quest_not_found' | 'quest_not_ready_to_turn_in';
export type QuestMutationFailure = {
ok: false;
code: QuestMutationFailureCode;
message: string;
};
export type QuestMutationSuccess = {
ok: true;
nextQuests: QuestLogEntry[];
updatedQuestIds: string[];
updatedQuests: QuestLogEntry[];
};
export type QuestMutationResult = QuestMutationFailure | QuestMutationSuccess;
function createFailure(
code: QuestMutationFailureCode,
message: string,
): QuestMutationFailure {
return {
ok: false,
code,
message,
};
}
function collectUpdatedQuestIds(
previous: QuestLogEntry[],
next: QuestLogEntry[],
): string[] {
const previousById = new Map(previous.map((quest) => [quest.id, quest]));
return next
.filter((quest) => {
const previousQuest = previousById.get(quest.id);
return JSON.stringify(previousQuest) !== JSON.stringify(quest);
})
.map((quest) => quest.id);
}
function buildSuccess(
previous: QuestLogEntry[],
next: QuestLogEntry[],
): QuestMutationSuccess {
const updatedQuestIds = collectUpdatedQuestIds(previous, next);
return {
ok: true,
nextQuests: next,
updatedQuestIds,
updatedQuests: next.filter((quest) => updatedQuestIds.includes(quest.id)),
};
}
export function normalizeQuestEntries(quests: QuestLogEntry[]): QuestLogEntry[] {
return normalizeQuestLogEntries(quests);
}
function getQuestActiveStep(quest: QuestLogEntry) {
if (!quest.steps?.length) {
return null;
}
if (quest.activeStepId) {
return quest.steps.find((step) => step.id === quest.activeStepId) ?? null;
}
return quest.steps.find((step) => step.progress < step.requiredCount) ?? null;
}
export function applyQuestSignal(
quests: QuestLogEntry[],
signal: QuestProgressSignal,
): QuestMutationSuccess {
const normalizedQuests = normalizeQuestEntries(quests);
const nextQuests = applyQuestProgressSignal(normalizedQuests, signal);
return buildSuccess(normalizedQuests, nextQuests);
}
export function acknowledgeQuestCompletion(
quests: QuestLogEntry[],
questId: string,
): QuestMutationResult {
const normalizedQuests = normalizeQuestEntries(quests);
const quest = findQuestById(normalizedQuests, questId);
if (!quest) {
return createFailure('quest_not_found', '未找到目标委托。');
}
const nextQuests = markQuestCompletionNotified(normalizedQuests, questId);
return buildSuccess(normalizedQuests, nextQuests);
}
export function findQuestById(quests: QuestLogEntry[], questId: string) {
return quests.find((quest) => quest.id === questId) ?? null;
}
export function getQuestForIssuer(
quests: QuestLogEntry[],
issuerNpcId: string,
) {
return (
normalizeQuestEntries(quests).find(
(quest) =>
quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
) ?? null
);
}
export function acceptQuest(
quests: QuestLogEntry[],
quest: QuestLogEntry,
) {
const normalizedQuests = normalizeQuestEntries(quests);
if (findQuestById(normalizedQuests, quest.id)) {
return normalizedQuests;
}
return [...normalizedQuests, normalizeQuestEntries([quest])[0]!];
}
export function buildQuestAcceptResultText(quest: QuestLogEntry) {
const normalizedQuest = normalizeQuestEntries([quest])[0]!;
const activeStep = getQuestActiveStep(normalizedQuest);
return `${normalizedQuest.issuerNpcName} 正式把委托交到了你手上。${
activeStep?.revealText ?? normalizedQuest.summary
}`;
}
export function buildQuestTurnInResultText(quest: QuestLogEntry) {
const normalizedQuest = normalizeQuestEntries([quest])[0]!;
const itemText = normalizedQuest.reward.items.map((item) => item.name).join('、');
const intelText = normalizedQuest.reward.intel?.rumorText
? `,并额外告诉了你一条消息:${normalizedQuest.reward.intel.rumorText}`
: '';
const storyHintText = normalizedQuest.reward.storyHint
? ` ${normalizedQuest.reward.storyHint}`
: '';
return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${normalizedQuest.reward.currency} 赏金和 ${itemText}${intelText}${storyHintText}`;
}
export function isQuestReadyToClaim(quest: QuestLogEntry) {
const status = normalizeQuestEntries([quest])[0]!.status;
return status === 'ready_to_turn_in' || status === 'completed';
}
export function markQuestTurnedIn(
quests: QuestLogEntry[],
questId: string,
) {
return quests.map((quest) =>
quest.id === questId
? normalizeQuestEntries([
{
...quest,
status: 'turned_in',
completionNotified: true,
steps: quest.steps?.map((step) => ({
...step,
progress: step.requiredCount,
})),
},
])[0]!
: normalizeQuestEntries([quest])[0]!,
);
}
export function markQuestCompletionNotified(
quests: QuestLogEntry[],
questId: string,
) {
return quests.map((quest) =>
quest.id === questId
? normalizeQuestEntries([
{
...quest,
completionNotified: true,
},
])[0]!
: normalizeQuestEntries([quest])[0]!,
);
}
export function turnInQuest(
quests: QuestLogEntry[],
questId: string,
): QuestMutationResult {
const normalizedQuests = normalizeQuestEntries(quests);
const quest = findQuestById(normalizedQuests, questId);
if (!quest) {
return createFailure('quest_not_found', '未找到目标委托。');
}
if (!isQuestReadyToClaim(quest)) {
return createFailure(
'quest_not_ready_to_turn_in',
`${quest.title} 当前还不能交付结算。`,
);
}
const nextQuests = markQuestTurnedIn(normalizedQuests, questId);
return buildSuccess(normalizedQuests, nextQuests);
}

View File

@@ -0,0 +1,84 @@
import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/story.js';
import {
applyQuestSignal,
normalizeQuestEntries,
} from './questProgressionService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
type JsonRecord = Record<string, unknown>;
type RuntimeGameState = {
currentScenePreset?: {
id?: string | null;
} | null;
quests?: unknown[];
};
function readSceneId(state: RuntimeGameState) {
return state.currentScenePreset?.id ?? null;
}
export function applyQuestSignalsForResolvedAction(params: {
session: RuntimeSession;
functionId: string;
previousEncounter: RuntimeSession['currentEncounter'];
battle?: RuntimeBattlePresentation | null;
}) {
const state = params.session.rawGameState as unknown as RuntimeGameState;
const quests = normalizeQuestEntries(Array.isArray(state.quests) ? state.quests : []);
if (quests.length <= 0) {
return;
}
let mutation = null;
if (
params.functionId === 'npc_chat' &&
params.previousEncounter?.kind === 'npc'
) {
mutation = applyQuestSignal(quests, {
kind: 'npc_talk_completed',
npcId: params.previousEncounter.id,
});
} else if (
params.battle?.outcome === 'victory' &&
typeof params.battle.targetId === 'string' &&
params.battle.targetId.trim()
) {
mutation = applyQuestSignal(quests, {
kind: 'hostile_npc_defeated',
sceneId: readSceneId(state),
hostileNpcId: params.battle.targetId,
});
} else if (
params.battle?.outcome === 'spar_complete' &&
params.previousEncounter?.kind === 'npc'
) {
mutation = applyQuestSignal(quests, {
kind: 'npc_spar_completed',
npcId: params.previousEncounter.id,
});
} else if (
params.functionId === 'treasure_inspect' ||
params.functionId === 'treasure_secure'
) {
mutation = applyQuestSignal(quests, {
kind: 'treasure_inspected',
sceneId: readSceneId(state),
});
}
if (!mutation || mutation.updatedQuestIds.length <= 0) {
return;
}
replaceRuntimeSessionRawGameState(
params.session,
{
...state,
quests: mutation.nextQuests,
} as unknown as JsonRecord,
);
}

View File

@@ -0,0 +1,242 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
appendStoryEngineCarrierMemory,
markNpcFirstMeaningfulContactResolved,
} from '../../bridges/legacyNpcTask6Bridge.js';
import {
acceptQuest,
addInventoryItems,
buildQuestAcceptResultText,
buildQuestForEncounter,
buildQuestTurnInResultText,
buildRelationState,
getQuestForIssuer,
incrementGameRuntimeStats,
isQuestReadyToClaim,
turnInQuest,
} from './questTask6Bridge.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set<string>([
'npc_quest_accept',
'npc_quest_turn_in',
]);
type QuestStoryResolution = {
actionText: string;
resultText: string;
patches: RuntimeStoryPatch[];
};
type JsonRecord = Record<string, unknown>;
type RuntimeGameState = Parameters<typeof appendStoryEngineCarrierMemory>[0];
type RuntimeNpcState = Parameters<
typeof markNpcFirstMeaningfulContactResolved
>[0];
type RuntimeEncounter = {
id?: string;
kind?: 'npc' | 'treasure';
npcAvatar?: string;
npcName: string;
npcDescription: string;
context: string;
hostile?: boolean;
characterId?: string | null;
monsterPresetId?: string | null;
};
function getNpcEncounter(
session: RuntimeSession,
state: RuntimeGameState,
): RuntimeEncounter | null {
const rawEncounter = state.currentEncounter;
if (!rawEncounter || rawEncounter.kind !== 'npc') {
return null;
}
return {
npcAvatar: '',
hostile: false,
...rawEncounter,
id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName,
} satisfies RuntimeEncounter;
}
function getNpcEncounterKey(encounter: RuntimeEncounter) {
return encounter.id?.trim() || encounter.npcName;
}
function readPayload(request: RuntimeStoryActionRequest) {
return typeof request.action.payload === 'object' && request.action.payload
? (request.action.payload as JsonRecord)
: {};
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function readQuestId(request: RuntimeStoryActionRequest) {
const payload = readPayload(request);
return readString(payload.questId) || readString(request.action.targetId);
}
function ensureEncounterQuestContext(session: RuntimeSession) {
const state = session.rawGameState as unknown as RuntimeGameState;
const encounter = getNpcEncounter(session, state);
if (!encounter) {
throw conflict('当前不在可结算的 NPC 委托态。');
}
const npcKey = getNpcEncounterKey(encounter);
const npcState = state.npcStates?.[npcKey];
if (!npcState) {
throw conflict('当前 NPC 状态不存在,无法处理委托。');
}
return {
state,
encounter,
npcKey,
npcState,
};
}
function resolveQuestAcceptAction(
session: RuntimeSession,
): QuestStoryResolution {
const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session);
const quests = Array.isArray(state.quests) ? state.quests : [];
const existingQuest = getQuestForIssuer(quests, npcKey);
if (existingQuest) {
throw conflict('当前角色已经有未结清的委托。');
}
const quest = buildQuestForEncounter({
issuerNpcId: npcKey,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
currentQuests: quests.map((item) => ({
id: item.id,
issuerNpcId: item.issuerNpcId,
status: item.status,
})),
});
if (!quest) {
throw conflict('当前场景缺少可落地的委托抓手,暂时无法接取任务。');
}
const nextState = {
...state,
quests: acceptQuest(quests, quest),
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, {
questsAccepted: 1,
}),
npcStates: {
...state.npcStates,
[npcKey]: {
...markNpcFirstMeaningfulContactResolved(npcState),
},
},
} satisfies RuntimeGameState;
replaceRuntimeSessionRawGameState(
session,
nextState as unknown as JsonRecord,
);
return {
actionText: `接下${encounter.npcName}的委托`,
resultText: buildQuestAcceptResultText(quest),
patches: [],
};
}
function resolveQuestTurnInAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
): QuestStoryResolution {
const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session);
const quests = Array.isArray(state.quests) ? state.quests : [];
const questId = readQuestId(request);
const quest =
(questId ? quests.find((item) => item.id === questId) : null) ??
getQuestForIssuer(quests, npcKey);
if (!quest) {
throw conflict('当前没有可交付的委托。');
}
if (!isQuestReadyToClaim(quest)) {
throw conflict('这份委托还没有达到可交付状态。');
}
const turnInResult = turnInQuest(quests, quest.id);
if (!turnInResult.ok) {
throw conflict(turnInResult.message);
}
const nextAffinity = npcState.affinity + quest.reward.affinityBonus;
let nextState = {
...state,
quests: turnInResult.nextQuests,
playerCurrency: state.playerCurrency + quest.reward.currency,
playerInventory: addInventoryItems(state.playerInventory, quest.reward.items),
npcStates: {
...state.npcStates,
[npcKey]: {
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
},
},
} satisfies RuntimeGameState;
nextState = appendStoryEngineCarrierMemory(nextState, quest.reward.items);
replaceRuntimeSessionRawGameState(
session,
nextState as unknown as JsonRecord,
);
return {
actionText: `${encounter.npcName}交付委托`,
resultText: buildQuestTurnInResultText(quest),
patches: [
{
type: 'npc_affinity_changed',
npcId: npcKey,
previousAffinity: npcState.affinity,
nextAffinity,
},
],
};
}
export function isSupportedQuestStoryFunctionId(functionId: string) {
return SUPPORTED_QUEST_STORY_FUNCTION_IDS.has(functionId);
}
export function resolveQuestStoryAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
): QuestStoryResolution {
switch (request.action.functionId) {
case 'npc_quest_accept':
return resolveQuestAcceptAction(session);
case 'npc_quest_turn_in':
return resolveQuestTurnInAction(session, request);
default:
throw invalidRequest(
`暂不支持的 Quest 动作:${request.action.functionId}`,
);
}
}

View File

@@ -0,0 +1,17 @@
// Temporary bridge for legacy pure quest task6 action logic from src/**.
export {
addInventoryItems,
buildRelationState,
incrementGameRuntimeStats,
} from '../runtime/runtimeStatePrimitives.js';
export {
buildQuestForEncounter,
} from '../../bridges/legacyQuestProgressBridge.js';
export {
acceptQuest,
buildQuestAcceptResultText,
buildQuestTurnInResultText,
getQuestForIssuer,
isQuestReadyToClaim,
turnInQuest,
} from './questProgressionService.js';

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,784 @@
import {
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
RUNTIME_ITEM_TONE_VALUES,
} from '../../../../packages/shared/src/contracts/story.js';
export type RuntimeItemFunctionalBias =
(typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number];
export type RuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number];
export type RuntimeRelationAnchor =
| { type: 'npc'; npcName: string }
| { type: 'scene'; sceneName: string }
| { type: 'monster'; monsterName: string }
| { type: 'quest'; questName: string }
| { type: 'faction'; factionName: string }
| { type: 'landmark'; landmarkName: string };
export type RuntimeItemPlan = {
slot: string;
itemKind: 'equipment' | 'consumable' | 'material' | 'relic' | 'quest';
permanence: 'permanent' | 'timed' | 'resource';
relationAnchor: RuntimeRelationAnchor;
targetBuildDirection: string[];
};
export type RuntimeItemAiPromptInput = {
worldSummary: string;
sceneSummary: string;
encounterSummary: string;
relatedNpcSummary: string;
recentStorySummary: string;
activeThreadSummary: string;
generationChannel: string;
playerBuildDirection: string[];
playerBuildGaps: string[];
desiredItemKind: RuntimeItemPlan['itemKind'];
permanence: RuntimeItemPlan['permanence'];
};
export type RuntimeItemAiIntent = {
shortNameSeed: string;
sourcePhrase: string;
reasonToAppear: string;
relationHooks: string[];
desiredBuildTags: string[];
desiredFunctionalBias: RuntimeItemFunctionalBias[];
tone: RuntimeItemTone;
visibleClue: string;
witnessMark: string;
unfinishedBusiness: string;
hiddenHook: string;
reactionHooks: string[];
namingPattern: string;
};
export type RuntimeItemStoryFingerprint = {
relatedScarIds: string[];
relatedThreadIds: string[];
visibleClue: string;
witnessMark: string;
unresolvedQuestion: string;
};
export type RuntimeItemInventory = {
id: string;
category: string;
name: string;
description: string;
quantity: number;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
tags: string[];
equipmentSlotId?: string;
buildProfile?: {
role: string;
tags: string[];
synergy: string[];
forgeRank: number;
};
statProfile?: {
maxHpBonus?: number;
outgoingDamageBonus?: number;
incomingDamageMultiplier?: number;
};
useProfile?: {
hpRestore: number;
manaRestore: number;
cooldownReduction: number;
buildBuffs: Array<{
id: string;
sourceType: 'item';
sourceId: string;
name: string;
tags: string[];
durationTurns: number;
}>;
};
runtimeMetadata?: {
origin: 'ai_compiled' | 'procedural';
generationChannel: string;
seedKey: string;
sourceReason: string;
storyFingerprint: RuntimeItemStoryFingerprint;
};
};
export type DirectedRuntimeReward = {
primaryItem: RuntimeItemInventory | null;
supportItems: RuntimeItemInventory[];
hp?: number;
mana?: number;
currency?: number;
storyHint?: string;
};
export type RuntimeItemGenerationContext = {
worldType: string | null | undefined;
customWorldProfile?: {
name?: string;
summary?: string;
} | null;
sceneId: string | null;
sceneName: string | null;
sceneDescription: string | null;
treasureHints: string[];
encounter: {
id?: string;
kind?: string;
npcName: string;
npcDescription?: string;
npcAvatar?: string;
context?: string;
} | null;
encounterNpcId: string | null;
encounterNpcName: string | null;
encounterContextText: string | null;
relatedNpcState: {
affinity?: number;
} | null;
relatedNpcNarrativeProfile: {
publicMask?: string;
visibleLine?: string;
immediatePressure?: string;
debtOrBurden?: string;
contradiction?: string;
taboo?: string;
reactionHooks?: string[];
relatedThreadIds?: string[];
} | null;
relatedScene: {
id: string;
name: string;
description?: string;
treasureHints?: string[];
} | null;
recentStorySummary: string;
recentActions: string[];
activeThreadIds: string[];
playerCharacterId: string;
playerBuildTags: string[];
playerBuildGaps: string[];
playerEquipmentTags: string[];
generationChannel: string;
};
type LooseContextInput = {
worldType: string | null | undefined;
customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile'];
scene?: RuntimeItemGenerationContext['relatedScene'];
encounter?: RuntimeItemGenerationContext['encounter'];
relatedNpcState?: RuntimeItemGenerationContext['relatedNpcState'];
storyHistory?: Array<{ text: string }>;
playerCharacterId?: string;
playerBuildTags?: string[];
playerEquipmentTags?: string[];
generationChannel: string;
};
function dedupeStrings(values: Array<string | null | undefined>) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))];
}
function sanitizeFragment(value: string | null | undefined, maxLength = 4) {
return (value ?? '')
.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '')
.slice(0, maxLength);
}
function resolveAnchorLabel(anchor: RuntimeRelationAnchor) {
switch (anchor.type) {
case 'npc':
return anchor.npcName;
case 'scene':
return anchor.sceneName;
case 'monster':
return anchor.monsterName;
case 'quest':
return anchor.questName;
case 'faction':
return anchor.factionName;
default:
return anchor.landmarkName;
}
}
function buildRecentStoryLines(storyHistory: Array<{ text: string }> = []) {
return storyHistory
.slice(-4)
.map((moment) => moment.text.trim())
.filter(Boolean)
.slice(-3);
}
function buildRecentStorySummary(lines: string[]) {
return lines.length > 0 ? lines.join(' / ') : '最近没有形成稳定的事件线索。';
}
function derivePlayerBuildGaps(playerBuildTags: string[]) {
const gapChecks = [
{ id: 'survival_gap', tags: ['守御', '护体', '回复', '续战'] },
{ id: 'mana_gap', tags: ['法力', '冷却', '护持', '过载'] },
{ id: 'finisher_gap', tags: ['爆发', '重击', '追击', '压制'] },
];
const tagSet = new Set(playerBuildTags);
return gapChecks
.filter((definition) => definition.tags.every((tag) => !tagSet.has(tag)))
.map((definition) => definition.id)
.slice(0, 3);
}
function buildRuntimeItemStoryFingerprint(params: {
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
}) {
const anchorKey = sanitizeFragment(resolveAnchorLabel(params.plan.relationAnchor), 6) || '旧痕';
return {
relatedScarIds: [`scar:${params.context.generationChannel}:${anchorKey}`],
relatedThreadIds: params.context.activeThreadIds.slice(0, 2),
visibleClue: params.intent.visibleClue,
witnessMark: params.intent.witnessMark,
unresolvedQuestion: params.intent.hiddenHook || params.intent.unfinishedBusiness,
} satisfies RuntimeItemStoryFingerprint;
}
function buildNarrativeName(
plan: RuntimeItemPlan,
intent: RuntimeItemAiIntent,
index: number,
) {
const seed = intent.shortNameSeed || '旧痕';
switch (plan.itemKind) {
case 'equipment':
return `${seed}${index === 0 ? '战符' : '护具'}`;
case 'consumable':
return `${seed}${intent.desiredFunctionalBias.includes('mana') ? '回息散' : '疗伤散'}`;
case 'material':
return `${seed}残材`;
case 'quest':
return `${seed}凭证`;
default:
return `${seed}遗物`;
}
}
function buildNarrativeDescription(params: {
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
}) {
const buildText = params.context.playerBuildTags.join('、') || '当前构筑';
const anchorText = resolveAnchorLabel(params.plan.relationAnchor);
return `${anchorText}把这件物件推到了你面前。它会围绕你的构筑 ${buildText} 发挥作用,原因是:${params.intent.reasonToAppear}`;
}
function createRelationAnchor(
context: RuntimeItemGenerationContext,
index = 0,
): RuntimeRelationAnchor {
if (context.encounterNpcName) {
return {
type: 'npc',
npcName: context.encounterNpcName,
};
}
if (context.sceneName) {
return {
type: 'scene',
sceneName: context.sceneName,
};
}
return {
type: 'landmark',
landmarkName: `遗址${index + 1}`,
};
}
function buildPlanFromOptions(params: {
context: RuntimeItemGenerationContext;
index: number;
fixedKinds?: RuntimeItemPlan['itemKind'][];
fixedPermanence?: RuntimeItemPlan['permanence'][];
}) {
return {
slot: `slot_${params.index + 1}`,
itemKind: params.fixedKinds?.[params.index] ?? 'relic',
permanence: params.fixedPermanence?.[params.index] ?? 'permanent',
relationAnchor: createRelationAnchor(params.context, params.index),
targetBuildDirection: params.context.playerBuildTags.slice(0, 3),
} satisfies RuntimeItemPlan;
}
function buildItemRarity(plan: RuntimeItemPlan) {
if (plan.itemKind === 'equipment' || plan.itemKind === 'relic') {
return 'rare' as const;
}
if (plan.itemKind === 'quest') {
return 'epic' as const;
}
return 'uncommon' as const;
}
function buildItemTags(
plan: RuntimeItemPlan,
intent: RuntimeItemAiIntent,
context: RuntimeItemGenerationContext,
) {
return dedupeStrings([
plan.itemKind,
...intent.desiredBuildTags,
...context.playerBuildTags.slice(0, 2),
...intent.desiredFunctionalBias,
]);
}
function buildItemProfiles(
itemId: string,
plan: RuntimeItemPlan,
intent: RuntimeItemAiIntent,
context: RuntimeItemGenerationContext,
) {
if (plan.itemKind === 'equipment') {
return {
equipmentSlotId: 'weapon',
buildProfile: {
role: context.playerBuildTags[0] ?? '均衡',
tags: buildItemTags(plan, intent, context).slice(0, 3),
synergy: buildItemTags(plan, intent, context).slice(0, 3),
forgeRank: 0,
},
statProfile: {
maxHpBonus: intent.desiredFunctionalBias.includes('guard') ? 16 : 8,
outgoingDamageBonus: intent.desiredFunctionalBias.includes('damage')
? 0.12
: 0.05,
incomingDamageMultiplier: intent.desiredFunctionalBias.includes('guard')
? 0.9
: 0.96,
},
};
}
if (plan.itemKind === 'consumable') {
return {
useProfile: {
hpRestore: intent.desiredFunctionalBias.includes('heal') ? 12 : 0,
manaRestore: intent.desiredFunctionalBias.includes('mana') ? 10 : 0,
cooldownReduction: intent.desiredFunctionalBias.includes('cooldown') ? 1 : 0,
buildBuffs: [
{
id: `${itemId}:buff`,
sourceType: 'item' as const,
sourceId: itemId,
name: `${intent.shortNameSeed || '旧痕'}增益`,
tags: buildItemTags(plan, intent, context).slice(0, 2),
durationTurns: 2,
},
],
},
};
}
return {};
}
function buildRuntimeInventoryItem(params: {
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
seedKey: string;
index: number;
}) {
const itemId = `${params.seedKey}:${params.index + 1}`;
const storyFingerprint = buildRuntimeItemStoryFingerprint(params);
const name = buildNarrativeName(params.plan, params.intent, params.index);
return {
id: itemId,
category:
params.plan.itemKind === 'equipment'
? '装备'
: params.plan.itemKind === 'consumable'
? '消耗品'
: params.plan.itemKind === 'material'
? '材料'
: params.plan.itemKind === 'quest'
? '凭证'
: '遗物',
name,
description: buildNarrativeDescription(params),
quantity: 1,
rarity: buildItemRarity(params.plan),
tags: buildItemTags(params.plan, params.intent, params.context),
runtimeMetadata: {
origin: 'ai_compiled' as const,
generationChannel: params.context.generationChannel,
seedKey: itemId,
sourceReason: params.intent.reasonToAppear,
storyFingerprint,
},
...buildItemProfiles(itemId, params.plan, params.intent, params.context),
} satisfies RuntimeItemInventory;
}
export function buildRuntimeItemAiPromptInput(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
) {
return {
worldSummary:
context.customWorldProfile?.summary ?? context.worldType ?? '未知世界',
sceneSummary: [context.sceneName, context.sceneDescription].filter(Boolean).join(' / '),
encounterSummary: [context.encounterNpcName, context.encounterContextText]
.filter(Boolean)
.join(' / '),
relatedNpcSummary: context.relatedNpcNarrativeProfile
? `${context.encounterNpcName ?? '相关人物'}:公开面 ${
context.relatedNpcNarrativeProfile.publicMask ?? '暂无'
};当前压力 ${context.relatedNpcNarrativeProfile.immediatePressure ?? '暂无'}`
: context.relatedNpcState
? `${context.encounterNpcName ?? '相关人物'} 当前好感 ${context.relatedNpcState.affinity ?? 0}`
: '暂无明确人物关系',
recentStorySummary: context.recentStorySummary,
activeThreadSummary: context.activeThreadIds.join('、'),
generationChannel: context.generationChannel,
playerBuildDirection: context.playerBuildTags,
playerBuildGaps: context.playerBuildGaps,
desiredItemKind: plan.itemKind,
permanence: plan.permanence,
} satisfies RuntimeItemAiPromptInput;
}
export function buildRuntimeItemAiIntent(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
) {
const anchorLabel = resolveAnchorLabel(plan.relationAnchor);
const sourceSeed =
sanitizeFragment(context.sceneName, 4) ||
sanitizeFragment(context.customWorldProfile?.name, 4) ||
sanitizeFragment(anchorLabel, 4) ||
'旧誓';
const functionalBias: RuntimeItemFunctionalBias[] = [];
if (plan.permanence === 'timed') {
functionalBias.push(
context.playerBuildGaps.includes('survival_gap') ? 'heal' : 'cooldown',
);
}
if (context.playerBuildGaps.includes('mana_gap')) functionalBias.push('mana');
if (context.playerBuildGaps.includes('survival_gap')) functionalBias.push('guard');
if (
functionalBias.length <= 0 ||
context.playerBuildGaps.includes('finisher_gap') ||
plan.itemKind === 'equipment'
) {
functionalBias.push('damage');
}
return {
shortNameSeed: sourceSeed,
sourcePhrase: anchorLabel,
reasonToAppear:
context.generationChannel === 'monster_drop'
? `${anchorLabel}倒下后,${context.sceneName ?? '这片战场'}里最值得带走的残留被翻了出来。`
: `${anchorLabel}与最近局势把它推到了你面前。`,
relationHooks: [context.encounterContextText ?? context.sceneName ?? anchorLabel, ...context.recentActions]
.filter(Boolean)
.slice(0, 2) as string[],
desiredBuildTags: dedupeStrings([
...plan.targetBuildDirection,
...context.playerBuildTags.slice(0, 2),
]).slice(0, 3),
desiredFunctionalBias: [...new Set(functionalBias)].slice(0, 2),
tone:
context.generationChannel === 'monster_drop'
? 'grim'
: context.generationChannel === 'quest_reward'
? 'ritual'
: context.playerBuildGaps.includes('survival_gap')
? 'survival'
: 'martial',
visibleClue:
context.relatedNpcNarrativeProfile?.visibleLine ??
`${anchorLabel}身上留下的旧痕`,
witnessMark:
context.relatedNpcNarrativeProfile?.debtOrBurden ??
`${anchorLabel}尚未散尽的使用痕`,
unfinishedBusiness:
context.relatedNpcNarrativeProfile?.contradiction ??
`${anchorLabel}背后还有没说完的问题`,
hiddenHook:
context.relatedNpcNarrativeProfile?.taboo ??
`${anchorLabel}为什么会在此刻重新出现`,
reactionHooks: [
...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []),
...(context.activeThreadIds ?? []),
].slice(0, 4),
namingPattern:
plan.itemKind === 'quest'
? 'quest_evidence'
: plan.itemKind === 'material'
? 'scene_relic'
: plan.relationAnchor.type === 'monster'
? 'monster_trophy'
: plan.relationAnchor.type === 'npc'
? 'npc_relic'
: 'faction_issue',
} satisfies RuntimeItemAiIntent;
}
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
switch (anchor.type) {
case 'npc':
return `NPC:${anchor.npcName}`;
case 'scene':
return `场景:${anchor.sceneName}`;
case 'monster':
return `怪物:${anchor.monsterName}`;
case 'quest':
return `任务:${anchor.questName}`;
case 'faction':
return `势力:${anchor.factionName}`;
default:
return `地标:${anchor.landmarkName}`;
}
}
function describePlan(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
index: number,
) {
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
return [
`物品 ${index + 1}`,
`- slot: ${plan.slot}`,
`- 物品类型: ${promptInput.desiredItemKind}`,
`- 持续性: ${promptInput.permanence}`,
`- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`,
`- 世界摘要: ${promptInput.worldSummary}`,
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
`- 相关人物: ${promptInput.relatedNpcSummary}`,
`- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`,
`- 近期剧情: ${promptInput.recentStorySummary}`,
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
].join('\n');
}
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
你只返回 JSON不要输出 Markdown、解释或代码块。
输出结构:
{
"intents": [
{
"shortNameSeed": "中文短种子",
"sourcePhrase": "中文来源短语",
"reasonToAppear": "中文出现理由",
"relationHooks": ["中文关系钩子"],
"desiredBuildTags": ["中文 build 标签"],
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
"tone": "grim|mysterious|martial|ritual|survival",
"visibleClue": "玩家第一眼能抓到的痕迹",
"witnessMark": "它见证过什么的使用痕",
"unfinishedBusiness": "背后仍未结清的问题",
"hiddenHook": "更深一层但别直接讲穿的钩子",
"reactionHooks": ["以后谁会对它起反应"],
"namingPattern": "命名范式建议"
}
]
}
规则:
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
- 所有自然语言字段都必须使用中文。
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
export function buildRuntimeItemIntentPrompt(params: {
context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[];
}) {
return [
`生成渠道:${params.context.generationChannel}`,
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
'请严格返回 JSON。',
].join('\n\n');
}
function buildBaseRuntimeContext(params: {
worldType: string | null | undefined;
customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile'];
scene?: RuntimeItemGenerationContext['relatedScene'];
encounter?: RuntimeItemGenerationContext['encounter'];
relatedNpcState?: RuntimeItemGenerationContext['relatedNpcState'];
storyHistory?: Array<{ text: string }>;
playerCharacterId?: string;
playerBuildTags?: string[];
playerEquipmentTags?: string[];
generationChannel: string;
}) {
const recentStoryLines = buildRecentStoryLines(params.storyHistory);
const activeThreadIds = dedupeStrings(
params.encounter?.kind === 'npc' && params.encounter?.id
? [`thread:${params.encounter.id}`]
: params.scene?.id
? [`thread:${params.scene.id}`]
: [],
).slice(0, 3);
return {
worldType: params.worldType,
customWorldProfile: params.customWorldProfile ?? null,
sceneId: params.scene?.id ?? null,
sceneName: params.scene?.name ?? null,
sceneDescription: params.scene?.description ?? null,
treasureHints: [...(params.scene?.treasureHints ?? [])],
encounter: params.encounter ?? null,
encounterNpcId:
params.encounter?.id ?? params.encounter?.npcName ?? null,
encounterNpcName: params.encounter?.npcName ?? null,
encounterContextText: params.encounter?.context ?? null,
relatedNpcState: params.relatedNpcState ?? null,
relatedNpcNarrativeProfile: null,
relatedScene: params.scene ?? null,
recentStorySummary: buildRecentStorySummary(recentStoryLines),
recentActions: recentStoryLines,
activeThreadIds,
playerCharacterId: params.playerCharacterId ?? 'runtime-loose-player',
playerBuildTags: params.playerBuildTags ?? [],
playerBuildGaps: derivePlayerBuildGaps(params.playerBuildTags ?? []),
playerEquipmentTags: params.playerEquipmentTags ?? [],
generationChannel: params.generationChannel,
} satisfies RuntimeItemGenerationContext;
}
export function buildLooseRuntimeItemGenerationContext(params: LooseContextInput) {
return buildBaseRuntimeContext(params);
}
export function buildQuestRuntimeItemGenerationContext(params: {
context: {
worldType?: string | null;
customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile'];
currentSceneId?: string | null;
currentSceneName?: string | null;
currentSceneDescription?: string | null;
issuerAffinity?: number | null;
recentStoryMoments?: Array<{ text: string }>;
playerCharacter?: { id: string } | null;
};
generationChannel?: string;
issuerNpcId: string;
issuerNpcName: string;
roleText: string;
scene?: RuntimeItemGenerationContext['relatedScene'];
}) {
const { context, issuerNpcId, issuerNpcName, roleText } = params;
return buildBaseRuntimeContext({
worldType: context.worldType ?? null,
customWorldProfile: context.customWorldProfile ?? null,
scene:
params.scene ??
(context.currentSceneName
? {
id: context.currentSceneId ?? '',
name: context.currentSceneName,
description: context.currentSceneDescription ?? '',
treasureHints: [],
}
: null),
encounter: {
id: issuerNpcId,
kind: 'npc',
npcName: issuerNpcName,
npcDescription: roleText,
npcAvatar: '',
context: roleText,
},
relatedNpcState:
context.issuerAffinity == null
? null
: {
affinity: context.issuerAffinity,
},
storyHistory: context.recentStoryMoments ?? [],
playerCharacterId: context.playerCharacter?.id ?? 'quest-player',
generationChannel: params.generationChannel ?? 'quest_reward',
});
}
export function buildDirectedRuntimeReward(
context: RuntimeItemGenerationContext,
options: {
seedKey: string;
itemCount?: number;
fixedKinds?: RuntimeItemPlan['itemKind'][];
fixedPermanence?: RuntimeItemPlan['permanence'][];
baseHp?: number;
baseMana?: number;
baseCurrency?: number;
storyHint?: string;
},
) {
const itemCount = Math.max(1, options.itemCount ?? 2);
const items = Array.from({ length: itemCount }, (_, index) => {
const plan = buildPlanFromOptions({
context,
index,
fixedKinds: options.fixedKinds,
fixedPermanence: options.fixedPermanence,
});
const intent = buildRuntimeItemAiIntent(context, plan);
return buildRuntimeInventoryItem({
context,
plan,
intent,
seedKey: options.seedKey,
index,
});
});
return {
primaryItem: items[0] ?? null,
supportItems: items.slice(1),
hp: options.baseHp ?? 0,
mana: options.baseMana ?? 0,
currency: options.baseCurrency ?? 0,
storyHint:
options.storyHint ??
(items[0]
? `${items[0].name} 先露出的是“${
items[0].runtimeMetadata?.storyFingerprint.visibleClue ?? '旧痕'
}”。`
: '你得到了一件与当前局势相关的物品。'),
} satisfies DirectedRuntimeReward;
}
export function flattenDirectedRuntimeRewardItems(reward: DirectedRuntimeReward) {
return [
...(reward.primaryItem ? [reward.primaryItem] : []),
...reward.supportItems,
];
}
export function buildRuntimeInventoryStock(
context: RuntimeItemGenerationContext,
options: Parameters<typeof buildDirectedRuntimeReward>[1],
) {
return flattenDirectedRuntimeRewardItems(
buildDirectedRuntimeReward(context, options),
);
}

View File

@@ -0,0 +1,96 @@
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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,80 @@
import {
buildDirectedRuntimeReward,
buildLooseRuntimeItemGenerationContext,
flattenDirectedRuntimeRewardItems,
} from './runtimeItemModule.js';
type TreasureInteractionAction = 'inspect' | 'leave' | 'secure';
type RuntimeStateLike = {
worldType: string | null | undefined;
currentScenePreset?: {
id: string;
name: string;
description?: string;
treasureHints?: string[];
} | null;
currentEncounter?: {
id?: string;
kind?: string;
npcName: string;
npcDescription?: string;
npcAvatar?: string;
context?: string;
} | null;
playerCharacter?: {
id: string;
} | null;
};
type RuntimeEncounterLike = NonNullable<RuntimeStateLike['currentEncounter']>;
export type TreasureReward = {
items: ReturnType<typeof flattenDirectedRuntimeRewardItems>;
hp: number;
mana: number;
currency: number;
storyHint?: string;
};
export function resolveTreasureReward(
state: RuntimeStateLike,
encounter: RuntimeEncounterLike,
action: TreasureInteractionAction,
) {
const context = buildLooseRuntimeItemGenerationContext({
worldType: state.worldType,
scene: state.currentScenePreset ?? null,
encounter,
playerCharacterId: state.playerCharacter?.id ?? 'treasure-player',
generationChannel: 'treasure',
});
const directed = buildDirectedRuntimeReward(context, {
seedKey: `treasure:${encounter.id ?? encounter.npcName}:${action}`,
variant: action,
itemCount: 2,
fixedKinds:
action === 'inspect' ? ['relic', 'consumable'] : ['relic', 'material'],
fixedPermanence:
action === 'inspect' ? ['permanent', 'timed'] : ['permanent', 'resource'],
baseHp: action === 'inspect' ? 10 : 0,
baseMana: action === 'inspect' ? 12 : 0,
baseCurrency:
action === 'inspect'
? state.worldType === 'XIANXIA'
? 34
: 48
: state.worldType === 'XIANXIA'
? 22
: 30,
storyHint: `${encounter.npcName}里藏着与你当前构筑和现场线索贴合的战利品。`,
} as Parameters<typeof buildDirectedRuntimeReward>[1]);
return {
items: flattenDirectedRuntimeRewardItems(directed),
hp: directed.hp ?? 0,
mana: directed.mana ?? 0,
currency: directed.currency ?? 0,
storyHint: directed.storyHint,
} satisfies TreasureReward;
}

View File

@@ -0,0 +1,140 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
addInventoryItems,
appendStoryEngineCarrierMemory,
} from '../../bridges/legacyNpcTask6Bridge.js';
import {
buildTreasureResultText,
resolveTreasureReward,
} from '../../bridges/legacyTreasureRuntimeBridge.js';
import { buildBuildToast } from '../inventory/inventoryStoryActionService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
const SUPPORTED_TREASURE_STORY_FUNCTION_IDS = new Set<string>([
'treasure_inspect',
'treasure_leave',
'treasure_secure',
]);
type TreasureStoryResolution = {
actionText: string;
resultText: string;
patches: RuntimeStoryPatch[];
toast?: string | null;
};
type JsonRecord = Record<string, unknown>;
type RuntimeGameState = Parameters<typeof resolveTreasureReward>[0];
type RuntimeEncounter = Parameters<typeof resolveTreasureReward>[1];
function resolveTreasureAction(functionId: string) {
switch (functionId) {
case 'treasure_secure':
return 'secure';
case 'treasure_inspect':
return 'inspect';
case 'treasure_leave':
return 'leave';
default:
throw invalidRequest(`暂不支持的 Treasure 动作:${functionId}`);
}
}
function getTreasureEncounter(
session: RuntimeSession,
state: RuntimeGameState,
): RuntimeEncounter | null {
const rawEncounter = state.currentEncounter;
if (!rawEncounter || rawEncounter.kind !== 'treasure') {
return null;
}
return {
npcAvatar: '',
hostile: false,
...rawEncounter,
id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName,
} satisfies RuntimeEncounter;
}
export function isSupportedTreasureStoryFunctionId(functionId: string) {
return SUPPORTED_TREASURE_STORY_FUNCTION_IDS.has(functionId);
}
export function resolveTreasureStoryAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
): TreasureStoryResolution {
const state = session.rawGameState as unknown as RuntimeGameState;
const encounter = getTreasureEncounter(session, state);
if (!encounter) {
throw conflict('当前没有可结算的宝藏遭遇。');
}
const action = resolveTreasureAction(request.action.functionId);
const reward =
action === 'leave' ? null : resolveTreasureReward(state, encounter, action);
let nextState = {
...state,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: state.animationState,
scrollWorld: false,
inBattle: false,
playerHp: reward
? Math.min(state.playerMaxHp, state.playerHp + reward.hp)
: state.playerHp,
playerMana: reward
? Math.min(state.playerMaxMana, state.playerMana + reward.mana)
: state.playerMana,
playerCurrency: reward
? state.playerCurrency + reward.currency
: state.playerCurrency,
playerInventory: reward
? addInventoryItems(state.playerInventory, reward.items)
: state.playerInventory,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} satisfies RuntimeGameState;
if (reward) {
nextState = appendStoryEngineCarrierMemory(nextState, reward.items);
}
replaceRuntimeSessionRawGameState(
session,
nextState as unknown as JsonRecord,
);
return {
actionText:
action === 'leave'
? '先记下位置'
: action === 'inspect'
? '仔细检查'
: '直接收取',
resultText: buildTreasureResultText(
encounter,
action,
reward ?? undefined,
state.worldType,
),
patches: [],
toast: reward ? buildBuildToast(nextState) : null,
};
}

View File

@@ -0,0 +1,211 @@
import {
getEquipmentBonuses,
type RuntimeEquipmentLoadout,
} from './runtimeEquipmentModule.js';
type RuntimeCharacterLike = {
attributes: {
strength: number;
agility: number;
intelligence: number;
spirit: number;
};
};
type RuntimeBuildBuff = {
id: string;
name: string;
tags: string[];
durationTurns: number;
};
type RuntimeInventoryItemLike = {
buildProfile?: {
role: string;
tags: string[];
synergy: string[];
forgeRank: number;
};
};
type RuntimeGameStateLike<TItem extends RuntimeInventoryItemLike = RuntimeInventoryItemLike> = {
playerEquipment: RuntimeEquipmentLoadout<TItem>;
activeBuildBuffs?: RuntimeBuildBuff[];
playerCharacter?: RuntimeCharacterLike | null;
};
export type BuildContributionRow = {
label: string;
source: 'buff' | 'weapon' | 'armor' | 'relic' | 'character';
fitScore: number;
sourceCoefficient: number;
bonusDelta: number;
attributeSimilarities: Record<string, number>;
attributeWeights: Record<string, number>;
attributeContributions: Record<string, number>;
attributeModifierDeltas: Record<string, number>;
};
export type BuildDamageBreakdown = {
tags: string[];
baseTagCount: number;
buildDamageBonus: number;
buildDamageMultiplier: number;
rows: BuildContributionRow[];
};
export type OutgoingDamageResult = {
damage: number;
isCritical: boolean;
critChance: number;
critDamageMultiplier: number;
attackPowerMultiplier: number;
};
function roundNumber(value: number, digits = 4) {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function hashSeed(seed: string) {
let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
}
return hash;
}
export function appendBuildBuffs<TBuff extends RuntimeBuildBuff>(
baseBuffs: TBuff[] | null | undefined,
additions: TBuff[] | null | undefined,
) {
const merged = new Map<string, TBuff>();
[...(baseBuffs ?? []), ...(additions ?? [])].forEach((buff) => {
const existing = merged.get(buff.id);
if (!existing || (buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)) {
merged.set(buff.id, {
...buff,
tags: [...new Set(buff.tags.map((tag) => tag.trim()).filter(Boolean))],
});
}
});
return [...merged.values()].filter(
(buff) => buff.tags.length > 0 && buff.durationTurns > 0,
);
}
function collectBuildTags<TItem extends RuntimeInventoryItemLike>(
state: RuntimeGameStateLike<TItem>,
character: RuntimeCharacterLike,
) {
const tags = new Set<string>();
state.activeBuildBuffs
?.filter((buff) => (buff.durationTurns ?? 0) > 0)
.forEach((buff) => buff.tags.forEach((tag) => tags.add(tag)));
(['weapon', 'armor', 'relic'] as const).forEach((slot) => {
const item = state.playerEquipment[slot];
item?.buildProfile?.tags?.forEach((tag) => tags.add(tag));
if (item?.buildProfile?.role) {
tags.add(item.buildProfile.role);
}
});
if (character.attributes.agility >= 10) tags.add('快剑');
if (character.attributes.strength >= 10) tags.add('重击');
if (character.attributes.spirit >= 10) tags.add('续战');
if (character.attributes.intelligence >= 8) tags.add('法力');
return [...tags].filter(Boolean).slice(0, 8);
}
export function getPlayerBuildDamageBreakdown<
TState extends RuntimeGameStateLike<TItem>,
TItem extends RuntimeInventoryItemLike,
>(state: TState, character: RuntimeCharacterLike) {
const tags = collectBuildTags(state, character);
const rows = tags.map((tag, index) => {
const bonusDelta = roundNumber(0.03 + Math.min(index, 3) * 0.01, 4);
return {
label: tag,
source: index === 0 ? 'buff' : 'weapon',
fitScore: roundNumber(0.6 + Math.max(0, 3 - index) * 0.08, 4),
sourceCoefficient: 1,
bonusDelta,
attributeSimilarities: {},
attributeWeights: {},
attributeContributions: {},
attributeModifierDeltas: {},
} satisfies BuildContributionRow;
});
const buildDamageBonus = roundNumber(
clamp(rows.reduce((sum, row) => sum + row.bonusDelta, 0), 0, 0.6),
4,
);
return {
tags,
baseTagCount: tags.length,
buildDamageBonus,
buildDamageMultiplier: roundNumber(1 + buildDamageBonus, 4),
rows,
} satisfies BuildDamageBreakdown;
}
export function resolvePlayerOutgoingDamageResult<
TState extends RuntimeGameStateLike<TItem>,
TItem extends RuntimeInventoryItemLike,
>(
state: TState,
character: RuntimeCharacterLike,
baseDamage: number,
functionMultiplier = 1,
critRollSeed?: string,
) {
const buildBreakdown = getPlayerBuildDamageBreakdown(state, character);
const equipmentBonuses = getEquipmentBonuses(state.playerEquipment);
const attackPowerMultiplier = roundNumber(
1 +
(character.attributes.strength * 0.01 +
character.attributes.agility * 0.006 +
character.attributes.spirit * 0.004),
4,
);
const critChance = roundNumber(
clamp(0.08 + character.attributes.agility * 0.01, 0.08, 0.45),
4,
);
const critDamageMultiplier = roundNumber(
1.45 + character.attributes.strength * 0.01,
4,
);
const roll = critRollSeed ? (hashSeed(critRollSeed) % 1000) / 1000 : 1;
const isCritical = roll < critChance;
const damage = Math.max(
1,
Math.round(
baseDamage *
functionMultiplier *
equipmentBonuses.outgoingDamageMultiplier *
buildBreakdown.buildDamageMultiplier *
attackPowerMultiplier *
(isCritical ? critDamageMultiplier : 1),
),
);
return {
damage,
isCritical,
critChance,
critDamageMultiplier,
attackPowerMultiplier,
} satisfies OutgoingDamageResult;
}

View File

@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
formatCurrency,
getCurrencyName,
getInventoryItemValue,
getNpcBuybackPrice,
getNpcPurchasePrice,
} from './runtimeEconomyPrimitives.js';
import { buildTreasureResultText } from './runtimeTreasureTexts.js';
test('runtime economy primitives calculate trade prices on the server without src/data/economy', () => {
const item = {
category: '专属物品',
name: '青铜令牌',
rarity: 'epic' as const,
tags: ['relic'],
};
assert.equal(getCurrencyName('WUXIA'), '铜钱');
assert.equal(getCurrencyName('XIANXIA'), '灵石');
assert.equal(formatCurrency(48, 'WUXIA'), '48 铜钱');
assert.equal(getInventoryItemValue(item), 118);
assert.equal(getNpcPurchasePrice(item, 0), 118);
assert.equal(getNpcPurchasePrice(item, 65), 99);
assert.equal(getNpcBuybackPrice(item, 95), 68);
});
test('runtime treasure text uses server-side currency formatting and reward summaries', () => {
const text = buildTreasureResultText(
{
npcName: '古旧木匣',
},
'inspect',
{
items: [{ name: '残卷' }, { name: '灵药' }],
hp: 10,
mana: 12,
currency: 34,
storyHint: '你察觉这批东西与当前线索彼此呼应。',
},
'XIANXIA',
);
assert.match(text, /34 灵石/);
assert.match(text, /残卷、灵药/);
assert.match(text, /气血 \+10/);
assert.match(text, /灵力 \+12/);
});

View File

@@ -0,0 +1,75 @@
type RuntimeInventoryItemLike = {
category: string;
name: string;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
tags: string[];
value?: number;
};
const RARITY_BASE_VALUES: Record<RuntimeInventoryItemLike['rarity'], number> = {
common: 12,
uncommon: 24,
rare: 48,
epic: 92,
legendary: 168,
};
export function getCurrencyName(worldType: string | null | undefined) {
if (worldType === 'XIANXIA') {
return '灵石';
}
if (worldType === 'WUXIA') {
return '铜钱';
}
return '钱币';
}
export function formatCurrency(
value: number,
worldType: string | null | undefined,
) {
return `${value} ${getCurrencyName(worldType)}`;
}
export function getDiscountTierForAffinity(affinity: number) {
if (affinity >= 90) return 3;
if (affinity >= 60) return 2;
if (affinity >= 30) return 1;
return 0;
}
export function getInventoryItemValue(item: RuntimeInventoryItemLike) {
if (typeof item.value === 'number' && Number.isFinite(item.value)) {
return Math.max(8, Math.round(item.value));
}
let value = RARITY_BASE_VALUES[item.rarity];
if (item.tags.includes('weapon')) value += 14;
if (item.tags.includes('armor')) value += 12;
if (item.tags.includes('relic')) value += 16;
if (item.tags.includes('mana')) value += 8;
if (item.tags.includes('healing')) value += 8;
if (item.tags.includes('material')) value += 4;
if (item.category.includes('专属')) value += 10;
return Math.max(8, value);
}
export function getNpcPurchasePrice(
item: RuntimeInventoryItemLike,
affinity: number,
) {
const discountTier = getDiscountTierForAffinity(affinity);
const discountMultiplier = 1 - discountTier * 0.08;
return Math.max(6, Math.round(getInventoryItemValue(item) * discountMultiplier));
}
export function getNpcBuybackPrice(
item: RuntimeInventoryItemLike,
affinity: number,
) {
const discountTier = getDiscountTierForAffinity(affinity);
const buybackMultiplier = 0.4 + discountTier * 0.06;
return Math.max(4, Math.round(getInventoryItemValue(item) * buybackMultiplier));
}

View File

@@ -0,0 +1,211 @@
type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
type RuntimeInventoryItemLike = {
id: string;
category: string;
name: string;
quantity: number;
rarity: ItemRarity;
tags: string[];
equipmentSlotId?: RuntimeEquipmentSlotId;
statProfile?: {
maxHpBonus?: number;
maxManaBonus?: number;
outgoingDamageBonus?: number;
incomingDamageMultiplier?: number;
};
buildProfile?: {
role: string;
tags: string[];
synergy: string[];
forgeRank: number;
};
};
export type RuntimeEquipmentSlotId = 'weapon' | 'armor' | 'relic';
export type RuntimeEquipmentLoadout<TItem = RuntimeInventoryItemLike> = {
weapon: TItem | null;
armor: TItem | null;
relic: TItem | null;
};
export type EquipmentBonuses = {
maxHpBonus: number;
maxManaBonus: number;
outgoingDamageMultiplier: number;
incomingDamageMultiplier: number;
};
const EQUIPMENT_SLOTS: RuntimeEquipmentSlotId[] = ['weapon', 'armor', 'relic'];
const WEAPON_DAMAGE_BONUS: Record<ItemRarity, number> = {
common: 0.06,
uncommon: 0.1,
rare: 0.14,
epic: 0.2,
legendary: 0.28,
};
const ARMOR_HP_BONUS: Record<ItemRarity, number> = {
common: 14,
uncommon: 22,
rare: 32,
epic: 44,
legendary: 58,
};
const ARMOR_DAMAGE_MULTIPLIER: Record<ItemRarity, number> = {
common: 0.97,
uncommon: 0.94,
rare: 0.9,
epic: 0.86,
legendary: 0.8,
};
const RELIC_MANA_BONUS: Record<ItemRarity, number> = {
common: 10,
uncommon: 18,
rare: 28,
epic: 40,
legendary: 54,
};
const RELIC_DAMAGE_BONUS: Record<ItemRarity, number> = {
common: 0.02,
uncommon: 0.04,
rare: 0.06,
epic: 0.09,
legendary: 0.12,
};
export function createEmptyEquipmentLoadout<TItem = RuntimeInventoryItemLike>(): RuntimeEquipmentLoadout<TItem> {
return {
weapon: null,
armor: null,
relic: null,
};
}
export function getEquipmentSlotLabel(slot: RuntimeEquipmentSlotId) {
return {
weapon: '武器',
armor: '护甲',
relic: '饰品',
}[slot];
}
function inferSlotFromText(value: string) {
if (/||||||||/u.test(value)) return 'weapon' as const;
if (/|||||/u.test(value)) return 'armor' as const;
if (/|||||||||/u.test(value)) return 'relic' as const;
return null;
}
export function getEquipmentSlotFromItem(
item: RuntimeInventoryItemLike,
): RuntimeEquipmentSlotId | null {
if (item.equipmentSlotId) return item.equipmentSlotId;
if (item.tags.includes('weapon')) return 'weapon';
if (item.tags.includes('armor')) return 'armor';
if (item.tags.includes('relic')) return 'relic';
return inferSlotFromText(`${item.category} ${item.name}`);
}
function getFallbackBonusesForItem(slot: RuntimeEquipmentSlotId, rarity: ItemRarity) {
if (slot === 'weapon') {
return {
maxHpBonus: 0,
maxManaBonus: 0,
outgoingDamageBonus: WEAPON_DAMAGE_BONUS[rarity],
incomingDamageMultiplier: 1,
};
}
if (slot === 'armor') {
return {
maxHpBonus: ARMOR_HP_BONUS[rarity],
maxManaBonus: 0,
outgoingDamageBonus: 0,
incomingDamageMultiplier: ARMOR_DAMAGE_MULTIPLIER[rarity],
};
}
return {
maxHpBonus: 0,
maxManaBonus: RELIC_MANA_BONUS[rarity],
outgoingDamageBonus: RELIC_DAMAGE_BONUS[rarity],
incomingDamageMultiplier: 1,
};
}
function getItemEquipmentBonuses(
item: RuntimeInventoryItemLike,
slot: RuntimeEquipmentSlotId,
) {
const fallback = getFallbackBonusesForItem(slot, item.rarity);
return {
maxHpBonus: item.statProfile?.maxHpBonus ?? fallback.maxHpBonus,
maxManaBonus: item.statProfile?.maxManaBonus ?? fallback.maxManaBonus,
outgoingDamageBonus:
item.statProfile?.outgoingDamageBonus ?? fallback.outgoingDamageBonus,
incomingDamageMultiplier:
item.statProfile?.incomingDamageMultiplier ?? fallback.incomingDamageMultiplier,
};
}
export function getEquipmentBonuses<TItem extends RuntimeInventoryItemLike>(
loadout: RuntimeEquipmentLoadout<TItem>,
): EquipmentBonuses {
let maxHpBonus = 0;
let maxManaBonus = 0;
let outgoingDamageBonus = 0;
let incomingDamageMultiplier = 1;
EQUIPMENT_SLOTS.forEach((slot) => {
const item = loadout[slot];
if (!item) return;
const itemBonuses = getItemEquipmentBonuses(item, slot);
maxHpBonus += itemBonuses.maxHpBonus;
maxManaBonus += itemBonuses.maxManaBonus;
outgoingDamageBonus += itemBonuses.outgoingDamageBonus;
incomingDamageMultiplier *= itemBonuses.incomingDamageMultiplier;
});
return {
maxHpBonus,
maxManaBonus,
outgoingDamageMultiplier: Number((1 + outgoingDamageBonus).toFixed(4)),
incomingDamageMultiplier: Number(incomingDamageMultiplier.toFixed(4)),
};
}
export function applyEquipmentLoadoutToState<
TState extends {
playerMaxHp: number;
playerHp: number;
playerMaxMana: number;
playerMana: number;
playerEquipment: RuntimeEquipmentLoadout<TItem>;
},
TItem extends RuntimeInventoryItemLike,
>(state: TState, nextEquipment: RuntimeEquipmentLoadout<TItem>) {
const previousBonuses = getEquipmentBonuses(state.playerEquipment);
const nextBonuses = getEquipmentBonuses(nextEquipment);
const baseMaxHp = Math.max(1, state.playerMaxHp - previousBonuses.maxHpBonus);
const baseMaxMana = Math.max(1, state.playerMaxMana - previousBonuses.maxManaBonus);
const nextMaxHp = baseMaxHp + nextBonuses.maxHpBonus;
const nextMaxMana = baseMaxMana + nextBonuses.maxManaBonus;
return {
...state,
playerMaxHp: nextMaxHp,
playerHp: Math.min(nextMaxHp, state.playerHp),
playerMaxMana: nextMaxMana,
playerMana: nextMaxMana,
playerEquipment: nextEquipment,
};
}

View File

@@ -0,0 +1,468 @@
import { formatCurrency } from './runtimeEconomyPrimitives.js';
import {
addInventoryItems,
removeInventoryItem,
} from './runtimeStatePrimitives.js';
import {
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
type RuntimeEquipmentSlotId,
} from './runtimeEquipmentModule.js';
type RuntimeInventoryItemLike = {
id: string;
category: string;
name: string;
quantity: number;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
tags: string[];
equipmentSlotId?: RuntimeEquipmentSlotId;
statProfile?: {
maxHpBonus?: number;
maxManaBonus?: number;
outgoingDamageBonus?: number;
incomingDamageMultiplier?: number;
};
buildProfile?: {
role: string;
tags: string[];
synergy: string[];
forgeRank: number;
};
};
type ForgeRequirement<TItem> = {
id: string;
label: string;
quantity: number;
matches: (item: TItem) => boolean;
};
type ForgeRecipeDefinition<TItem> = {
id: string;
name: string;
kind: 'synthesis' | 'forge';
description: string;
resultLabel: string;
currencyCost: number;
requirements: ForgeRequirement<TItem>[];
createResult: (worldType: string | null | undefined) => TItem;
};
function createItemId(prefix: string) {
return `${prefix}:${Date.now().toString(36)}:${Math.random()
.toString(36)
.slice(2, 8)}`;
}
function normalizeBuildTags(tags: string[]) {
return [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
}
function buildMaterialItem(
name: string,
quantity: number,
tags: string[],
rarity: RuntimeInventoryItemLike['rarity'] = 'uncommon',
description?: string,
) {
return {
id: createItemId(`forge-material:${name}`),
category: '材料',
name,
quantity: Math.max(1, Math.floor(quantity)),
rarity,
tags: ['material', ...normalizeBuildTags(tags)],
description,
buildProfile: {
role: '工巧',
tags: normalizeBuildTags(tags),
synergy: normalizeBuildTags(tags),
forgeRank: 0,
},
} satisfies RuntimeInventoryItemLike;
}
function buildEquipmentItem(params: {
name: string;
slot: RuntimeEquipmentSlotId;
rarity: RuntimeInventoryItemLike['rarity'];
description: string;
role: string;
tags: string[];
synergy: string[];
statProfile: NonNullable<RuntimeInventoryItemLike['statProfile']>;
}) {
return {
id: createItemId(`forge-equip:${params.name}`),
category: getEquipmentSlotLabel(params.slot),
name: params.name,
quantity: 1,
rarity: params.rarity,
tags: [
params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic',
...normalizeBuildTags(params.tags),
],
description: params.description,
equipmentSlotId: params.slot,
statProfile: params.statProfile,
buildProfile: {
role: params.role,
tags: normalizeBuildTags(params.tags),
synergy: normalizeBuildTags(params.synergy),
forgeRank: 1,
},
} satisfies RuntimeInventoryItemLike;
}
function buildNamedMaterialRequirement<TItem extends RuntimeInventoryItemLike>(
name: string,
quantity: number,
): ForgeRequirement<TItem> {
return {
id: `name:${name}`,
label: name,
quantity,
matches: (item) => item.name === name,
};
}
function buildAnyMaterialRequirement<TItem extends RuntimeInventoryItemLike>(
id: string,
label: string,
quantity: number,
): ForgeRequirement<TItem> {
return {
id,
label,
quantity,
matches: (item) => item.tags.includes('material') || item.category.includes('材料'),
};
}
function buildForgeRecipes<TItem extends RuntimeInventoryItemLike>() {
return [
{
id: 'synthesis-refined-ingot',
name: '压炼锭材',
kind: 'synthesis',
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
resultLabel: '精炼锭材',
currencyCost: 18,
requirements: [buildAnyMaterialRequirement<TItem>('material:any', '任意材料', 3)],
createResult: () =>
buildMaterialItem('精炼锭材', 1, ['工巧', '守御'], 'rare') as TItem,
},
{
id: 'forge-duelist-blade',
name: '锻造 百炼追风剑',
kind: 'forge',
description: '围绕快剑、突进、追击构筑的轻灵主武器。',
resultLabel: '百炼追风剑',
currencyCost: 72,
requirements: [
buildNamedMaterialRequirement<TItem>('精炼锭材', 2),
buildNamedMaterialRequirement<TItem>('快剑精粹', 1),
],
createResult: () =>
buildEquipmentItem({
name: '百炼追风剑',
slot: 'weapon',
rarity: 'epic',
description: '为快剑与追身构筑准备的锻造兵刃。',
role: '快剑',
tags: ['快剑', '突进', '追击'],
synergy: ['快剑', '突进', '追击'],
statProfile: {
maxManaBonus: 10,
outgoingDamageBonus: 0.2,
},
}) as TItem,
},
] satisfies ForgeRecipeDefinition<TItem>[];
}
type ForgeRecipeView = {
id: string;
name: string;
kind: 'synthesis' | 'forge';
description: string;
resultLabel: string;
currencyCost: number;
currencyText: string;
requirements: Array<{
id: string;
label: string;
quantity: number;
owned: number;
}>;
canCraft: boolean;
};
function countMatchingItems<TItem extends RuntimeInventoryItemLike>(
inventory: TItem[],
requirement: ForgeRequirement<TItem>,
) {
return inventory
.filter((item) => requirement.matches(item))
.reduce((sum, item) => sum + item.quantity, 0);
}
function consumeRequirement<TItem extends RuntimeInventoryItemLike>(
inventory: TItem[],
requirement: ForgeRequirement<TItem>,
) {
let remaining = requirement.quantity;
let nextInventory = [...inventory];
for (const item of inventory) {
if (remaining <= 0) break;
if (!requirement.matches(item)) continue;
const consumed = Math.min(item.quantity, remaining);
nextInventory = removeInventoryItem(nextInventory, item.id, consumed);
remaining -= consumed;
}
return remaining === 0 ? nextInventory : null;
}
function applyRequirementsIfPossible<TItem extends RuntimeInventoryItemLike>(
inventory: TItem[],
requirements: ForgeRequirement<TItem>[],
) {
let nextInventory = [...inventory];
for (const requirement of requirements) {
const consumedInventory = consumeRequirement(nextInventory, requirement);
if (!consumedInventory) return null;
nextInventory = consumedInventory;
}
return nextInventory;
}
function buildTagEssence(tag: string) {
return buildMaterialItem(`${tag}精粹`, 1, [tag, '工巧'], 'rare');
}
function buildDismantleBaseMaterials(
item: RuntimeInventoryItemLike,
slot: RuntimeEquipmentSlotId | null,
) {
const rarityScale: Record<RuntimeInventoryItemLike['rarity'], number> = {
common: 1,
uncommon: 2,
rare: 3,
epic: 4,
legendary: 5,
};
const amount = rarityScale[item.rarity];
if (slot === 'weapon') {
return [buildMaterialItem('武器残片', amount, ['工巧', '重击'])];
}
if (slot === 'armor') {
return [buildMaterialItem('甲片', amount, ['工巧', '守御'])];
}
if (slot === 'relic') {
return [buildMaterialItem('灵饰碎片', amount, ['工巧', '法力'])];
}
return [buildMaterialItem('零散材料', Math.max(1, Math.ceil(amount / 2)), ['工巧'])];
}
function buildDismantleEssences(item: RuntimeInventoryItemLike) {
const buildTags = normalizeBuildTags([
...(item.buildProfile?.tags ?? []),
item.buildProfile?.role ?? '',
]).slice(0, item.rarity === 'legendary' ? 3 : 2);
return buildTags.map((tag) => buildTagEssence(tag));
}
function getReforgeCost<TItem extends RuntimeInventoryItemLike>(
slot: RuntimeEquipmentSlotId | null,
) {
if (slot === 'relic') {
return {
requirements: [buildNamedMaterialRequirement<TItem>('凝光纱', 1)],
currencyCost: 52,
};
}
return {
requirements: [buildNamedMaterialRequirement<TItem>('精炼锭材', 1)],
currencyCost: 46,
};
}
function buildReforgedItem(item: RuntimeInventoryItemLike) {
const slot = getEquipmentSlotFromItem(item);
if (!slot || !item.buildProfile) return null;
const nextTags = normalizeBuildTags([
...item.buildProfile.tags,
slot === 'weapon' ? '追击' : slot === 'armor' ? '护体' : '法力',
]).slice(0, 3);
return {
...item,
id: createItemId(`reforge:${item.name}`),
name: item.name.includes('重铸') ? item.name : `${item.name}·重铸`,
statProfile: {
...item.statProfile,
maxHpBonus: (item.statProfile?.maxHpBonus ?? 0) + (slot === 'armor' ? 10 : 4),
maxManaBonus: (item.statProfile?.maxManaBonus ?? 0) + (slot === 'relic' ? 10 : 4),
outgoingDamageBonus: Number(
(((item.statProfile?.outgoingDamageBonus ?? 0) + 0.03)).toFixed(3),
),
incomingDamageMultiplier:
typeof item.statProfile?.incomingDamageMultiplier === 'number'
? Number(Math.max(0.72, item.statProfile.incomingDamageMultiplier - 0.03).toFixed(3))
: slot === 'armor'
? 0.94
: 0.97,
},
buildProfile: {
...item.buildProfile,
tags: nextTags,
synergy: nextTags,
forgeRank: (item.buildProfile.forgeRank ?? 0) + 1,
},
} satisfies RuntimeInventoryItemLike;
}
export function getForgeRecipeViews<TItem extends RuntimeInventoryItemLike>(
inventory: TItem[],
playerCurrency = 0,
worldType: string | null | undefined = null,
) {
return buildForgeRecipes<TItem>().map((recipe) => ({
id: recipe.id,
name: recipe.name,
kind: recipe.kind,
description: recipe.description,
resultLabel: recipe.resultLabel,
currencyCost: recipe.currencyCost,
currencyText: formatCurrency(recipe.currencyCost, worldType),
requirements: recipe.requirements.map((requirement) => ({
id: requirement.id,
label: requirement.label,
quantity: requirement.quantity,
owned: countMatchingItems(inventory, requirement),
})),
canCraft:
playerCurrency >= recipe.currencyCost &&
recipe.requirements.every(
(requirement) => countMatchingItems(inventory, requirement) >= requirement.quantity,
),
})) satisfies ForgeRecipeView[];
}
export function executeForgeRecipe<TItem extends RuntimeInventoryItemLike>(
inventory: TItem[],
recipeId: string,
worldType: string | null | undefined,
playerCurrency: number,
) {
const recipe = buildForgeRecipes<TItem>().find((candidate) => candidate.id === recipeId);
if (!recipe || playerCurrency < recipe.currencyCost) return null;
const consumedInventory = applyRequirementsIfPossible(inventory, recipe.requirements);
if (!consumedInventory) return null;
const createdItem = recipe.createResult(worldType);
return {
inventory: addInventoryItems(consumedInventory, [createdItem]),
currency: playerCurrency - recipe.currencyCost,
createdItem,
};
}
export function executeDismantleItem<TItem extends RuntimeInventoryItemLike>(
inventory: TItem[],
itemId: string,
) {
const targetItem = inventory.find((item) => item.id === itemId);
if (!targetItem || targetItem.quantity <= 0) return null;
const slot = getEquipmentSlotFromItem(targetItem);
if (!slot && !targetItem.buildProfile) return null;
const outputs = [
...buildDismantleBaseMaterials(targetItem, slot),
...buildDismantleEssences(targetItem),
] as TItem[];
return {
inventory: addInventoryItems(removeInventoryItem(inventory, itemId, 1), outputs),
outputs,
};
}
export function executeReforgeItem<TItem extends RuntimeInventoryItemLike>(
inventory: TItem[],
itemId: string,
playerCurrency: number,
) {
const targetItem = inventory.find((item) => item.id === itemId);
if (!targetItem || targetItem.quantity <= 0) return null;
const slot = getEquipmentSlotFromItem(targetItem);
const reforgedItem = buildReforgedItem(targetItem) as TItem | null;
const reforgeCost = getReforgeCost<TItem>(slot);
if (!reforgedItem || playerCurrency < reforgeCost.currencyCost) return null;
const consumedInventory = applyRequirementsIfPossible(
removeInventoryItem(inventory, itemId, 1),
reforgeCost.requirements,
);
if (!consumedInventory) return null;
return {
inventory: addInventoryItems(consumedInventory, [reforgedItem]),
reforgedItem,
currencyCost: reforgeCost.currencyCost,
};
}
export function getReforgeCostView<TItem extends RuntimeInventoryItemLike>(
item: TItem,
worldType: string | null | undefined,
) {
const slot = getEquipmentSlotFromItem(item);
const cost = getReforgeCost<TItem>(slot);
return {
currencyCost: cost.currencyCost,
currencyText: formatCurrency(cost.currencyCost, worldType),
requirements: cost.requirements.map((requirement) => ({
id: requirement.id,
label: requirement.label,
quantity: requirement.quantity,
})),
};
}
export function buildForgeSuccessText(
action: 'craft' | 'dismantle' | 'reforge',
params: {
sourceItemName?: string;
recipeName?: string;
createdItemName?: string;
outputNames?: string[];
currencyText?: string;
},
) {
if (action === 'craft') {
return `你在工坊中完成了${params.recipeName},获得了${params.createdItemName}${
params.currencyText ? `,并支付了${params.currencyText}` : ''
}`;
}
if (action === 'reforge') {
return `你消耗材料重新淬炼了${params.sourceItemName},最终得到${params.createdItemName}${
params.currencyText ? `,并支付了${params.currencyText}` : ''
}`;
}
return `你拆解了${params.sourceItemName},回收出${(params.outputNames ?? []).join('、')}`;
}

View File

@@ -0,0 +1,130 @@
type RuntimeCharacterLike = {
attributes: {
strength: number;
agility: number;
intelligence: number;
spirit: number;
};
};
type RuntimeBuildBuff = {
id: string;
sourceType: 'item';
sourceId: string;
name: string;
tags: string[];
durationTurns: number;
};
type RuntimeInventoryItemLike = {
name: string;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
tags: string[];
useProfile?: {
hpRestore?: number;
manaRestore?: number;
cooldownReduction?: number;
buildBuffs?: RuntimeBuildBuff[];
};
};
export type InventoryUseEffect = {
hpRestore: number;
manaRestore: number;
cooldownReduction: number;
buildBuffs: RuntimeBuildBuff[];
};
function getRarityMultiplier(rarity: RuntimeInventoryItemLike['rarity']) {
switch (rarity) {
case 'legendary':
return 2.4;
case 'epic':
return 1.9;
case 'rare':
return 1.55;
case 'uncommon':
return 1.2;
default:
return 1;
}
}
export function isInventoryItemUsable(item: RuntimeInventoryItemLike) {
return (
Boolean(item.useProfile) ||
item.tags.includes('healing') ||
item.tags.includes('mana')
);
}
export function resolveInventoryItemUseEffect(
item: RuntimeInventoryItemLike,
character: RuntimeCharacterLike,
): InventoryUseEffect | null {
if (!isInventoryItemUsable(item)) return null;
if (item.useProfile) {
return {
hpRestore: item.useProfile.hpRestore ?? 0,
manaRestore: item.useProfile.manaRestore ?? 0,
cooldownReduction: item.useProfile.cooldownReduction ?? 0,
buildBuffs: item.useProfile.buildBuffs ?? [],
};
}
const rarityMultiplier = getRarityMultiplier(item.rarity);
const hasHealing =
item.tags.includes('healing') ||
/药|包|补给|恢复|疗伤|meat|apple|mushroom|water/i.test(item.name);
const hasMana =
item.tags.includes('mana') ||
/灵液|法力|mana|crystal|essence|spirit/i.test(item.name);
const hpRestore = hasHealing
? Math.max(
10,
Math.round((14 + character.attributes.spirit * 1.4) * rarityMultiplier),
)
: 0;
const manaRestore = hasMana
? Math.max(
8,
Math.round(
(12 + character.attributes.intelligence * 1.4) * rarityMultiplier,
),
)
: 0;
const cooldownReduction = /凝神|回气|醒神|booster|essence/i.test(item.name)
? 1
: 0;
if (hpRestore <= 0 && manaRestore <= 0 && cooldownReduction <= 0) {
return null;
}
return {
hpRestore,
manaRestore,
cooldownReduction,
buildBuffs: [],
};
}
export function buildInventoryUseResultText(
item: RuntimeInventoryItemLike,
effect: InventoryUseEffect,
) {
const parts = [
effect.hpRestore > 0 ? `恢复 ${effect.hpRestore} 点气血` : null,
effect.manaRestore > 0 ? `恢复 ${effect.manaRestore} 点灵力` : null,
effect.cooldownReduction > 0
? `额外推进 ${effect.cooldownReduction} 回合冷却`
: null,
effect.buildBuffs.length > 0
? `获得 ${effect.buildBuffs.map((buff) => buff.name).join('、')}`
: null,
].filter(Boolean);
return `你取出${item.name}立刻使用,${parts.join('')}`;
}

View File

@@ -0,0 +1,88 @@
function dedupeStrings(values: Array<string | null | undefined>, limit = 16) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(-limit);
}
type RuntimeStoryFingerprint = {
relatedScarIds?: string[];
relatedThreadIds?: string[];
visibleClue?: string | null;
};
type RuntimeInventoryItemLike = {
id: string;
runtimeMetadata?: {
storyFingerprint?: RuntimeStoryFingerprint | null;
} | null;
};
type RuntimeStoryEngineMemoryLike = {
discoveredFactIds: string[];
inferredFactIds?: string[];
activeThreadIds: string[];
resolvedScarIds: string[];
recentCarrierIds: string[];
};
type RuntimeGameStateLike = {
storyEngineMemory?: RuntimeStoryEngineMemoryLike | null;
};
function createEmptyStoryEngineMemoryState(): RuntimeStoryEngineMemoryLike {
return {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
};
}
export function appendStoryEngineCarrierMemory<
TState extends RuntimeGameStateLike,
TItem extends RuntimeInventoryItemLike,
>(state: TState, items: TItem[]) {
const storyEngineMemory =
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const carriers = items.filter((item) => item.runtimeMetadata?.storyFingerprint);
if (carriers.length <= 0) {
return {
...state,
storyEngineMemory,
};
}
const recentCarrierIds = dedupeStrings(
[...storyEngineMemory.recentCarrierIds, ...carriers.map((item) => item.id)],
8,
);
const scarIds = carriers.flatMap(
(item) => item.runtimeMetadata?.storyFingerprint?.relatedScarIds ?? [],
);
const threadIds = carriers.flatMap(
(item) => item.runtimeMetadata?.storyFingerprint?.relatedThreadIds ?? [],
);
const visibleClues = carriers.flatMap((item) => {
const clue = item.runtimeMetadata?.storyFingerprint?.visibleClue;
return clue ? [clue] : [];
});
return {
...state,
storyEngineMemory: {
...storyEngineMemory,
recentCarrierIds,
resolvedScarIds: dedupeStrings(
[...storyEngineMemory.resolvedScarIds, ...scarIds],
10,
),
activeThreadIds: dedupeStrings(
[...storyEngineMemory.activeThreadIds, ...threadIds],
8,
),
discoveredFactIds: dedupeStrings(
[...storyEngineMemory.discoveredFactIds, ...visibleClues],
24,
),
},
};
}

View File

@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
markNpcFirstMeaningfulContactResolved,
normalizeNpcPersistentState,
} from './runtimeNpcStatePrimitives.js';
import { appendStoryEngineCarrierMemory } from './runtimeNarrativeMemory.js';
test('runtime npc state primitives normalize arrays, relation state and stance defaults on the server', () => {
const normalized = normalizeNpcPersistentState({
affinity: 18,
recruited: false,
revealedFacts: ['thread:a', 1, null],
knownAttributeRumors: ['力量偏盛', false],
seenBackstoryChapterIds: ['past-1', 2],
stanceProfile: null,
});
assert.equal(normalized.relationState.stance, 'neutral');
assert.deepEqual(normalized.revealedFacts, ['thread:a']);
assert.deepEqual(normalized.knownAttributeRumors, ['力量偏盛']);
assert.deepEqual(normalized.seenBackstoryChapterIds, ['past-1']);
assert.equal(normalized.firstMeaningfulContactResolved, false);
assert.equal(normalized.stanceProfile.currentConflictTag, null);
});
test('runtime npc state primitives can mark first meaningful contact as resolved locally on the server', () => {
const nextState = markNpcFirstMeaningfulContactResolved({
affinity: 64,
recruited: false,
revealedFacts: [],
knownAttributeRumors: [],
seenBackstoryChapterIds: [],
firstMeaningfulContactResolved: false,
});
assert.equal(nextState.firstMeaningfulContactResolved, true);
assert.equal(nextState.relationState.stance, 'bonded');
});
test('runtime narrative memory appends carrier facts without depending on src/services/storyEngine/echoMemory', () => {
const nextState = appendStoryEngineCarrierMemory(
{
storyEngineMemory: {
discoveredFactIds: ['clue:old'],
activeThreadIds: ['thread:old'],
resolvedScarIds: [],
recentCarrierIds: [],
},
},
[
{
id: 'carrier-1',
runtimeMetadata: {
storyFingerprint: {
relatedScarIds: ['scar:one'],
relatedThreadIds: ['thread:new'],
visibleClue: 'clue:new',
},
},
},
],
);
assert.deepEqual(nextState.storyEngineMemory.recentCarrierIds, ['carrier-1']);
assert.deepEqual(nextState.storyEngineMemory.resolvedScarIds, ['scar:one']);
assert.deepEqual(nextState.storyEngineMemory.activeThreadIds, [
'thread:old',
'thread:new',
]);
assert.deepEqual(nextState.storyEngineMemory.discoveredFactIds, [
'clue:old',
'clue:new',
]);
});

View File

@@ -0,0 +1,117 @@
import { buildRelationState } from './runtimeStatePrimitives.js';
type RuntimeNpcStanceProfile = {
trust?: number;
warmth?: number;
ideologicalFit?: number;
fearOrGuard?: number;
loyalty?: number;
currentConflictTag?: string | null;
recentApprovals?: unknown;
recentDisapprovals?: unknown;
};
type RuntimeNpcPersistentStateLike = {
affinity: number;
recruited?: boolean;
relationState?: unknown;
revealedFacts?: unknown;
knownAttributeRumors?: unknown;
tradeStockSignature?: string | null;
firstMeaningfulContactResolved?: boolean;
seenBackstoryChapterIds?: unknown;
stanceProfile?: RuntimeNpcStanceProfile | null;
};
function clampStanceMetric(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
function normalizeRecentStanceNotes(value: unknown) {
return Array.isArray(value)
? value
.filter(
(item): item is string => typeof item === 'string' && item.trim().length > 0,
)
.slice(-3)
: [];
}
function buildInitialStanceProfile(
affinity: number,
options: {
recruited?: boolean;
} = {},
) {
const recruitedBonus = options.recruited ? 14 : 0;
return {
trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus),
warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus),
ideologicalFit: clampStanceMetric(48 + affinity * 0.25),
fearOrGuard: clampStanceMetric(62 - affinity * 0.55),
loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)),
currentConflictTag: null,
recentApprovals: [],
recentDisapprovals: [],
};
}
function normalizeStanceProfile(
stanceProfile: RuntimeNpcPersistentStateLike['stanceProfile'],
npcState: RuntimeNpcPersistentStateLike,
) {
if (!stanceProfile) {
return buildInitialStanceProfile(npcState.affinity, {
recruited: npcState.recruited,
});
}
return {
trust: clampStanceMetric(stanceProfile.trust ?? 40),
warmth: clampStanceMetric(stanceProfile.warmth ?? 35),
ideologicalFit: clampStanceMetric(stanceProfile.ideologicalFit ?? 45),
fearOrGuard: clampStanceMetric(stanceProfile.fearOrGuard ?? 55),
loyalty: clampStanceMetric(stanceProfile.loyalty ?? 20),
currentConflictTag: stanceProfile.currentConflictTag ?? null,
recentApprovals: normalizeRecentStanceNotes(stanceProfile.recentApprovals),
recentDisapprovals: normalizeRecentStanceNotes(stanceProfile.recentDisapprovals),
};
}
export function normalizeNpcPersistentState<
TNpcState extends RuntimeNpcPersistentStateLike,
>(npcState: TNpcState) {
return {
...npcState,
relationState: buildRelationState(npcState.affinity),
revealedFacts: Array.isArray(npcState.revealedFacts)
? npcState.revealedFacts.filter(
(fact): fact is string => typeof fact === 'string',
)
: [],
knownAttributeRumors: Array.isArray(npcState.knownAttributeRumors)
? npcState.knownAttributeRumors.filter(
(fact): fact is string => typeof fact === 'string',
)
: [],
tradeStockSignature: npcState.tradeStockSignature ?? null,
firstMeaningfulContactResolved:
npcState.firstMeaningfulContactResolved ?? false,
seenBackstoryChapterIds: Array.isArray(npcState.seenBackstoryChapterIds)
? npcState.seenBackstoryChapterIds.filter(
(fact): fact is string => typeof fact === 'string',
)
: [],
stanceProfile: normalizeStanceProfile(npcState.stanceProfile, npcState),
};
}
export function markNpcFirstMeaningfulContactResolved<
TNpcState extends RuntimeNpcPersistentStateLike,
>(npcState: TNpcState) {
return normalizeNpcPersistentState({
...npcState,
firstMeaningfulContactResolved: true,
});
}

View File

@@ -0,0 +1,206 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { hydrateSavedSnapshot } from './runtimeSnapshotHydration.js';
test('runtime snapshot hydration normalizes server snapshots for frontend restore flows', () => {
const snapshot = hydrateSavedSnapshot({
version: 2,
savedAt: '2026-04-09T00:00:00.000Z',
bottomTab: 'unknown-tab',
gameState: {
currentScene: 'Story',
worldType: 'WUXIA',
playerCharacter: {
id: 'hero',
title: '试剑客',
description: '在风里试探局势的人。',
personality: '谨慎而果断',
attributes: {
strength: 8,
spirit: 6,
},
skills: [{ id: 'skill-1' }],
resourceProfile: {
maxHp: 150,
maxMana: 80,
},
},
playerHp: 180,
playerMaxHp: 120,
playerMana: 22,
playerMaxMana: 18,
playerEquipment: {
weapon: null,
armor: {
id: 'armor-1',
category: '护甲',
name: '试炼轻甲',
quantity: 1,
rarity: 'rare',
tags: ['armor'],
statProfile: {
maxHpBonus: 20,
},
},
relic: {
id: 'relic-1',
category: '饰品',
name: '回气坠',
quantity: 1,
rarity: 'rare',
tags: ['relic'],
statProfile: {
maxManaBonus: 15,
},
},
},
quests: [
{
id: 'quest-1',
title: '试炼委托',
summary: '完成一轮测试',
description: '完成一轮测试',
issuerNpcId: 'npc-1',
issuerNpcName: '引路人',
status: 'active',
rewardText: '完成后可领取测试奖励。',
reward: {
currency: 10,
items: [],
},
steps: [
{
id: 'quest-1-step-1',
title: '完成一轮测试',
detail: '推进这条测试委托。',
kind: 'reach_scene',
targetSceneId: 'test-scene',
requiredCount: 1,
progress: 0,
},
],
},
],
roster: [
{
npcId: 'npc-companion',
characterId: 'companion-a',
joinedAtAffinity: 8,
},
],
companions: [
{
npcId: 'npc-companion',
characterId: 'companion-a',
joinedAtAffinity: 8,
},
],
npcStates: {
npc_guard: {
affinity: 12,
revealedFacts: ['fact:a', 1],
},
},
characterChats: {
companion_a: {
history: [
{ speaker: 'player', text: '最近风声不对。' },
{ speaker: 'npc', text: '这条不该留下。' },
],
summary: '已经建立起初步信任。',
},
},
},
currentStory: {
text: '恢复中的故事',
options: [],
streaming: true,
},
});
assert.ok(snapshot);
assert.equal(snapshot.bottomTab, 'adventure');
assert.equal(snapshot.currentStory?.streaming, false);
assert.equal(snapshot.gameState.runtimeActionVersion, 0);
assert.equal(snapshot.gameState.playerMaxHp, 170);
assert.equal(snapshot.gameState.playerHp, 170);
assert.equal(snapshot.gameState.playerMaxMana, 95);
assert.equal(snapshot.gameState.playerMana, 22);
assert.equal(snapshot.gameState.playerCurrency, 160);
assert.deepEqual(snapshot.gameState.roster, []);
assert.deepEqual(snapshot.gameState.storyEngineMemory.activeThreadIds, []);
assert.equal(
snapshot.gameState.storyEngineMemory.saveMigrationManifest?.version,
'story-engine-v5',
);
assert.deepEqual(snapshot.gameState.npcStates.npc_guard.revealedFacts, [
'fact:a',
]);
assert.deepEqual(snapshot.gameState.characterChats.companion_a.history, [
{
speaker: 'player',
text: '最近风声不对。',
},
]);
});
test('runtime snapshot hydration keeps custom world economy defaults on the server', () => {
const snapshot = hydrateSavedSnapshot({
version: 2,
savedAt: '2026-04-09T00:00:00.000Z',
bottomTab: 'inventory',
gameState: {
worldType: 'CUSTOM',
customWorldProfile: {
ownedSettingLayers: {
ruleProfile: {
economyProfile: {
initialCurrency: 228,
},
},
},
},
},
currentStory: null,
});
assert.ok(snapshot);
assert.equal(snapshot.bottomTab, 'inventory');
assert.equal(snapshot.gameState.playerCurrency, 228);
});
test('runtime snapshot hydration backfills starter loadout when legacy saves omitted playerEquipment', () => {
const snapshot = hydrateSavedSnapshot({
version: 2,
savedAt: '2026-04-09T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {
currentScene: 'Story',
worldType: 'WUXIA',
playerCharacter: {
id: 'hero',
title: '试剑客',
description: '在风里试探局势的人。',
personality: '谨慎而果断',
attributes: {
strength: 8,
spirit: 6,
},
skills: [],
},
playerHp: 140,
playerMaxHp: 140,
playerMana: 60,
playerMaxMana: 60,
},
currentStory: null,
});
assert.ok(snapshot);
assert.equal(snapshot.gameState.playerMaxHp, 208);
assert.equal(snapshot.gameState.playerMaxMana, 1009);
assert.equal(snapshot.gameState.playerEquipment.weapon?.id, 'starter:hero:weapon');
assert.equal(snapshot.gameState.playerEquipment.armor?.id, 'starter:hero:armor');
assert.equal(snapshot.gameState.playerEquipment.relic?.id, 'starter:hero:relic');
});

View File

@@ -0,0 +1,643 @@
import { jsonClone } from '../../http.js';
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
import { normalizeQuestEntries } from '../quest/questProgressionService.js';
import {
createEmptyEquipmentLoadout,
getEquipmentBonuses,
getEquipmentSlotLabel,
} from './runtimeEquipmentModule.js';
import { normalizeNpcPersistentState } from './runtimeNpcStatePrimitives.js';
type JsonRecord = Record<string, unknown>;
type SnapshotShape = {
savedAt: string;
bottomTab: unknown;
gameState: unknown;
currentStory: unknown;
};
const STORY_ENGINE_MIGRATION_VERSION = 'story-engine-v5';
const STORY_ENGINE_REQUIRED_TRANSFORMS = [
'ensure_story_engine_memory',
'ensure_campaign_state',
'ensure_player_style_profile',
];
const UNIVERSAL_MAX_MANA = 999;
const EQUIPMENT_SLOTS = ['weapon', 'armor', 'relic'] as const;
type RuntimeEquipmentSlotId = (typeof EQUIPMENT_SLOTS)[number];
type LegacyCharacterEquipmentItem = {
slot: string;
item: string;
rarity?: string;
};
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown, fallback = '') {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readBoolean(value: unknown, fallback = false) {
return typeof value === 'boolean' ? value : fallback;
}
function readArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
function clampNonNegativeInteger(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.floor(value));
}
function normalizeBottomTab(value: unknown) {
return value === 'character' || value === 'inventory'
? value
: 'adventure';
}
function buildSaveMigrationManifest() {
return {
version: 'story-engine-v5',
requiredTransforms: [
'ensure_story_engine_memory',
'ensure_campaign_state',
'ensure_player_style_profile',
],
backwardCompatible: true,
};
}
function createEmptyStoryEngineMemoryState() {
return {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,
currentJourneyBeatId: null,
currentJourneyBeat: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
campaignState: null,
actState: null,
consequenceLedger: [],
companionResolutions: [],
endingState: null,
authorialConstraintPack: null,
branchBudgetStatus: null,
narrativeQaReport: null,
narrativeCodex: [],
playerStyleProfile: null,
simulationRunResults: [],
releaseGateReport: null,
saveMigrationManifest: {
version: STORY_ENGINE_MIGRATION_VERSION,
requiredTransforms: STORY_ENGINE_REQUIRED_TRANSFORMS,
backwardCompatible: true,
},
};
}
function normalizeRuntimeStats(
stats: unknown,
options: {
isActiveRun?: boolean;
now?: number;
} = {},
) {
const now = options.now ?? Date.now();
const rawStats = isRecord(stats) ? stats : {};
return {
playTimeMs:
typeof rawStats.playTimeMs === 'number' &&
Number.isFinite(rawStats.playTimeMs)
? Math.max(0, rawStats.playTimeMs)
: 0,
lastPlayTickAt: options.isActiveRun ? new Date(now).toISOString() : null,
hostileNpcsDefeated: clampNonNegativeInteger(
rawStats.hostileNpcsDefeated,
),
questsAccepted: clampNonNegativeInteger(rawStats.questsAccepted),
itemsUsed: clampNonNegativeInteger(rawStats.itemsUsed),
scenesTraveled: clampNonNegativeInteger(rawStats.scenesTraveled),
};
}
function normalizeCharacterChats(value: unknown) {
return Object.fromEntries(
Object.entries(isRecord(value) ? value : {}).map(([characterId, record]) => {
const rawRecord = isRecord(record) ? record : {};
return [
characterId,
{
history: readArray(rawRecord.history)
.filter(
(turn) =>
isRecord(turn) &&
typeof turn.text === 'string' &&
(turn.speaker === 'player' || turn.speaker === 'character'),
)
.map((turn) => ({
speaker: turn.speaker,
text: turn.text,
})),
summary: readString(rawRecord.summary),
updatedAt: readString(rawRecord.updatedAt) || null,
},
];
}),
);
}
function normalizeCompanionState(value: unknown) {
if (!isRecord(value)) {
return null;
}
const npcId = readString(value.npcId);
if (!npcId) {
return null;
}
return {
...jsonClone(value),
npcId,
characterId: readString(value.characterId),
joinedAtAffinity: Math.round(readNumber(value.joinedAtAffinity, 0)),
};
}
function dedupeCompanions(value: unknown) {
const seenNpcIds = new Set<string>();
return readArray(value)
.map((entry) => normalizeCompanionState(entry))
.filter((entry): entry is NonNullable<ReturnType<typeof normalizeCompanionState>> => {
if (!entry || seenNpcIds.has(entry.npcId)) {
return false;
}
seenNpcIds.add(entry.npcId);
return true;
});
}
function normalizeRoster(
roster: ReturnType<typeof dedupeCompanions>,
companions: ReturnType<typeof dedupeCompanions>,
) {
const activeNpcIds = new Set(companions.map((companion) => companion.npcId));
return roster.filter((companion) => !activeNpcIds.has(companion.npcId));
}
function normalizeNpcStates(value: unknown) {
return Object.fromEntries(
Object.entries(isRecord(value) ? value : {}).map(([npcId, npcState]) => {
const rawState = isRecord(npcState) ? npcState : {};
return [
npcId,
normalizeNpcPersistentState({
...jsonClone(rawState),
affinity: Math.round(readNumber(rawState.affinity, 0)),
chattedCount: Math.max(
0,
Math.round(readNumber(rawState.chattedCount, 0)),
),
helpUsed: readBoolean(rawState.helpUsed),
giftsGiven: Math.max(
0,
Math.round(readNumber(rawState.giftsGiven, 0)),
),
inventory: jsonClone(readArray(rawState.inventory)),
recruited: readBoolean(rawState.recruited),
}),
];
}),
);
}
function resolveInitialPlayerCurrency(gameState: JsonRecord) {
const customWorldProfile = isRecord(gameState.customWorldProfile)
? gameState.customWorldProfile
: null;
const customWorldInitialCurrency = readNumber(
(customWorldProfile?.ownedSettingLayers as JsonRecord | undefined)
?.ruleProfile &&
isRecord(
(customWorldProfile.ownedSettingLayers as JsonRecord).ruleProfile,
) &&
isRecord(
(
(customWorldProfile.ownedSettingLayers as JsonRecord)
.ruleProfile as JsonRecord
).economyProfile,
)
? (
(
(
customWorldProfile.ownedSettingLayers as JsonRecord
).ruleProfile as JsonRecord
).economyProfile as JsonRecord
).initialCurrency
: undefined,
Number.NaN,
);
if (Number.isFinite(customWorldInitialCurrency)) {
return Math.max(0, Math.round(customWorldInitialCurrency));
}
return readString(gameState.worldType).toUpperCase() === 'XIANXIA' ? 140 : 160;
}
function normalizeEquipmentLoadout(value: unknown) {
if (!isRecord(value)) {
return null;
}
return {
weapon: isRecord(value.weapon) ? jsonClone(value.weapon) : null,
armor: isRecord(value.armor) ? jsonClone(value.armor) : null,
relic: isRecord(value.relic) ? jsonClone(value.relic) : null,
};
}
function normalizePresetRarity(rarityText: string | undefined) {
if (!rarityText) return 'common' as const;
if (/|legendary/iu.test(rarityText)) return 'legendary' as const;
if (/|epic/iu.test(rarityText)) return 'epic' as const;
if (/|rare/iu.test(rarityText)) return 'rare' as const;
if (/|uncommon/iu.test(rarityText)) return 'uncommon' as const;
return 'common' as const;
}
function inferEquipmentSlot(value: string) {
if (/||||||||/u.test(value)) {
return 'weapon' as const;
}
if (/|||||/u.test(value)) {
return 'armor' as const;
}
if (/|||||||||/u.test(value)) {
return 'relic' as const;
}
return null;
}
function inferEquipmentTags(slot: RuntimeEquipmentSlotId, name: string) {
const tags = new Set<string>([slot]);
if (/|||||/u.test(name)) tags.add('mana');
if (/|||/u.test(name)) tags.add('armor');
if (/||||/u.test(name)) tags.add('weapon');
if (/|||||/u.test(name)) tags.add('relic');
if (/||/u.test(name)) tags.add('healing');
return [...tags];
}
function getLegacyCharacterEquipment(character: JsonRecord): LegacyCharacterEquipmentItem[] {
const equipmentById: Record<string, LegacyCharacterEquipmentItem[]> = {
'sword-princess': [
{ slot: '武器', item: '王庭剑', rarity: '稀有' },
{ slot: '护甲', item: '王庭轻甲', rarity: '稀有' },
{ slot: '饰品', item: '皇室徽章', rarity: '史诗' },
],
'archer-hero': [
{ slot: '武器', item: '流风弓', rarity: '稀有' },
{ slot: '护甲', item: '风行者皮甲', rarity: '稀有' },
{ slot: '饰品', item: '鹰眼石', rarity: '史诗' },
],
'girl-hero': [
{ slot: '武器', item: '双影刃', rarity: '稀有' },
{ slot: '护甲', item: '疾影轻甲', rarity: '稀有' },
{ slot: '饰品', item: '敏捷徽章', rarity: '史诗' },
],
'punch-hero': [
{ slot: '武器', item: '破军拳套', rarity: '稀有' },
{ slot: '护甲', item: '刚岩护甲', rarity: '稀有' },
{ slot: '饰品', item: '力量护符', rarity: '史诗' },
],
'fighter-4': [
{ slot: '武器', item: '玄甲战刃', rarity: '稀有' },
{ slot: '护甲', item: '玄铁甲', rarity: '稀有' },
{ slot: '饰品', item: '守护徽章', rarity: '史诗' },
],
};
const characterId = readString(character.id);
if (equipmentById[characterId]) {
return equipmentById[characterId];
}
const characterName = readString(character.name, '旅人');
return EQUIPMENT_SLOTS.map((slot) => ({
slot: getEquipmentSlotLabel(slot),
item: {
weapon: `${characterName}的主手器`,
armor: `${characterName}的护身装`,
relic: `${characterName}的随身符`,
}[slot],
rarity: '普通',
}));
}
function buildLegacyStarterEquipmentLoadout(character: JsonRecord) {
const characterId = readString(character.id, 'unknown');
const loadout = createEmptyEquipmentLoadout();
const starterEquipment = getLegacyCharacterEquipment(character);
starterEquipment.forEach((equipmentItem, index) => {
const slot =
inferEquipmentSlot(`${equipmentItem.slot} ${equipmentItem.item}`) ??
EQUIPMENT_SLOTS[index] ??
null;
if (!slot || loadout[slot]) {
return;
}
loadout[slot] = {
id: `starter:${characterId}:${slot}`,
category: getEquipmentSlotLabel(slot),
name: equipmentItem.item,
quantity: 1,
rarity: normalizePresetRarity(equipmentItem.rarity),
tags: inferEquipmentTags(slot, equipmentItem.item),
equipmentSlotId: slot,
};
});
return loadout;
}
function hasEquippedItems(
equipment: ReturnType<typeof normalizeEquipmentLoadout>,
) {
return Boolean(equipment?.weapon || equipment?.armor || equipment?.relic);
}
function readCharacterAttributes(character: JsonRecord) {
return isRecord(character.attributes) ? character.attributes : {};
}
function getLegacyCharacterBaseMaxHp(character: JsonRecord) {
const attributes = readCharacterAttributes(character);
return Math.max(
120,
90 +
readNumber(attributes.strength, 0) * 10 +
readNumber(attributes.spirit, 0) * 4,
);
}
function buildCharacterResourceProfile(character: JsonRecord) {
const resourceProfile = isRecord(character.resourceProfile)
? character.resourceProfile
: null;
if (
resourceProfile &&
Number.isFinite(resourceProfile.maxHp) &&
Number.isFinite(resourceProfile.maxMana)
) {
return {
maxHp: Math.max(1, Math.round(readNumber(resourceProfile.maxHp, 1))),
maxMana: Math.max(
1,
Math.round(readNumber(resourceProfile.maxMana, UNIVERSAL_MAX_MANA)),
),
};
}
const source = [
readString(character.title),
readString(character.description),
readString(character.personality),
...readArray(character.combatTags).filter(
(tag): tag is string => typeof tag === 'string' && tag.trim().length > 0,
),
].join(' ');
const skills = readArray(character.skills);
const baseHp = /|||||/u.test(source)
? 210
: /|||/u.test(source)
? 168
: /||||/u.test(source)
? 176
: 188;
return {
maxHp: Math.max(
getLegacyCharacterBaseMaxHp(character),
baseHp + Math.min(18, skills.length * 4),
),
maxMana: UNIVERSAL_MAX_MANA,
};
}
function normalizeSavedStory(currentStory: unknown) {
if (!isRecord(currentStory)) {
return null;
}
return {
...jsonClone(currentStory),
streaming: false,
};
}
function normalizeGameState(gameState: unknown) {
const rawState = isRecord(gameState) ? jsonClone(gameState) : {};
const { playerEquipment: _rawPlayerEquipment, ...rawStateWithoutEquipment } =
rawState;
const playerCharacter = isRecord(rawState.playerCharacter)
? rawState.playerCharacter
: null;
const companions = dedupeCompanions(rawState.companions);
const roster = normalizeRoster(dedupeCompanions(rawState.roster), companions);
const storyEngineMemory = {
...createEmptyStoryEngineMemoryState(),
...(isRecord(rawState.storyEngineMemory)
? jsonClone(rawState.storyEngineMemory)
: {}),
saveMigrationManifest: buildSaveMigrationManifest(),
};
const savedPlayerMaxHp = Math.max(
1,
Math.round(readNumber(rawState.playerMaxHp, 1)),
);
const savedPlayerMaxMana = Math.max(
1,
Math.round(readNumber(rawState.playerMaxMana, 1)),
);
const resolvedEquipment =
normalizeEquipmentLoadout(rawState.playerEquipment) ??
(playerCharacter ? buildLegacyStarterEquipmentLoadout(playerCharacter) : null);
const baseResourceProfile = playerCharacter
? buildCharacterResourceProfile(playerCharacter)
: null;
const basePlayerMaxHp = baseResourceProfile
? hasEquippedItems(resolvedEquipment)
? baseResourceProfile.maxHp
: Math.max(baseResourceProfile.maxHp, savedPlayerMaxHp)
: savedPlayerMaxHp;
const basePlayerMaxMana = baseResourceProfile
? hasEquippedItems(resolvedEquipment)
? baseResourceProfile.maxMana
: Math.max(baseResourceProfile.maxMana, savedPlayerMaxMana)
: savedPlayerMaxMana;
const normalizedCommonState = {
...rawStateWithoutEquipment,
customWorldProfile:
isRecord(rawState.customWorldProfile) || rawState.customWorldProfile === null
? rawState.customWorldProfile ?? null
: null,
runtimeStats: normalizeRuntimeStats(rawState.runtimeStats, {
isActiveRun: Boolean(
rawState.playerCharacter && rawState.currentScene === 'Story',
),
}),
storyEngineMemory,
chapterState:
rawState.chapterState ??
(isRecord(storyEngineMemory.currentChapter)
? storyEngineMemory.currentChapter
: null),
campaignState:
rawState.campaignState ??
(isRecord(storyEngineMemory.campaignState)
? storyEngineMemory.campaignState
: storyEngineMemory.campaignState ?? null),
activeScenarioPackId:
readString(rawState.activeScenarioPackId) ||
readString(
(rawState.customWorldProfile as JsonRecord | null)?.scenarioPackId,
) ||
null,
activeCampaignPackId:
readString(rawState.activeCampaignPackId) ||
readString(
(rawState.customWorldProfile as JsonRecord | null)?.campaignPackId,
) ||
null,
npcInteractionActive: readBoolean(rawState.npcInteractionActive),
playerCurrency:
typeof rawState.playerCurrency === 'number' &&
Number.isFinite(rawState.playerCurrency)
? Math.round(rawState.playerCurrency)
: resolveInitialPlayerCurrency(rawState),
quests: normalizeQuestEntries(
jsonClone(readArray(rawState.quests)) as Parameters<
typeof normalizeQuestEntries
>[0],
),
roster,
companions,
npcStates: normalizeNpcStates(rawState.npcStates),
characterChats: normalizeCharacterChats(rawState.characterChats),
activeBuildBuffs: jsonClone(readArray(rawState.activeBuildBuffs)),
runtimeSessionId: readString(rawState.runtimeSessionId) || null,
runtimeActionVersion:
typeof rawState.runtimeActionVersion === 'number' &&
Number.isFinite(rawState.runtimeActionVersion)
? Math.round(rawState.runtimeActionVersion)
: 0,
};
if (!playerCharacter) {
return {
...normalizedCommonState,
playerEquipment: createEmptyEquipmentLoadout(),
playerMaxHp: savedPlayerMaxHp,
playerHp: Math.max(
0,
Math.min(
savedPlayerMaxHp,
Math.round(readNumber(rawState.playerHp, savedPlayerMaxHp)),
),
),
playerMaxMana: savedPlayerMaxMana,
playerMana: Math.max(
0,
Math.min(
savedPlayerMaxMana,
Math.round(readNumber(rawState.playerMana, savedPlayerMaxMana)),
),
),
};
}
const stateWithResourceCaps = {
...normalizedCommonState,
playerCharacter,
playerMaxHp: basePlayerMaxHp,
playerHp: Math.max(
0,
Math.round(readNumber(rawState.playerHp, basePlayerMaxHp)),
),
playerMaxMana: basePlayerMaxMana,
playerMana: Math.max(
0,
Math.round(readNumber(rawState.playerMana, basePlayerMaxMana)),
),
};
if (!resolvedEquipment) {
return stateWithResourceCaps;
}
const equipmentBonuses = getEquipmentBonuses(resolvedEquipment);
const nextPlayerMaxHp = basePlayerMaxHp + equipmentBonuses.maxHpBonus;
const nextPlayerMaxMana = basePlayerMaxMana + equipmentBonuses.maxManaBonus;
return {
...stateWithResourceCaps,
playerEquipment: resolvedEquipment,
playerMaxHp: nextPlayerMaxHp,
playerHp: Math.min(nextPlayerMaxHp, stateWithResourceCaps.playerHp),
playerMaxMana: nextPlayerMaxMana,
playerMana: Math.min(nextPlayerMaxMana, stateWithResourceCaps.playerMana),
};
}
export function normalizeSavedSnapshotPayload<T extends SnapshotShape>(snapshot: T) {
return {
...snapshot,
bottomTab: normalizeBottomTab(snapshot.bottomTab),
gameState: normalizeGameState(snapshot.gameState),
currentStory: normalizeSavedStory(snapshot.currentStory),
};
}
export function hydrateSavedSnapshot(
snapshot: SavedSnapshot | null,
): SavedSnapshot | null {
if (!snapshot) {
return null;
}
return normalizeSavedSnapshotPayload(snapshot);
}

View File

@@ -0,0 +1,113 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
addInventoryItems,
buildRelationState,
incrementGameRuntimeStats,
removeInventoryItem,
} from './runtimeStatePrimitives.js';
test('runtime state primitives merge stackable inventory items but preserve identity-sensitive items', () => {
const merged = addInventoryItems(
[
{
id: 'potion-1',
category: '消耗品',
name: '疗伤丹',
quantity: 1,
rarity: 'uncommon',
tags: ['healing'],
},
{
id: 'relic-1',
category: '专属物品',
name: '青铜令牌',
quantity: 1,
rarity: 'epic',
tags: ['relic'],
},
],
[
{
id: 'potion-2',
category: '消耗品',
name: '疗伤丹',
quantity: 2,
rarity: 'uncommon',
tags: ['healing'],
},
{
id: 'relic-2',
category: '专属物品',
name: '青铜令牌',
quantity: 1,
rarity: 'epic',
tags: ['relic'],
},
],
);
assert.equal(
merged.find((item) => item.name === '疗伤丹')?.quantity,
3,
);
assert.equal(
merged.filter((item) => item.name === '青铜令牌').length,
2,
);
});
test('runtime state primitives remove inventory quantity without leaving zero-count entries', () => {
const nextInventory = removeInventoryItem(
[
{
id: 'potion-1',
category: '消耗品',
name: '疗伤丹',
quantity: 2,
rarity: 'uncommon',
tags: ['healing'],
},
],
'potion-1',
2,
);
assert.deepEqual(nextInventory, []);
});
test('runtime state primitives increment stats and resolve relation stances locally on the server', () => {
const nextStats = incrementGameRuntimeStats(
{
hostileNpcsDefeated: 1,
questsAccepted: 0,
itemsUsed: 2,
scenesTraveled: 3,
},
{
questsAccepted: 2,
itemsUsed: -1,
scenesTraveled: 4,
},
);
assert.deepEqual(nextStats, {
hostileNpcsDefeated: 1,
questsAccepted: 2,
itemsUsed: 2,
scenesTraveled: 7,
});
assert.deepEqual(buildRelationState(-5), {
affinity: -5,
stance: 'hostile',
});
assert.deepEqual(buildRelationState(18), {
affinity: 18,
stance: 'neutral',
});
assert.deepEqual(buildRelationState(72), {
affinity: 72,
stance: 'bonded',
});
});

View File

@@ -0,0 +1,221 @@
type RuntimeInventoryBuildBuff = {
name: string;
durationTurns: number;
tags: string[];
};
type RuntimeInventoryUseProfile = {
hpRestore?: number;
manaRestore?: number;
cooldownReduction?: number;
buildBuffs?: RuntimeInventoryBuildBuff[];
};
type RuntimeInventoryItemLike = {
id: string;
category: string;
name: string;
quantity: number;
rarity?: string | null;
tags: string[];
runtimeMetadata?: unknown;
equipmentSlotId?: unknown;
buildProfile?: unknown;
statProfile?: unknown;
attributeResonance?: unknown;
useProfile?: RuntimeInventoryUseProfile | null;
};
type RuntimeStatsLike = {
hostileNpcsDefeated: number;
questsAccepted: number;
itemsUsed: number;
scenesTraveled: number;
};
type RuntimeRelationState = {
affinity: number;
stance: 'hostile' | 'guarded' | 'neutral' | 'cooperative' | 'bonded';
};
const RARITY_SCORES: Record<string, number> = {
common: 1,
uncommon: 2,
rare: 3,
epic: 4,
legendary: 5,
};
function clampNonNegativeInteger(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.floor(value));
}
function getRarityScore(rarity: string | null | undefined) {
if (!rarity) {
return 0;
}
return RARITY_SCORES[rarity] ?? 0;
}
function isIdentitySensitiveInventoryItem(item: RuntimeInventoryItemLike) {
return Boolean(
item.runtimeMetadata ||
item.equipmentSlotId ||
item.buildProfile ||
item.statProfile ||
item.attributeResonance ||
item.category.includes('专属') ||
item.rarity === 'epic' ||
item.rarity === 'legendary',
);
}
function buildInventoryMergeKey(item: RuntimeInventoryItemLike) {
if (isIdentitySensitiveInventoryItem(item)) {
return `identity:${item.id}`;
}
const buildBuffKey = (item.useProfile?.buildBuffs ?? [])
.map(
(buff) =>
`${buff.name}:${buff.durationTurns}:${(buff.tags ?? []).join('|')}`,
)
.join(',');
return [
item.category,
item.name,
item.rarity ?? '',
[...(item.tags ?? [])].sort().join('|'),
item.useProfile?.hpRestore ?? 0,
item.useProfile?.manaRestore ?? 0,
item.useProfile?.cooldownReduction ?? 0,
buildBuffKey,
].join('::');
}
function mergeInventory<TItem extends RuntimeInventoryItemLike>(items: TItem[]) {
const merged = new Map<string, TItem>();
for (const item of items) {
const key = buildInventoryMergeKey(item);
const existing = merged.get(key);
if (existing) {
merged.set(key, {
...existing,
quantity: existing.quantity + item.quantity,
tags: [...new Set([...(existing.tags ?? []), ...(item.tags ?? [])])],
runtimeMetadata:
existing.runtimeMetadata ?? item.runtimeMetadata ?? null,
});
continue;
}
merged.set(key, {
...item,
tags: [...new Set(item.tags ?? [])],
});
}
return [...merged.values()];
}
export function sortInventoryItems<TItem extends RuntimeInventoryItemLike>(
items: TItem[],
) {
return [...items].sort((left, right) => {
const rarityDiff = getRarityScore(right.rarity) - getRarityScore(left.rarity);
if (rarityDiff !== 0) {
return rarityDiff;
}
const categoryDiff = left.category.localeCompare(
right.category,
'zh-Hans-CN',
);
if (categoryDiff !== 0) {
return categoryDiff;
}
return left.name.localeCompare(right.name, 'zh-Hans-CN');
});
}
export function addInventoryItems<TItem extends RuntimeInventoryItemLike>(
base: TItem[],
additions: TItem[],
) {
return sortInventoryItems(mergeInventory([...base, ...additions]));
}
export function removeInventoryItem<TItem extends RuntimeInventoryItemLike>(
base: TItem[],
itemId: string,
quantity = 1,
) {
return sortInventoryItems(
base
.map((item) =>
item.id === itemId
? {
...item,
quantity: Math.max(0, item.quantity - quantity),
}
: item,
)
.filter((item) => item.quantity > 0),
);
}
export function incrementGameRuntimeStats<TStats extends RuntimeStatsLike>(
stats: TStats,
increments: Partial<
Pick<
RuntimeStatsLike,
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
>
>,
) {
return {
...stats,
hostileNpcsDefeated:
stats.hostileNpcsDefeated +
clampNonNegativeInteger(increments.hostileNpcsDefeated),
questsAccepted:
stats.questsAccepted + clampNonNegativeInteger(increments.questsAccepted),
itemsUsed: stats.itemsUsed + clampNonNegativeInteger(increments.itemsUsed),
scenesTraveled:
stats.scenesTraveled +
clampNonNegativeInteger(increments.scenesTraveled),
};
}
export function resolveRelationStance(
affinity: number,
): RuntimeRelationState['stance'] {
if (affinity < 0) {
return 'hostile';
}
if (affinity < 15) {
return 'guarded';
}
if (affinity < 30) {
return 'neutral';
}
if (affinity < 60) {
return 'cooperative';
}
return 'bonded';
}
export function buildRelationState(affinity: number): RuntimeRelationState {
return {
affinity,
stance: resolveRelationStance(affinity),
};
}

View File

@@ -0,0 +1,49 @@
import { formatCurrency } from './runtimeEconomyPrimitives.js';
type TreasureRewardItem = {
name: string;
};
type TreasureRewardLike = {
items: TreasureRewardItem[];
hp: number;
mana: number;
currency: number;
storyHint?: string;
};
type TreasureEncounterLike = {
npcName: string;
};
type TreasureInteractionAction = 'inspect' | 'leave' | 'secure';
export function buildTreasureResultText(
encounter: TreasureEncounterLike,
action: TreasureInteractionAction,
reward?: TreasureRewardLike,
worldType?: string | null,
) {
if (action === 'leave') {
return `你暂时没有触碰 ${encounter.npcName},只是把它的异常位置和痕迹牢牢记下。`;
}
const itemText =
reward?.items.length ? reward.items.map((item) => item.name).join('、') : '零散战利品';
const restoreParts = [
(reward?.hp ?? 0) > 0 ? `气血 +${reward?.hp ?? 0}` : null,
(reward?.mana ?? 0) > 0 ? `灵力 +${reward?.mana ?? 0}` : null,
].filter(Boolean);
const restoreText =
restoreParts.length > 0 ? `,并恢复 ${restoreParts.join('、')}` : '';
const currencyText = reward
? `,另得 ${formatCurrency(reward.currency, worldType ?? null)}`
: '';
const storyHint = reward?.storyHint ? ` ${reward.storyHint}` : '';
if (action === 'inspect') {
return `你仔细检查了 ${encounter.npcName},顺着现场痕迹拆开机关与伪装,最终收回 ${itemText}${currencyText}${restoreText}${storyHint}`;
}
return `你迅速收下了 ${encounter.npcName} 中最关键的收获:${itemText}${currencyText}${storyHint}`;
}

View File

@@ -0,0 +1,854 @@
import type {
RuntimeStoryEncounterViewModel,
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';
type JsonRecord = Record<string, unknown>;
type StoryHistoryRole = 'action' | 'result';
type FunctionDefinition = {
actionText: string;
detailText: string;
scope: Task5RuntimeOptionScope;
};
export type RuntimeStoryHistoryEntry = {
text: string;
historyRole: StoryHistoryRole;
};
export type RuntimeNpcState = {
affinity: number;
chattedCount: number;
helpUsed: boolean;
giftsGiven: number;
inventory: unknown[];
recruited: boolean;
firstMeaningfulContactResolved: boolean;
relationState: JsonRecord | null;
stanceProfile: JsonRecord | null;
tradeStockSignature?: string | null;
revealedFacts?: string[];
knownAttributeRumors?: string[];
seenBackstoryChapterIds?: string[];
};
export type RuntimeEncounter = {
id: string;
kind: 'npc' | 'treasure';
npcName: string;
npcDescription: string;
context: string;
hostile: boolean;
characterId: string | null;
monsterPresetId: string | null;
};
export type RuntimeHostileNpc = {
id: string;
name: string;
hp: number;
maxHp: number;
description: string;
};
export type RuntimeCompanion = {
npcId: string;
characterId: string;
joinedAtAffinity: number;
};
export type RuntimeSession = {
sessionId: string;
runtimeVersion: number;
snapshotBottomTab: string;
rawGameState: JsonRecord;
worldType: string | null;
storyHistory: RuntimeStoryHistoryEntry[];
currentEncounter: RuntimeEncounter | null;
npcInteractionActive: boolean;
sceneHostileNpcs: RuntimeHostileNpc[];
inBattle: boolean;
playerHp: number;
playerMaxHp: number;
playerMana: number;
playerMaxMana: number;
npcStates: Record<string, RuntimeNpcState>;
companions: RuntimeCompanion[];
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
};
export const MAX_TASK5_COMPANIONS = 2;
const STORY_FUNCTION_IDS = new Set<string>([
'story_continue_adventure',
'story_opening_camp_dialogue',
'camp_travel_home_scene',
'idle_call_out',
'idle_explore_forward',
'idle_observe_signs',
'idle_rest_focus',
'idle_travel_next_scene',
]);
const COMBAT_FUNCTION_IDS = new Set<string>([
'battle_all_in_crush',
'battle_escape_breakout',
'battle_feint_step',
'battle_finisher_window',
'battle_guard_break',
'battle_probe_pressure',
'battle_recover_breath',
]);
const NPC_FUNCTION_IDS = new Set<string>([
'npc_chat',
'npc_fight',
'npc_help',
'npc_leave',
'npc_preview_talk',
'npc_recruit',
'npc_spar',
]);
const TASK6_RUNTIME_FUNCTION_ID_SET = new Set<string>(
TASK6_RUNTIME_FUNCTION_IDS,
);
export const TASK6_DEFERRED_FUNCTION_IDS = new Set<string>([
]);
const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
story_continue_adventure: {
actionText: '继续推进冒险',
detailText: '让后端基于当前快照继续推进当前故事状态。',
scope: 'story',
},
story_opening_camp_dialogue: {
actionText: '交换开场判断',
detailText: '把当前营地里的第一次正式对话切进服务端交互态。',
scope: 'story',
},
camp_travel_home_scene: {
actionText: '返回营地',
detailText: '结束当前遭遇,把流程带回安全的营地状态。',
scope: 'story',
},
idle_call_out: {
actionText: '主动出声试探',
detailText: '对前路喊话,逼迫附近的动静更快浮出水面。',
scope: 'story',
},
idle_explore_forward: {
actionText: '继续向前探索',
detailText: '继续沿当前路径深入,把新遭遇交给后端推进。',
scope: 'story',
},
idle_observe_signs: {
actionText: '观察周围迹象',
detailText: '先读环境,再决定下一轮要不要靠近或出手。',
scope: 'story',
},
idle_rest_focus: {
actionText: '原地调息',
detailText: '恢复少量生命与灵力,稳住下一轮节奏。',
scope: 'story',
},
idle_travel_next_scene: {
actionText: '前往相邻场景',
detailText: '收束当前遭遇并切往下一段场景流程。',
scope: 'story',
},
battle_all_in_crush: {
actionText: '正面强压',
detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。',
scope: 'combat',
},
battle_escape_breakout: {
actionText: '强行脱离战斗',
detailText: '打断当前战斗,把状态切回探索或脱身结果。',
scope: 'combat',
},
battle_feint_step: {
actionText: '虚晃切步',
detailText: '用更轻的代价制造伤害,同时压低敌方反击力度。',
scope: 'combat',
},
battle_finisher_window: {
actionText: '抓破绽终结',
detailText: '对残血目标有额外收益,适合收尾。',
scope: 'combat',
},
battle_guard_break: {
actionText: '破架重击',
detailText: '偏稳定的伤害动作,能打断对方的站稳节奏。',
scope: 'combat',
},
battle_probe_pressure: {
actionText: '稳步试探',
detailText: '低风险压迫,兼顾伤害和节奏控制。',
scope: 'combat',
},
battle_recover_breath: {
actionText: '边守边调息',
detailText: '优先回稳资源,但仍可能吃到轻量反击。',
scope: 'combat',
},
npc_chat: {
actionText: '继续交谈',
detailText: '围绕当前话题延续对话,推进好感与关系判断。',
scope: 'npc',
},
npc_fight: {
actionText: '与对方战斗',
detailText: '把当前 NPC 交互直接切进正式战斗结算。',
scope: 'npc',
},
npc_help: {
actionText: '请求援手',
detailText: '向当前 NPC 请求一次性支援,恢复部分状态。',
scope: 'npc',
},
npc_leave: {
actionText: '离开当前角色',
detailText: '结束当前 NPC 交互,重新回到探索态。',
scope: 'npc',
},
npc_preview_talk: {
actionText: '转向眼前角色',
detailText: '从遭遇预览切进正式 NPC 互动菜单。',
scope: 'npc',
},
npc_recruit: {
actionText: '邀请加入队伍',
detailText: '关系达标后可以直接把当前 NPC 收进同行队伍。',
scope: 'npc',
},
npc_spar: {
actionText: '点到为止切磋',
detailText: '用 spar 模式进入轻量战斗,结果会回流到关系状态。',
scope: 'npc',
},
npc_trade: {
actionText: '交易',
detailText: '查看库存并执行买入或卖出。',
scope: 'npc',
},
npc_gift: {
actionText: '赠送礼物',
detailText: '把背包里的物品正式交给当前角色。',
scope: 'npc',
},
npc_quest_accept: {
actionText: '接下委托',
detailText: '把当前角色的委托正式收进任务日志。',
scope: 'npc',
},
npc_quest_turn_in: {
actionText: '交付委托',
detailText: '向当前角色结算已经完成的委托奖励。',
scope: 'npc',
},
treasure_secure: {
actionText: '直接收取',
detailText: '不再拖延,直接把眼前最关键的收获带走。',
scope: 'story',
},
treasure_inspect: {
actionText: '仔细检查',
detailText: '多花些时间拆开机关、痕迹和伪装。',
scope: 'story',
},
treasure_leave: {
actionText: '先记下位置',
detailText: '暂时不碰它,只把异常位置和痕迹记住。',
scope: 'story',
},
};
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function isObject(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown, fallback = '') {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readBoolean(value: unknown, fallback = false) {
return typeof value === 'boolean' ? value : fallback;
}
function readArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
function normalizeStoryHistory(value: unknown) {
return readArray(value)
.map((entry) => {
const rawEntry = isObject(entry) ? entry : {};
const historyRole =
rawEntry.historyRole === 'action' ? 'action' : 'result';
return {
text: readString(rawEntry.text),
historyRole,
} satisfies RuntimeStoryHistoryEntry;
})
.filter((entry) => entry.text);
}
function normalizeNpcState(value: unknown): RuntimeNpcState {
const rawState = isObject(value) ? value : {};
return {
affinity: Math.round(readNumber(rawState.affinity, 0)),
chattedCount: Math.max(0, Math.round(readNumber(rawState.chattedCount, 0))),
helpUsed: readBoolean(rawState.helpUsed),
giftsGiven: Math.max(0, Math.round(readNumber(rawState.giftsGiven, 0))),
inventory: cloneJson(readArray(rawState.inventory)),
recruited: readBoolean(rawState.recruited),
firstMeaningfulContactResolved: readBoolean(
rawState.firstMeaningfulContactResolved,
),
tradeStockSignature: readString(rawState.tradeStockSignature) || null,
relationState: isObject(rawState.relationState)
? cloneJson(rawState.relationState)
: null,
stanceProfile: isObject(rawState.stanceProfile)
? cloneJson(rawState.stanceProfile)
: null,
revealedFacts: readArray(rawState.revealedFacts).filter(
(item): item is string => typeof item === 'string' && item.trim().length > 0,
),
knownAttributeRumors: readArray(rawState.knownAttributeRumors).filter(
(item): item is string => typeof item === 'string' && item.trim().length > 0,
),
seenBackstoryChapterIds: readArray(rawState.seenBackstoryChapterIds).filter(
(item): item is string => typeof item === 'string' && item.trim().length > 0,
),
};
}
function normalizeEncounter(value: unknown): RuntimeEncounter | null {
const rawEncounter = isObject(value) ? value : null;
if (!rawEncounter) {
return null;
}
const kind = rawEncounter.kind === 'treasure' ? 'treasure' : 'npc';
const npcName = readString(rawEncounter.npcName);
if (!npcName) {
return null;
}
return {
id: readString(rawEncounter.id, npcName),
kind,
npcName,
npcDescription: readString(rawEncounter.npcDescription),
context: readString(rawEncounter.context),
hostile:
readBoolean(rawEncounter.hostile) ||
Boolean(readString(rawEncounter.monsterPresetId)),
characterId: readString(rawEncounter.characterId) || null,
monsterPresetId: readString(rawEncounter.monsterPresetId) || null,
};
}
function normalizeHostileNpc(value: unknown): RuntimeHostileNpc | null {
const rawNpc = isObject(value) ? value : null;
if (!rawNpc) {
return null;
}
const id = readString(rawNpc.id);
const name = readString(rawNpc.name, id);
if (!id || !name) {
return null;
}
const maxHp = Math.max(1, Math.round(readNumber(rawNpc.maxHp, 1)));
const hp = Math.max(0, Math.min(maxHp, Math.round(readNumber(rawNpc.hp, maxHp))));
return {
id,
name,
hp,
maxHp,
description: readString(rawNpc.description),
};
}
function normalizeCompanion(value: unknown): RuntimeCompanion | null {
const rawCompanion = isObject(value) ? value : null;
if (!rawCompanion) {
return null;
}
const npcId = readString(rawCompanion.npcId);
if (!npcId) {
return null;
}
return {
npcId,
characterId: readString(rawCompanion.characterId),
joinedAtAffinity: Math.round(readNumber(rawCompanion.joinedAtAffinity, 0)),
};
}
function normalizeNpcStates(value: unknown) {
const rawStates = isObject(value) ? value : {};
return Object.fromEntries(
Object.entries(rawStates).map(([key, state]) => [key, normalizeNpcState(state)]),
) as Record<string, RuntimeNpcState>;
}
function normalizeCompanions(value: unknown) {
return readArray(value)
.map((entry) => normalizeCompanion(entry))
.filter((entry): entry is RuntimeCompanion => Boolean(entry));
}
function normalizeHostileNpcs(value: unknown) {
return readArray(value)
.map((entry) => normalizeHostileNpc(entry))
.filter((entry): entry is RuntimeHostileNpc => Boolean(entry));
}
export function getEncounterKey(encounter: RuntimeEncounter) {
return encounter.id || encounter.npcName;
}
export function loadRuntimeSession(
snapshot: SavedSnapshot,
requestedSessionId: string,
): RuntimeSession {
const rawGameState = isObject(snapshot.gameState)
? cloneJson(snapshot.gameState)
: {};
const currentEncounter = normalizeEncounter(rawGameState.currentEncounter);
const sceneHostileNpcs = normalizeHostileNpcs(rawGameState.sceneHostileNpcs);
const inBattle =
readBoolean(rawGameState.inBattle) &&
sceneHostileNpcs.some((npc) => npc.hp > 0);
return {
sessionId: readString(rawGameState.runtimeSessionId, requestedSessionId),
runtimeVersion: Math.max(
0,
Math.round(readNumber(rawGameState.runtimeActionVersion, 0)),
),
snapshotBottomTab: readString(snapshot.bottomTab, 'adventure'),
rawGameState,
worldType: readString(rawGameState.worldType) || null,
storyHistory: normalizeStoryHistory(rawGameState.storyHistory),
currentEncounter,
npcInteractionActive: readBoolean(rawGameState.npcInteractionActive),
sceneHostileNpcs,
inBattle,
playerHp: Math.max(0, Math.round(readNumber(rawGameState.playerHp, 0))),
playerMaxHp: Math.max(1, Math.round(readNumber(rawGameState.playerMaxHp, 1))),
playerMana: Math.max(0, Math.round(readNumber(rawGameState.playerMana, 0))),
playerMaxMana: Math.max(
1,
Math.round(readNumber(rawGameState.playerMaxMana, 1)),
),
npcStates: normalizeNpcStates(rawGameState.npcStates),
companions: normalizeCompanions(rawGameState.companions),
currentNpcBattleMode:
rawGameState.currentNpcBattleMode === 'fight' ||
rawGameState.currentNpcBattleMode === 'spar'
? rawGameState.currentNpcBattleMode
: null,
currentNpcBattleOutcome:
rawGameState.currentNpcBattleOutcome === 'fight_victory' ||
rawGameState.currentNpcBattleOutcome === 'spar_complete'
? rawGameState.currentNpcBattleOutcome
: null,
};
}
export function isStoryFunctionId(functionId: string) {
return STORY_FUNCTION_IDS.has(functionId);
}
export function isCombatFunctionId(functionId: string) {
return COMBAT_FUNCTION_IDS.has(functionId);
}
export function isNpcFunctionId(functionId: string) {
return NPC_FUNCTION_IDS.has(functionId);
}
export function isTask5FunctionId(functionId: string) {
return (
isStoryFunctionId(functionId) ||
isCombatFunctionId(functionId) ||
isNpcFunctionId(functionId)
);
}
export function isTask6RuntimeFunctionId(functionId: string) {
return TASK6_RUNTIME_FUNCTION_ID_SET.has(functionId);
}
export function getEncounterNpcState(session: RuntimeSession) {
if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') {
return null;
}
const key = getEncounterKey(session.currentEncounter);
return (
session.npcStates[key] ?? {
affinity: 0,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: false,
relationState: null,
stanceProfile: null,
}
);
}
export function setEncounterNpcState(
session: RuntimeSession,
npcState: RuntimeNpcState,
) {
if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') {
return;
}
session.npcStates[getEncounterKey(session.currentEncounter)] = npcState;
}
function buildOptionView(
functionId: string,
overrides: Partial<RuntimeStoryOptionView> = {},
): RuntimeStoryOptionView {
const definition = FUNCTION_DEFINITIONS[functionId];
if (!definition) {
return {
functionId,
actionText: functionId,
detailText: '',
scope: 'story',
...overrides,
};
}
return {
functionId,
actionText: definition.actionText,
detailText: definition.detailText,
scope: definition.scope,
...overrides,
};
}
type RuntimeQuestPreview = {
id: string;
issuerNpcId: string;
status: string;
};
function readQuestPreviews(session: RuntimeSession): RuntimeQuestPreview[] {
return readArray(session.rawGameState.quests)
.map((quest) => {
const rawQuest = isObject(quest) ? quest : {};
const id = readString(rawQuest.id);
const issuerNpcId = readString(rawQuest.issuerNpcId);
const status = readString(rawQuest.status);
if (!id || !issuerNpcId || !status) {
return null;
}
return {
id,
issuerNpcId,
status,
} satisfies RuntimeQuestPreview;
})
.filter((quest): quest is RuntimeQuestPreview => Boolean(quest));
}
function getActiveEncounterQuest(session: RuntimeSession) {
if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') {
return null;
}
return (
readQuestPreviews(session).find(
(quest) =>
quest.issuerNpcId === session.currentEncounter?.id &&
quest.status !== 'turned_in',
) ?? null
);
}
function hasGiftablePlayerInventory(session: RuntimeSession) {
return readArray(session.rawGameState.playerInventory).some((item) => {
const rawItem = isObject(item) ? item : {};
return readNumber(rawItem.quantity, 0) > 0;
});
}
export function buildAvailableOptions(session: RuntimeSession) {
if (session.inBattle) {
return [
'battle_probe_pressure',
'battle_guard_break',
'battle_feint_step',
'battle_finisher_window',
'battle_all_in_crush',
'battle_recover_breath',
'battle_escape_breakout',
].map((functionId) => buildOptionView(functionId));
}
if (session.currentEncounter?.kind === 'npc') {
const npcState = getEncounterNpcState(session);
if (session.currentEncounter.hostile) {
return [
buildOptionView('npc_fight'),
buildOptionView('npc_leave'),
];
}
if (!session.npcInteractionActive) {
return [
buildOptionView('npc_preview_talk'),
buildOptionView('npc_fight'),
buildOptionView('npc_leave'),
];
}
const activeQuest = getActiveEncounterQuest(session);
const options = [
buildOptionView('npc_chat'),
buildOptionView('npc_help', npcState?.helpUsed
? {
disabled: true,
reason: '当前 NPC 的一次性援手已经用完了。',
}
: {}),
buildOptionView('npc_spar'),
buildOptionView('npc_fight'),
];
if ((npcState?.inventory?.length ?? 0) > 0) {
options.push(buildOptionView('npc_trade'));
}
if (hasGiftablePlayerInventory(session)) {
options.push(buildOptionView('npc_gift'));
}
if (
activeQuest &&
(activeQuest.status === 'completed' ||
activeQuest.status === 'ready_to_turn_in')
) {
options.push(buildOptionView('npc_quest_turn_in'));
} else if (!activeQuest) {
options.push(buildOptionView('npc_quest_accept'));
}
if (npcState && !npcState.recruited && npcState.affinity >= 60) {
options.push(
buildOptionView(
'npc_recruit',
session.companions.length >= MAX_TASK5_COMPANIONS
? {
disabled: true,
reason: '队伍已满任务5首轮后端接口暂不处理换队逻辑。',
}
: {},
),
);
}
options.push(buildOptionView('npc_leave'));
return options;
}
if (session.currentEncounter?.kind === 'treasure') {
return [
buildOptionView('treasure_secure'),
buildOptionView('treasure_inspect'),
buildOptionView('treasure_leave'),
];
}
return [
'idle_observe_signs',
'idle_call_out',
'idle_rest_focus',
'idle_explore_forward',
'idle_travel_next_scene',
'story_continue_adventure',
].map((functionId) => buildOptionView(functionId));
}
function buildEncounterViewModel(
session: RuntimeSession,
): RuntimeStoryEncounterViewModel | null {
if (!session.currentEncounter) {
return null;
}
const npcState = getEncounterNpcState(session);
return {
id: session.currentEncounter.id,
kind: session.currentEncounter.kind,
npcName: session.currentEncounter.npcName,
hostile: session.currentEncounter.hostile,
affinity: npcState?.affinity,
recruited: npcState?.recruited,
interactionActive: session.npcInteractionActive,
battleMode: session.currentNpcBattleMode,
};
}
export function buildRuntimeViewModel(
session: RuntimeSession,
options = buildAvailableOptions(session),
): RuntimeStoryViewModel {
return {
player: {
hp: session.playerHp,
maxHp: session.playerMaxHp,
mana: session.playerMana,
maxMana: session.playerMaxMana,
},
encounter: buildEncounterViewModel(session),
companions: session.companions.map((companion) => ({
npcId: companion.npcId,
characterId: companion.characterId || undefined,
joinedAtAffinity: companion.joinedAtAffinity,
})),
availableOptions: options,
status: {
inBattle: session.inBattle,
npcInteractionActive: session.npcInteractionActive,
currentNpcBattleMode: session.currentNpcBattleMode,
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
},
};
}
export function appendStoryHistory(
session: RuntimeSession,
actionText: string,
resultText: string,
) {
session.storyHistory.push(
{
text: actionText,
historyRole: 'action',
},
{
text: resultText,
historyRole: 'result',
},
);
}
export function buildLegacyCurrentStory(
storyText: string,
options: RuntimeStoryOptionView[],
) {
return {
text: storyText,
options: options.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
text: option.actionText,
detailText: option.detailText,
priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1,
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
})),
};
}
export function syncRawGameState(session: RuntimeSession) {
session.rawGameState.runtimeSessionId = session.sessionId;
session.rawGameState.runtimeActionVersion = session.runtimeVersion;
session.rawGameState.storyHistory = cloneJson(session.storyHistory);
session.rawGameState.currentEncounter = session.currentEncounter
? cloneJson(session.currentEncounter)
: null;
session.rawGameState.npcInteractionActive = session.npcInteractionActive;
session.rawGameState.sceneHostileNpcs = cloneJson(session.sceneHostileNpcs);
session.rawGameState.inBattle = session.inBattle;
session.rawGameState.playerHp = session.playerHp;
session.rawGameState.playerMaxHp = session.playerMaxHp;
session.rawGameState.playerMana = session.playerMana;
session.rawGameState.playerMaxMana = session.playerMaxMana;
session.rawGameState.npcStates = cloneJson(session.npcStates);
session.rawGameState.companions = cloneJson(session.companions);
session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode;
session.rawGameState.currentNpcBattleOutcome = session.currentNpcBattleOutcome;
session.rawGameState.currentBattleNpcId = session.currentEncounter?.id ?? null;
session.rawGameState.activeCombatEffects = [];
session.rawGameState.playerActionMode = 'idle';
session.rawGameState.scrollWorld = false;
session.rawGameState.animationState = 'idle';
}
export function replaceRuntimeSessionRawGameState(
session: RuntimeSession,
nextGameState: JsonRecord,
) {
session.rawGameState = cloneJson(nextGameState);
const refreshed = loadRuntimeSession(
{
version: 2,
savedAt: '',
bottomTab: session.snapshotBottomTab,
gameState: session.rawGameState,
currentStory: null,
},
session.sessionId,
);
session.worldType = refreshed.worldType;
session.storyHistory = refreshed.storyHistory;
session.currentEncounter = refreshed.currentEncounter;
session.npcInteractionActive = refreshed.npcInteractionActive;
session.sceneHostileNpcs = refreshed.sceneHostileNpcs;
session.inBattle = refreshed.inBattle;
session.playerHp = refreshed.playerHp;
session.playerMaxHp = refreshed.playerMaxHp;
session.playerMana = refreshed.playerMana;
session.playerMaxMana = refreshed.playerMaxMana;
session.npcStates = refreshed.npcStates;
session.companions = refreshed.companions;
session.currentNpcBattleMode = refreshed.currentNpcBattleMode;
session.currentNpcBattleOutcome = refreshed.currentNpcBattleOutcome;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
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,
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;
}

View File

@@ -0,0 +1,377 @@
import type {
RuntimeBattlePresentation,
RuntimeStoryActionRequest,
RuntimeStoryActionResponse,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import { conflict, invalidRequest } from '../../errors.js';
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js';
import { resolveCombatAction } from '../combat/combatResolutionService.js';
import { resolveInventoryStoryAction, isSupportedInventoryStoryFunctionId } from '../inventory/inventoryStoryActionService.js';
import {
ensureNpcInventorySessionState,
isSupportedNpcInventoryStoryFunctionId,
resolveNpcInventoryStoryAction,
} from '../inventory/npcInventoryStoryActionService.js';
import { resolveNpcInteraction } from '../npc/npcInteractionService.js';
import {
applyQuestSignalsForResolvedAction,
} from '../quest/questRuntimeSignalService.js';
import {
isSupportedQuestStoryFunctionId,
resolveQuestStoryAction,
} from '../quest/questStoryActionService.js';
import {
isSupportedTreasureStoryFunctionId,
resolveTreasureStoryAction,
} from '../runtime-item/treasureStoryActionService.js';
import {
TASK6_DEFERRED_FUNCTION_IDS,
appendStoryHistory,
buildAvailableOptions,
buildLegacyCurrentStory,
buildRuntimeViewModel,
getEncounterNpcState,
isCombatFunctionId,
isNpcFunctionId,
isStoryFunctionId,
isTask5FunctionId,
loadRuntimeSession,
setEncounterNpcState,
syncRawGameState,
type RuntimeSession,
} from './runtimeSession.js';
import {
hydrateSavedSnapshot,
normalizeSavedSnapshotPayload,
} from '../runtime/runtimeSnapshotHydration.js';
type StoryResolution = {
actionText: string;
resultText: string;
patches: RuntimeStoryPatch[];
storyText?: string;
battle?: RuntimeBattlePresentation | null;
toast?: string | null;
};
function resolveActionText(defaultText: string, request: RuntimeStoryActionRequest) {
const payload = request.action.payload;
const optionText =
payload && typeof payload.optionText === 'string'
? payload.optionText.trim()
: '';
return optionText || defaultText;
}
function normalizeStatusPatch(session: RuntimeSession) {
return {
type: 'status_changed',
inBattle: session.inBattle,
npcInteractionActive: session.npcInteractionActive,
currentNpcBattleMode: session.currentNpcBattleMode,
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
} satisfies RuntimeStoryPatch;
}
function clearEncounterState(session: RuntimeSession) {
session.currentEncounter = null;
session.npcInteractionActive = false;
session.sceneHostileNpcs = [];
session.inBattle = false;
session.currentNpcBattleMode = null;
}
function readSavedStoryText(currentStory: unknown) {
if (
currentStory &&
typeof currentStory === 'object' &&
'text' in currentStory &&
typeof currentStory.text === 'string' &&
currentStory.text.trim()
) {
return currentStory.text.trim();
}
return '';
}
function buildFallbackStoryText(session: RuntimeSession) {
if (session.inBattle && session.sceneHostileNpcs.length > 0) {
return `眼前的冲突还没结束,${session.sceneHostileNpcs[0]!.name}仍在逼你立刻做出下一步判断。`;
}
if (session.currentEncounter?.kind === 'npc') {
return `${session.currentEncounter.npcName}正在等你表态,接下来这一轮该怎么回应,由服务端规则来继续收口。`;
}
return '当前故事状态已经同步到后端,接下来可以继续推进这一轮运行时动作。';
}
function resolveStoryFlowAction(
session: RuntimeSession,
functionId: string,
): StoryResolution {
switch (functionId) {
case 'story_continue_adventure':
return {
actionText: '继续推进冒险',
resultText: '你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。',
patches: [normalizeStatusPatch(session)],
};
case 'story_opening_camp_dialogue': {
const encounter = session.currentEncounter;
const npcState = getEncounterNpcState(session);
if (encounter && npcState) {
const nextAffinity = npcState.affinity + 2;
setEncounterNpcState(session, {
...npcState,
affinity: nextAffinity,
firstMeaningfulContactResolved: true,
});
session.npcInteractionActive = true;
return {
actionText: `${encounter.npcName}交换开场判断`,
resultText: `${encounter.npcName}终于愿意把营地里的第一轮判断说出口,彼此的警惕也略微放下了一点。`,
patches: [
{
type: 'npc_affinity_changed',
npcId: encounter.id,
previousAffinity: npcState.affinity,
nextAffinity,
},
normalizeStatusPatch(session),
],
};
}
return {
actionText: '交换开场判断',
resultText: '你先把眼前局势梳理了一遍,为后续真正推进对话和行动留出了空间。',
patches: [normalizeStatusPatch(session)],
};
}
case 'camp_travel_home_scene':
clearEncounterState(session);
return {
actionText: '返回营地',
resultText: '你主动结束了当前遭遇,把这轮流程带回了更安全的营地节奏里。',
patches: [
normalizeStatusPatch(session),
{
type: 'encounter_changed',
encounterId: null,
},
],
};
case 'idle_call_out':
return {
actionText: '主动出声试探',
resultText: '你的喊话打破了当前的静场,周围潜着的动静也因此更难继续藏着。',
patches: [normalizeStatusPatch(session)],
};
case 'idle_explore_forward':
return {
actionText: '继续向前探索',
resultText: '你没有停在原地,而是继续往前压,把下一段遭遇主动推到了自己面前。',
patches: [normalizeStatusPatch(session)],
};
case 'idle_observe_signs':
return {
actionText: '观察周围迹象',
resultText: '你先压住动作,把风向、脚印和气味这些细节重新读了一遍。',
patches: [normalizeStatusPatch(session)],
};
case 'idle_rest_focus':
session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 8);
session.playerMana = Math.min(session.playerMaxMana, session.playerMana + 6);
return {
actionText: '原地调息',
resultText: '你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。',
patches: [normalizeStatusPatch(session)],
};
case 'idle_travel_next_scene':
clearEncounterState(session);
return {
actionText: '前往相邻场景',
resultText: '你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。',
patches: [
normalizeStatusPatch(session),
{
type: 'encounter_changed',
encounterId: null,
},
],
};
default:
throw invalidRequest(`暂不支持的 story action${functionId}`);
}
}
export async function resolveRuntimeStoryAction(params: {
runtimeRepository: RuntimeRepositoryPort;
userId: string;
request: RuntimeStoryActionRequest;
}) {
const snapshot = await params.runtimeRepository.getSnapshot(params.userId);
if (!snapshot) {
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
}
const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!;
const functionId =
typeof params.request.action.functionId === 'string'
? params.request.action.functionId.trim()
: '';
if (!functionId) {
throw invalidRequest('functionId 不能为空');
}
if (
!isSupportedInventoryStoryFunctionId(functionId) &&
!isSupportedNpcInventoryStoryFunctionId(functionId) &&
!isSupportedQuestStoryFunctionId(functionId) &&
!isSupportedTreasureStoryFunctionId(functionId) &&
TASK6_DEFERRED_FUNCTION_IDS.has(functionId)
) {
throw conflict(
`动作 ${functionId} 属于任务6的 Inventory / Quest / Build 范围本轮任务5接口暂未承接`,
);
}
if (
!isSupportedInventoryStoryFunctionId(functionId) &&
!isSupportedNpcInventoryStoryFunctionId(functionId) &&
!isSupportedQuestStoryFunctionId(functionId) &&
!isSupportedTreasureStoryFunctionId(functionId) &&
!isTask5FunctionId(functionId)
) {
throw invalidRequest(`暂不支持的 runtime action${functionId}`);
}
const session = loadRuntimeSession(
hydratedSnapshot,
params.request.sessionId,
);
if (
typeof params.request.clientVersion === 'number' &&
params.request.clientVersion !== session.runtimeVersion
) {
throw conflict('运行时版本已变化,请先同步最新快照后再提交动作', {
clientVersion: params.request.clientVersion,
serverVersion: session.runtimeVersion,
});
}
let resolution: StoryResolution;
const previousEncounter = session.currentEncounter
? { ...session.currentEncounter }
: null;
if (isCombatFunctionId(functionId)) {
resolution = resolveCombatAction(session, functionId);
} else if (isNpcFunctionId(functionId)) {
resolution = resolveNpcInteraction(session, functionId);
} else if (isSupportedInventoryStoryFunctionId(functionId)) {
resolution = resolveInventoryStoryAction(session, params.request);
} else if (isSupportedNpcInventoryStoryFunctionId(functionId)) {
resolution = resolveNpcInventoryStoryAction(session, params.request);
} else if (isSupportedQuestStoryFunctionId(functionId)) {
resolution = resolveQuestStoryAction(session, params.request);
} else if (isSupportedTreasureStoryFunctionId(functionId)) {
resolution = resolveTreasureStoryAction(session, params.request);
} else if (isStoryFunctionId(functionId)) {
resolution = resolveStoryFlowAction(session, functionId);
} else {
throw invalidRequest(`当前动作没有可用的后端执行器:${functionId}`);
}
syncRawGameState(session);
applyQuestSignalsForResolvedAction({
session,
functionId,
previousEncounter,
battle: resolution.battle ?? null,
});
const actionText = resolveActionText(resolution.actionText, params.request);
const storyText = resolution.storyText ?? resolution.resultText;
appendStoryHistory(session, actionText, resolution.resultText);
session.runtimeVersion += 1;
session.sessionId = params.request.sessionId;
syncRawGameState(session);
ensureNpcInventorySessionState(session);
const options = buildAvailableOptions(session);
syncRawGameState(session);
const persistedSnapshot = await params.runtimeRepository.putSnapshot(
params.userId,
normalizeSavedSnapshotPayload({
savedAt: new Date().toISOString(),
bottomTab: session.snapshotBottomTab,
gameState: session.rawGameState,
currentStory: buildLegacyCurrentStory(storyText, options),
}),
);
return {
sessionId: session.sessionId,
serverVersion: session.runtimeVersion,
viewModel: buildRuntimeViewModel(session, options),
presentation: {
actionText,
resultText: resolution.resultText,
storyText,
options,
toast: resolution.toast ?? null,
battle: resolution.battle ?? null,
},
patches: [
{
type: 'story_history_append',
actionText,
resultText: resolution.resultText,
},
...resolution.patches,
],
snapshot: hydrateSavedSnapshot(persistedSnapshot)!,
} satisfies RuntimeStoryActionResponse;
}
export async function getRuntimeStoryState(params: {
runtimeRepository: RuntimeRepositoryPort;
userId: string;
sessionId: string;
}) {
const snapshot = await params.runtimeRepository.getSnapshot(params.userId);
if (!snapshot) {
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
}
const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!;
const session = loadRuntimeSession(hydratedSnapshot, params.sessionId);
ensureNpcInventorySessionState(session);
const options = buildAvailableOptions(session);
const storyText =
readSavedStoryText(hydratedSnapshot.currentStory) ||
buildFallbackStoryText(session);
return {
sessionId: session.sessionId,
serverVersion: session.runtimeVersion,
viewModel: buildRuntimeViewModel(session, options),
presentation: {
actionText: '',
resultText: '',
storyText,
options,
toast: null,
battle: null,
},
patches: [],
snapshot: hydratedSnapshot,
} satisfies RuntimeStoryActionResponse;
}

View File

@@ -0,0 +1,281 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import type { AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { Writable } from 'node:stream';
import test from 'node:test';
import pino, { type Logger } from 'pino';
import { createApp } from './app.ts';
import type { AppConfig } from './config.ts';
import { createAppContext } from './server.ts';
import { httpRequest } from './testHttp.ts';
type LogRecord = Record<string, unknown>;
function createTestConfig(testName: string): AppConfig {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), `genarrative-server-node-${testName}-`),
);
return {
nodeEnv: 'test',
projectRoot: tempRoot,
publicDir: path.join(tempRoot, 'public'),
logsDir: path.join(tempRoot, 'logs'),
dataDir: path.join(tempRoot, 'data'),
rawEnv: {},
databaseUrl: `pg-mem://genarrative-${testName}`,
serverAddr: ':0',
logLevel: 'silent',
editorApiEnabled: true,
assetsApiEnabled: true,
jwtSecret: 'test-secret',
jwtExpiresIn: '7d',
jwtIssuer: 'genarrative-server-node-test',
llm: {
baseUrl: 'https://example.invalid',
apiKey: '',
model: 'test-model',
},
dashScope: {
baseUrl: 'https://example.invalid',
apiKey: '',
imageModel: 'test-image-model',
requestTimeoutMs: 1000,
},
smsAuth: {
enabled: true,
provider: 'mock',
endpoint: 'dypnsapi.aliyuncs.com',
accessKeyId: '',
accessKeySecret: '',
signName: 'Test Sign',
templateCode: '100001',
templateParamKey: 'code',
countryCode: '86',
schemeName: '',
codeLength: 6,
codeType: 1,
validTimeSeconds: 300,
intervalSeconds: 60,
duplicatePolicy: 1,
caseAuthPolicy: 1,
returnVerifyCode: false,
mockVerifyCode: '123456',
maxSendPerPhonePerDay: 20,
maxSendPerIpPerHour: 30,
maxVerifyFailuresPerPhonePerHour: 12,
maxVerifyFailuresPerIpPerHour: 24,
captchaTtlSeconds: 180,
captchaTriggerVerifyFailuresPerPhone: 3,
captchaTriggerVerifyFailuresPerIp: 5,
blockPhoneFailureThreshold: 6,
blockIpFailureThreshold: 10,
blockPhoneDurationMinutes: 30,
blockIpDurationMinutes: 30,
},
wechatAuth: {
enabled: true,
provider: 'mock',
appId: '',
appSecret: '',
authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect',
accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token',
userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo',
callbackPath: '/api/auth/wechat/callback',
defaultRedirectPath: '/',
mockUserId: 'mock_wechat_user',
mockUnionId: 'mock_wechat_union',
mockDisplayName: '微信旅人',
mockAvatarUrl: '',
},
authSession: {
refreshCookieName: 'genarrative_refresh_session',
refreshSessionTtlDays: 30,
refreshCookieSecure: false,
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/api/auth',
},
};
}
function createLogCollector() {
const records: LogRecord[] = [];
let buffer = '';
const destination = new Writable({
write(chunk, _encoding, callback) {
buffer += chunk.toString('utf8');
let newlineIndex = buffer.indexOf('\n');
while (newlineIndex >= 0) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) {
records.push(JSON.parse(line) as LogRecord);
}
newlineIndex = buffer.indexOf('\n');
}
callback();
},
});
return {
logger: pino(
{
level: 'info',
base: undefined,
timestamp: false,
},
destination,
) as Logger,
records,
};
}
async function withTestServer<T>(
testName: string,
logger: Logger,
run: (options: { baseUrl: string }) => Promise<T>,
) {
const context = await createAppContext(createTestConfig(testName));
context.logger = logger;
const app = createApp(context);
const server = await new Promise<import('node:http').Server>((resolve) => {
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
});
try {
const address = server.address() as AddressInfo;
return await run({
baseUrl: `http://127.0.0.1:${address.port}`,
});
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
await context.db.close();
}
}
async function waitForRecord(
records: LogRecord[],
predicate: (record: LogRecord) => boolean,
timeoutMs = 2000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const match = records.find(predicate);
if (match) {
return match;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.fail('Timed out waiting for log record');
}
test('healthz echoes x-request-id and writes access log fields', async () => {
const { logger, records } = createLogCollector();
await withTestServer('observability-healthz', logger, async ({ baseUrl }) => {
const requestId = 'obs-healthz-request';
const response = await httpRequest(`${baseUrl}/healthz`, {
headers: {
'X-Request-Id': requestId,
},
});
const payload = (await response.json()) as {
ok: boolean;
service: string;
};
assert.equal(response.status, 200);
assert.equal(response.headers.get('x-request-id'), requestId);
assert.equal(payload.ok, true);
assert.equal(payload.service, 'genarrative-node-server');
const accessLog = await waitForRecord(
records,
(record) =>
record.request_id === requestId &&
record.path === '/healthz' &&
record.status === 200,
);
assert.equal(accessLog.method, 'GET');
assert.equal(accessLog.user_id, null);
assert.equal(accessLog.api_version, '2026-04-08');
assert.equal(accessLog.route_version, '2026-04-08');
assert.equal(accessLog.operation, 'health.check');
assert.equal(typeof accessLog.latency_ms, 'number');
});
});
test('unauthorized request keeps request trace in error log and response header', async () => {
const { logger, records } = createLogCollector();
await withTestServer(
'observability-unauthorized',
logger,
async ({ baseUrl }) => {
const requestId = 'obs-unauthorized-request';
const response = await httpRequest(`${baseUrl}/api/auth/me`, {
headers: {
'X-Request-Id': requestId,
},
});
const payload = (await response.json()) as {
error: {
message: string;
};
};
assert.equal(response.status, 401);
assert.equal(response.headers.get('x-request-id'), requestId);
assert.equal(payload.error.message, '缺少 Authorization Bearer Token');
const errorLog = await waitForRecord(
records,
(record) =>
record.msg === 'request failed' && record.request_id === requestId,
);
assert.equal(errorLog.user_id, null);
assert.equal(
(errorLog.err as { message?: string } | undefined)?.message,
'缺少 Authorization Bearer Token',
);
assert.equal(errorLog.api_version, '2026-04-08');
assert.equal(errorLog.route_version, '2026-04-08');
assert.equal(errorLog.operation, 'auth.me');
const accessLog = await waitForRecord(
records,
(record) =>
record.request_id === requestId &&
record.path === '/api/auth/me' &&
record.status === 401,
);
assert.equal(accessLog.method, 'GET');
assert.equal(accessLog.api_version, '2026-04-08');
assert.equal(accessLog.route_version, '2026-04-08');
assert.equal(accessLog.operation, 'auth.me');
assert.equal(typeof accessLog.latency_ms, 'number');
},
);
});

View File

@@ -0,0 +1,105 @@
import crypto from 'node:crypto';
import type { QueryResultRow } from 'pg';
import type { AuthAuditLogEventType } from '../../../packages/shared/src/contracts/auth.js';
import type { AppDatabase } from '../db.js';
export type AuthAuditLogRecord = {
id: string;
userId: string;
eventType: AuthAuditLogEventType;
detail: string;
ip: string | null;
userAgent: string | null;
metaJson: Record<string, unknown> | null;
createdAt: string;
};
type AuthAuditLogRow = QueryResultRow & {
id: string;
user_id: string;
event_type: AuthAuditLogEventType;
detail: string;
ip: string | null;
user_agent: string | null;
meta_json: Record<string, unknown> | null;
created_at: string;
};
function toAuthAuditLogRecord(
row: AuthAuditLogRow | undefined,
): AuthAuditLogRecord | null {
if (!row) {
return null;
}
return {
id: row.id,
userId: row.user_id,
eventType: row.event_type,
detail: row.detail,
ip: row.ip,
userAgent: row.user_agent,
metaJson: row.meta_json,
createdAt: row.created_at,
};
}
export class AuthAuditLogRepository {
constructor(private readonly db: AppDatabase) {}
async create(input: {
userId: string;
eventType: AuthAuditLogEventType;
detail: string;
ip: string | null;
userAgent: string | null;
metaJson?: Record<string, unknown> | null;
}) {
const id = `audit_${crypto.randomBytes(16).toString('hex')}`;
const createdAt = new Date().toISOString();
const result = await this.db.query<AuthAuditLogRow>(
`INSERT INTO auth_audit_logs (
id,
user_id,
event_type,
detail,
ip,
user_agent,
meta_json,
created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, event_type, detail, ip, user_agent, meta_json, created_at`,
[
id,
input.userId,
input.eventType,
input.detail,
input.ip,
input.userAgent,
input.metaJson ?? null,
createdAt,
],
);
return toAuthAuditLogRecord(result.rows[0]);
}
async listRecentByUserId(userId: string, limit = 20) {
const result = await this.db.query<AuthAuditLogRow>(
`SELECT id, user_id, event_type, detail, ip, user_agent, meta_json, created_at
FROM auth_audit_logs
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[userId, limit],
);
return result.rows
.map((row) => toAuthAuditLogRecord(row))
.filter((row): row is AuthAuditLogRecord => Boolean(row));
}
}

View File

@@ -0,0 +1,156 @@
import crypto from 'node:crypto';
import type { QueryResultRow } from 'pg';
import type { AppDatabase } from '../db.js';
export type AuthIdentityProvider = 'wechat';
export type AuthIdentityRecord = {
id: string;
userId: string;
provider: AuthIdentityProvider;
providerUid: string;
providerUnionId: string | null;
displayName: string | null;
avatarUrl: string | null;
isVerified: boolean;
metaJson: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
};
type AuthIdentityRow = QueryResultRow & {
id: string;
user_id: string;
provider: AuthIdentityProvider;
provider_uid: string;
provider_unionid: string | null;
display_name: string | null;
avatar_url: string | null;
is_verified: boolean;
meta_json: Record<string, unknown> | null;
created_at: string;
updated_at: string;
};
function toAuthIdentityRecord(
row: AuthIdentityRow | undefined,
): AuthIdentityRecord | null {
if (!row) {
return null;
}
return {
id: row.id,
userId: row.user_id,
provider: row.provider,
providerUid: row.provider_uid,
providerUnionId: row.provider_unionid,
displayName: row.display_name,
avatarUrl: row.avatar_url,
isVerified: row.is_verified,
metaJson: row.meta_json,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export type CreateWechatIdentityInput = {
userId: string;
providerUid: string;
providerUnionId: string | null;
displayName: string | null;
avatarUrl: string | null;
metaJson?: Record<string, unknown> | null;
};
export class AuthIdentityRepository {
constructor(private readonly db: AppDatabase) {}
async findWechatIdentityByProfile(params: {
providerUid: string;
providerUnionId: string | null;
}) {
const result = params.providerUnionId
? await this.db.query<AuthIdentityRow>(
`SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at
FROM auth_identities
WHERE provider = 'wechat'
AND (provider_unionid = $1 OR provider_uid = $2)
ORDER BY
CASE WHEN provider_unionid = $1 THEN 0 ELSE 1 END
LIMIT 1`,
[params.providerUnionId, params.providerUid],
)
: await this.db.query<AuthIdentityRow>(
`SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at
FROM auth_identities
WHERE provider = 'wechat'
AND provider_uid = $1
LIMIT 1`,
[params.providerUid],
);
return toAuthIdentityRecord(result.rows[0]);
}
async listByUserId(userId: string) {
const result = await this.db.query<AuthIdentityRow>(
`SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at
FROM auth_identities
WHERE user_id = $1
ORDER BY provider, created_at`,
[userId],
);
return result.rows
.map((row) => toAuthIdentityRecord(row))
.filter((row): row is AuthIdentityRecord => Boolean(row));
}
async createWechatIdentity(input: CreateWechatIdentityInput) {
const now = new Date().toISOString();
const identityId = `authi_${crypto.randomBytes(16).toString('hex')}`;
const result = await this.db.query<AuthIdentityRow>(
`INSERT INTO auth_identities (
id,
user_id,
provider,
provider_uid,
provider_unionid,
display_name,
avatar_url,
is_verified,
meta_json,
created_at,
updated_at
)
VALUES ($1, $2, 'wechat', $3, $4, $5, $6, TRUE, $7, $8, $9)
RETURNING id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at`,
[
identityId,
input.userId,
input.providerUid,
input.providerUnionId,
input.displayName,
input.avatarUrl,
input.metaJson ?? null,
now,
now,
],
);
return toAuthIdentityRecord(result.rows[0]);
}
async moveWechatIdentitiesToUser(sourceUserId: string, targetUserId: string) {
await this.db.query(
`UPDATE auth_identities
SET user_id = $1, updated_at = $2
WHERE user_id = $3
AND provider = 'wechat'`,
[targetUserId, new Date().toISOString(), sourceUserId],
);
}
}

View File

@@ -0,0 +1,128 @@
import crypto from 'node:crypto';
import type { QueryResultRow } from 'pg';
import type { AppDatabase } from '../db.js';
export type AuthRiskBlockScopeType = 'phone' | 'ip';
export type AuthRiskBlockRecord = {
id: string;
scopeType: AuthRiskBlockScopeType;
scopeKey: string;
reason: string;
expiresAt: string;
liftedAt: string | null;
createdAt: string;
updatedAt: string;
};
type AuthRiskBlockRow = QueryResultRow & {
id: string;
scope_type: AuthRiskBlockScopeType;
scope_key: string;
reason: string;
expires_at: string;
lifted_at: string | null;
created_at: string;
updated_at: string;
};
function toAuthRiskBlockRecord(
row: AuthRiskBlockRow | undefined,
): AuthRiskBlockRecord | null {
if (!row) {
return null;
}
return {
id: row.id,
scopeType: row.scope_type,
scopeKey: row.scope_key,
reason: row.reason,
expiresAt: row.expires_at,
liftedAt: row.lifted_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export class AuthRiskBlockRepository {
constructor(private readonly db: AppDatabase) {}
async findActive(scopeType: AuthRiskBlockScopeType, scopeKey: string) {
const result = await this.db.query<AuthRiskBlockRow>(
`SELECT id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at
FROM auth_risk_blocks
WHERE scope_type = $1
AND scope_key = $2
AND lifted_at IS NULL
AND expires_at > $3
ORDER BY expires_at DESC
LIMIT 1`,
[scopeType, scopeKey, new Date().toISOString()],
);
return toAuthRiskBlockRecord(result.rows[0]);
}
async createOrRefresh(input: {
scopeType: AuthRiskBlockScopeType;
scopeKey: string;
reason: string;
expiresAt: string;
}) {
const existing = await this.findActive(input.scopeType, input.scopeKey);
if (existing) {
const result = await this.db.query<AuthRiskBlockRow>(
`UPDATE auth_risk_blocks
SET reason = $1,
expires_at = $2,
updated_at = $3
WHERE id = $4
RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`,
[input.reason, input.expiresAt, new Date().toISOString(), existing.id],
);
return toAuthRiskBlockRecord(result.rows[0]);
}
const id = `risk_${crypto.randomBytes(16).toString('hex')}`;
const now = new Date().toISOString();
const result = await this.db.query<AuthRiskBlockRow>(
`INSERT INTO auth_risk_blocks (
id,
scope_type,
scope_key,
reason,
expires_at,
lifted_at,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, NULL, $6, $7)
RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`,
[id, input.scopeType, input.scopeKey, input.reason, input.expiresAt, now, now],
);
return toAuthRiskBlockRecord(result.rows[0]);
}
async liftActive(scopeType: AuthRiskBlockScopeType, scopeKey: string) {
const now = new Date().toISOString();
const result = await this.db.query<AuthRiskBlockRow>(
`UPDATE auth_risk_blocks
SET lifted_at = $1,
updated_at = $2
WHERE scope_type = $3
AND scope_key = $4
AND lifted_at IS NULL
AND expires_at > $5
RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`,
[now, now, scopeType, scopeKey, now],
);
return result.rows
.map((row) => toAuthRiskBlockRecord(row))
.filter((row): row is AuthRiskBlockRecord => Boolean(row));
}
}

View File

@@ -1,10 +1,21 @@
import type { QueryResultRow } from 'pg';
import {
DEFAULT_MUSIC_VOLUME,
SAVE_SNAPSHOT_VERSION,
} from '../../../packages/shared/src/contracts/runtime.js';
import type {
CustomWorldProfileRecord,
RuntimeSettings,
SavedGameSnapshot,
} from '../../../packages/shared/src/contracts/runtime.js';
import type { AppDatabase } from '../db.js';
const SAVE_SNAPSHOT_VERSION = 2;
const DEFAULT_MUSIC_VOLUME = 0.42;
const MAX_CUSTOM_WORLD_PROFILES = 12;
export type SavedSnapshot = {
export type SavedSnapshot = SavedGameSnapshot<unknown, string, unknown>;
type SnapshotRow = QueryResultRow & {
version: number;
savedAt: string;
gameState: unknown;
@@ -12,37 +23,53 @@ export type SavedSnapshot = {
currentStory: unknown;
};
export type RuntimeSettings = {
type SettingsRow = QueryResultRow & {
musicVolume: number;
};
function parseJson<T>(value: string): T {
return JSON.parse(value) as T;
}
type ProfileRow = QueryResultRow & {
payload: CustomWorldProfileRecord;
};
function toJson(value: unknown) {
return JSON.stringify(value ?? null);
}
export type RuntimeRepositoryPort = {
getSnapshot(userId: string): Promise<SavedSnapshot | null>;
putSnapshot(
userId: string,
payload: Omit<SavedSnapshot, 'version'>,
): Promise<SavedSnapshot>;
deleteSnapshot(userId: string): Promise<void>;
getSettings(userId: string): Promise<RuntimeSettings>;
putSettings(
userId: string,
settings: RuntimeSettings,
): Promise<RuntimeSettings>;
listCustomWorldProfiles(userId: string): Promise<CustomWorldProfileRecord[]>;
upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
): Promise<CustomWorldProfileRecord[]>;
deleteCustomWorldProfile(
userId: string,
profileId: string,
): Promise<CustomWorldProfileRecord[]>;
};
export class RuntimeRepository {
export class RuntimeRepository implements RuntimeRepositoryPort {
constructor(private readonly db: AppDatabase) {}
getSnapshot(userId: string) {
const row = this.db
.prepare(
`SELECT version, saved_at, game_state_json, bottom_tab, current_story_json
FROM save_snapshots
WHERE user_id = ?`,
)
.get(userId) as
| {
version: number;
saved_at: string;
game_state_json: string;
bottom_tab: string;
current_story_json: string;
}
| undefined;
async getSnapshot(userId: string) {
const result = await this.db.query<SnapshotRow>(
`SELECT version,
saved_at AS "savedAt",
game_state_json AS "gameState",
bottom_tab AS "bottomTab",
current_story_json AS "currentStory"
FROM save_snapshots
WHERE user_id = $1`,
[userId],
);
const row = result.rows[0];
if (!row) {
return null;
@@ -50,14 +77,14 @@ export class RuntimeRepository {
return {
version: row.version,
savedAt: row.saved_at,
gameState: parseJson(row.game_state_json),
bottomTab: row.bottom_tab,
currentStory: parseJson(row.current_story_json),
savedAt: row.savedAt,
gameState: row.gameState,
bottomTab: row.bottomTab,
currentStory: row.currentStory,
} satisfies SavedSnapshot;
}
putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
async putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
const snapshot = {
version: SAVE_SNAPSHOT_VERSION,
savedAt: payload.savedAt,
@@ -67,115 +94,126 @@ export class RuntimeRepository {
} satisfies SavedSnapshot;
const now = new Date().toISOString();
this.db
.prepare(
`INSERT INTO save_snapshots (
const result = await this.db.query<SnapshotRow>(
`INSERT INTO save_snapshots (
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
version = excluded.version,
saved_at = excluded.saved_at,
bottom_tab = excluded.bottom_tab,
game_state_json = excluded.game_state_json,
current_story_json = excluded.current_story_json,
updated_at = excluded.updated_at`,
)
.run(
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id) DO UPDATE SET
version = EXCLUDED.version,
saved_at = EXCLUDED.saved_at,
bottom_tab = EXCLUDED.bottom_tab,
game_state_json = EXCLUDED.game_state_json,
current_story_json = EXCLUDED.current_story_json,
updated_at = EXCLUDED.updated_at
RETURNING version,
saved_at AS "savedAt",
game_state_json AS "gameState",
bottom_tab AS "bottomTab",
current_story_json AS "currentStory"`,
[
userId,
snapshot.version,
snapshot.savedAt,
snapshot.bottomTab,
toJson(snapshot.gameState),
toJson(snapshot.currentStory),
snapshot.gameState,
snapshot.currentStory,
now,
);
],
);
return snapshot;
const row = result.rows[0];
return {
version: row.version,
savedAt: row.savedAt,
gameState: row.gameState,
bottomTab: row.bottomTab,
currentStory: row.currentStory,
} satisfies SavedSnapshot;
}
deleteSnapshot(userId: string) {
this.db.prepare(`DELETE FROM save_snapshots WHERE user_id = ?`).run(userId);
async deleteSnapshot(userId: string) {
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [userId]);
}
getSettings(userId: string) {
const row = this.db
.prepare(
`SELECT music_volume
FROM runtime_settings
WHERE user_id = ?`,
)
.get(userId) as { music_volume: number } | undefined;
async getSettings(userId: string) {
const result = await this.db.query<SettingsRow>(
`SELECT music_volume AS "musicVolume"
FROM runtime_settings
WHERE user_id = $1`,
[userId],
);
const row = result.rows[0];
return {
musicVolume:
typeof row?.music_volume === 'number'
? row.music_volume
typeof row?.musicVolume === 'number'
? row.musicVolume
: DEFAULT_MUSIC_VOLUME,
} satisfies RuntimeSettings;
}
putSettings(userId: string, settings: RuntimeSettings) {
async putSettings(userId: string, settings: RuntimeSettings) {
const nextSettings = {
musicVolume: Math.max(0, Math.min(1, settings.musicVolume)),
} satisfies RuntimeSettings;
this.db
.prepare(
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
music_volume = excluded.music_volume,
updated_at = excluded.updated_at`,
)
.run(userId, nextSettings.musicVolume, new Date().toISOString());
const result = await this.db.query<SettingsRow>(
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE SET
music_volume = EXCLUDED.music_volume,
updated_at = EXCLUDED.updated_at
RETURNING music_volume AS "musicVolume"`,
[userId, nextSettings.musicVolume, new Date().toISOString()],
);
return nextSettings;
return {
musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume,
} satisfies RuntimeSettings;
}
listCustomWorldProfiles(userId: string) {
const rows = this.db
.prepare(
`SELECT payload_json
FROM custom_world_profiles
WHERE user_id = ?
ORDER BY updated_at DESC
LIMIT ?`,
)
.all(userId, MAX_CUSTOM_WORLD_PROFILES) as Array<{ payload_json: string }>;
async listCustomWorldProfiles(userId: string) {
const result = await this.db.query<ProfileRow>(
`SELECT payload_json AS payload
FROM custom_world_profiles
WHERE user_id = $1
ORDER BY updated_at DESC
LIMIT $2`,
[userId, MAX_CUSTOM_WORLD_PROFILES],
);
return rows.map((row) => parseJson<Record<string, unknown>>(row.payload_json));
return result.rows.map((row: ProfileRow) => row.payload);
}
upsertCustomWorldProfile(
async upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
profile: CustomWorldProfileRecord,
) {
const payload = {
...profile,
id: profileId,
};
this.db
.prepare(
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET
payload_json = excluded.payload_json,
updated_at = excluded.updated_at`,
)
.run(userId, profileId, JSON.stringify(payload), new Date().toISOString());
await this.db.query(
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at`,
[userId, profileId, payload, new Date().toISOString()],
);
return this.listCustomWorldProfiles(userId);
}
deleteCustomWorldProfile(userId: string, profileId: string) {
this.db
.prepare(
`DELETE FROM custom_world_profiles
WHERE user_id = ? AND profile_id = ?`,
)
.run(userId, profileId);
async deleteCustomWorldProfile(userId: string, profileId: string) {
await this.db.query(
`DELETE FROM custom_world_profiles
WHERE user_id = $1 AND profile_id = $2`,
[userId, profileId],
);
return this.listCustomWorldProfiles(userId);
}

View File

@@ -0,0 +1,102 @@
import crypto from 'node:crypto';
import type { QueryResultRow } from 'pg';
import type { AppDatabase } from '../db.js';
export type SmsAuthScene = 'login' | 'bind_phone' | 'change_phone';
export type SmsAuthAction = 'send_code' | 'verify_code';
type SmsAuthEventRow = QueryResultRow & {
total: number;
};
export class SmsAuthEventRepository {
constructor(private readonly db: AppDatabase) {}
async create(input: {
phoneNumber: string;
scene: SmsAuthScene;
action: SmsAuthAction;
success: boolean;
ip: string | null;
userAgent: string | null;
}) {
const id = `smsev_${crypto.randomBytes(16).toString('hex')}`;
await this.db.query(
`INSERT INTO sms_auth_events (
id,
phone_number,
scene,
action,
success,
ip,
user_agent,
created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
id,
input.phoneNumber,
input.scene,
input.action,
input.success,
input.ip,
input.userAgent,
new Date().toISOString(),
],
);
}
async countSinceByPhone(params: {
phoneNumber: string;
action: SmsAuthAction;
success?: boolean;
since: string;
}) {
const result = await this.db.query<SmsAuthEventRow>(
`SELECT COUNT(*)::int AS total
FROM sms_auth_events
WHERE phone_number = $1
AND action = $2
AND ($3::boolean IS NULL OR success = $3)
AND created_at >= $4`,
[
params.phoneNumber,
params.action,
params.success ?? null,
params.since,
],
);
return result.rows[0]?.total ?? 0;
}
async countSinceByIp(params: {
ip: string | null;
action: SmsAuthAction;
success?: boolean;
since: string;
}) {
if (!params.ip) {
return 0;
}
const result = await this.db.query<SmsAuthEventRow>(
`SELECT COUNT(*)::int AS total
FROM sms_auth_events
WHERE ip = $1
AND action = $2
AND ($3::boolean IS NULL OR success = $3)
AND created_at >= $4`,
[
params.ip,
params.action,
params.success ?? null,
params.since,
],
);
return result.rows[0]?.total ?? 0;
}
}

View File

@@ -1,21 +1,33 @@
import crypto from 'node:crypto';
import type { QueryResultRow } from 'pg';
import type { AppDatabase } from '../db.js';
export type UserRecord = {
id: string;
username: string;
username: string | null;
passwordHash: string;
tokenVersion: number;
displayName: string;
loginProvider: 'password' | 'phone' | 'wechat';
accountStatus: 'active' | 'pending_bind_phone' | 'disabled';
phoneNumber: string | null;
phoneVerifiedAt: string | null;
createdAt: string;
updatedAt: string;
};
type UserRow = {
type UserRow = QueryResultRow & {
id: string;
username: string;
username: string | null;
password_hash: string;
token_version: number;
display_name: string;
login_provider: 'password' | 'phone' | 'wechat';
account_status: 'active' | 'pending_bind_phone' | 'disabled';
phone_number: string | null;
phone_verified_at: string | null;
created_at: string;
updated_at: string;
};
@@ -30,59 +42,249 @@ function toUserRecord(row: UserRow | undefined): UserRecord | null {
username: row.username,
passwordHash: row.password_hash,
tokenVersion: row.token_version,
displayName: row.display_name,
loginProvider: row.login_provider,
accountStatus: row.account_status,
phoneNumber: row.phone_number,
phoneVerifiedAt: row.phone_verified_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export class UserRepository {
export type CreatePhoneUserInput = {
username: string;
passwordHash: string;
displayName: string;
phoneNumber: string;
phoneVerifiedAt: string;
};
export type CreateWechatPendingUserInput = {
username: string;
passwordHash: string;
displayName: string;
};
export type UserRepositoryPort = {
findByUsername(username: string): Promise<UserRecord | null>;
findByPhoneNumber(phoneNumber: string): Promise<UserRecord | null>;
findById(userId: string): Promise<UserRecord | null>;
create(username: string, passwordHash: string): Promise<UserRecord | null>;
createPhoneUser(input: CreatePhoneUserInput): Promise<UserRecord | null>;
createWechatPendingUser(
input: CreateWechatPendingUserInput,
): Promise<UserRecord | null>;
activatePendingWechatUser(
userId: string,
params: {
displayName: string;
phoneNumber: string;
phoneVerifiedAt: string;
},
): Promise<UserRecord | null>;
updatePhoneInfo(
userId: string,
params: {
phoneNumber: string;
phoneVerifiedAt: string;
displayName?: string;
},
): Promise<UserRecord | null>;
deleteUser(userId: string): Promise<void>;
incrementTokenVersion(userId: string): Promise<UserRecord | null>;
};
export class UserRepository implements UserRepositoryPort {
constructor(private readonly db: AppDatabase) {}
findByUsername(username: string) {
const row = this.db
.prepare(
`SELECT id, username, password_hash, token_version, created_at, updated_at
FROM users
WHERE username = ?`,
)
.get(username) as UserRow | undefined;
return toUserRecord(row);
async findByUsername(username: string) {
const result = await this.db.query<UserRow>(
`SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at
FROM users
WHERE username = $1`,
[username],
);
return toUserRecord(result.rows[0]);
}
findById(userId: string) {
const row = this.db
.prepare(
`SELECT id, username, password_hash, token_version, created_at, updated_at
FROM users
WHERE id = ?`,
)
.get(userId) as UserRow | undefined;
return toUserRecord(row);
async findByPhoneNumber(phoneNumber: string) {
const result = await this.db.query<UserRow>(
`SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at
FROM users
WHERE phone_number = $1`,
[phoneNumber],
);
return toUserRecord(result.rows[0]);
}
create(username: string, passwordHash: string) {
async findById(userId: string) {
const result = await this.db.query<UserRow>(
`SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at
FROM users
WHERE id = $1`,
[userId],
);
return toUserRecord(result.rows[0]);
}
async create(username: string, passwordHash: string) {
const now = new Date().toISOString();
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
this.db
.prepare(
`INSERT INTO users (id, username, password_hash, token_version, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)`,
)
.run(id, username, passwordHash, now, now);
const result = await this.db.query<UserRow>(
`INSERT INTO users (
id,
username,
password_hash,
token_version,
display_name,
login_provider,
account_status,
created_at,
updated_at
)
VALUES ($1, $2, $3, 1, $4, 'password', 'active', $5, $6)
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
[id, username, passwordHash, username, now, now],
);
return this.findById(id);
return toUserRecord(result.rows[0]);
}
incrementTokenVersion(userId: string) {
this.db
.prepare(
`UPDATE users
SET token_version = token_version + 1, updated_at = ?
WHERE id = ?`,
)
.run(new Date().toISOString(), userId);
async createPhoneUser(input: CreatePhoneUserInput) {
const now = new Date().toISOString();
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
return this.findById(userId);
const result = await this.db.query<UserRow>(
`INSERT INTO users (
id,
username,
password_hash,
token_version,
display_name,
login_provider,
account_status,
phone_number,
phone_verified_at,
created_at,
updated_at
)
VALUES ($1, $2, $3, 1, $4, 'phone', 'active', $5, $6, $7, $8)
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
[
id,
input.username,
input.passwordHash,
input.displayName,
input.phoneNumber,
input.phoneVerifiedAt,
now,
now,
],
);
return toUserRecord(result.rows[0]);
}
async createWechatPendingUser(input: CreateWechatPendingUserInput) {
const now = new Date().toISOString();
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
const result = await this.db.query<UserRow>(
`INSERT INTO users (
id,
username,
password_hash,
token_version,
display_name,
login_provider,
account_status,
created_at,
updated_at
)
VALUES ($1, $2, $3, 1, $4, 'wechat', 'pending_bind_phone', $5, $6)
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
[id, input.username, input.passwordHash, input.displayName, now, now],
);
return toUserRecord(result.rows[0]);
}
async activatePendingWechatUser(
userId: string,
params: {
displayName: string;
phoneNumber: string;
phoneVerifiedAt: string;
},
) {
const result = await this.db.query<UserRow>(
`UPDATE users
SET account_status = 'active',
phone_number = $1,
phone_verified_at = $2,
display_name = $3,
updated_at = $4
WHERE id = $5
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
[
params.phoneNumber,
params.phoneVerifiedAt,
params.displayName,
new Date().toISOString(),
userId,
],
);
return toUserRecord(result.rows[0]);
}
async updatePhoneInfo(
userId: string,
params: {
phoneNumber: string;
phoneVerifiedAt: string;
displayName?: string;
},
) {
const result = await this.db.query<UserRow>(
`UPDATE users
SET phone_number = $1,
phone_verified_at = $2,
display_name = COALESCE($3, display_name),
updated_at = $4
WHERE id = $5
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
[
params.phoneNumber,
params.phoneVerifiedAt,
params.displayName ?? null,
new Date().toISOString(),
userId,
],
);
return toUserRecord(result.rows[0]);
}
async deleteUser(userId: string) {
await this.db.query(
`DELETE FROM users
WHERE id = $1`,
[userId],
);
}
async incrementTokenVersion(userId: string) {
const result = await this.db.query<UserRow>(
`UPDATE users
SET token_version = token_version + 1, updated_at = $1
WHERE id = $2
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
[new Date().toISOString(), userId],
);
return toUserRecord(result.rows[0]);
}
}

View File

@@ -0,0 +1,214 @@
import crypto from 'node:crypto';
import type { QueryResultRow } from 'pg';
import type { AppDatabase } from '../db.js';
export type UserSessionRecord = {
id: string;
userId: string;
refreshTokenHash: string;
clientType: string;
userAgent: string | null;
ip: string | null;
expiresAt: string;
revokedAt: string | null;
createdAt: string;
updatedAt: string;
lastSeenAt: string;
};
type UserSessionRow = QueryResultRow & {
id: string;
user_id: string;
refresh_token_hash: string;
client_type: string;
user_agent: string | null;
ip: string | null;
expires_at: string;
revoked_at: string | null;
created_at: string;
updated_at: string;
last_seen_at: string;
};
function toUserSessionRecord(
row: UserSessionRow | undefined,
): UserSessionRecord | null {
if (!row) {
return null;
}
return {
id: row.id,
userId: row.user_id,
refreshTokenHash: row.refresh_token_hash,
clientType: row.client_type,
userAgent: row.user_agent,
ip: row.ip,
expiresAt: row.expires_at,
revokedAt: row.revoked_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastSeenAt: row.last_seen_at,
};
}
export type CreateUserSessionInput = {
userId: string;
refreshTokenHash: string;
clientType: string;
userAgent: string | null;
ip: string | null;
expiresAt: string;
};
export class UserSessionRepository {
constructor(private readonly db: AppDatabase) {}
async create(input: CreateUserSessionInput) {
const now = new Date().toISOString();
const sessionId = `usess_${crypto.randomBytes(16).toString('hex')}`;
const result = await this.db.query<UserSessionRow>(
`INSERT INTO user_sessions (
id,
user_id,
refresh_token_hash,
client_type,
user_agent,
ip,
expires_at,
revoked_at,
created_at,
updated_at,
last_seen_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9, $10)
RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`,
[
sessionId,
input.userId,
input.refreshTokenHash,
input.clientType,
input.userAgent,
input.ip,
input.expiresAt,
now,
now,
now,
],
);
return toUserSessionRecord(result.rows[0]);
}
async findActiveByRefreshTokenHash(refreshTokenHash: string) {
const result = await this.db.query<UserSessionRow>(
`SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at
FROM user_sessions
WHERE refresh_token_hash = $1
LIMIT 1`,
[refreshTokenHash],
);
return toUserSessionRecord(result.rows[0]);
}
async rotate(
sessionId: string,
input: {
refreshTokenHash: string;
expiresAt: string;
lastSeenAt: string;
},
) {
const result = await this.db.query<UserSessionRow>(
`UPDATE user_sessions
SET refresh_token_hash = $1,
expires_at = $2,
last_seen_at = $3,
updated_at = $4
WHERE id = $5
RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`,
[
input.refreshTokenHash,
input.expiresAt,
input.lastSeenAt,
new Date().toISOString(),
sessionId,
],
);
return toUserSessionRecord(result.rows[0]);
}
async revoke(sessionId: string) {
const now = new Date().toISOString();
const result = await this.db.query<UserSessionRow>(
`UPDATE user_sessions
SET revoked_at = $1,
updated_at = $2
WHERE id = $3
RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`,
[now, now, sessionId],
);
return toUserSessionRecord(result.rows[0]);
}
async findById(sessionId: string) {
const result = await this.db.query<UserSessionRow>(
`SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at
FROM user_sessions
WHERE id = $1
LIMIT 1`,
[sessionId],
);
return toUserSessionRecord(result.rows[0]);
}
async listActiveByUserId(userId: string) {
const result = await this.db.query<UserSessionRow>(
`SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at
FROM user_sessions
WHERE user_id = $1
AND revoked_at IS NULL
ORDER BY last_seen_at DESC, created_at DESC`,
[userId],
);
return result.rows
.map((row) => toUserSessionRecord(row))
.filter((row): row is UserSessionRecord => Boolean(row));
}
async revokeAllByUserId(userId: string) {
const now = new Date().toISOString();
await this.db.query(
`UPDATE user_sessions
SET revoked_at = $1,
updated_at = $2
WHERE user_id = $3
AND revoked_at IS NULL`,
[now, now, userId],
);
}
async revokeByUserIdAndSessionId(userId: string, sessionId: string) {
const now = new Date().toISOString();
const result = await this.db.query<UserSessionRow>(
`UPDATE user_sessions
SET revoked_at = $1,
updated_at = $2
WHERE user_id = $3
AND id = $4
AND revoked_at IS NULL
RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`,
[now, now, userId, sessionId],
);
return toUserSessionRecord(result.rows[0]);
}
}

View File

@@ -1,51 +1,497 @@
import { Router } from 'express';
import { type Request, Router } from 'express';
import { z } from 'zod';
import { entryWithPassword, logoutUser } from '../auth/authService.js';
import type {
AuthEntryRequest,
AuthPhoneChangeRequest,
AuthPhoneLoginRequest,
AuthPhoneSendCodeRequest,
AuthWechatBindPhoneRequest,
} from '../../../packages/shared/src/contracts/auth.js';
import { buildAuthRequestContext } from '../auth/authRequestContext.js';
import {
bindWechatPhone,
buildAuthMeResponse,
changeUserPhone,
createRefreshSession,
entryWithPassword,
entryWithPhoneCode,
liftRiskBlock,
listActiveRiskBlocks,
listAuthAuditLogs,
listUserSessions,
logoutAllUserSessions,
logoutUser,
refreshAuthSession,
resolveWechatCallback,
revokeRefreshSession,
revokeUserSession,
sendPhoneLoginCode,
startWechatLogin,
} from '../auth/authService.js';
import {
clearRefreshSessionCookie,
readRefreshSessionToken,
setRefreshSessionCookie,
} from '../auth/refreshSessionCookie.js';
import type { AppContext } from '../context.js';
import { asyncHandler } from '../http.js';
import { asyncHandler, sendApiResponse } from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
const authEntrySchema = z.object({
username: z.string(),
password: z.string(),
});
const authPhoneSendCodeSchema = z.object({
phone: z.string(),
scene: z.enum(['login', 'bind_phone', 'change_phone']).optional(),
captchaChallengeId: z.string().optional(),
captchaAnswer: z.string().optional(),
});
const authPhoneLoginSchema = z.object({
phone: z.string(),
code: z.string(),
});
const authPhoneChangeSchema = z.object({
phone: z.string(),
code: z.string(),
});
const authWechatBindPhoneSchema = z.object({
phone: z.string(),
code: z.string(),
});
function resolveRequestOrigin(request: Request) {
const forwardedProto = request.header('x-forwarded-proto')?.split(',')[0]?.trim();
const forwardedHost = request.header('x-forwarded-host')?.split(',')[0]?.trim();
const protocol = forwardedProto || request.protocol || 'http';
const host = forwardedHost || request.header('host') || '127.0.0.1:8081';
return `${protocol}://${host}`;
}
function normalizeRedirectPath(rawValue: unknown, fallback: string) {
if (typeof rawValue !== 'string' || !rawValue.trim()) {
return fallback;
}
const value = rawValue.trim();
if (value.startsWith('/')) {
return value;
}
try {
const url = new URL(value);
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return fallback;
}
}
function buildAuthResultRedirectUrl(
redirectPath: string,
params: Record<string, string>,
) {
const hash = new URLSearchParams(params).toString();
const [pathWithoutHash] = redirectPath.split('#');
return `${pathWithoutHash || '/'}#${hash}`;
}
function buildRefreshCookieLifetimeSeconds(
context: AppContext,
expiresAt: string,
) {
return Math.max(
0,
Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000),
);
}
export function createAuthRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.post(
'/entry',
routeMeta({ operation: 'auth.entry' }),
asyncHandler(async (request, response) => {
const payload = authEntrySchema.parse(request.body);
response.json(
await entryWithPassword(context, payload.username, payload.password),
const payload = authEntrySchema.parse(request.body) as AuthEntryRequest;
const requestContext = buildAuthRequestContext(request);
const result = await entryWithPassword(
context,
payload.username,
payload.password,
requestContext,
);
const user = await context.userRepository.findById(result.user.id);
if (!user) {
throw new Error('failed to resolve auth user after password entry');
}
const refreshSession = await createRefreshSession(
context,
user,
requestContext,
);
setRefreshSessionCookie(
response,
context.config,
refreshSession.refreshToken,
buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt),
);
sendApiResponse(response, result);
}),
);
router.post(
'/phone/send-code',
routeMeta({ operation: 'auth.phone.send_code' }),
asyncHandler(async (request, response) => {
const payload = authPhoneSendCodeSchema.parse(
request.body,
) as AuthPhoneSendCodeRequest;
sendApiResponse(
response,
await sendPhoneLoginCode(
context,
payload.phone,
payload.scene,
buildAuthRequestContext(request),
{
captchaChallengeId: payload.captchaChallengeId,
captchaAnswer: payload.captchaAnswer,
},
),
);
}),
);
router.post(
'/phone/change',
routeMeta({ operation: 'auth.phone.change' }),
requireAuth,
asyncHandler(async (request, response) => {
const payload = authPhoneChangeSchema.parse(
request.body,
) as AuthPhoneChangeRequest;
const requestContext = buildAuthRequestContext(request);
sendApiResponse(
response,
await changeUserPhone(
context,
request.userId!,
payload.phone,
payload.code,
requestContext,
),
);
}),
);
router.post(
'/phone/login',
routeMeta({ operation: 'auth.phone.login' }),
asyncHandler(async (request, response) => {
const payload = authPhoneLoginSchema.parse(
request.body,
) as AuthPhoneLoginRequest;
const requestContext = buildAuthRequestContext(request);
const result = await entryWithPhoneCode(
context,
payload.phone,
payload.code,
requestContext,
);
const user = await context.userRepository.findById(result.user.id);
if (!user) {
throw new Error('failed to resolve auth user after phone entry');
}
const refreshSession = await createRefreshSession(
context,
user,
requestContext,
);
setRefreshSessionCookie(
response,
context.config,
refreshSession.refreshToken,
buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt),
);
sendApiResponse(response, result);
}),
);
router.get(
'/wechat/start',
routeMeta({ operation: 'auth.wechat.start' }),
asyncHandler(async (request, response) => {
const redirectPath = normalizeRedirectPath(
request.query.redirectPath,
context.config.wechatAuth.defaultRedirectPath,
);
const callbackUrl = new URL(
context.config.wechatAuth.callbackPath,
resolveRequestOrigin(request),
).toString();
sendApiResponse(
response,
await startWechatLogin(context, callbackUrl, redirectPath),
);
}),
);
router.get(
'/wechat/callback',
routeMeta({ operation: 'auth.wechat.callback' }),
asyncHandler(async (request, response) => {
const state =
typeof request.query.state === 'string' ? request.query.state.trim() : '';
const stateRecord = context.wechatAuthStates.consume(state);
const redirectPath =
stateRecord?.redirectPath ?? context.config.wechatAuth.defaultRedirectPath;
if (!stateRecord) {
response.redirect(
302,
buildAuthResultRedirectUrl(redirectPath, {
auth_provider: 'wechat',
auth_error: '微信登录状态已失效,请重新发起登录。',
}),
);
return;
}
try {
const requestContext = buildAuthRequestContext(request);
const result = await resolveWechatCallback(context, {
code: typeof request.query.code === 'string' ? request.query.code : null,
mockCode:
typeof request.query.mock_code === 'string'
? request.query.mock_code
: null,
}, requestContext);
const user = await context.userRepository.findById(result.user.id);
if (!user) {
throw new Error('failed to resolve auth user after wechat callback');
}
const refreshSession = await createRefreshSession(
context,
user,
requestContext,
);
setRefreshSessionCookie(
response,
context.config,
refreshSession.refreshToken,
buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt),
);
response.redirect(
302,
buildAuthResultRedirectUrl(redirectPath, {
auth_provider: 'wechat',
auth_token: result.token,
auth_binding_status: result.user.bindingStatus,
}),
);
} catch (error) {
const message =
error instanceof Error ? error.message : '微信登录失败,请稍后再试。';
response.redirect(
302,
buildAuthResultRedirectUrl(redirectPath, {
auth_provider: 'wechat',
auth_error: message,
}),
);
}
}),
);
router.post(
'/wechat/bind-phone',
routeMeta({ operation: 'auth.wechat.bind_phone' }),
requireAuth,
asyncHandler(async (request, response) => {
const payload = authWechatBindPhoneSchema.parse(
request.body,
) as AuthWechatBindPhoneRequest;
const requestContext = buildAuthRequestContext(request);
const result = await bindWechatPhone(
context,
request.userId!,
payload.phone,
payload.code,
requestContext,
);
const user = await context.userRepository.findById(result.user.id);
if (!user) {
throw new Error('failed to resolve auth user after wechat bind');
}
const refreshSession = await createRefreshSession(
context,
user,
requestContext,
);
setRefreshSessionCookie(
response,
context.config,
refreshSession.refreshToken,
buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt),
);
sendApiResponse(response, result);
}),
);
router.post(
'/refresh',
routeMeta({ operation: 'auth.refresh' }),
asyncHandler(async (request, response) => {
const refreshToken = readRefreshSessionToken(request, context.config);
try {
const result = await refreshAuthSession(context, refreshToken);
setRefreshSessionCookie(
response,
context.config,
result.refreshToken,
buildRefreshCookieLifetimeSeconds(context, result.refreshExpiresAt),
);
sendApiResponse(response, {
token: result.token,
});
} catch (error) {
clearRefreshSessionCookie(response, context.config);
throw error;
}
}),
);
router.get(
'/risk-blocks',
routeMeta({ operation: 'auth.risk_blocks' }),
requireAuth,
asyncHandler(async (request, response) => {
const user = await context.userRepository.findById(request.userId!);
sendApiResponse(
response,
await listActiveRiskBlocks(
context,
user!,
buildAuthRequestContext(request),
),
);
}),
);
router.post(
'/risk-blocks/:scopeType/lift',
routeMeta({ operation: 'auth.risk_blocks.lift' }),
requireAuth,
asyncHandler(async (request, response) => {
const user = await context.userRepository.findById(request.userId!);
sendApiResponse(
response,
await liftRiskBlock(
context,
user!,
buildAuthRequestContext(request),
request.params.scopeType === 'phone' ? 'phone' : 'ip',
),
);
}),
);
router.get(
'/sessions',
routeMeta({ operation: 'auth.sessions' }),
requireAuth,
asyncHandler(async (request, response) => {
const refreshToken = readRefreshSessionToken(request, context.config);
sendApiResponse(
response,
await listUserSessions(context, request.userId!, refreshToken),
);
}),
);
router.post(
'/sessions/:sessionId/revoke',
routeMeta({ operation: 'auth.sessions.revoke' }),
requireAuth,
asyncHandler(async (request, response) => {
const refreshToken = readRefreshSessionToken(request, context.config);
sendApiResponse(
response,
await revokeUserSession(
context,
request.userId!,
request.params.sessionId,
refreshToken,
buildAuthRequestContext(request),
),
);
}),
);
router.get(
'/audit-logs',
routeMeta({ operation: 'auth.audit_logs' }),
requireAuth,
asyncHandler(async (request, response) => {
sendApiResponse(
response,
await listAuthAuditLogs(context, request.userId!),
);
}),
);
router.get(
'/me',
routeMeta({ operation: 'auth.me' }),
requireAuth,
asyncHandler(async (request, response) => {
const user = context.userRepository.findById(request.userId!);
response.json({
user: user
? {
id: user.id,
username: user.username,
}
: null,
});
const user = await context.userRepository.findById(request.userId!);
sendApiResponse(response, await buildAuthMeResponse(context, user));
}),
);
router.post(
'/logout-all',
routeMeta({ operation: 'auth.logout_all' }),
requireAuth,
asyncHandler(async (request, response) => {
clearRefreshSessionCookie(response, context.config);
sendApiResponse(
response,
await logoutAllUserSessions(
context,
request.userId!,
buildAuthRequestContext(request),
),
);
}),
);
router.post(
'/logout',
routeMeta({ operation: 'auth.logout' }),
requireAuth,
asyncHandler(async (request, response) => {
response.json(await logoutUser(context, request.userId!));
const refreshToken = readRefreshSessionToken(request, context.config);
await revokeRefreshSession(context, refreshToken);
clearRefreshSessionCookie(response, context.config);
sendApiResponse(
response,
await logoutUser(
context,
request.userId!,
buildAuthRequestContext(request),
),
);
}),
);

View File

@@ -1,27 +1,67 @@
import { Router } from 'express';
import { z } from 'zod';
import type { GameState } from '../../../src/types/game.js';
import type {
RuntimeItemGenerationContext,
RuntimeItemPlan,
} from '../../../src/types/runtimeItem.js';
import type { Encounter } from '../../../src/types/scene.js';
AnswerCustomWorldSessionQuestionRequest,
CreateCustomWorldSessionRequest,
RuntimeSettings,
SavedGameSnapshotInput,
} from '../../../packages/shared/src/contracts/runtime.js';
import { CUSTOM_WORLD_GENERATION_MODES } from '../../../packages/shared/src/contracts/runtime.js';
import type {
QuestGenerationRequest,
RuntimeItemIntentRequest,
} from '../../../packages/shared/src/contracts/story.js';
import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcRecruitDialogueRequest,
} from '../../../packages/shared/src/contracts/story.js';
import type { AppContext } from '../context.js';
import { badRequest, notFound } from '../errors.js';
import { asyncHandler, jsonClone } from '../http.js';
import {
asyncHandler,
jsonClone,
prepareEventStreamResponse,
sendApiResponse,
} from '../http.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,
generateCharacterChatSummaryFromOrchestrator,
streamCharacterChatReplyFromOrchestrator,
streamNpcChatDialogueFromOrchestrator,
streamNpcRecruitDialogueFromOrchestrator,
} from '../modules/ai/chatOrchestrator.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { plainTextRequestSchema } from '../services/chatService.js';
import { routeMeta } from '../middleware/routeMeta.js';
import {
hydrateSavedSnapshot,
normalizeSavedSnapshotPayload,
} from '../modules/runtime/runtimeSnapshotHydration.js';
import {
characterChatReplyRequestSchema,
characterChatSuggestionsRequestSchema,
characterChatSummaryRequestSchema,
npcChatDialogueRequestSchema,
npcRecruitDialogueRequestSchema,
} from '../services/chatService.js';
import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js';
import { generateQuestForNpcEncounter } from '../services/questService.js';
import { generateRuntimeItemIntents } from '../services/runtimeItemService.js';
import { generateSceneImage, sceneImageSchema } from '../services/sceneImageService.js';
import {
generateSceneImage,
sceneImageSchema,
} from '../services/sceneImageService.js';
import {
generateHighQualityInitialStory,
generateHighQualityNextStory,
parseStoryRequest,
} from '../services/storyService.js';
const jsonObjectSchema = z.record(z.string(), z.unknown());
const saveSnapshotSchema = z.object({
gameState: z.unknown(),
bottomTab: z.string().trim().min(1),
@@ -34,13 +74,13 @@ const settingsSchema = z.object({
});
const customWorldProfileSchema = z.object({
profile: z.record(z.string(), z.unknown()),
profile: jsonObjectSchema,
});
const customWorldSessionSchema = z.object({
settingText: z.string().trim().min(1),
creatorIntent: z.record(z.string(), z.unknown()).nullable().optional().default(null),
generationMode: z.enum(['fast', 'full']).default('fast'),
creatorIntent: jsonObjectSchema.nullable().optional().default(null),
generationMode: z.enum(CUSTOM_WORLD_GENERATION_MODES).default('fast'),
});
const customWorldAnswerSchema = z.object({
@@ -49,16 +89,16 @@ const customWorldAnswerSchema = z.object({
});
const runtimeItemIntentSchema = z.object({
context: z.custom<RuntimeItemGenerationContext>(),
plans: z.array(z.custom<RuntimeItemPlan>()),
context: jsonObjectSchema,
plans: z.array(jsonObjectSchema),
});
const questGenerationSchema = z.object({
state: z.custom<GameState>(),
encounter: z.custom<Encounter>(),
state: jsonObjectSchema,
encounter: jsonObjectSchema,
});
const llmProxySchema = z.record(z.string(), z.unknown());
const llmProxySchema = jsonObjectSchema;
function readParam(param: string | string[] | undefined) {
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
@@ -72,84 +112,115 @@ export function createRuntimeRoutes(context: AppContext) {
router.post(
'/llm/chat/completions',
routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }),
asyncHandler(async (request, response) => {
const body = llmProxySchema.parse(request.body);
await context.llmClient.forwardCompletion(body, response);
await context.llmClient.forwardCompletion(request, body, response);
}),
);
router.post(
'/custom-world/scene-image',
routeMeta({ operation: 'runtime.customWorld.sceneImage' }),
asyncHandler(async (request, response) => {
const payload = sceneImageSchema.parse(request.body);
response.json(await generateSceneImage(context, payload));
sendApiResponse(response, await generateSceneImage(context, payload));
}),
);
router.get(
'/runtime/save/snapshot',
routeMeta({ operation: 'runtime.snapshot.get' }),
asyncHandler(async (request, response) => {
response.json(context.runtimeRepository.getSnapshot(request.userId!) ?? null);
sendApiResponse(
response,
hydrateSavedSnapshot(
await context.runtimeRepository.getSnapshot(request.userId!),
),
);
}),
);
router.put(
'/runtime/save/snapshot',
routeMeta({ operation: 'runtime.snapshot.put' }),
asyncHandler(async (request, response) => {
const payload = saveSnapshotSchema.parse(request.body);
response.json(
context.runtimeRepository.putSnapshot(request.userId!, {
savedAt: payload.savedAt || new Date().toISOString(),
gameState: payload.gameState,
bottomTab: payload.bottomTab,
currentStory: payload.currentStory ?? null,
}),
const payload = saveSnapshotSchema.parse(
request.body,
) as SavedGameSnapshotInput;
const normalizedSnapshot = normalizeSavedSnapshotPayload({
savedAt: payload.savedAt || new Date().toISOString(),
gameState: payload.gameState,
bottomTab: payload.bottomTab,
currentStory: payload.currentStory ?? null,
});
sendApiResponse(
response,
hydrateSavedSnapshot(
await context.runtimeRepository.putSnapshot(
request.userId!,
normalizedSnapshot,
),
),
);
}),
);
router.delete(
'/runtime/save/snapshot',
routeMeta({ operation: 'runtime.snapshot.delete' }),
asyncHandler(async (request, response) => {
context.runtimeRepository.deleteSnapshot(request.userId!);
response.json({ ok: true });
await context.runtimeRepository.deleteSnapshot(request.userId!);
sendApiResponse(response, { ok: true });
}),
);
router.get(
'/runtime/settings',
routeMeta({ operation: 'runtime.settings.get' }),
asyncHandler(async (request, response) => {
response.json(context.runtimeRepository.getSettings(request.userId!));
sendApiResponse(
response,
await context.runtimeRepository.getSettings(request.userId!),
);
}),
);
router.put(
'/runtime/settings',
routeMeta({ operation: 'runtime.settings.put' }),
asyncHandler(async (request, response) => {
const payload = settingsSchema.parse(request.body);
response.json(context.runtimeRepository.putSettings(request.userId!, payload));
const payload = settingsSchema.parse(request.body) as RuntimeSettings;
sendApiResponse(
response,
await context.runtimeRepository.putSettings(request.userId!, payload),
);
}),
);
router.get(
'/runtime/custom-world-library',
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
asyncHandler(async (request, response) => {
response.json({
profiles: context.runtimeRepository.listCustomWorldProfiles(request.userId!),
sendApiResponse(response, {
profiles: await context.runtimeRepository.listCustomWorldProfiles(
request.userId!,
),
});
}),
);
router.put(
'/runtime/custom-world-library/:profileId',
routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
const payload = customWorldProfileSchema.parse(request.body);
response.json({
profiles: context.runtimeRepository.upsertCustomWorldProfile(
sendApiResponse(response, {
profiles: await context.runtimeRepository.upsertCustomWorldProfile(
request.userId!,
profileId,
jsonClone(payload.profile),
@@ -160,13 +231,14 @@ export function createRuntimeRoutes(context: AppContext) {
router.delete(
'/runtime/custom-world-library/:profileId',
routeMeta({ operation: 'runtime.customWorldLibrary.delete' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
response.json({
profiles: context.runtimeRepository.deleteCustomWorldProfile(
sendApiResponse(response, {
profiles: await context.runtimeRepository.deleteCustomWorldProfile(
request.userId!,
profileId,
),
@@ -176,78 +248,114 @@ export function createRuntimeRoutes(context: AppContext) {
router.post(
'/runtime/story/initial',
routeMeta({ operation: 'runtime.story.initial' }),
asyncHandler(async (request, response) => {
const payload = parseStoryRequest(request.body);
response.json(await generateHighQualityInitialStory(payload));
sendApiResponse(
response,
await generateHighQualityInitialStory(context.llmClient, payload),
);
}),
);
router.post(
'/runtime/story/continue',
routeMeta({ operation: 'runtime.story.continue' }),
asyncHandler(async (request, response) => {
const payload = parseStoryRequest(request.body);
response.json(await generateHighQualityNextStory(payload));
sendApiResponse(
response,
await generateHighQualityNextStory(context.llmClient, payload),
);
}),
);
router.post(
'/runtime/chat/character/suggestions',
routeMeta({ operation: 'runtime.chat.character.suggestions' }),
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
response.json({
text: await context.llmClient.requestMessageContent(payload),
const payload = characterChatSuggestionsRequestSchema.parse(
request.body,
) as CharacterChatSuggestionsRequest;
sendApiResponse(response, {
text: await generateCharacterChatSuggestionsFromOrchestrator(
context.llmClient,
payload,
),
});
}),
);
router.post(
'/runtime/chat/character/summary',
routeMeta({ operation: 'runtime.chat.character.summary' }),
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
response.json({
text: await context.llmClient.requestMessageContent(payload),
const payload = characterChatSummaryRequestSchema.parse(
request.body,
) as CharacterChatSummaryRequest;
sendApiResponse(response, {
text: await generateCharacterChatSummaryFromOrchestrator(
context.llmClient,
payload,
),
});
}),
);
router.post(
'/runtime/chat/character/reply/stream',
routeMeta({ operation: 'runtime.chat.character.replyStream' }),
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
await context.llmClient.forwardSseText({
...payload,
const payload = characterChatReplyRequestSchema.parse(
request.body,
) as CharacterChatReplyRequest;
await streamCharacterChatReplyFromOrchestrator(context.llmClient, {
request,
response,
payload,
});
}),
);
router.post(
'/runtime/chat/npc/dialogue/stream',
routeMeta({ operation: 'runtime.chat.npc.dialogueStream' }),
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
await context.llmClient.forwardSseText({
...payload,
const payload = npcChatDialogueRequestSchema.parse(
request.body,
) as NpcChatDialogueRequest;
await streamNpcChatDialogueFromOrchestrator(context.llmClient, {
request,
response,
payload,
});
}),
);
router.post(
'/runtime/chat/npc/recruit/stream',
routeMeta({ operation: 'runtime.chat.npc.recruitStream' }),
asyncHandler(async (request, response) => {
const payload = plainTextRequestSchema.parse(request.body);
await context.llmClient.forwardSseText({
...payload,
const payload = npcRecruitDialogueRequestSchema.parse(
request.body,
) as NpcRecruitDialogueRequest;
await streamNpcRecruitDialogueFromOrchestrator(context.llmClient, {
request,
response,
payload,
});
}),
);
router.post(
'/runtime/custom-world/sessions',
routeMeta({ operation: 'runtime.customWorldSession.create' }),
asyncHandler(async (request, response) => {
const payload = customWorldSessionSchema.parse(request.body);
response.json(
const payload = customWorldSessionSchema.parse(
request.body,
) as CreateCustomWorldSessionRequest;
sendApiResponse(
response,
context.customWorldSessions.create(
request.userId!,
payload.settingText,
@@ -260,6 +368,7 @@ export function createRuntimeRoutes(context: AppContext) {
router.get(
'/runtime/custom-world/sessions/:sessionId',
routeMeta({ operation: 'runtime.customWorldSession.get' }),
asyncHandler(async (request, response) => {
const session = context.customWorldSessions.get(
request.userId!,
@@ -268,14 +377,17 @@ export function createRuntimeRoutes(context: AppContext) {
if (!session) {
throw notFound('custom world session not found');
}
response.json(session);
sendApiResponse(response, session);
}),
);
router.post(
'/runtime/custom-world/sessions/:sessionId/answers',
routeMeta({ operation: 'runtime.customWorldSession.answer' }),
asyncHandler(async (request, response) => {
const payload = customWorldAnswerSchema.parse(request.body);
const payload = customWorldAnswerSchema.parse(
request.body,
) as AnswerCustomWorldSessionQuestionRequest;
const session = context.customWorldSessions.answer(
request.userId!,
readParam(request.params.sessionId),
@@ -285,12 +397,13 @@ export function createRuntimeRoutes(context: AppContext) {
if (!session) {
throw notFound('custom world session not found');
}
response.json(session);
sendApiResponse(response, session);
}),
);
router.get(
'/runtime/custom-world/sessions/:sessionId/generate/stream',
routeMeta({ operation: 'runtime.customWorldSession.generateStream' }),
asyncHandler(async (request, response) => {
const session = context.customWorldSessions.get(
request.userId!,
@@ -300,11 +413,7 @@ export function createRuntimeRoutes(context: AppContext) {
throw notFound('custom world session not found');
}
response.status(200);
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
response.setHeader('Cache-Control', 'no-cache');
response.setHeader('Connection', 'keep-alive');
response.setHeader('X-Accel-Buffering', 'no');
prepareEventStreamResponse(request, response);
const controller = new AbortController();
request.on('close', () => {
@@ -328,7 +437,10 @@ export function createRuntimeRoutes(context: AppContext) {
const profile = await generateCustomWorldProfile(context, session, {
signal: controller.signal,
onProgress: (progress) => {
writeEvent('progress', progress as unknown as Record<string, unknown>);
writeEvent(
'progress',
progress as unknown as Record<string, unknown>,
);
},
});
context.customWorldSessions.setResult(
@@ -341,7 +453,9 @@ export function createRuntimeRoutes(context: AppContext) {
writeEvent('done', { ok: true });
} catch (error) {
const message =
error instanceof Error ? error.message : 'custom world generation failed';
error instanceof Error
? error.message
: 'custom world generation failed';
context.customWorldSessions.updateStatus(
request.userId!,
readParam(request.params.sessionId),
@@ -357,9 +471,12 @@ export function createRuntimeRoutes(context: AppContext) {
router.post(
'/runtime/items/runtime-intent',
routeMeta({ operation: 'runtime.items.intent' }),
asyncHandler(async (request, response) => {
const payload = runtimeItemIntentSchema.parse(request.body);
response.json({
const payload = runtimeItemIntentSchema.parse(
request.body,
) as RuntimeItemIntentRequest;
sendApiResponse(response, {
intents: await generateRuntimeItemIntents(context.llmClient, payload),
});
}),
@@ -367,20 +484,28 @@ export function createRuntimeRoutes(context: AppContext) {
router.post(
'/runtime/quests/generate',
routeMeta({ operation: 'runtime.quests.generate' }),
asyncHandler(async (request, response) => {
const payload = questGenerationSchema.parse(request.body);
response.json(
const payload = questGenerationSchema.parse(
request.body,
) as QuestGenerationRequest;
sendApiResponse(
response,
await generateQuestForNpcEncounter(context.llmClient, payload),
);
}),
);
router.get('/ws/health', (_request, response) => {
response.json({
ok: true,
message: 'websocket routes reserved for future real-time support',
});
});
router.get(
'/ws/health',
routeMeta({ operation: 'runtime.ws.health' }),
(_request, response) => {
sendApiResponse(response, {
ok: true,
message: 'websocket routes reserved for future real-time support',
});
},
);
return router;
}

View File

@@ -1,14 +1,23 @@
import { pathToFileURL } from 'node:url';
import { createApp } from './app.js';
import { type AppConfig,loadConfig } from './config.js';
import { type AppConfig, loadConfig } from './config.js';
import type { AppContext } from './context.js';
import { createDatabase } from './db.js';
import { createLogger } from './logging.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js';
import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js';
import { RuntimeRepository } from './repositories/runtimeRepository.js';
import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js';
import { UserRepository } from './repositories/userRepository.js';
import { UserSessionRepository } from './repositories/userSessionRepository.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
import { UpstreamLlmClient } from './services/llmClient.js';
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
import { createSmsVerificationService } from './services/smsVerificationService.js';
import { createWechatAuthService } from './services/wechatAuthService.js';
import { WechatAuthStateStore } from './services/wechatAuthStateStore.js';
function resolveListenTarget(serverAddr: string) {
const trimmed = serverAddr.trim();
@@ -41,24 +50,57 @@ function resolveListenTarget(serverAddr: string) {
};
}
export function createAppContext(config: AppConfig = loadConfig()) {
function describeDatabase(databaseUrl: string) {
if (databaseUrl.startsWith('pg-mem://')) {
return {
database_engine: 'pg-mem',
database_name: databaseUrl.slice('pg-mem://'.length) || 'memory',
};
}
try {
const url = new URL(databaseUrl);
return {
database_engine: url.protocol.replace(/:$/u, ''),
database_host: url.hostname,
database_port: Number(url.port || 5432),
database_name: url.pathname.replace(/^\/+/u, '') || 'postgres',
};
} catch {
return {
database_engine: 'postgresql',
database_target: 'configured',
};
}
}
export async function createAppContext(config: AppConfig = loadConfig()) {
const logger = createLogger(config);
const db = createDatabase(config);
const db = await createDatabase(config);
const context: AppContext = {
config,
logger,
db,
userRepository: new UserRepository(db),
authIdentityRepository: new AuthIdentityRepository(db),
authAuditLogRepository: new AuthAuditLogRepository(db),
authRiskBlockRepository: new AuthRiskBlockRepository(db),
smsAuthEventRepository: new SmsAuthEventRepository(db),
userSessionRepository: new UserSessionRepository(db),
runtimeRepository: new RuntimeRepository(db),
llmClient: new UpstreamLlmClient(config, logger),
customWorldSessions: new CustomWorldSessionStore(),
smsVerificationService: createSmsVerificationService(config, logger),
wechatAuthService: createWechatAuthService(config, logger),
wechatAuthStates: new WechatAuthStateStore(),
captchaChallenges: new CaptchaChallengeStore(),
};
return context;
}
async function main() {
const context = createAppContext();
const context = await createAppContext();
const app = createApp(context);
const { host, port } = resolveListenTarget(context.config.serverAddr);
const server = app.listen(port, host, () => {
@@ -66,17 +108,29 @@ async function main() {
{
host,
port,
sqlite_path: context.config.sqlitePath,
...describeDatabase(context.config.databaseUrl),
},
'server-node started',
);
});
let shuttingDown = false;
const shutdown = () => {
if (shuttingDown) {
return;
}
shuttingDown = true;
context.logger.info('server-node shutting down');
server.close(() => {
context.db.close();
process.exit(0);
void context.db
.close()
.then(() => {
process.exit(0);
})
.catch((error) => {
context.logger.error({ err: error }, 'failed to close database');
process.exit(1);
});
});
};
@@ -89,5 +143,8 @@ const isEntryPoint =
import.meta.url === pathToFileURL(process.argv[1]).href;
if (isEntryPoint) {
void main();
void main().catch((error) => {
console.error(error);
process.exit(1);
});
}

View File

@@ -0,0 +1,97 @@
import crypto from 'node:crypto';
import type { AuthCaptchaChallenge } from '../../../packages/shared/src/contracts/auth.js';
type CaptchaChallengeRecord = {
challengeId: string;
scopeKey: string;
answer: string;
createdAt: string;
expiresAt: string;
imageDataUrl: string;
};
function buildCaptchaSvgDataUrl(text: string) {
const lines = Array.from({ length: 4 }, (_, index) => {
const x1 = 8 + index * 18;
const x2 = 150 - index * 16;
const y1 = 12 + index * 8;
const y2 = 46 - index * 6;
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="rgba(245,158,11,0.38)" stroke-width="1.4" />`;
}).join('');
const noise = Array.from(text).map((char, index) => {
const x = 24 + index * 24;
const y = 30 + ((index % 2) * 6 - 3);
const rotate = index % 2 === 0 ? -8 : 7;
return `<text x="${x}" y="${y}" fill="#f8fafc" font-size="22" font-family="monospace" transform="rotate(${rotate} ${x} ${y})">${char}</text>`;
}).join('');
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="56" viewBox="0 0 160 56">
<rect width="160" height="56" rx="14" fill="#11131a"/>
<rect x="1" y="1" width="158" height="54" rx="13" fill="none" stroke="rgba(255,255,255,0.08)"/>
${lines}
${noise}
</svg>`;
return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`;
}
function normalizeCaptchaAnswer(answer: string) {
return answer.trim().toLowerCase();
}
function buildCaptchaText() {
return crypto.randomBytes(3).toString('hex').slice(0, 5).toUpperCase();
}
export class CaptchaChallengeStore {
private readonly challenges = new Map<string, CaptchaChallengeRecord>();
create(scopeKey: string, expiresInSeconds: number): AuthCaptchaChallenge {
const text = buildCaptchaText();
const challengeId = `captcha_${crypto.randomBytes(16).toString('hex')}`;
const createdAt = new Date();
const expiresAt = new Date(createdAt.getTime() + expiresInSeconds * 1000);
this.challenges.set(challengeId, {
challengeId,
scopeKey,
answer: normalizeCaptchaAnswer(text),
createdAt: createdAt.toISOString(),
expiresAt: expiresAt.toISOString(),
imageDataUrl: buildCaptchaSvgDataUrl(text),
});
return {
challengeId,
promptText: '请输入图中的验证码后再获取短信验证码',
imageDataUrl: buildCaptchaSvgDataUrl(text),
expiresInSeconds,
};
}
verify(params: {
challengeId: string;
scopeKey: string;
answer: string;
}) {
const record = this.challenges.get(params.challengeId);
if (!record) {
return false;
}
if (record.scopeKey !== params.scopeKey) {
this.challenges.delete(params.challengeId);
return false;
}
if (new Date(record.expiresAt).getTime() <= Date.now()) {
this.challenges.delete(params.challengeId);
return false;
}
const isValid =
record.answer === normalizeCaptchaAnswer(params.answer);
this.challenges.delete(params.challengeId);
return isValid;
}
}

View File

@@ -1,6 +1,56 @@
import { z } from 'zod';
export const plainTextRequestSchema = z.object({
systemPrompt: z.string().trim().min(1),
userPrompt: z.string().trim().min(1),
import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcRecruitDialogueRequest,
} from '../../../packages/shared/src/contracts/story.js';
const jsonObjectSchema = z.record(z.string(), z.unknown());
const baseCharacterChatSchema = z.object({
worldType: z.string().trim().min(1),
playerCharacter: jsonObjectSchema,
targetCharacter: jsonObjectSchema,
storyHistory: z.array(jsonObjectSchema).default([]),
context: jsonObjectSchema,
conversationHistory: z.array(jsonObjectSchema).default([]),
targetStatus: jsonObjectSchema,
});
const baseNpcChatSchema = z.object({
worldType: z.string().trim().min(1),
character: jsonObjectSchema,
encounter: jsonObjectSchema,
monsters: z.array(jsonObjectSchema).default([]),
history: z.array(jsonObjectSchema).default([]),
context: jsonObjectSchema,
});
export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({
conversationSummary: z.string().optional().default(''),
playerMessage: z.string().trim().min(1),
}) satisfies z.ZodType<CharacterChatReplyRequest>;
export const characterChatSuggestionsRequestSchema =
baseCharacterChatSchema.extend({
conversationSummary: z.string().optional().default(''),
}) satisfies z.ZodType<CharacterChatSuggestionsRequest>;
export const characterChatSummaryRequestSchema = baseCharacterChatSchema.extend(
{
previousSummary: z.string().optional().default(''),
},
) satisfies z.ZodType<CharacterChatSummaryRequest>;
export const npcChatDialogueRequestSchema = baseNpcChatSchema.extend({
topic: z.string().trim().min(1),
resultSummary: z.string().optional().default(''),
}) satisfies z.ZodType<NpcChatDialogueRequest>;
export const npcRecruitDialogueRequestSchema = baseNpcChatSchema.extend({
invitationText: z.string().trim().min(1),
recruitSummary: z.string().optional().default(''),
}) satisfies z.ZodType<NpcRecruitDialogueRequest>;

View File

@@ -1,8 +1,8 @@
import {
type CustomWorldGenerationProgress,
generateCustomWorldProfile as generateCustomWorldProfileFromAi,
type GenerateCustomWorldProfileInput,
} from '../../../src/services/ai.js';
generateCustomWorldProfileFromOrchestrator,
} from '../modules/ai/customWorldOrchestrator.js';
import type { AppContext } from '../context.js';
import type { CustomWorldSession } from './customWorldSessionStore.js';
@@ -20,7 +20,7 @@ export async function generateCustomWorldProfile(
generationMode: session.generationMode,
} satisfies GenerateCustomWorldProfileInput;
const profile = await generateCustomWorldProfileFromAi(input, {
const profile = await generateCustomWorldProfileFromOrchestrator(input, {
onProgress: options.onProgress,
signal: options.signal,
});

View File

@@ -1,28 +1,21 @@
import crypto from 'node:crypto';
export type CustomWorldSessionStatus =
| 'clarifying'
| 'ready_to_generate'
| 'generating'
| 'completed'
| 'generation_error';
export type CustomWorldQuestion = {
id: string;
label: string;
question: string;
answer?: string;
};
import type { JsonObject } from '../../../packages/shared/src/contracts/common.js';
import type {
CustomWorldGenerationMode,
CustomWorldQuestion,
CustomWorldSessionStatus,
} from '../../../packages/shared/src/contracts/runtime.js';
export type CustomWorldSession = {
sessionId: string;
userId: string;
status: CustomWorldSessionStatus;
settingText: string;
creatorIntent: Record<string, unknown> | null;
generationMode: 'fast' | 'full';
creatorIntent: JsonObject | null;
generationMode: CustomWorldGenerationMode;
questions: CustomWorldQuestion[];
result?: Record<string, unknown>;
result?: JsonObject;
lastError?: string;
createdAt: string;
updatedAt: string;
@@ -38,7 +31,7 @@ function hasPendingQuestion(questions: CustomWorldQuestion[]) {
function buildClarificationQuestions(
settingText: string,
creatorIntent: Record<string, unknown> | null,
creatorIntent: JsonObject | null,
) {
const questions: CustomWorldQuestion[] = [];
const worldHook =
@@ -91,8 +84,8 @@ export class CustomWorldSessionStore {
create(
userId: string,
settingText: string,
creatorIntent: Record<string, unknown> | null,
generationMode: 'fast' | 'full',
creatorIntent: JsonObject | null,
generationMode: CustomWorldGenerationMode,
) {
const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`;
const now = new Date().toISOString();
@@ -159,7 +152,7 @@ export class CustomWorldSessionStore {
return cloneSession(session);
}
setResult(userId: string, sessionId: string, result: Record<string, unknown>) {
setResult(userId: string, sessionId: string, result: JsonObject) {
const session = this.sessions.get(userId)?.get(sessionId);
if (!session) {
return null;
@@ -167,7 +160,7 @@ export class CustomWorldSessionStore {
session.status = 'completed';
session.lastError = undefined;
session.result = JSON.parse(JSON.stringify(result)) as Record<string, unknown>;
session.result = JSON.parse(JSON.stringify(result)) as JsonObject;
session.updatedAt = new Date().toISOString();
return cloneSession(session);
}

View File

@@ -1,11 +1,18 @@
import { Readable } from 'node:stream';
import type { Response as ExpressResponse } from 'express';
import type {
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express';
import type { Logger } from 'pino';
import type { AppConfig } from '../config.js';
import { upstreamError } from '../errors.js';
import { extractApiErrorMessage } from '../http.js';
import { HttpError, upstreamError } from '../errors.js';
import {
extractApiErrorMessage,
prepareApiResponse,
prepareEventStreamResponse,
} from '../http.js';
export type ChatMessage = {
role: 'system' | 'user' | 'assistant';
@@ -18,6 +25,14 @@ type CompletionRequest = {
messages: ChatMessage[];
};
type RequestExecutionOptions = {
signal?: AbortSignal;
timeoutMs?: number;
debugLabel?: string;
};
const DEFAULT_LLM_REQUEST_TIMEOUT_MS = 30000;
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.replace(/\/+$/u, '');
}
@@ -26,11 +41,69 @@ function buildCompletionUrl(baseUrl: string) {
return `${normalizeBaseUrl(baseUrl)}/chat/completions`;
}
function isAbortLikeError(error: unknown) {
return (
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError') ||
(error instanceof Error && error.name === 'AbortError')
);
}
function readTimeoutMs(config: AppConfig) {
const parsed = Number(config.rawEnv.LLM_REQUEST_TIMEOUT_MS);
return Number.isFinite(parsed) && parsed > 0
? Math.round(parsed)
: DEFAULT_LLM_REQUEST_TIMEOUT_MS;
}
export class UpstreamLlmTimeoutError extends HttpError {
constructor(message = 'LLM 上游请求超时') {
super(502, message, {
code: 'UPSTREAM_TIMEOUT',
});
this.name = 'UpstreamLlmTimeoutError';
}
}
export class UpstreamLlmConnectivityError extends HttpError {
constructor(message = '无法连接 LLM 上游服务') {
super(502, message, {
code: 'UPSTREAM_CONNECTIVITY',
});
this.name = 'UpstreamLlmConnectivityError';
}
}
export function isUpstreamLlmTimeoutError(
error: unknown,
): error is UpstreamLlmTimeoutError {
return (
error instanceof UpstreamLlmTimeoutError ||
(error instanceof HttpError && error.code === 'UPSTREAM_TIMEOUT')
);
}
export function isUpstreamLlmConnectivityError(
error: unknown,
): error is UpstreamLlmConnectivityError {
return (
error instanceof UpstreamLlmConnectivityError ||
(error instanceof HttpError && error.code === 'UPSTREAM_CONNECTIVITY')
);
}
export class UpstreamLlmClient {
readonly logger: Logger;
private readonly requestTimeoutMs: number;
constructor(
private readonly config: AppConfig,
private readonly logger: Logger,
) {}
logger: Logger,
) {
this.logger = logger;
this.requestTimeoutMs = readTimeoutMs(config);
}
private resolveModel(model?: string) {
return model?.trim() || this.config.llm.model;
@@ -47,24 +120,128 @@ export class UpstreamLlmClient {
};
}
async requestCompletion(body: CompletionRequest, signal?: AbortSignal) {
const response = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify({
...body,
model: this.resolveModel(body.model),
}),
signal,
});
private createRequestSignal(
externalSignal?: AbortSignal,
timeoutMs = this.requestTimeoutMs,
) {
const controller = new AbortController();
let timedOut = false;
const handleAbort = () => controller.abort(externalSignal?.reason);
const timeout = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
if (externalSignal) {
if (externalSignal.aborted) {
handleAbort();
} else {
externalSignal.addEventListener('abort', handleAbort, {
once: true,
});
}
}
return {
signal: controller.signal,
didTimeout() {
return timedOut;
},
cleanup() {
clearTimeout(timeout);
externalSignal?.removeEventListener('abort', handleAbort);
},
};
}
private attachRequestAbort(request: ExpressRequest) {
const controller = new AbortController();
const handleClose = () => controller.abort();
request.on('close', handleClose);
return {
signal: controller.signal,
cleanup() {
request.removeListener('close', handleClose);
},
};
}
async requestCompletion(
body: CompletionRequest,
options: RequestExecutionOptions = {},
) {
const timeoutMs =
typeof options.timeoutMs === 'number' && options.timeoutMs > 0
? Math.round(options.timeoutMs)
: this.requestTimeoutMs;
const requestSignal = this.createRequestSignal(options.signal, timeoutMs);
const model = this.resolveModel(body.model);
const debugLabel =
typeof options.debugLabel === 'string' && options.debugLabel.trim()
? options.debugLabel.trim()
: undefined;
this.logger.debug(
{
llm_model: model,
llm_stream: body.stream === true,
llm_timeout_ms: timeoutMs,
llm_debug_label: debugLabel,
},
'llm upstream request started',
);
let response: globalThis.Response;
try {
response = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify({
...body,
model,
}),
signal: requestSignal.signal,
});
} catch (error) {
requestSignal.cleanup();
if (requestSignal.didTimeout() && isAbortLikeError(error)) {
throw new UpstreamLlmTimeoutError();
}
if (error instanceof TypeError) {
throw new UpstreamLlmConnectivityError();
}
this.logger.warn(
{
err: error,
llm_model: model,
llm_stream: body.stream === true,
llm_debug_label: debugLabel,
},
'llm upstream request failed',
);
throw error;
}
requestSignal.cleanup();
if (!response.ok) {
const rawText = await response.text();
throw upstreamError(
extractApiErrorMessage(rawText, 'LLM 上游请求失败'),
);
throw upstreamError(extractApiErrorMessage(rawText, 'LLM 上游请求失败'));
}
this.logger.debug(
{
llm_model: model,
llm_stream: body.stream === true,
llm_status: response.status,
llm_debug_label: debugLabel,
},
'llm upstream request succeeded',
);
return response;
}
@@ -73,6 +250,8 @@ export class UpstreamLlmClient {
userPrompt: string;
model?: string;
signal?: AbortSignal;
timeoutMs?: number;
debugLabel?: string;
}) {
const response = await this.requestCompletion(
{
@@ -82,7 +261,11 @@ export class UpstreamLlmClient {
{ role: 'user', content: params.userPrompt },
],
},
params.signal,
{
signal: params.signal,
timeoutMs: params.timeoutMs,
debugLabel: params.debugLabel,
},
);
const rawText = await response.text();
const parsed = JSON.parse(rawText) as {
@@ -101,69 +284,116 @@ export class UpstreamLlmClient {
return content;
}
async forwardCompletion(body: Record<string, unknown>, response: ExpressResponse) {
const upstreamResponse = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify({
...body,
model:
typeof body.model === 'string' && body.model.trim()
? body.model
: this.config.llm.model,
}),
});
async forwardCompletion(
request: ExpressRequest,
body: Record<string, unknown>,
response: ExpressResponse,
) {
const requestAbort = this.attachRequestAbort(request);
let upstreamResponse: globalThis.Response;
if (!upstreamResponse.ok) {
const rawText = await upstreamResponse.text();
throw upstreamError(
extractApiErrorMessage(rawText, 'LLM 上游请求失败'),
);
try {
upstreamResponse = await fetch(buildCompletionUrl(this.config.llm.baseUrl), {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify({
...body,
model:
typeof body.model === 'string' && body.model.trim()
? body.model
: this.config.llm.model,
}),
signal: requestAbort.signal,
});
} catch (error) {
requestAbort.cleanup();
if (requestAbort.signal.aborted && response.writableEnded) {
return;
}
throw error;
}
response.status(upstreamResponse.status);
response.setHeader(
'Content-Type',
upstreamResponse.headers.get('content-type') || 'application/json; charset=utf-8',
);
if (!upstreamResponse.ok) {
requestAbort.cleanup();
const rawText = await upstreamResponse.text();
throw upstreamError(extractApiErrorMessage(rawText, 'LLM 上游请求失败'));
}
prepareApiResponse(request, response, {
statusCode: upstreamResponse.status,
headers: {
'Content-Type':
upstreamResponse.headers.get('content-type') ||
'application/json; charset=utf-8',
},
});
if (!upstreamResponse.body) {
requestAbort.cleanup();
response.end();
return;
}
await Readable.fromWeb(upstreamResponse.body as never).pipe(response);
try {
await Readable.fromWeb(upstreamResponse.body as never).pipe(response);
} finally {
requestAbort.cleanup();
}
}
async forwardSseText(params: {
request: ExpressRequest;
systemPrompt: string;
userPrompt: string;
response: ExpressResponse;
model?: string;
}) {
const upstreamResponse = await this.requestCompletion({
model: params.model,
stream: true,
messages: [
{ role: 'system', content: params.systemPrompt },
{ role: 'user', content: params.userPrompt },
],
const requestAbort = this.attachRequestAbort(params.request);
let upstreamResponse: globalThis.Response;
try {
upstreamResponse = await this.requestCompletion(
{
model: params.model,
stream: true,
messages: [
{ role: 'system', content: params.systemPrompt },
{ role: 'user', content: params.userPrompt },
],
},
{
signal: requestAbort.signal,
},
);
} catch (error) {
requestAbort.cleanup();
if (requestAbort.signal.aborted && params.response.writableEnded) {
return;
}
throw error;
}
prepareEventStreamResponse(params.request, params.response, {
statusCode: upstreamResponse.status,
headers: {
'Content-Type':
upstreamResponse.headers.get('content-type') ||
'text/event-stream; charset=utf-8',
},
});
params.response.status(upstreamResponse.status);
params.response.setHeader(
'Content-Type',
upstreamResponse.headers.get('content-type') || 'text/event-stream; charset=utf-8',
);
params.response.setHeader('Cache-Control', 'no-cache');
params.response.setHeader('Connection', 'keep-alive');
params.response.setHeader('X-Accel-Buffering', 'no');
if (!upstreamResponse.body) {
requestAbort.cleanup();
params.response.end();
return;
}
await Readable.fromWeb(upstreamResponse.body as never).pipe(params.response);
try {
await Readable.fromWeb(upstreamResponse.body as never).pipe(
params.response,
);
} finally {
requestAbort.cleanup();
}
}
}

View File

@@ -1,17 +1,29 @@
import type { QuestGenerationRequest } from '../../../packages/shared/src/contracts/story.js';
import {
QUEST_INTIMACY_LEVELS,
QUEST_NARRATIVE_TYPES,
QUEST_OBJECTIVE_KINDS,
QUEST_REWARD_THEMES,
QUEST_URGENCY_LEVELS,
} from '../../../packages/shared/src/contracts/story.js';
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import {
buildFallbackQuestIntent,
compileQuestIntentToQuest,
evaluateQuestOpportunity,
} from '../../../src/data/questFlow.js';
import { parseJsonResponseText } from '../../../src/services/llmParsers.js';
import { buildQuestGenerationContextFromState } from '../../../src/services/questDirector.js';
import { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT } from '../../../src/services/questPrompt.js';
import type { QuestIntent, QuestPreviewRequest } from '../../../src/services/questTypes.js';
import type { GameState } from '../../../src/types/game.js';
import type { Encounter } from '../../../src/types/scene.js';
import type { QuestLogEntry } from '../../../src/types/story.js';
buildQuestGenerationContextFromState,
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,
} from '../bridges/legacyQuestRuntimeBridge.js';
import type { UpstreamLlmClient } from './llmClient.js';
type QuestPreviewRequest = Parameters<typeof evaluateQuestOpportunity>[0];
type QuestIntent = ReturnType<typeof buildFallbackQuestIntent>;
type QuestGenerationInput = Parameters<typeof buildQuestGenerationContextFromState>[0];
type QuestGenerationState = QuestGenerationInput['state'];
type QuestGenerationEncounter = QuestGenerationInput['encounter'];
type QuestLogEntry = ReturnType<typeof compileQuestIntentToQuest>;
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
@@ -41,7 +53,7 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
summary: coerceString(intent.summary, fallback.summary),
narrativeType:
typeof intent.narrativeType === 'string' &&
['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType)
QUEST_NARRATIVE_TYPES.includes(intent.narrativeType)
? (intent.narrativeType as QuestIntent['narrativeType'])
: fallback.narrativeType,
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
@@ -51,29 +63,20 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
recommendedObjectiveKinds: coerceStringArray(
intent.recommendedObjectiveKinds,
fallback.recommendedObjectiveKinds,
).filter((kind) =>
[
'defeat_hostile_npc',
'inspect_treasure',
'spar_with_npc',
'talk_to_npc',
'reach_scene',
'deliver_item',
].includes(kind),
) as QuestIntent['recommendedObjectiveKinds'],
).filter((kind) => QUEST_OBJECTIVE_KINDS.includes(kind)) as QuestIntent['recommendedObjectiveKinds'],
urgency:
typeof intent.urgency === 'string' &&
['low', 'medium', 'high'].includes(intent.urgency)
QUEST_URGENCY_LEVELS.includes(intent.urgency)
? (intent.urgency as QuestIntent['urgency'])
: fallback.urgency,
intimacy:
typeof intent.intimacy === 'string' &&
['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
QUEST_INTIMACY_LEVELS.includes(intent.intimacy)
? (intent.intimacy as QuestIntent['intimacy'])
: fallback.intimacy,
rewardTheme:
typeof intent.rewardTheme === 'string' &&
['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme)
QUEST_REWARD_THEMES.includes(intent.rewardTheme)
? (intent.rewardTheme as QuestIntent['rewardTheme'])
: fallback.rewardTheme,
followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks),
@@ -82,10 +85,7 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
export async function generateQuestForNpcEncounter(
llmClient: UpstreamLlmClient,
params: {
state: GameState;
encounter: Encounter;
},
params: QuestGenerationRequest<QuestGenerationState, QuestGenerationEncounter>,
): Promise<QuestLogEntry | null> {
const { state, encounter } = params;
const issuerNpcId = encounter.id ?? encounter.npcName;
@@ -95,7 +95,7 @@ export async function generateQuestForNpcEncounter(
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
currentQuests: state.quests.map((quest: QuestLogEntry) => ({
currentQuests: state.quests.map((quest) => ({
id: quest.id,
issuerNpcId: quest.issuerNpcId,
status: quest.status,

View File

@@ -1,16 +1,22 @@
import { buildRuntimeItemAiIntent } from '../../../src/data/runtimeItemNarrative.js';
import { parseJsonResponseText } from '../../../src/services/llmParsers.js';
import {
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
RUNTIME_ITEM_TONE_VALUES,
} from '../../../packages/shared/src/contracts/story.js';
import type {
RuntimeItemIntentRequest,
} from '../../../packages/shared/src/contracts/story.js';
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import {
buildRuntimeItemAiIntent,
buildRuntimeItemIntentPrompt,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
} from '../../../src/services/runtimeItemAiPrompt.js';
import type {
RuntimeItemAiIntent,
RuntimeItemGenerationContext,
RuntimeItemPlan,
} from '../../../src/types/runtimeItem.js';
} from '../bridges/legacyRuntimeItemBridge.js';
import type { UpstreamLlmClient } from './llmClient.js';
type RuntimeItemGenerationContext = Parameters<typeof buildRuntimeItemAiIntent>[0];
type RuntimeItemPlan = Parameters<typeof buildRuntimeItemAiIntent>[1];
type RuntimeItemAiIntent = ReturnType<typeof buildRuntimeItemAiIntent>;
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
@@ -45,7 +51,7 @@ function sanitizeRuntimeItemAiIntent(
(
item,
): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] =>
['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item),
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES.includes(item),
);
const tone = coerceString(intent.tone, fallback.tone);
@@ -59,7 +65,7 @@ function sanitizeRuntimeItemAiIntent(
desiredFunctionalBias.length > 0
? desiredFunctionalBias
: fallback.desiredFunctionalBias,
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
tone: RUNTIME_ITEM_TONE_VALUES.includes(tone)
? (tone as RuntimeItemAiIntent['tone'])
: fallback.tone,
visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''),
@@ -80,10 +86,7 @@ function sanitizeRuntimeItemAiIntent(
export async function generateRuntimeItemIntents(
llmClient: UpstreamLlmClient,
params: {
context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[];
},
params: RuntimeItemIntentRequest<RuntimeItemGenerationContext, RuntimeItemPlan>,
) {
const fallbackIntents = params.plans.map((plan) =>
buildRuntimeItemAiIntent(params.context, plan),

View File

@@ -0,0 +1,239 @@
import crypto from 'node:crypto';
import DypnsClient, {
CheckSmsVerifyCodeRequest,
SendSmsVerifyCodeRequest,
} from '@alicloud/dypnsapi20170525';
import OpenApiClient from '@alicloud/openapi-client';
import type { Logger } from 'pino';
import type { NormalizedPhoneNumber } from '../auth/phoneNumber.js';
import type { AppConfig } from '../config.js';
import {
badRequest,
unauthorized,
upstreamError,
} from '../errors.js';
export type SendLoginCodeResult = {
cooldownSeconds: number;
expiresInSeconds: number;
providerRequestId: string | null;
};
export type SmsVerificationService = {
sendLoginCode(phoneNumber: NormalizedPhoneNumber): Promise<SendLoginCodeResult>;
verifyLoginCode(
phoneNumber: NormalizedPhoneNumber,
verifyCode: string,
): Promise<void>;
};
function isAliyunConfigMissing(config: AppConfig['smsAuth']) {
return !config.accessKeyId || !config.accessKeySecret;
}
function buildProviderErrorMessage(prefix: string, message: string) {
const normalizedMessage = message.trim();
return normalizedMessage ? `${prefix}${normalizedMessage}` : prefix;
}
class AliyunSmsVerificationService implements SmsVerificationService {
private readonly client: DypnsClient;
constructor(
private readonly config: AppConfig['smsAuth'],
private readonly logger: Logger,
) {
if (isAliyunConfigMissing(config)) {
throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置');
}
const clientConfig = new OpenApiClient.Config({
accessKeyId: config.accessKeyId,
accessKeySecret: config.accessKeySecret,
endpoint: config.endpoint,
protocol: 'HTTPS',
});
this.client = new DypnsClient(clientConfig);
}
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
const templateParam = JSON.stringify({
[this.config.templateParamKey]: '##code##',
});
const request = new SendSmsVerifyCodeRequest({
phoneNumber: phoneNumber.nationalNumber,
countryCode: this.config.countryCode,
signName: this.config.signName,
templateCode: this.config.templateCode,
templateParam,
codeLength: this.config.codeLength,
codeType: this.config.codeType,
validTime: this.config.validTimeSeconds,
interval: this.config.intervalSeconds,
duplicatePolicy: this.config.duplicatePolicy,
returnVerifyCode: this.config.returnVerifyCode,
schemeName: this.config.schemeName || undefined,
outId: `login_${crypto.randomBytes(12).toString('hex')}`,
});
try {
const response = await this.client.sendSmsVerifyCode(request);
const body = response.body;
if (!body?.success || body.code !== 'OK') {
throw this.resolveAliyunRequestError(
'短信验证码发送失败',
body?.message ?? '',
body?.code ?? '',
);
}
return {
cooldownSeconds: this.config.intervalSeconds,
expiresInSeconds: this.config.validTimeSeconds,
providerRequestId: body.requestId ?? body.model?.requestId ?? null,
} satisfies SendLoginCodeResult;
} catch (error) {
if (error instanceof Error && error.name === 'HttpError') {
throw error;
}
this.logger.error(
{
err: error,
phone_suffix: phoneNumber.nationalNumber.slice(-4),
},
'aliyun sms send failed',
);
throw upstreamError(
buildProviderErrorMessage(
'短信验证码发送失败',
error instanceof Error ? error.message : 'unknown error',
),
);
}
}
async verifyLoginCode(
phoneNumber: NormalizedPhoneNumber,
verifyCode: string,
) {
const request = new CheckSmsVerifyCodeRequest({
phoneNumber: phoneNumber.nationalNumber,
countryCode: this.config.countryCode,
verifyCode,
caseAuthPolicy: this.config.caseAuthPolicy,
schemeName: this.config.schemeName || undefined,
});
try {
const response = await this.client.checkSmsVerifyCode(request);
const body = response.body;
if (!body?.success || body.code !== 'OK') {
throw this.resolveAliyunRequestError(
'验证码校验失败',
body?.message ?? '',
body?.code ?? '',
);
}
if (body.model?.verifyResult !== 'PASS') {
throw unauthorized('验证码错误或已失效');
}
} catch (error) {
if (error instanceof Error && error.name === 'HttpError') {
throw error;
}
this.logger.error(
{
err: error,
phone_suffix: phoneNumber.nationalNumber.slice(-4),
},
'aliyun sms verify failed',
);
throw upstreamError(
buildProviderErrorMessage(
'验证码校验失败',
error instanceof Error ? error.message : 'unknown error',
),
);
}
}
private resolveAliyunRequestError(
fallbackMessage: string,
providerMessage: string,
providerCode: string,
) {
const normalizedCode = providerCode.trim().toUpperCase();
if (
normalizedCode.includes('MOBILE') ||
normalizedCode.includes('PHONE') ||
normalizedCode.includes('TEMPLATE') ||
normalizedCode.includes('SIGN')
) {
return badRequest(
buildProviderErrorMessage(fallbackMessage, providerMessage),
{
providerCode,
},
);
}
return upstreamError(
buildProviderErrorMessage(fallbackMessage, providerMessage),
{
providerCode,
},
);
}
}
class MockSmsVerificationService implements SmsVerificationService {
private readonly sentCodes = new Map<string, string>();
constructor(private readonly config: AppConfig['smsAuth']) {}
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
this.sentCodes.set(phoneNumber.e164, this.config.mockVerifyCode);
return {
cooldownSeconds: this.config.intervalSeconds,
expiresInSeconds: this.config.validTimeSeconds,
providerRequestId: 'mock-request-id',
} satisfies SendLoginCodeResult;
}
async verifyLoginCode(
phoneNumber: NormalizedPhoneNumber,
verifyCode: string,
) {
const expectedCode = this.sentCodes.get(phoneNumber.e164);
if (!expectedCode || expectedCode !== verifyCode) {
throw unauthorized('验证码错误或已失效');
}
}
}
export function createSmsVerificationService(
config: AppConfig,
logger: Logger,
): SmsVerificationService {
if (!config.smsAuth.enabled) {
return {
async sendLoginCode() {
throw badRequest('短信验证码登录未启用');
},
async verifyLoginCode() {
throw badRequest('短信验证码登录未启用');
},
};
}
if (config.smsAuth.provider === 'mock') {
return new MockSmsVerificationService(config.smsAuth);
}
return new AliyunSmsVerificationService(config.smsAuth, logger);
}

View File

@@ -1,26 +1,24 @@
import { z } from 'zod';
import type { StoryRequestPayload } from '../../../packages/shared/src/contracts/story.js';
import {
generateInitialStoryStrict as generateInitialStoryFromAi,
generateNextStepStrict as generateNextStepFromAi,
type StoryGenerationContext,
type StoryRequestOptions,
} from '../../../src/services/ai.js';
import type { Character } from '../../../src/types/characters.js';
import type { WorldType } from '../../../src/types/core.js';
import type { SceneHostileNpc } from '../../../src/types/scene.js';
import type { StoryMoment } from '../../../src/types/story.js';
generateInitialStoryFromOrchestrator,
generateNextStoryFromOrchestrator,
} from '../modules/ai/storyOrchestrator.js';
import type { UpstreamLlmClient } from './llmClient.js';
const jsonObjectSchema = z.record(z.string(), z.unknown());
const storyRequestSchema = z.object({
worldType: z.string().trim().min(1),
character: z.record(z.string(), z.unknown()),
monsters: z.array(z.record(z.string(), z.unknown())).default([]),
history: z.array(z.record(z.string(), z.unknown())).default([]),
character: jsonObjectSchema,
monsters: z.array(jsonObjectSchema).default([]),
history: z.array(jsonObjectSchema).default([]),
choice: z.string().optional().default(''),
context: z.record(z.string(), z.unknown()),
context: jsonObjectSchema,
requestOptions: z.object({
availableOptions: z.array(z.record(z.string(), z.unknown())).optional().default([]),
optionCatalog: z.array(z.record(z.string(), z.unknown())).optional().default([]),
availableOptions: z.array(jsonObjectSchema).optional().default([]),
optionCatalog: z.array(jsonObjectSchema).optional().default([]),
}).optional().default({
availableOptions: [],
optionCatalog: [],
@@ -28,28 +26,30 @@ const storyRequestSchema = z.object({
});
export function parseStoryRequest(body: unknown) {
return storyRequestSchema.parse(body);
return storyRequestSchema.parse(body) as StoryRequestPayload;
}
function toTypedStoryParams(
request: ReturnType<typeof parseStoryRequest>,
) {
return {
worldType: request.worldType as WorldType,
character: request.character as unknown as Character,
monsters: request.monsters as unknown as SceneHostileNpc[],
history: request.history as unknown as StoryMoment[],
worldType: request.worldType,
character: request.character,
monsters: request.monsters,
history: request.history,
choice: request.choice.trim(),
context: request.context as unknown as StoryGenerationContext,
requestOptions: request.requestOptions as unknown as StoryRequestOptions,
context: request.context,
requestOptions: request.requestOptions,
};
}
export async function generateHighQualityInitialStory(
llmClient: UpstreamLlmClient,
request: ReturnType<typeof parseStoryRequest>,
) {
const params = toTypedStoryParams(request);
return generateInitialStoryFromAi(
return generateInitialStoryFromOrchestrator(
llmClient,
params.worldType,
params.character,
params.monsters,
@@ -59,10 +59,12 @@ export async function generateHighQualityInitialStory(
}
export async function generateHighQualityNextStory(
llmClient: UpstreamLlmClient,
request: ReturnType<typeof parseStoryRequest>,
) {
const params = toTypedStoryParams(request);
return generateNextStepFromAi(
return generateNextStoryFromOrchestrator(
llmClient,
params.worldType,
params.character,
params.monsters,

View File

@@ -0,0 +1,182 @@
import type { Logger } from 'pino';
import type { AppConfig } from '../config.js';
import { badRequest, upstreamError } from '../errors.js';
export type WechatIdentityProfile = {
providerUid: string;
providerUnionId: string | null;
displayName: string | null;
avatarUrl: string | null;
metaJson: Record<string, unknown> | null;
};
export type WechatAuthService = {
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
}): string;
resolveCallbackProfile(params: {
code?: string | null;
mockCode?: string | null;
}): Promise<WechatIdentityProfile>;
};
class MockWechatAuthService implements WechatAuthService {
constructor(private readonly config: AppConfig['wechatAuth']) {}
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
}) {
const callbackUrl = new URL(params.callbackUrl);
callbackUrl.searchParams.set('mock_code', this.config.mockUserId);
callbackUrl.searchParams.set('state', params.state);
return callbackUrl.toString();
}
async resolveCallbackProfile(params: {
mockCode?: string | null;
}) {
const mockCode = params.mockCode?.trim() || this.config.mockUserId;
return {
providerUid: mockCode,
providerUnionId: this.config.mockUnionId || null,
displayName: this.config.mockDisplayName || '微信旅人',
avatarUrl: this.config.mockAvatarUrl || null,
metaJson: {
mockCode,
},
} satisfies WechatIdentityProfile;
}
}
class RealWechatAuthService implements WechatAuthService {
constructor(
private readonly config: AppConfig['wechatAuth'],
private readonly logger: Logger,
) {
if (!config.appId || !config.appSecret) {
throw new Error('WECHAT_APP_ID 或 WECHAT_APP_SECRET 未配置');
}
}
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
}) {
const url = new URL(this.config.authorizeEndpoint);
url.searchParams.set('appid', this.config.appId);
url.searchParams.set('redirect_uri', params.callbackUrl);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'snsapi_login');
url.searchParams.set('state', params.state);
return `${url.toString()}#wechat_redirect`;
}
async resolveCallbackProfile(params: {
code?: string | null;
}) {
const code = params.code?.trim();
if (!code) {
throw badRequest('缺少微信授权 code');
}
try {
const accessTokenUrl = new URL(this.config.accessTokenEndpoint);
accessTokenUrl.searchParams.set('appid', this.config.appId);
accessTokenUrl.searchParams.set('secret', this.config.appSecret);
accessTokenUrl.searchParams.set('code', code);
accessTokenUrl.searchParams.set('grant_type', 'authorization_code');
const accessTokenResponse = await fetch(accessTokenUrl.toString());
const accessTokenPayload =
(await accessTokenResponse.json()) as Record<string, unknown>;
if (!accessTokenResponse.ok || typeof accessTokenPayload.openid !== 'string') {
throw new Error(
typeof accessTokenPayload.errmsg === 'string'
? accessTokenPayload.errmsg
: 'failed to exchange code',
);
}
const accessToken =
typeof accessTokenPayload.access_token === 'string'
? accessTokenPayload.access_token
: '';
const openId = accessTokenPayload.openid;
const fallbackUnionId =
typeof accessTokenPayload.unionid === 'string'
? accessTokenPayload.unionid
: null;
if (!accessToken) {
throw new Error('missing access_token');
}
const userInfoUrl = new URL(this.config.userInfoEndpoint);
userInfoUrl.searchParams.set('access_token', accessToken);
userInfoUrl.searchParams.set('openid', openId);
userInfoUrl.searchParams.set('lang', 'zh_CN');
const userInfoResponse = await fetch(userInfoUrl.toString());
const userInfoPayload =
(await userInfoResponse.json()) as Record<string, unknown>;
if (!userInfoResponse.ok || typeof userInfoPayload.openid !== 'string') {
throw new Error(
typeof userInfoPayload.errmsg === 'string'
? userInfoPayload.errmsg
: 'failed to fetch user info',
);
}
return {
providerUid: userInfoPayload.openid,
providerUnionId:
typeof userInfoPayload.unionid === 'string'
? userInfoPayload.unionid
: fallbackUnionId,
displayName:
typeof userInfoPayload.nickname === 'string'
? userInfoPayload.nickname
: null,
avatarUrl:
typeof userInfoPayload.headimgurl === 'string'
? userInfoPayload.headimgurl
: null,
metaJson: userInfoPayload,
} satisfies WechatIdentityProfile;
} catch (error) {
this.logger.error({ err: error }, 'wechat auth callback failed');
throw upstreamError(
error instanceof Error
? `微信登录失败:${error.message}`
: '微信登录失败',
);
}
}
}
export function createWechatAuthService(
config: AppConfig,
logger: Logger,
): WechatAuthService {
if (!config.wechatAuth.enabled) {
return {
buildAuthorizationUrl() {
throw badRequest('微信登录暂未启用');
},
async resolveCallbackProfile() {
throw badRequest('微信登录暂未启用');
},
};
}
if (config.wechatAuth.provider === 'mock') {
return new MockWechatAuthService(config.wechatAuth);
}
return new RealWechatAuthService(config.wechatAuth, logger);
}

View File

@@ -0,0 +1,32 @@
import crypto from 'node:crypto';
export type WechatAuthStateRecord = {
state: string;
redirectPath: string;
createdAt: string;
};
export class WechatAuthStateStore {
private readonly states = new Map<string, WechatAuthStateRecord>();
create(redirectPath: string) {
const state = crypto.randomBytes(18).toString('hex');
const record: WechatAuthStateRecord = {
state,
redirectPath,
createdAt: new Date().toISOString(),
};
this.states.set(state, record);
return record;
}
consume(state: string) {
const record = this.states.get(state) ?? null;
if (!record) {
return null;
}
this.states.delete(state);
return record;
}
}

View File

@@ -0,0 +1,34 @@
export function createTestPlayerCharacter<TCharacter>() {
return {
id: 'test-hero',
name: '测试主角',
title: '断桥行者',
description: '用于后端运行时测试的稳定角色夹具。',
backstory: '在断桥旧哨附近长期行动,熟悉近身交锋和临场判断。',
avatar: '/test-hero.png',
portrait: '/test-hero-portrait.png',
assetFolder: 'test-hero',
assetVariant: 'default',
gender: 'female',
attributes: {
strength: 12,
agility: 11,
intelligence: 8,
spirit: 10,
},
personality: '沉稳果断',
skills: [
{
id: 'slash',
name: '试锋斩',
animation: 'attack',
damage: 18,
manaCost: 4,
cooldownTurns: 1,
range: 1,
style: 'steady',
},
],
adventureOpenings: {},
} as TCharacter;
}

Some files were not shown because too many files have changed in this diff Show More