init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
{
"name": "@genarrative/shared",
"private": true,
"version": "0.1.0",
"type": "module"
}

View File

@@ -0,0 +1,478 @@
export type MutableRgbaBuffer = Uint8Array | Uint8ClampedArray;
const SOFT_EDGE_ALPHA_THRESHOLD = 224;
const FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD = 96;
function clamp01(value: number) {
return Math.max(0, Math.min(1, value));
}
function lerp(from: number, to: number, t: number) {
return from + (to - from) * clamp01(t);
}
function computeGreenBackgroundScore(
red: number,
green: number,
blue: number,
alpha: number,
) {
if (alpha === 0) {
return 1;
}
const greenLead = green - Math.max(red, blue);
if (green < 52 || greenLead <= 8) {
return 0;
}
const greenRatio = green / Math.max(1, red + blue);
if (greenRatio <= 0.52) {
return 0;
}
return clamp01(
((green - 52) / 168) * 0.22 +
((greenLead - 8) / 96) * 0.53 +
((greenRatio - 0.52) / 0.82) * 0.25,
);
}
function computeWhiteBackgroundScore(
red: number,
green: number,
blue: number,
alpha: number,
) {
if (alpha === 0) {
return 1;
}
const maxChannel = Math.max(red, green, blue);
const minChannel = Math.min(red, green, blue);
const average = (red + green + blue) / 3;
if (average < 188 || minChannel < 168) {
return 0;
}
const spread = maxChannel - minChannel;
const neutrality = 1 - clamp01((spread - 6) / 34);
const brightness = clamp01((average - 188) / 55);
const floor = clamp01((minChannel - 168) / 60);
return clamp01(neutrality * (brightness * 0.85 + floor * 0.15));
}
function collectForegroundNeighborColor(
pixels: MutableRgbaBuffer,
width: number,
height: number,
x: number,
y: number,
backgroundMask: Uint8Array,
backgroundHints: Float32Array,
) {
let totalWeight = 0;
let totalRed = 0;
let totalGreen = 0;
let totalBlue = 0;
for (let offsetY = -2; offsetY <= 2; offsetY += 1) {
for (let offsetX = -2; offsetX <= 2; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
continue;
}
const nextPixelIndex = nextY * width + nextX;
if (backgroundMask[nextPixelIndex]) {
continue;
}
if ((backgroundHints[nextPixelIndex] ?? 0) >= 0.18) {
continue;
}
const nextOffset = nextPixelIndex * 4;
const nextAlpha = pixels[nextOffset + 3] ?? 0;
if (nextAlpha < FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD) {
continue;
}
const distance = Math.abs(offsetX) + Math.abs(offsetY);
const weight =
(nextAlpha / 255) *
(distance <= 1 ? 1.8 : distance === 2 ? 1.2 : 0.7);
totalWeight += weight;
totalRed += (pixels[nextOffset] ?? 0) * weight;
totalGreen += (pixels[nextOffset + 1] ?? 0) * weight;
totalBlue += (pixels[nextOffset + 2] ?? 0) * weight;
}
}
if (totalWeight <= 0) {
return null;
}
return {
red: Math.round(totalRed / totalWeight),
green: Math.round(totalGreen / totalWeight),
blue: Math.round(totalBlue / totalWeight),
};
}
export function removeBackgroundFromRgba(
pixels: MutableRgbaBuffer,
width: number,
height: number,
) {
const pixelCount = width * height;
if (pixelCount <= 0) {
return false;
}
const backgroundMask = new Uint8Array(pixelCount);
const greenScores = new Float32Array(pixelCount);
const whiteScores = new Float32Array(pixelCount);
const backgroundHints = new Float32Array(pixelCount);
const queue: number[] = [];
let queueIndex = 0;
let changed = false;
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
const offset = pixelIndex * 4;
const red = pixels[offset] ?? 0;
const green = pixels[offset + 1] ?? 0;
const blue = pixels[offset + 2] ?? 0;
const alpha = pixels[offset + 3] ?? 0;
const greenScore = computeGreenBackgroundScore(red, green, blue, alpha);
const whiteScore = computeWhiteBackgroundScore(red, green, blue, alpha);
const transparencyHint = clamp01((56 - alpha) / 56) * 0.75;
greenScores[pixelIndex] = greenScore;
whiteScores[pixelIndex] = whiteScore;
backgroundHints[pixelIndex] = Math.max(
greenScore,
whiteScore,
transparencyHint,
);
}
const trySeedBackground = (pixelIndex: number) => {
if (backgroundMask[pixelIndex]) {
return;
}
const offset = pixelIndex * 4;
const alpha = pixels[offset + 3] ?? 0;
const strongCandidate =
alpha < 40 ||
(greenScores[pixelIndex] ?? 0) > 0.12 ||
(whiteScores[pixelIndex] ?? 0) > 0.32;
if (!strongCandidate) {
return;
}
backgroundMask[pixelIndex] = 1;
queue.push(pixelIndex);
};
for (let x = 0; x < width; x += 1) {
trySeedBackground(x);
trySeedBackground((height - 1) * width + x);
}
for (let y = 1; y < height - 1; y += 1) {
trySeedBackground(y * width);
trySeedBackground(y * width + width - 1);
}
while (queueIndex < queue.length) {
const pixelIndex = queue[queueIndex]!;
queueIndex += 1;
const x = pixelIndex % width;
const y = Math.floor(pixelIndex / width);
const neighborIndexes = [
x > 0 ? pixelIndex - 1 : -1,
x + 1 < width ? pixelIndex + 1 : -1,
y > 0 ? pixelIndex - width : -1,
y + 1 < height ? pixelIndex + width : -1,
];
for (const nextPixelIndex of neighborIndexes) {
if (nextPixelIndex < 0 || backgroundMask[nextPixelIndex]) {
continue;
}
const nextOffset = nextPixelIndex * 4;
const nextAlpha = pixels[nextOffset + 3] ?? 0;
const nextGreenScore = greenScores[nextPixelIndex] ?? 0;
const nextWhiteScore = whiteScores[nextPixelIndex] ?? 0;
const nextHint = backgroundHints[nextPixelIndex] ?? 0;
const reachableSoftEdge =
nextHint > 0.08 &&
nextAlpha < SOFT_EDGE_ALPHA_THRESHOLD &&
(nextGreenScore > 0.04 || nextWhiteScore > 0.08 || nextAlpha < 180);
if (
nextAlpha < 40 ||
nextGreenScore > 0.12 ||
nextWhiteScore > 0.32 ||
reachableSoftEdge
) {
backgroundMask[nextPixelIndex] = 1;
queue.push(nextPixelIndex);
}
}
}
for (let iteration = 0; iteration < 2; iteration += 1) {
const expandedMask = new Uint8Array(backgroundMask);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const pixelIndex = y * width + x;
if (expandedMask[pixelIndex]) {
continue;
}
const alpha = pixels[pixelIndex * 4 + 3] ?? 0;
const hint = backgroundHints[pixelIndex] ?? 0;
if (alpha >= SOFT_EDGE_ALPHA_THRESHOLD || hint <= 0.06) {
continue;
}
let adjacentBackgroundCount = 0;
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
continue;
}
if (backgroundMask[nextY * width + nextX]) {
adjacentBackgroundCount += 1;
}
}
}
if (
adjacentBackgroundCount >= 2 ||
(adjacentBackgroundCount >= 1 && hint > 0.18)
) {
expandedMask[pixelIndex] = 1;
}
}
}
backgroundMask.set(expandedMask);
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const pixelIndex = y * width + x;
if (!backgroundMask[pixelIndex]) {
continue;
}
const offset = pixelIndex * 4;
const alpha = pixels[offset + 3] ?? 0;
if (alpha === 0) {
continue;
}
const matteScore = Math.max(
backgroundHints[pixelIndex] ?? 0,
greenScores[pixelIndex] ?? 0,
whiteScores[pixelIndex] ?? 0,
);
let foregroundSupport = 0;
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
continue;
}
const nextPixelIndex = nextY * width + nextX;
if (backgroundMask[nextPixelIndex]) {
continue;
}
const nextAlpha = pixels[nextPixelIndex * 4 + 3] ?? 0;
if (nextAlpha >= FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD) {
foregroundSupport += 1;
}
}
}
let nextAlpha = alpha;
if (matteScore > 0.9 || foregroundSupport === 0) {
nextAlpha = 0;
} else if (matteScore > 0.72 && foregroundSupport <= 1) {
nextAlpha = Math.min(alpha, Math.round(alpha * 0.08));
} else {
nextAlpha = Math.min(
alpha,
Math.round(alpha * Math.max(0.08, 1 - matteScore * 0.95)),
);
}
if (foregroundSupport >= 3 && matteScore < 0.55) {
nextAlpha = Math.max(nextAlpha, Math.round(alpha * 0.22));
}
if (nextAlpha < 10) {
nextAlpha = 0;
}
if (nextAlpha !== alpha) {
pixels[offset + 3] = nextAlpha;
changed = true;
}
}
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const pixelIndex = y * width + x;
const offset = pixelIndex * 4;
const alpha = pixels[offset + 3] ?? 0;
if (alpha === 0) {
continue;
}
let touchesTransparentEdge = false;
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
touchesTransparentEdge = true;
continue;
}
const nextPixelIndex = nextY * width + nextX;
if (
backgroundMask[nextPixelIndex] ||
(pixels[nextPixelIndex * 4 + 3] ?? 0) < 16
) {
touchesTransparentEdge = true;
}
}
}
if (!touchesTransparentEdge) {
continue;
}
const greenScore = greenScores[pixelIndex] ?? 0;
const whiteScore = whiteScores[pixelIndex] ?? 0;
const contamination = Math.max(
greenScore,
whiteScore,
backgroundMask[pixelIndex] ? 0.35 : 0,
alpha < 220 ? ((220 - alpha) / 220) * 0.25 : 0,
);
if (contamination < 0.06) {
continue;
}
let red = pixels[offset] ?? 0;
let green = pixels[offset + 1] ?? 0;
let blue = pixels[offset + 2] ?? 0;
const sample = collectForegroundNeighborColor(
pixels,
width,
height,
x,
y,
backgroundMask,
backgroundHints,
);
const blend = clamp01(
Math.max(contamination * 0.82, touchesTransparentEdge ? 0.22 : 0),
);
if (sample) {
red = Math.round(lerp(red, sample.red, blend));
green = Math.round(lerp(green, sample.green, blend));
blue = Math.round(lerp(blue, sample.blue, blend));
if (greenScore > 0.04) {
green = Math.min(green, sample.green + 18);
}
if (whiteScore > 0.1) {
red = Math.min(red, sample.red + 26);
green = Math.min(green, sample.green + 26);
blue = Math.min(blue, sample.blue + 26);
}
} else {
if (greenScore > 0.04) {
green = Math.max(
Math.max(red, blue),
Math.round(green - (green - Math.max(red, blue)) * 0.78),
);
}
if (whiteScore > 0.12) {
const spread = Math.max(red, green, blue) - Math.min(red, green, blue);
if (spread < 20) {
const tonedValue = Math.round(((red + green + blue) / 3) * 0.88);
red = Math.min(red, tonedValue);
green = Math.min(green, tonedValue);
blue = Math.min(blue, tonedValue);
}
}
}
let nextAlpha = alpha;
const edgeFade = Math.max(greenScore * 0.35, whiteScore * 0.28);
if (edgeFade > 0.08) {
nextAlpha = Math.min(alpha, Math.round(alpha * (1 - edgeFade)));
if (nextAlpha < 10) {
nextAlpha = 0;
}
}
if (
red !== (pixels[offset] ?? 0) ||
green !== (pixels[offset + 1] ?? 0) ||
blue !== (pixels[offset + 2] ?? 0) ||
nextAlpha !== alpha
) {
pixels[offset] = red;
pixels[offset + 1] = green;
pixels[offset + 2] = blue;
pixels[offset + 3] = nextAlpha;
changed = true;
}
}
}
return changed;
}

View File

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

View File

@@ -0,0 +1,199 @@
export type AuthBindingStatus = 'active' | 'pending_bind_phone';
export type AuthLoginMethod = 'password' | 'phone' | 'wechat';
export type AuthUser = {
id: string;
publicUserCode: string;
username: string;
displayName: string;
phoneNumberMasked: string | null;
loginMethod: AuthLoginMethod;
bindingStatus: AuthBindingStatus;
wechatBound: boolean;
};
export type PublicUserSummary = {
id: string;
publicUserCode: string;
displayName: string;
};
export type PublicUserSearchResponse = {
user: PublicUserSummary;
};
export type AuthEntryRequest = {
phone: string;
password: string;
};
export type AuthEntryResponse = {
token: string;
user: AuthUser;
};
export type AuthPasswordChangeRequest = {
currentPassword?: string;
newPassword: string;
};
export type AuthPasswordChangeResponse = {
user: AuthUser;
};
export type AuthPasswordResetRequest = {
phone: string;
code: string;
newPassword: string;
};
export type AuthPasswordResetResponse = {
token: string;
user: AuthUser;
};
export type AuthPhoneSendCodeRequest = {
phone: string;
scene?: 'login' | 'bind_phone' | 'change_phone';
captchaChallengeId?: string;
captchaAnswer?: string;
};
export type AuthPhoneSendCodeResponse = {
ok: true;
cooldownSeconds: number;
expiresInSeconds: number;
providerRequestId: string | null;
};
export type AuthPhoneLoginRequest = {
phone: string;
code: string;
};
export type AuthPhoneLoginResponse = {
token: string;
user: AuthUser;
};
export type AuthMeResponse = {
user: AuthUser | null;
availableLoginMethods: AuthLoginMethod[];
};
export type AuthLoginOptionsResponse = {
availableLoginMethods: AuthLoginMethod[];
};
export type AuthWechatStartResponse = {
authorizationUrl: string;
};
export type AuthWechatBindPhoneRequest = {
phone: string;
code: string;
};
export type AuthWechatBindPhoneResponse = {
token: string;
user: AuthUser;
};
export type AuthPhoneChangeRequest = {
phone: string;
code: string;
};
export type AuthPhoneChangeResponse = {
user: AuthUser;
};
export type AuthRefreshResponse = {
ok: true;
token?: string;
};
export type AuthSessionSummary = {
sessionId: string;
clientType: string;
clientRuntime: string;
clientPlatform: string;
clientLabel: string;
deviceDisplayName: string;
miniProgramAppId: string | null;
miniProgramEnv: string | null;
userAgent: string | null;
ipMasked: string | null;
isCurrent: boolean;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
};
export type AuthSessionsResponse = {
sessions: AuthSessionSummary[];
};
export type AuthLogoutAllResponse = {
ok: true;
};
export type AuthRevokeSessionResponse = {
ok: true;
};
export type AuthAuditLogEventType =
| 'password_login'
| 'phone_login'
| 'wechat_login'
| 'wechat_bind_phone'
| 'change_phone'
| 'captcha_required'
| 'logout'
| 'logout_all'
| 'revoke_session'
| 'risk_block_phone'
| 'risk_block_ip'
| 'risk_unblock_phone'
| 'risk_unblock_ip';
export type AuthAuditLogEntry = {
id: string;
eventType: AuthAuditLogEventType;
title: string;
detail: string;
ipMasked: string | null;
userAgent: string | null;
createdAt: string;
};
export type AuthAuditLogsResponse = {
logs: AuthAuditLogEntry[];
};
export type AuthCaptchaChallenge = {
challengeId: string;
promptText: string;
imageDataUrl: string;
expiresInSeconds: number;
};
export type AuthRiskBlockSummary = {
scopeType: 'phone' | 'ip';
title: string;
detail: string;
expiresAt: string;
remainingSeconds: number;
};
export type AuthRiskBlocksResponse = {
blocks: AuthRiskBlockSummary[];
};
export type AuthLiftRiskBlockResponse = {
ok: true;
};
export type LogoutResponse = {
ok: true;
};

View File

@@ -0,0 +1,191 @@
/**
* 大鱼吃小鱼玩法域前端共享契约。
* 字段与 server-rs/shared-contracts/src/big_fish.rs 保持 camelCase 对齐。
*/
export type CreateBigFishSessionRequest = {
seedText?: string;
};
export type SendBigFishMessageRequest = {
clientMessageId: string;
text: string;
quickFillRequested?: boolean;
};
export type BigFishActionId =
| 'big_fish_compile_draft'
| 'big_fish_generate_level_main_image'
| 'big_fish_generate_level_motion'
| 'big_fish_generate_stage_background'
| 'big_fish_publish_game';
export type ExecuteBigFishActionRequest = {
action: BigFishActionId;
level?: number;
motionKey?: 'idle_float' | 'move_swim' | string;
};
export type SubmitBigFishInputRequest = {
x: number;
y: number;
};
export type BigFishAnchorStatus =
| 'confirmed'
| 'inferred'
| 'missing'
| 'locked'
| string;
export type BigFishAnchorItemResponse = {
key: string;
label: string;
value: string;
status: BigFishAnchorStatus;
};
export type BigFishAnchorPackResponse = {
gameplayPromise: BigFishAnchorItemResponse;
ecologyVisualTheme: BigFishAnchorItemResponse;
growthLadder: BigFishAnchorItemResponse;
riskTempo: BigFishAnchorItemResponse;
};
export type BigFishLevelBlueprintResponse = {
level: number;
name: string;
oneLineFantasy: string;
silhouetteDirection: string;
sizeRatio: number;
visualPromptSeed: string;
motionPromptSeed: string;
mergeSourceLevel?: number | null;
preyWindow: number[];
threatWindow: number[];
isFinalLevel: boolean;
};
export type BigFishBackgroundBlueprintResponse = {
theme: string;
colorMood: string;
foregroundHints: string;
midgroundComposition: string;
backgroundDepth: string;
safePlayAreaHint: string;
spawnEdgeHint: string;
backgroundPromptSeed: string;
};
export type BigFishRuntimeParamsResponse = {
levelCount: number;
mergeCountPerUpgrade: number;
spawnTargetCount: number;
leaderMoveSpeed: number;
followerCatchUpSpeed: number;
offscreenCullSeconds: number;
preySpawnDeltaLevels: number[];
threatSpawnDeltaLevels: number[];
winLevel: number;
};
export type BigFishGameDraftResponse = {
title: string;
subtitle: string;
coreFun: string;
ecologyTheme: string;
levels: BigFishLevelBlueprintResponse[];
background: BigFishBackgroundBlueprintResponse;
runtimeParams: BigFishRuntimeParamsResponse;
};
export type BigFishAgentMessageResponse = {
id: string;
role: 'user' | 'assistant' | string;
kind: 'chat' | 'system' | 'warning' | string;
text: string;
createdAt: string;
};
export type BigFishAssetKind =
| 'level_main_image'
| 'level_motion'
| 'stage_background'
| string;
export type BigFishAssetStatus = 'empty' | 'ready' | 'generating' | string;
export type BigFishAssetSlotResponse = {
slotId: string;
assetKind: BigFishAssetKind;
level?: number | null;
motionKey?: string | null;
status: BigFishAssetStatus;
assetUrl?: string | null;
promptSnapshot: string;
updatedAt: string;
};
export type BigFishAssetCoverageResponse = {
levelMainImageReadyCount: number;
levelMotionReadyCount: number;
backgroundReady: boolean;
requiredLevelCount: number;
publishReady: boolean;
blockers: string[];
};
export type BigFishSessionSnapshotResponse = {
sessionId: string;
currentTurn: number;
progressPercent: number;
stage: string;
anchorPack: BigFishAnchorPackResponse;
draft?: BigFishGameDraftResponse | null;
assetSlots: BigFishAssetSlotResponse[];
assetCoverage: BigFishAssetCoverageResponse;
messages: BigFishAgentMessageResponse[];
lastAssistantReply?: string | null;
publishReady: boolean;
updatedAt: string;
};
export type BigFishSessionResponse = {
session: BigFishSessionSnapshotResponse;
};
export type BigFishActionResponse = {
session: BigFishSessionSnapshotResponse;
};
export type BigFishVector2Response = {
x: number;
y: number;
};
export type BigFishRuntimeEntityResponse = {
entityId: string;
level: number;
position: BigFishVector2Response;
radius: number;
offscreenSeconds: number;
};
export type BigFishRuntimeSnapshotResponse = {
runId: string;
sessionId: string;
status: 'running' | 'won' | 'failed' | string;
tick: number;
playerLevel: number;
winLevel: number;
leaderEntityId?: string | null;
ownedEntities: BigFishRuntimeEntityResponse[];
wildEntities: BigFishRuntimeEntityResponse[];
cameraCenter: BigFishVector2Response;
lastInput: BigFishVector2Response;
eventLog: string[];
updatedAt: string;
};
export type BigFishRunResponse = {
run: BigFishRuntimeSnapshotResponse;
};

View File

@@ -0,0 +1,21 @@
export type BigFishWorkStatus = 'draft' | 'published';
export interface BigFishWorkSummary {
workId: string;
sourceSessionId: string;
title: string;
subtitle: string;
summary: string;
coverImageSrc: string | null;
status: BigFishWorkStatus;
updatedAt: string;
publishReady: boolean;
levelCount: number;
levelMainImageReadyCount: number;
levelMotionReadyCount: number;
backgroundReady: boolean;
}
export interface BigFishWorksResponse {
items: BigFishWorkSummary[];
}

View File

@@ -0,0 +1,3 @@
export type JsonObject = Record<string, unknown>;
export type JsonArray = unknown[];

View File

@@ -0,0 +1,16 @@
export interface ParseCreationAgentDocumentInputRequest {
fileName: string;
contentType?: string | null;
contentBase64: string;
}
export interface CreationAgentDocumentInputPayload {
fileName: string;
contentType?: string | null;
sizeBytes: number;
text: string;
}
export interface ParseCreationAgentDocumentInputResponse {
document: CreationAgentDocumentInputPayload;
}

View File

@@ -0,0 +1,12 @@
/**
* 兼容出口:
* 当前仓库仍有大量旧 customWorld 命名导入,这个文件继续作为过渡层保留。
* 工作包 H 完成后,真实类型定义已经迁移到 rpg* 契约文件中;这里仅聚合旧命名分文件。
*/
export type * from './customWorldAgentAnchors';
export type * from './customWorldAgentDraft';
export type * from './customWorldAgentActions';
export type * from './customWorldAgentSession';
export type * from './customWorldResultPreview';
export type * from './customWorldWorkSummary';

View File

@@ -0,0 +1,14 @@
/**
* 旧 custom world 动作契约兼容出口。
* 后续若逐步迁移旧代码,建议直接改用 rpgAgentActions.ts。
*/
export type {
RpgAgentActionRequest as CustomWorldAgentActionRequest,
RpgAgentActionResponse as CustomWorldAgentActionResponse,
RpgAgentOperationRecord as CustomWorldAgentOperationRecord,
RpgAgentOperationStatus as CustomWorldAgentOperationStatus,
RpgAgentOperationType as CustomWorldAgentOperationType,
RpgAgentSupportedAction as CustomWorldSupportedAction,
RpgAgentSuggestedAction as CustomWorldSuggestedAction,
} from './rpgAgentActions';

View File

@@ -0,0 +1,9 @@
/**
* 旧 custom world 八锚点兼容出口。
* 这里只保留旧命名到 RPG 创作域新契约的映射,便于旧导入渐进迁移。
*/
export type {
RpgCreationAnchorText as AnchorTextValue,
RpgCreationAnchorContent as EightAnchorContent,
} from './rpgAgentAnchors';

View File

@@ -0,0 +1,29 @@
/**
* 旧 custom world 草稿契约兼容出口。
* 工作包 H 完成后,真实定义已经迁到 rpgAgentDraft.ts这里只负责旧命名映射。
*/
export type {
RpgAgentAssetCoverageSummary as CustomWorldAssetCoverageSummary,
RpgAgentAssetPriorityTier as CustomWorldAssetPriorityTier,
RpgAgentDraftCardDetail as CustomWorldDraftCardDetail,
RpgAgentDraftCardDetailSection as CustomWorldDraftCardDetailSection,
RpgAgentDraftCardKind as CustomWorldDraftCardKind,
RpgAgentDraftCardStatus as CustomWorldDraftCardStatus,
RpgAgentDraftCardSummary as CustomWorldDraftCardSummary,
RpgAgentFoundationDraftCamp as CustomWorldFoundationDraftCamp,
RpgAgentFoundationDraftChapter as CustomWorldFoundationDraftChapter,
RpgAgentFoundationDraftCharacter as CustomWorldFoundationDraftCharacter,
RpgAgentFoundationDraftFaction as CustomWorldFoundationDraftFaction,
RpgAgentFoundationDraftLandmark as CustomWorldFoundationDraftLandmark,
RpgAgentFoundationDraftProfile as CustomWorldFoundationDraftProfile,
RpgAgentFoundationDraftResult as CustomWorldFoundationDraftResult,
RpgAgentFoundationDraftSceneAct as CustomWorldFoundationDraftSceneAct,
RpgAgentFoundationDraftSceneChapter as CustomWorldFoundationDraftSceneChapter,
RpgAgentFoundationDraftThread as CustomWorldFoundationDraftThread,
RpgAgentRoleAssetStatus as CustomWorldRoleAssetStatus,
RpgAgentRoleAssetSummary as CustomWorldRoleAssetSummary,
RpgAgentSceneActAdvanceRule as CustomWorldSceneActAdvanceRule,
RpgAgentSceneActStage as CustomWorldSceneActStage,
RpgAgentSceneAssetSummary as CustomWorldSceneAssetSummary,
} from './rpgAgentDraft';

View File

@@ -0,0 +1,20 @@
/**
* 旧 custom world 会话契约兼容出口。
* 这一层只做命名映射,不再承担 session 真相源结构定义。
*/
export type {
CreateRpgAgentSessionRequest as CreateCustomWorldAgentSessionRequest,
CreateRpgAgentSessionResponse as CreateCustomWorldAgentSessionResponse,
GetRpgAgentCardDetailResponse as GetCustomWorldAgentCardDetailResponse,
RpgAgentMessage as CustomWorldAgentMessage,
RpgAgentMessageKind as CustomWorldAgentMessageKind,
RpgAgentMessageRole as CustomWorldAgentMessageRole,
RpgAgentPendingClarification as CustomWorldPendingClarification,
RpgAgentQualityFinding as CustomWorldAgentQualityFinding,
RpgAgentSessionSnapshot as CustomWorldAgentSessionSnapshot,
RpgAgentStage as CustomWorldAgentStage,
RpgCreationIntentReadiness as CreatorIntentReadiness,
SendRpgAgentMessageRequest as SendCustomWorldAgentMessageRequest,
SendRpgAgentMessageResponse as SendCustomWorldAgentMessageResponse,
} from './rpgAgentSession';

View File

@@ -0,0 +1,12 @@
/**
* 旧 custom world 结果页预览兼容出口。
* 额外单独拆一个 preview 兼容文件,避免预览别名继续堆回 customWorldAgent.ts 聚合层。
*/
export type {
RpgCreationPreview as CustomWorldResultPreview,
RpgCreationPreviewBlocker as CustomWorldResultPreviewBlocker,
RpgCreationPreviewEnvelope as CustomWorldResultPreviewEnvelope,
RpgCreationPreviewFinding as CustomWorldResultPreviewFinding,
RpgCreationPreviewSource as CustomWorldResultPreviewSource,
} from './rpgCreationPreview';

View File

@@ -0,0 +1,11 @@
/**
* 旧 custom world works 读模型兼容出口。
* 用于把旧作品列表命名平滑映射到新的 RPG 创作域 works 契约。
*/
export type {
ListRpgCreationWorksResponse as ListCustomWorldWorksResponse,
RpgCreationWorkSource as CustomWorldWorkSource,
RpgCreationWorkStatus as CustomWorldWorkStatus,
RpgCreationWorkSummary as CustomWorldWorkSummary,
} from './rpgCreationWorkSummary';

View File

@@ -0,0 +1,65 @@
import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession';
export type PuzzleAgentSuggestedActionType =
| 'request_summary'
| 'compile_puzzle_draft'
| 'generate_puzzle_images'
| 'publish_puzzle_work';
export interface PuzzleAgentSuggestedAction {
id: string;
actionType: PuzzleAgentSuggestedActionType;
label: string;
}
export type PuzzleAgentActionType =
| 'compile_puzzle_draft'
| 'generate_puzzle_images'
| 'select_puzzle_image'
| 'publish_puzzle_work';
export type PuzzleAgentOperationType =
| 'process_message'
| PuzzleAgentActionType;
export type PuzzleAgentOperationStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed';
export interface PuzzleAgentOperationRecord {
operationId: string;
type: PuzzleAgentOperationType;
status: PuzzleAgentOperationStatus;
phaseLabel: string;
phaseDetail: string;
progress: number;
error?: string | null;
}
export type PuzzleAgentActionRequest =
| { action: 'compile_puzzle_draft' }
| {
action: 'generate_puzzle_images';
promptText?: string | null;
candidateCount?: number;
}
| {
action: 'select_puzzle_image';
candidateId: string;
}
| {
action: 'publish_puzzle_work';
levelName?: string;
summary?: string;
themeTags?: string[];
};
/**
* 拼图操作接口直接返回最新会话,避免前端在选图等轻操作后再额外 GET 大体积快照。
*/
export interface PuzzleAgentActionResponse {
operation: PuzzleAgentOperationRecord;
session: PuzzleAgentSessionSnapshot;
}

View File

@@ -0,0 +1,58 @@
import type { JsonObject } from './common';
export type PuzzleAnchorStatus =
| 'missing'
| 'inferred'
| 'confirmed'
| 'locked';
export interface PuzzleAnchorItem {
key: string;
label: string;
value: string;
status: PuzzleAnchorStatus;
}
export interface PuzzleAnchorPack {
themePromise: PuzzleAnchorItem;
visualSubject: PuzzleAnchorItem;
visualMood: PuzzleAnchorItem;
compositionHooks: PuzzleAnchorItem;
tagsAndForbidden: PuzzleAnchorItem;
}
export interface PuzzleCreatorIntent {
sourceMode: 'agent_chat';
rawMessagesSummary: string;
themePromise: string;
visualSubject: string;
visualMood: string[];
compositionHooks: string[];
themeTags: string[];
forbiddenDirectives: string[];
}
export interface PuzzleGeneratedImageCandidate {
candidateId: string;
imageSrc: string;
assetId: string;
prompt: string;
actualPrompt?: string | null;
sourceType: 'generated' | 'uploaded';
selected: boolean;
}
export interface PuzzleResultDraft {
levelName: string;
summary: string;
themeTags: string[];
forbiddenDirectives: string[];
creatorIntent: PuzzleCreatorIntent | null;
anchorPack: PuzzleAnchorPack;
candidates: PuzzleGeneratedImageCandidate[];
selectedCandidateId: string | null;
coverImageSrc: string | null;
coverAssetId: string | null;
generationStatus: 'idle' | 'generating' | 'ready';
metadata?: JsonObject | null;
}

View File

@@ -0,0 +1,59 @@
import type { PuzzleAgentActionResponse, PuzzleAgentSuggestedAction } from './puzzleAgentActions';
import type { PuzzleAnchorPack, PuzzleResultDraft } from './puzzleAgentDraft';
import type { PuzzleResultPreviewEnvelope } from './puzzleResultPreview';
export type PuzzleAgentStage =
| 'collecting_anchors'
| 'draft_ready'
| 'image_refining'
| 'ready_to_publish'
| 'published';
export type PuzzleAgentMessageRole = 'user' | 'assistant' | 'system';
export type PuzzleAgentMessageKind =
| 'chat'
| 'summary'
| 'action_result'
| 'warning';
export interface PuzzleAgentMessage {
id: string;
role: PuzzleAgentMessageRole;
kind: PuzzleAgentMessageKind;
text: string;
createdAt: string;
}
export interface PuzzleAgentSessionSnapshot {
sessionId: string;
currentTurn: number;
progressPercent: number;
stage: PuzzleAgentStage;
anchorPack: PuzzleAnchorPack;
draft: PuzzleResultDraft | null;
messages: PuzzleAgentMessage[];
lastAssistantReply: string | null;
publishedProfileId: string | null;
suggestedActions: PuzzleAgentSuggestedAction[];
resultPreview: PuzzleResultPreviewEnvelope | null;
updatedAt: string;
}
export interface CreatePuzzleAgentSessionRequest {
seedText?: string;
}
export interface CreatePuzzleAgentSessionResponse {
session: PuzzleAgentSessionSnapshot;
}
export interface SendPuzzleAgentMessageRequest {
clientMessageId: string;
text: string;
quickFillRequested?: boolean;
}
export interface SendPuzzleAgentMessageResponse extends PuzzleAgentActionResponse {
session: PuzzleAgentSessionSnapshot;
}

View File

@@ -0,0 +1,21 @@
import type { PuzzleResultDraft } from './puzzleAgentDraft';
export interface PuzzleResultPreviewBlocker {
id: string;
code: string;
message: string;
}
export interface PuzzleResultPreviewFinding {
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
message: string;
}
export interface PuzzleResultPreviewEnvelope {
draft: PuzzleResultDraft;
blockers: PuzzleResultPreviewBlocker[];
qualityFindings: PuzzleResultPreviewFinding[];
publishReady: boolean;
}

View File

@@ -0,0 +1,79 @@
export type PuzzleGridSize = 3 | 4;
export interface PuzzleCellPosition {
row: number;
col: number;
}
export interface PuzzlePieceState {
pieceId: string;
correctRow: number;
correctCol: number;
currentRow: number;
currentCol: number;
mergedGroupId: string | null;
}
export interface PuzzleMergedGroupState {
groupId: string;
pieceIds: string[];
occupiedCells: PuzzleCellPosition[];
}
export interface PuzzleBoardSnapshot {
rows: number;
cols: number;
pieces: PuzzlePieceState[];
mergedGroups: PuzzleMergedGroupState[];
selectedPieceId: string | null;
allTilesResolved: boolean;
}
export interface PuzzleRuntimeLevelSnapshot {
runId: string;
levelIndex: number;
gridSize: PuzzleGridSize;
profileId: string;
levelName: string;
authorDisplayName: string;
themeTags: string[];
coverImageSrc: string | null;
board: PuzzleBoardSnapshot;
status: 'playing' | 'cleared';
}
export interface PuzzleRunSnapshot {
runId: string;
entryProfileId: string;
clearedLevelCount: number;
currentLevelIndex: number;
currentGridSize: PuzzleGridSize;
playedProfileIds: string[];
previousLevelTags: string[];
currentLevel: PuzzleRuntimeLevelSnapshot | null;
recommendedNextProfileId: string | null;
}
export interface StartPuzzleRunRequest {
profileId: string;
}
export interface AdvanceLocalPuzzleNextLevelRequest {
run: PuzzleRunSnapshot;
sourceSessionId?: string | null;
}
export interface PuzzleRunResponse {
run: PuzzleRunSnapshot;
}
export interface SwapPuzzlePiecesRequest {
firstPieceId: string;
secondPieceId: string;
}
export interface DragPuzzlePieceRequest {
pieceId: string;
targetRow: number;
targetCol: number;
}

View File

@@ -0,0 +1,39 @@
import type { JsonObject } from './common';
import type { PuzzleAnchorPack } from './puzzleAgentDraft';
export type PuzzleWorkPublicationStatus = 'draft' | 'published';
export interface PuzzleWorkSummary {
workId: string;
profileId: string;
ownerUserId: string;
sourceSessionId?: string | null;
authorDisplayName: string;
levelName: string;
summary: string;
themeTags: string[];
coverImageSrc: string | null;
coverAssetId?: string | null;
publicationStatus: PuzzleWorkPublicationStatus;
updatedAt: string;
publishedAt: string | null;
playCount: number;
publishReady: boolean;
}
export interface PuzzleWorkProfile extends PuzzleWorkSummary {
anchorPack: PuzzleAnchorPack;
metadata?: JsonObject | null;
}
export interface PuzzleWorksResponse {
items: PuzzleWorkSummary[];
}
export interface PuzzleWorkDetailResponse {
item: PuzzleWorkProfile;
}
export interface PuzzleWorkMutationResponse {
item: PuzzleWorkProfile;
}

View File

@@ -0,0 +1,132 @@
/**
* RPG Agent 动作与异步操作契约。
* 这里显式区分“建议动作”和“真实可执行动作”,为后续后端 registry 收口预留接口。
*/
export type RpgAgentSuggestedActionType =
| 'request_summary'
| 'draft_foundation'
| 'refine_focus_target'
| 'lock_current_target'
| 'generate_role_assets'
| 'generate_scene_assets'
| 'expand_long_tail'
| 'publish_world';
export interface RpgAgentSuggestedAction {
id: string;
type: RpgAgentSuggestedActionType;
label: string;
targetId?: string | null;
}
export type RpgAgentActionType =
| 'draft_foundation'
| 'update_draft_card'
| 'sync_result_profile'
| 'generate_characters'
| 'generate_landmarks'
| 'delete_characters'
| 'delete_landmarks'
| 'generate_role_assets'
| 'sync_role_assets'
| 'generate_scene_assets'
| 'sync_scene_assets'
| 'expand_long_tail'
| 'publish_world'
| 'revert_checkpoint';
export type RpgAgentActionCapabilityKey =
| RpgAgentSuggestedActionType
| RpgAgentActionType;
/**
* 当前先把能力矩阵定义为可选契约。
* 等工作包 E 的 registry 落地后,后端可以把真实 supportedActions 填充到 session snapshot。
*/
export interface RpgAgentSupportedAction {
action: RpgAgentActionCapabilityKey;
enabled: boolean;
reason?: string | null;
}
export type RpgAgentOperationType = RpgAgentActionType | 'process_message';
export type RpgAgentOperationStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed';
export interface RpgAgentOperationRecord {
operationId: string;
type: RpgAgentOperationType;
status: RpgAgentOperationStatus;
phaseLabel: string;
phaseDetail: string;
progress: number;
error?: string | null;
/** 操作创建时间,草稿生成进度页用它计算总耗时。 */
startedAt?: string | null;
updatedAt?: string | null;
}
export type RpgAgentActionRequest =
| { action: 'draft_foundation' }
| {
action: 'update_draft_card';
cardId: string;
sections: Array<{
sectionId: string;
value: string;
}>;
}
| {
action: 'sync_result_profile';
profile: Record<string, unknown>;
}
| {
action: 'generate_characters';
count: number;
roleType?: 'playable' | 'story' | null;
promptText?: string | null;
anchorCardIds?: string[];
}
| {
action: 'generate_landmarks';
count: number;
promptText?: string | null;
anchorCardIds?: string[];
}
| { action: 'delete_characters'; roleIds: string[] }
| { action: 'delete_landmarks'; sceneIds: string[] }
| { action: 'generate_role_assets'; roleIds: string[] }
| {
action: 'sync_role_assets';
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
}
| {
action: 'generate_scene_assets';
sceneIds: string[];
sceneKind?: 'camp' | 'landmark' | null;
}
| {
action: 'sync_scene_assets';
sceneId: string;
sceneKind: 'camp' | 'landmark';
imageSrc: string;
generatedSceneAssetId: string;
generatedScenePrompt?: string | null;
generatedSceneModel?: string | null;
}
| { action: 'expand_long_tail' }
| { action: 'publish_world' }
| { action: 'revert_checkpoint'; checkpointId: string };
export interface RpgAgentActionResponse {
operation: RpgAgentOperationRecord;
}

View File

@@ -0,0 +1,17 @@
/**
* RPG 创作八锚点契约。
* 每个锚点只保留一段凝练文本;细分关注点由 Agent prompt 负责,不再进入存储结构。
*/
export type RpgCreationAnchorText = string | null;
export interface RpgCreationAnchorContent {
worldPromise: RpgCreationAnchorText;
playerFantasy: RpgCreationAnchorText;
themeBoundary: RpgCreationAnchorText;
playerEntryPoint: RpgCreationAnchorText;
coreConflict: RpgCreationAnchorText;
keyRelationships: RpgCreationAnchorText;
hiddenLines: RpgCreationAnchorText;
iconicElements: RpgCreationAnchorText;
}

View File

@@ -0,0 +1,279 @@
/**
* RPG Agent 草稿与资产覆盖率契约。
* 这一层只描述 foundation draft、草稿卡片与资产状态不包含会话编排语义。
*/
export type RpgAgentDraftCardKind =
| 'world'
| 'camp'
| 'faction'
| 'character'
| 'landmark'
| 'thread'
| 'chapter'
| 'scene_chapter'
| 'carrier'
| 'sidequest_seed';
export type RpgAgentDraftCardStatus =
| 'suggested'
| 'confirmed'
| 'locked'
| 'warning';
export interface RpgAgentDraftCardSummary {
id: string;
kind: RpgAgentDraftCardKind;
title: string;
subtitle: string;
summary: string;
status: RpgAgentDraftCardStatus;
linkedIds: string[];
warningCount: number;
assetStatus?: RpgAgentRoleAssetStatus | null;
assetStatusLabel?: string | null;
}
export interface RpgAgentDraftCardDetailSection {
id: string;
label: string;
value: string;
}
export interface RpgAgentFoundationDraftFaction {
id: string;
name: string;
title?: string;
subtitle?: string;
publicGoal: string;
relatedConflict: string;
tension?: string;
playerRelation: string;
summary: string;
}
export interface RpgAgentFoundationDraftCharacter {
id: string;
name: string;
title: string;
role: string;
publicIdentity: string;
publicMask?: string;
currentPressure: string;
hiddenHook?: string;
relationToPlayer: string;
threadIds: string[];
summary: string;
skills?: Array<{
id: string;
name: string;
actionPreviewConfig?: Record<string, unknown> | null;
}>;
imageSrc?: string | null;
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
}
export interface RpgAgentFoundationDraftLandmark {
id: string;
name: string;
description?: string;
purpose: string;
mood: string;
importance: string;
secret?: string;
imageSrc?: string | null;
generatedSceneAssetId?: string | null;
generatedScenePrompt?: string | null;
generatedSceneModel?: string | null;
characterIds: string[];
threadIds: string[];
summary: string;
}
export interface RpgAgentFoundationDraftThread {
id: string;
title: string;
type: 'main' | 'hidden';
conflictType?: string;
conflict: string;
stakes?: string;
characterIds: string[];
landmarkIds: string[];
summary: string;
}
export interface RpgAgentFoundationDraftChapter {
id: string;
title: string;
openingEvent: string;
playerGoal: string;
characterIds: string[];
landmarkIds: string[];
understandingShift: string;
summary: string;
}
export interface RpgAgentFoundationDraftCamp {
id: string;
name: string;
description: string;
mood: string;
imageSrc?: string | null;
generatedSceneAssetId?: string | null;
generatedScenePrompt?: string | null;
generatedSceneModel?: string | null;
summary: string;
}
export interface RpgAgentWorldAttributeSlot {
slotId: 'axis_a' | 'axis_b' | 'axis_c' | 'axis_d' | 'axis_e' | 'axis_f';
name: string;
definition: string;
positiveSignals: string[];
negativeSignals: string[];
combatUseText: string;
socialUseText: string;
explorationUseText: string;
}
export interface RpgAgentWorldAttributeSchema {
id: string;
worldId: string;
schemaVersion: number;
generatedFrom: {
worldType: 'CUSTOM' | 'WUXIA' | 'XIANXIA';
worldName: string;
settingSummary: string;
tone: string;
conflictCore: string;
};
schemaName?: string;
slots: RpgAgentWorldAttributeSlot[];
}
export type RpgAgentSceneActStage =
| 'opening'
| 'expansion'
| 'turning_point'
| 'climax'
| 'aftermath';
export type RpgAgentSceneActAdvanceRule =
| 'after_primary_contact'
| 'after_active_step_complete'
| 'after_chapter_resolution';
export interface RpgAgentFoundationDraftSceneAct {
id: string;
title: string;
summary: string;
stageCoverage: RpgAgentSceneActStage[];
backgroundImageSrc?: string | null;
backgroundAssetId?: string | null;
encounterNpcIds: string[];
primaryNpcId: string;
oppositeNpcId: string;
eventDescription: string;
linkedThreadIds: string[];
actGoal: string;
transitionHook: string;
advanceRule: RpgAgentSceneActAdvanceRule;
}
export interface RpgAgentFoundationDraftSceneChapter {
id: string;
sceneId: string;
sceneName: string;
title: string;
summary: string;
sceneTaskDescription: string;
linkedThreadIds: string[];
linkedLandmarkIds: string[];
acts: RpgAgentFoundationDraftSceneAct[];
}
export interface RpgAgentFoundationDraftProfile {
name: string;
subtitle: string;
summary: string;
tone: string;
playerGoal: string;
majorFactions: string[];
coreConflicts: string[];
playableNpcs: RpgAgentFoundationDraftCharacter[];
storyNpcs: RpgAgentFoundationDraftCharacter[];
landmarks: RpgAgentFoundationDraftLandmark[];
camp?: RpgAgentFoundationDraftCamp | null;
themePack?: Record<string, unknown> | null;
storyGraph?: Record<string, unknown> | null;
attributeSchema: RpgAgentWorldAttributeSchema;
factions: RpgAgentFoundationDraftFaction[];
threads: RpgAgentFoundationDraftThread[];
chapters: RpgAgentFoundationDraftChapter[];
sceneChapters: RpgAgentFoundationDraftSceneChapter[];
worldHook: string;
playerPremise: string;
openingSituation: string;
iconicElements: string[];
sourceAnchorSummary: string;
}
export interface RpgAgentFoundationDraftResult {
draftProfile: RpgAgentFoundationDraftProfile;
draftCards: RpgAgentDraftCardSummary[];
}
export interface RpgAgentDraftCardDetail {
id: string;
kind: RpgAgentDraftCardKind;
title: string;
sections: RpgAgentDraftCardDetailSection[];
linkedIds: string[];
locked: false;
editable: boolean;
editableSectionIds: string[];
warningMessages: string[];
assetStatus?: RpgAgentRoleAssetStatus | null;
assetStatusLabel?: string | null;
}
export type RpgAgentAssetPriorityTier = 'hero' | 'featured' | 'supporting';
export type RpgAgentRoleAssetStatus =
| 'missing'
| 'visual_ready'
| 'animations_ready'
| 'complete';
export interface RpgAgentRoleAssetSummary {
roleId: string;
roleName: string;
roleKind: 'playable' | 'story';
priorityTier: RpgAgentAssetPriorityTier;
portraitPath?: string | null;
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
status: RpgAgentRoleAssetStatus;
missingAnimations: string[];
nextPointCost: number;
}
export interface RpgAgentSceneAssetSummary {
sceneId: string;
sceneName: string;
actId?: string | null;
actTitle?: string | null;
imageSrc?: string | null;
assetId?: string | null;
status: 'missing' | 'ready';
nextPointCost: number;
}
export interface RpgAgentAssetCoverageSummary {
roleAssets: RpgAgentRoleAssetSummary[];
sceneAssets: RpgAgentSceneAssetSummary[];
allRoleAssetsReady: boolean;
allSceneAssetsReady: boolean;
}

View File

@@ -0,0 +1,134 @@
import type { RpgAgentActionResponse, RpgAgentOperationRecord, RpgAgentSupportedAction, RpgAgentSuggestedAction } from './rpgAgentActions';
import type { RpgCreationAnchorContent } from './rpgAgentAnchors';
import type { RpgAgentAssetCoverageSummary, RpgAgentDraftCardDetail, RpgAgentDraftCardSummary } from './rpgAgentDraft';
import type { RpgCreationPreviewEnvelope } from './rpgCreationPreview';
/**
* RPG Agent 会话层契约。
* 这里承载 session 真相源与会话编排元数据,同时预留 resultPreview 与 supportedActions 两个后续主链字段。
*/
export interface RpgCreationIntentReadiness {
isReady: boolean;
completedKeys: string[];
missingKeys: string[];
}
export interface RpgAgentPendingClarification {
id: string;
label: string;
question: string;
targetKey:
| 'world_hook'
| 'player_premise'
| 'theme_and_tone'
| 'core_conflict'
| 'relationship_seed'
| 'iconic_element';
priority: number;
answer?: string;
}
export type RpgAgentStage =
| 'collecting_intent'
| 'clarifying'
| 'foundation_review'
| 'object_refining'
| 'visual_refining'
| 'long_tail_review'
| 'ready_to_publish'
| 'published'
| 'error';
export type RpgAgentMessageRole = 'user' | 'assistant' | 'system';
export type RpgAgentMessageKind =
| 'chat'
| 'clarification'
| 'summary'
| 'checkpoint'
| 'warning'
| 'action_result';
export interface RpgAgentMessage {
id: string;
role: RpgAgentMessageRole;
kind: RpgAgentMessageKind;
text: string;
createdAt: string;
relatedOperationId?: string | null;
}
export interface RpgAgentQualityFinding {
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
targetId?: string | null;
message: string;
}
export interface RpgAgentSessionSnapshot {
sessionId: string;
currentTurn: number;
anchorContent: RpgCreationAnchorContent;
progressPercent: number;
lastAssistantReply: string | null;
stage: RpgAgentStage;
focusCardId: string | null;
creatorIntent: Record<string, unknown> | null;
creatorIntentReadiness: RpgCreationIntentReadiness;
anchorPack: Record<string, unknown> | null;
lockState: Record<string, unknown> | null;
draftProfile: Record<string, unknown> | null;
messages: RpgAgentMessage[];
draftCards: RpgAgentDraftCardSummary[];
pendingClarifications: RpgAgentPendingClarification[];
suggestedActions: RpgAgentSuggestedAction[];
recommendedReplies: string[];
qualityFindings: RpgAgentQualityFinding[];
assetCoverage: RpgAgentAssetCoverageSummary;
/**
* checkpoint 元数据需要进入 session snapshot 主链,
* 这样前端后续才能拿到真实可回滚目标,而不是只能盲发 checkpointId。
*/
checkpoints?: Array<{
checkpointId: string;
createdAt: string;
label: string;
}>;
/**
* 后续由工作包 E 的 action registry 真实填充。
* 当前保持可选,确保主链迁移期间不影响旧 session snapshot。
*/
supportedActions?: RpgAgentSupportedAction[];
/**
* 后续由服务端 preview compiler 输出。
* 当前保持可选,允许前端兼容层继续走 legacy profile。
*/
resultPreview?: RpgCreationPreviewEnvelope | null;
updatedAt: string;
}
export interface CreateRpgAgentSessionRequest {
seedText?: string;
}
export interface CreateRpgAgentSessionResponse {
session: RpgAgentSessionSnapshot;
}
export interface SendRpgAgentMessageRequest {
clientMessageId: string;
text: string;
quickFillRequested?: boolean;
focusCardId?: string | null;
selectedCardIds?: string[];
}
export interface SendRpgAgentMessageResponse extends RpgAgentActionResponse {
operation: RpgAgentOperationRecord;
}
export interface GetRpgAgentCardDetailResponse {
card: RpgAgentDraftCardDetail;
}

View File

@@ -0,0 +1,146 @@
import { describe, expect, test } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from './customWorldAgentSession';
import type { CustomWorldResultPreviewEnvelope } from './customWorldResultPreview';
import type { CustomWorldWorkSummary } from './customWorldWorkSummary';
import {
createRpgAgentFoundationDraftProfileFixture,
createRpgAgentSupportedActionsFixture,
createRpgAgentSessionFixture,
createRpgCreationAnchorContentFixture,
createRpgCreationPreviewEnvelopeFixture,
createRpgCreationPublishedProfileFixture,
createRpgCreationWorksResponseFixture,
createRpgWorldLibraryEntryFixture,
} from './rpgCreationFixtures';
describe('RPG 创作共享契约 fixture', () => {
test('旧命名兼容分文件可以直接承接新 fixture 的类型消费', () => {
const legacySession: CustomWorldAgentSessionSnapshot =
createRpgAgentSessionFixture();
const legacyPreview: CustomWorldResultPreviewEnvelope =
createRpgCreationPreviewEnvelopeFixture();
const legacyWork: CustomWorldWorkSummary =
createRpgCreationWorksResponseFixture().items[0]!;
expect(legacySession.stage).toBe('ready_to_publish');
expect(legacySession.resultPreview?.source).toBe(legacyPreview.source);
expect(legacyWork.status).toBe('draft');
});
test('anchor fixture 与 foundation draft fixture 保持最小创作真相源对应关系', () => {
const anchors = createRpgCreationAnchorContentFixture();
const draftProfile = createRpgAgentFoundationDraftProfileFixture();
expect(anchors.worldPromise).toContain('旧航路群岛');
expect(draftProfile.worldHook).toContain('旧航路群岛');
expect(draftProfile.playableNpcs).toHaveLength(1);
expect(draftProfile.storyNpcs).toHaveLength(1);
expect(draftProfile.sceneChapters[0]?.acts[0]?.backgroundImageSrc).toContain(
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
);
});
test('session fixture 同时暴露 supportedActions 与 resultPreview', () => {
const session = createRpgAgentSessionFixture();
expect(session.sessionId).toBe('rpg-session-fixture');
expect(session.stage).toBe('ready_to_publish');
expect(session.checkpoints?.[0]?.checkpointId).toBe(
'checkpoint-foundation-v1',
);
expect(session.supportedActions?.map((entry) => entry.action)).toEqual(
expect.arrayContaining(['draft_foundation', 'generate_role_assets', 'publish_world']),
);
expect(session.resultPreview?.source).toBe('session_preview');
expect(session.resultPreview?.blockers).toEqual([]);
});
test('preview fixture 保持预览来源、质量结论与 profile 载体三层边界', () => {
const preview = createRpgCreationPreviewEnvelopeFixture();
expect(preview.source).toBe('session_preview');
expect(preview.preview.previewId).toBe('preview-fixture-1');
expect(preview.preview.sessionId).toBe('rpg-session-fixture');
expect(preview.qualityFindings?.[0]).toMatchObject({
severity: 'info',
code: 'scene_asset_ready',
});
});
test('supported actions fixture 明确区分可执行能力矩阵,而不是让前端自行猜测按钮状态', () => {
const supportedActions = createRpgAgentSupportedActionsFixture();
expect(supportedActions).toEqual([
{ action: 'draft_foundation', enabled: true },
{ action: 'generate_role_assets', enabled: true },
{ action: 'publish_world', enabled: true },
]);
});
test('published profile fixture 能稳定承载作品库与结果页所需的封面、场景幕与角色资产字段', () => {
const profile = createRpgCreationPublishedProfileFixture();
expect(profile.id).toBe('rpg-profile-fixture');
expect(profile.playableNpcs).toHaveLength(1);
expect(profile.landmarks).toHaveLength(1);
expect(profile.sceneChapterBlueprints).toHaveLength(1);
expect(
(profile.sceneChapterBlueprints as Array<{ acts?: Array<{ backgroundImageSrc?: string }> }>)[0]
?.acts?.[0]?.backgroundImageSrc,
).toContain('/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png');
});
test('regression: session preview 与 published profile 需要共同保留角色动作资产和分幕背景字段', () => {
const session = createRpgAgentSessionFixture();
const publishedProfile = createRpgCreationPublishedProfileFixture();
const preview = createRpgCreationPreviewEnvelopeFixture();
expect(
((session.draftProfile as { playableNpcs?: Array<{ animationMap?: { run?: { basePath?: string } } }> })
.playableNpcs?.[0]?.animationMap?.run?.basePath ?? ''),
).toContain('/generated-characters/playable-1/animations/run');
expect(
((preview.preview.playableNpcs as Array<{ generatedAnimationSetId?: string }>)[0]
?.generatedAnimationSetId ?? ''),
).toBe('animation-set-playable-1');
expect(
((publishedProfile.sceneChapterBlueprints as Array<{
acts?: Array<{ backgroundAssetId?: string }>;
}>)[0]?.acts?.[0]?.backgroundAssetId ?? ''),
).toBe('scene-asset-runtime');
});
test('works fixture 与 library fixture 对齐同一 published profile', () => {
const works = createRpgCreationWorksResponseFixture();
const libraryEntry = createRpgWorldLibraryEntryFixture();
const publishedWork = works.items.find((entry) => entry.status === 'published');
expect(publishedWork?.profileId).toBe(libraryEntry.profileId);
expect(publishedWork?.title).toBe(libraryEntry.worldName);
expect(publishedWork?.canEnterWorld).toBe(true);
expect(libraryEntry.profile.id).toBe(libraryEntry.profileId);
});
test('regression: works fixture 需要稳定保留草稿与发布态的作品门槛字段', () => {
const works = createRpgCreationWorksResponseFixture();
const draftWork = works.items.find((entry) => entry.status === 'draft');
const publishedWork = works.items.find((entry) => entry.status === 'published');
expect(draftWork).toMatchObject({
stage: 'ready_to_publish',
stageLabel: '准备发布',
canResume: true,
canEnterWorld: false,
roleVisualReadyCount: 2,
roleAnimationReadyCount: 2,
});
expect(publishedWork).toMatchObject({
stage: 'published',
stageLabel: '已发布',
canResume: false,
canEnterWorld: true,
});
});
});

View File

@@ -0,0 +1,766 @@
import type {
CustomWorldLibraryEntry,
CustomWorldProfileRecord,
} from './runtime';
import type { RpgAgentSupportedAction } from './rpgAgentActions';
import type { RpgCreationAnchorContent } from './rpgAgentAnchors';
import type {
RpgAgentAssetCoverageSummary,
RpgAgentDraftCardSummary,
RpgAgentFoundationDraftProfile,
} from './rpgAgentDraft';
import type { RpgAgentSessionSnapshot } from './rpgAgentSession';
import type { RpgCreationPreviewEnvelope } from './rpgCreationPreview';
import type {
ListRpgCreationWorksResponse,
RpgCreationWorkSummary,
} from './rpgCreationWorkSummary';
const RPG_CREATION_FIXTURE_SESSION_ID = 'rpg-session-fixture';
const RPG_CREATION_FIXTURE_PROFILE_ID = 'rpg-profile-fixture';
const RPG_CREATION_FIXTURE_USER_ID = 'fixture-user';
const RPG_CREATION_FIXTURE_UPDATED_AT = '2026-04-21T09:30:00.000Z';
const RPG_CREATION_FIXTURE_PUBLISHED_AT = '2026-04-21T10:00:00.000Z';
function cloneFixture<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
/**
* 共享八锚点 fixture。
* 用于 contract test、session fixture 和 works 集成测试复用同一份创作意图样本。
*/
export function createRpgCreationAnchorContentFixture(): RpgCreationAnchorContent {
return cloneFixture({
worldPromise:
'被海雾吞没的旧航路群岛,灯塔与禁航令共同决定谁能活着穿过去,体验压抑、悬疑、潮湿。',
playerFantasy:
'玩家回到群岛调查沉船真相,核心追求是找出失控航路背后的真相,风险是失去最后一个还能对上旧案的人。',
themeBoundary:
'压抑、潮湿、悬疑;旧灯塔、潮雾、断裂航路;不要出现现代枪械。',
playerEntryPoint:
'玩家是被迫返乡的失职守灯人,首夜就有陌生船只闯入禁航区,动机是查清沉船夜里被谁改动了灯册。',
coreConflict:
'守灯会与航运公会争夺旧航路控制权,沉船夜的航灯与灯册被人动过手脚,玩家开局会撞上新的封航命令。',
keyRelationships:
'玩家与沈砺是旧友兼潜在背叛者,沈砺暗地里在替沉船商盟引路。',
hiddenLines:
'沉船夜的真实失误并不是单纯天灾;所有人都会先把问题推给潮雾本身;第一章露出痕迹,第二章才让玩家摸到灯册线。',
iconicElements:
'会移动的海雾、回潮旧灯塔、封灯令、旧潮图;禁航信号一旦点亮,任何船都必须退航。',
} satisfies RpgCreationAnchorContent);
}
/**
* 共享 foundation draft fixture。
* 这份样本同时服务 session 草稿、preview 适配回归测试和 works 聚合测试。
*/
export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundationDraftProfile {
return cloneFixture({
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成,玩家将从回潮旧灯塔切入沉船旧案。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
skills: [
{
id: 'skill-playable-1',
name: '潮行引路',
actionPreviewConfig: {
basePath:
'/generated-characters/playable-1/animations/skills/skill-playable-1',
},
},
],
imageSrc:
'/generated-characters/playable-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-playable',
generatedAnimationSetId: 'animation-set-playable-1',
animationMap: {
idle: {
basePath: '/generated-characters/playable-1/animations/idle',
},
run: {
basePath: '/generated-characters/playable-1/animations/run',
},
attack: {
basePath: '/generated-characters/playable-1/animations/attack',
},
},
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
skills: [
{
id: 'skill-story-1',
name: '夜潮灯语',
actionPreviewConfig: {
basePath:
'/generated-characters/story-1/animations/skills/skill-story-1',
},
},
],
imageSrc:
'/generated-characters/story-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-story',
generatedAnimationSetId: 'animation-set-story-1',
animationMap: {
run: {
basePath: '/generated-characters/story-1/animations/run',
},
attack: {
basePath: '/generated-characters/story-1/animations/attack',
},
},
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
secret: '高处潮痕说明海面异常抬升过。',
imageSrc: '/generated-custom-world-scenes/landmark-1/latest-scene.png',
generatedSceneAssetId: 'scene-asset-landmark-1',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
camp: {
id: 'camp-1',
name: '回潮暂栖所',
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
mood: '克制、紧绷,但还能暂时收拢局势',
imageSrc: '/custom/camp/huichao.png',
generatedSceneAssetId: 'scene-asset-camp-1',
summary: '玩家能在这里整理情报、回看旧灯册和沉船名单。',
},
themePack: {
id: 'theme-pack:tide',
displayName: '潮雾悬疑',
},
storyGraph: {
visibleThreads: [
{
id: 'thread-visible-1',
title: '封航争夺',
},
],
},
attributeSchema: {
id: 'schema:rpg-agent:tide-fixture',
worldId: 'custom:潮雾列岛',
schemaVersion: 1,
schemaName: '潮雾六脉',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '潮雾列岛',
settingSummary: '旧灯塔与失控航路',
tone: '压抑、潮湿、悬疑',
conflictCore: '沉船夜的航灯与灯册被人动过手脚。',
},
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: '在漫长远行与恶劣天气里保有余力。',
},
],
},
factions: [
{
id: 'faction-1',
name: '守灯会',
title: '守灯会',
subtitle: '把控禁航灯令的人',
publicGoal: '维持封航秩序并压住灯册流出。',
relatedConflict: '想把旧案继续压在禁航记录之下。',
tension: '他们越强调规矩,越像在遮掩灯册。',
playerRelation: '玩家迟早要与他们正面冲突。',
summary: '掌握灯塔与封航令的势力,也是最怕旧案被翻出来的一方。',
},
],
threads: [
{
id: 'thread-1',
title: '沉船旧案',
type: 'main',
conflictType: '真相遮蔽',
conflict: '沉船夜的航灯与灯册被人动过手脚。',
stakes: '真相一旦坐实,群岛秩序会先崩。',
characterIds: ['playable-1', 'story-1'],
landmarkIds: ['landmark-1'],
summary: '玩家会从灯塔高处潮痕一路追到沉船夜的真相。',
},
],
chapters: [
{
id: 'chapter-1',
title: '灯塔回潮',
openingEvent: '禁航区闯入了一艘不该出现的陌生船。',
playerGoal: '先稳住局势,再拿到第一份灯册线索。',
characterIds: ['playable-1', 'story-1'],
landmarkIds: ['landmark-1'],
understandingShift: '玩家会意识到沉船旧案至今仍在操控群岛秩序。',
summary: '第一章聚焦灯塔与封航令,给玩家一条可追的旧案线索。',
},
],
sceneChapters: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
sceneName: '回潮旧灯塔',
title: '灯塔初章',
summary: '围绕灯塔推进的首个场景章节。',
sceneTaskDescription: '首次进入回潮旧灯塔时,追查禁航灯册为何被人改写。',
linkedThreadIds: ['thread-1'],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-1',
title: '第一幕',
summary: '先接住回潮灯塔的入口压力。',
stageCoverage: ['opening'],
backgroundImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
backgroundAssetId: 'scene-asset-runtime',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
oppositeNpcId: 'story-1',
eventDescription: '顾潮音在旧灯塔门前拦住玩家,交出第一段灯册疑点。',
linkedThreadIds: ['thread-1'],
actGoal: '接住首幕入口',
transitionHook: '向第二幕推进。',
advanceRule: 'after_primary_contact',
},
],
},
],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾', '回潮旧灯塔'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
} satisfies RpgAgentFoundationDraftProfile);
}
function createRpgAgentDraftCardsFixture(): RpgAgentDraftCardSummary[] {
return cloneFixture([
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'suggested',
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
warningCount: 0,
},
{
id: 'playable-1',
kind: 'character',
title: '沈砺',
subtitle: '旧航路引路人 / 动作已齐',
summary: '最熟悉旧航路的人,也可能是最危险的旧友。',
status: 'suggested',
linkedIds: ['thread-1', 'landmark-1'],
warningCount: 0,
assetStatus: 'complete',
assetStatusLabel: '动作已齐',
},
{
id: 'landmark-1',
kind: 'landmark',
title: '回潮旧灯塔',
subtitle: '观察雾潮与往来船只',
summary: '旧灯塔是整片群岛最先看见异动的地方。',
status: 'suggested',
linkedIds: ['story-1', 'thread-1'],
warningCount: 0,
},
] satisfies RpgAgentDraftCardSummary[]);
}
function createRpgAgentAssetCoverageFixture(): RpgAgentAssetCoverageSummary {
return cloneFixture({
roleAssets: [
{
roleId: 'playable-1',
roleName: '沈砺',
roleKind: 'playable',
priorityTier: 'hero',
portraitPath:
'/generated-characters/playable-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-playable',
generatedAnimationSetId: 'animation-set-playable-1',
status: 'complete',
missingAnimations: [],
nextPointCost: 0,
},
{
roleId: 'story-1',
roleName: '顾潮音',
roleKind: 'story',
priorityTier: 'featured',
portraitPath:
'/generated-characters/story-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-story',
generatedAnimationSetId: 'animation-set-story-1',
status: 'complete',
missingAnimations: [],
nextPointCost: 0,
},
],
sceneAssets: [
{
sceneId: 'landmark-1',
sceneName: '回潮旧灯塔',
actId: 'scene-act-1',
actTitle: '第一幕',
imageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
assetId: 'scene-asset-runtime',
status: 'ready',
nextPointCost: 0,
},
],
allRoleAssetsReady: true,
allSceneAssetsReady: true,
} satisfies RpgAgentAssetCoverageSummary);
}
/**
* 已发布 profile fixture。
* 用于 preview compiler、works 聚合和 library 元数据解析测试。
*/
export function createRpgCreationPublishedProfileFixture(): CustomWorldProfileRecord {
const draft = createRpgAgentFoundationDraftProfileFixture();
return cloneFixture({
id: RPG_CREATION_FIXTURE_PROFILE_ID,
settingText: draft.worldHook,
name: draft.name,
subtitle: draft.subtitle,
summary: draft.summary,
tone: draft.tone,
playerGoal: draft.playerGoal,
templateWorldType: 'WUXIA',
compatibilityTemplateWorldType: 'WUXIA',
majorFactions: draft.majorFactions,
coreConflicts: draft.coreConflicts,
playableNpcs: draft.playableNpcs.map((role) => ({
id: role.id,
name: role.name,
title: role.title,
role: role.role,
description: role.publicIdentity,
backstory: role.hiddenHook || role.summary,
personality: role.publicMask || role.summary,
motivation: role.currentPressure,
combatStyle: '借地形和潮路换位,先拉扯再压近。',
initialAffinity: 18,
relationshipHooks: [role.relationToPlayer],
tags: ['潮路', '旧案'],
imageSrc: role.imageSrc,
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
animationMap: role.animationMap,
skills: [
{
id: 'skill-playable-1',
name: '潮行引路',
summary: '踩着旧潮阶切线前压,替队伍打开角度。',
style: '机动周旋',
},
],
})),
storyNpcs: draft.storyNpcs.map((role) => ({
id: role.id,
name: role.name,
title: role.title,
role: role.role,
description: role.publicIdentity,
backstory: role.hiddenHook || role.summary,
personality: role.publicMask || role.summary,
motivation: role.currentPressure,
combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。',
initialAffinity: 8,
relationshipHooks: [role.relationToPlayer],
tags: ['守灯会', '灯塔'],
imageSrc: role.imageSrc,
generatedVisualAssetId: role.generatedVisualAssetId,
skills: [
{
id: 'skill-story-1',
name: '夜潮灯语',
summary: '借灯语与潮声干扰对方判断。',
style: '起手压制',
},
],
})),
camp: {
name: draft.camp?.name,
description: draft.camp?.description,
imageSrc: draft.camp?.imageSrc,
},
landmarks: draft.landmarks.map((landmark) => ({
id: landmark.id,
name: landmark.name,
description: landmark.description,
imageSrc: landmark.imageSrc,
sceneNpcIds: landmark.characterIds,
connections: [
{
targetLandmarkId: 'landmark-1',
relativePosition: 'forward',
summary: '沿着旧潮阶继续前压到雾栈尽头。',
},
],
})),
cover: {
sourceType: 'default',
characterRoleIds: ['playable-1'],
},
sceneChapterBlueprints: draft.sceneChapters.map((chapter) => ({
id: chapter.id,
sceneId: chapter.sceneId,
sceneName: chapter.sceneName,
title: chapter.title,
summary: chapter.summary,
sceneTaskDescription: chapter.sceneTaskDescription,
acts: chapter.acts.map((act) => ({
id: act.id,
title: act.title,
summary: act.summary,
backgroundImageSrc: act.backgroundImageSrc,
backgroundAssetId: act.backgroundAssetId,
encounterNpcIds: act.encounterNpcIds,
primaryNpcId: act.primaryNpcId,
oppositeNpcId: act.oppositeNpcId,
eventDescription: act.eventDescription,
actGoal: act.actGoal,
transitionHook: act.transitionHook,
})),
})),
themePack: draft.themePack,
storyGraph: draft.storyGraph,
scenarioPackId: 'scenario-pack:tide',
campaignPackId: 'campaign-pack:tide',
generationMode: 'fast',
generationStatus: 'key_only',
updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT,
publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT,
} satisfies CustomWorldProfileRecord);
}
export function createRpgCreationPreviewEnvelopeFixture(): RpgCreationPreviewEnvelope {
return cloneFixture({
preview: {
...createRpgCreationPublishedProfileFixture(),
previewId: 'preview-fixture-1',
sessionId: RPG_CREATION_FIXTURE_SESSION_ID,
profileId: RPG_CREATION_FIXTURE_PROFILE_ID,
},
source: 'session_preview',
generatedAt: RPG_CREATION_FIXTURE_UPDATED_AT,
qualityFindings: [
{
id: 'finding-scene-asset-ready',
severity: 'info',
code: 'scene_asset_ready',
targetId: 'scene-act-1',
message: '首幕背景图已经就绪,可直接用于结果页预览。',
},
],
blockers: [],
publishReady: true,
canEnterWorld: false,
} satisfies RpgCreationPreviewEnvelope);
}
export function createRpgAgentSupportedActionsFixture(): RpgAgentSupportedAction[] {
return cloneFixture([
{
action: 'draft_foundation',
enabled: true,
},
{
action: 'generate_role_assets',
enabled: true,
},
{
action: 'publish_world',
enabled: true,
},
] satisfies RpgAgentSupportedAction[]);
}
/**
* 共享 session snapshot fixture。
* 默认模拟“底稿、预览、资产都已准备好”的 ready_to_publish 状态。
*/
export function createRpgAgentSessionFixture(): RpgAgentSessionSnapshot {
const draftProfile = createRpgAgentFoundationDraftProfileFixture();
return cloneFixture({
sessionId: RPG_CREATION_FIXTURE_SESSION_ID,
currentTurn: 6,
anchorContent: createRpgCreationAnchorContentFixture(),
progressPercent: 100,
lastAssistantReply: '八锚点与底稿都已经齐备,可以进入结果页收口。',
stage: 'ready_to_publish',
focusCardId: null,
creatorIntent: {
sourceMode: 'card',
rawSettingText: draftProfile.worldHook,
worldHook: draftProfile.worldHook,
playerPremise: draftProfile.playerPremise,
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: draftProfile.openingSituation,
coreConflicts: draftProfile.coreConflicts,
keyFactions: ['守灯会'],
keyCharacters: ['沈砺', '顾潮音'],
keyLandmarks: ['回潮旧灯塔'],
iconicElements: draftProfile.iconicElements,
forbiddenDirectives: ['不要出现现代枪械'],
},
creatorIntentReadiness: {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
},
anchorPack: {
summary: draftProfile.sourceAnchorSummary,
},
lockState: {
lockedCardIds: ['world-foundation'],
},
draftProfile: draftProfile as unknown as Record<string, unknown>,
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'summary',
text: '世界底稿已整理完成,建议进入结果页确认资产与发布门槛。',
createdAt: RPG_CREATION_FIXTURE_UPDATED_AT,
relatedOperationId: null,
},
],
draftCards: createRpgAgentDraftCardsFixture(),
pendingClarifications: [],
suggestedActions: [
{
id: 'action-publish',
type: 'publish_world',
label: '发布世界',
},
],
recommendedReplies: ['先看结果页', '继续精修角色关系'],
qualityFindings: [
{
id: 'finding-scene-asset-ready',
severity: 'info',
code: 'scene_asset_ready',
targetId: 'scene-act-1',
message: '首幕背景图已经就绪,可直接用于结果页预览。',
},
],
assetCoverage: createRpgAgentAssetCoverageFixture(),
checkpoints: [
{
checkpointId: 'checkpoint-foundation-v1',
createdAt: RPG_CREATION_FIXTURE_UPDATED_AT,
label: '世界底稿 V1',
},
],
supportedActions: createRpgAgentSupportedActionsFixture(),
resultPreview: createRpgCreationPreviewEnvelopeFixture(),
updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT,
} satisfies RpgAgentSessionSnapshot);
}
export function createRpgWorldLibraryEntryFixture(): CustomWorldLibraryEntry<CustomWorldProfileRecord> {
const profile = createRpgCreationPublishedProfileFixture();
return cloneFixture({
ownerUserId: RPG_CREATION_FIXTURE_USER_ID,
profileId: RPG_CREATION_FIXTURE_PROFILE_ID,
publicWorkCode: 'cw-fixture-001',
authorPublicUserCode: 'sy-fixture-user',
profile,
visibility: 'published',
publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT,
updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT,
authorDisplayName: '测试玩家',
worldName: String(profile.name ?? '潮雾列岛'),
subtitle: String(profile.subtitle ?? '旧灯塔与失控航路'),
summaryText: String(profile.summary ?? '第一版世界底稿已经整理完成。'),
coverImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
themeMode: 'tide',
playableNpcCount: Array.isArray(profile.playableNpcs)
? profile.playableNpcs.length
: 0,
landmarkCount: Array.isArray(profile.landmarks)
? profile.landmarks.length
: 0,
} satisfies CustomWorldLibraryEntry<CustomWorldProfileRecord>);
}
export function createRpgCreationWorksResponseFixture(): ListRpgCreationWorksResponse {
return cloneFixture({
items: [
{
workId: `draft:${RPG_CREATION_FIXTURE_SESSION_ID}`,
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成,玩家将从回潮旧灯塔切入沉船旧案。',
coverImageSrc: '/custom/camp/huichao.png',
coverRenderMode: 'scene_with_roles',
coverCharacterImageSrcs: [
'/generated-characters/playable-1/visual/asset-runtime/master.png',
],
updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT,
publishedAt: null,
stage: 'ready_to_publish',
stageLabel: '准备发布',
playableNpcCount: 2,
landmarkCount: 1,
roleVisualReadyCount: 2,
roleAnimationReadyCount: 2,
roleAssetSummaryLabel: '沈砺 · 动作已就绪',
sessionId: RPG_CREATION_FIXTURE_SESSION_ID,
profileId: null,
canResume: true,
canEnterWorld: false,
blockerCount: 0,
publishReady: true,
},
{
workId: `published:${RPG_CREATION_FIXTURE_PROFILE_ID}`,
sourceType: 'published_profile',
status: 'published',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成,玩家将从回潮旧灯塔切入沉船旧案。',
coverImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
coverRenderMode: 'scene_with_roles',
coverCharacterImageSrcs: [
'/generated-characters/playable-1/visual/asset-runtime/master.png',
],
updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT,
publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT,
stage: 'published',
stageLabel: '已发布',
playableNpcCount: 1,
landmarkCount: 1,
roleVisualReadyCount: 1,
roleAnimationReadyCount: 1,
roleAssetSummaryLabel: '动作已就绪 1',
sessionId: null,
profileId: RPG_CREATION_FIXTURE_PROFILE_ID,
canResume: false,
canEnterWorld: true,
blockerCount: 0,
publishReady: true,
},
] satisfies RpgCreationWorkSummary[],
} satisfies ListRpgCreationWorksResponse);
}

View File

@@ -0,0 +1,40 @@
import type { CustomWorldProfileRecord } from './runtime';
/**
* 结果页预览契约。
* 当前 preview 仍以兼容 profile 作为承载体,但已经把来源、阻断项和质量结论从 session 草稿里显式剥离出来。
*/
export type RpgCreationPreviewSource =
| 'session_preview'
| 'published_profile';
export interface RpgCreationPreviewFinding {
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
targetId?: string | null;
message: string;
}
export interface RpgCreationPreviewBlocker {
id: string;
code: string;
message: string;
}
export type RpgCreationPreview = CustomWorldProfileRecord & {
previewId?: string;
sessionId?: string | null;
profileId?: string | null;
};
export interface RpgCreationPreviewEnvelope {
preview: RpgCreationPreview;
source: RpgCreationPreviewSource;
generatedAt?: string;
qualityFindings?: RpgCreationPreviewFinding[];
blockers?: RpgCreationPreviewBlocker[];
publishReady?: boolean;
canEnterWorld?: boolean;
}

View File

@@ -0,0 +1,38 @@
/**
* RPG 创作作品卡读模型契约。
* works 列表只暴露继续创作与进入世界判断所需的稳定字段。
*/
export type RpgCreationWorkStatus = 'draft' | 'published';
export type RpgCreationWorkSource = 'agent_session' | 'published_profile';
export interface RpgCreationWorkSummary {
workId: string;
sourceType: RpgCreationWorkSource;
status: RpgCreationWorkStatus;
title: string;
subtitle: string;
summary: string;
coverImageSrc?: string | null;
coverRenderMode?: 'image' | 'scene_with_roles';
coverCharacterImageSrcs?: string[];
updatedAt: string;
publishedAt?: string | null;
stage?: string | null;
stageLabel?: string | null;
playableNpcCount: number;
landmarkCount: number;
roleVisualReadyCount?: number;
roleAnimationReadyCount?: number;
roleAssetSummaryLabel?: string | null;
sessionId?: string | null;
profileId?: string | null;
canResume: boolean;
canEnterWorld: boolean;
blockerCount?: number;
publishReady?: boolean;
}
export interface ListRpgCreationWorksResponse {
items: RpgCreationWorkSummary[];
}

View File

@@ -0,0 +1,206 @@
/**
* RPG 运行时聊天相关共享契约。
* 将角色聊天、NPC 对话与轻量 story 请求载荷从旧 story.ts 中独立出来。
*/
import type { JsonObject } from './common';
export type NpcChatTurnLimitReason = 'negative_affinity';
export type NpcChatTurnClosingMode = 'free' | 'foreshadow_close';
export type NpcChatTurnTerminationMode = 'none' | 'hostile_model';
export type NpcChatTurnTerminationReason = 'hostile_breakoff' | 'player_exit';
export type NpcChatFunctionOption = {
functionId: string;
actionText: string;
detailText?: string | null;
action?: string | null;
};
export type NpcChatTurnDirective = {
sceneActId?: string | null;
turnLimit?: number | null;
remainingTurns?: number | null;
limitReason?: NpcChatTurnLimitReason | null;
closingMode?: NpcChatTurnClosingMode | null;
forceExitAfterTurn?: boolean;
terminationMode?: NpcChatTurnTerminationMode | null;
terminationReason?: NpcChatTurnTerminationReason | null;
isHostileChat?: boolean;
functionOptions?: NpcChatFunctionOption[];
};
export type NpcChatTurnCompletionDirective = {
turnLimit?: number | null;
remainingTurns?: number | null;
forceExit?: boolean;
closingMode?: NpcChatTurnClosingMode;
terminationReason?: NpcChatTurnTerminationReason | null;
};
export type CharacterChatReplyRequest<
TCharacter = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TTargetStatus = unknown,
> = {
worldType: string;
playerCharacter: TCharacter;
targetCharacter: TCharacter;
storyHistory: TStoryMoment[];
context: TContext;
conversationHistory: TConversationTurn[];
conversationSummary: string;
playerMessage: string;
targetStatus: TTargetStatus;
};
export type CharacterChatSuggestionsRequest<
TCharacter = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TTargetStatus = unknown,
> = {
worldType: string;
playerCharacter: TCharacter;
targetCharacter: TCharacter;
storyHistory: TStoryMoment[];
context: TContext;
conversationHistory: TConversationTurn[];
conversationSummary: string;
targetStatus: TTargetStatus;
};
export type CharacterChatSummaryRequest<
TCharacter = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TTargetStatus = unknown,
> = {
worldType: string;
playerCharacter: TCharacter;
targetCharacter: TCharacter;
storyHistory: TStoryMoment[];
context: TContext;
conversationHistory: TConversationTurn[];
previousSummary: string;
targetStatus: TTargetStatus;
};
export type NpcChatDialogueRequest<
TCharacter = unknown,
TEncounter = unknown,
TMonster = unknown,
TStoryMoment = unknown,
TContext = unknown,
> = {
worldType: string;
character: TCharacter;
encounter: TEncounter;
monsters: TMonster[];
history: TStoryMoment[];
context: TContext;
topic: string;
resultSummary: string;
npcInitiatesConversation?: boolean;
};
export type NpcChatTurnRequest<
TCharacter = unknown,
TEncounter = unknown,
TMonster = unknown,
TStoryMoment = unknown,
TContext = unknown,
TConversationTurn = unknown,
TCombatContext = unknown,
TNpcState = unknown,
TQuestOfferState = unknown,
TQuestOfferEncounter = unknown,
TChatDirective = NpcChatTurnDirective,
> = {
worldType: string;
character?: TCharacter;
player?: TCharacter;
encounter: TEncounter;
monsters: TMonster[];
history: TStoryMoment[];
context: TContext;
conversationHistory?: TConversationTurn[];
dialogue?: TConversationTurn[];
combatContext?: TCombatContext | null;
playerMessage: string;
npcState: TNpcState;
npcInitiatesConversation?: boolean;
questOfferContext?: {
state: TQuestOfferState;
encounter: TQuestOfferEncounter;
turnCount: number;
} | null;
chatDirective?: TChatDirective | null;
};
export type NpcChatPendingQuestOffer<TQuest = unknown> = {
quest: TQuest;
introText?: string;
};
export type NpcChatFunctionSuggestion = {
functionId: string;
actionText: string;
};
export type NpcChatTurnResult<TQuest = unknown> = {
npcReply: string;
affinityDelta: number;
affinityText: string;
suggestions: string[];
functionSuggestions?: NpcChatFunctionSuggestion[];
pendingQuestOffer?: NpcChatPendingQuestOffer<TQuest> | null;
chatDirective?: NpcChatTurnCompletionDirective | null;
};
export type NpcRecruitDialogueRequest<
TCharacter = unknown,
TEncounter = unknown,
TMonster = unknown,
TStoryMoment = unknown,
TContext = unknown,
> = {
worldType: string;
character: TCharacter;
encounter: TEncounter;
monsters: TMonster[];
history: TStoryMoment[];
context: TContext;
invitationText: string;
recruitSummary: string;
};
export type StoryRequestOptionsPayload = {
availableOptions?: JsonObject[];
optionCatalog?: JsonObject[];
};
export type StoryRequestPayload<TWorldType extends string = string> = {
worldType: TWorldType;
character: JsonObject;
monsters?: JsonObject[];
history?: JsonObject[];
choice?: string;
context: JsonObject;
requestOptions?: StoryRequestOptionsPayload;
};
export type PlainTextPromptRequest = {
systemPrompt: string;
userPrompt: string;
};
export type PlainTextResponse = {
text: string;
};

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from 'vitest';
import type { CharacterChatReplyRequest } from './rpgRuntimeChat';
import { QUEST_NARRATIVE_TYPES } from './rpgRuntimeQuestAssist';
import {
SERVER_RUNTIME_FUNCTION_IDS,
TASK5_RUNTIME_OPTION_SCOPES,
TASK6_RUNTIME_FUNCTION_IDS,
type RuntimeStoryActionRequest,
} from './rpgRuntimeStoryAction';
import type { RuntimeStoryStateRequest } from './rpgRuntimeStoryState';
describe('RPG runtime shared contracts', () => {
test('拆分后的 runtime story action 契约继续导出常量与类型', () => {
expect(SERVER_RUNTIME_FUNCTION_IDS).toContain('npc_chat');
expect(TASK6_RUNTIME_FUNCTION_IDS).toContain('npc_trade');
expect(TASK5_RUNTIME_OPTION_SCOPES).toEqual(['story', 'combat', 'npc']);
const request: RuntimeStoryActionRequest = {
sessionId: 'runtime-session-1',
action: {
type: 'story_choice',
functionId: 'npc_chat',
},
};
expect(request.action.functionId).toBe('npc_chat');
});
test('拆分后的 chat 与 quest assist 契约继续导出运行时类型', () => {
const payload: CharacterChatReplyRequest = {
worldType: 'WUXIA',
playerCharacter: {},
targetCharacter: {},
storyHistory: [],
context: {},
conversationHistory: [],
conversationSummary: '测试摘要',
playerMessage: '近况如何?',
targetStatus: {},
};
const stateRequest: RuntimeStoryStateRequest = {
sessionId: 'runtime-session-2',
};
expect(payload.playerMessage).toBe('近况如何?');
expect(stateRequest.sessionId).toBe('runtime-session-2');
expect(QUEST_NARRATIVE_TYPES).toContain('relationship');
});
});

View File

@@ -0,0 +1,83 @@
/**
* RPG 运行时任务辅助与道具意图共享契约。
* 该文件只承载 quest / runtime item 辅助类型,不混入 runtime story 主状态。
*/
import type { JsonObject } from './common';
export const QUEST_NARRATIVE_TYPES = [
'bounty',
'escort',
'investigation',
'retrieval',
'relationship',
'trial',
] as const;
export type SharedQuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number];
export const QUEST_OBJECTIVE_KINDS = [
'defeat_hostile_npc',
'inspect_treasure',
'spar_with_npc',
'talk_to_npc',
'reach_scene',
'deliver_item',
] as const;
export type SharedQuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number];
export const QUEST_URGENCY_LEVELS = ['low', 'medium', 'high'] as const;
export type SharedQuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number];
export const QUEST_INTIMACY_LEVELS = [
'transactional',
'cooperative',
'trust_based',
] as const;
export type SharedQuestIntimacy = (typeof QUEST_INTIMACY_LEVELS)[number];
export const QUEST_REWARD_THEMES = [
'currency',
'resource',
'relationship',
'intel',
'rare_item',
] as const;
export type SharedQuestRewardTheme = (typeof QUEST_REWARD_THEMES)[number];
export const RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES = [
'heal',
'mana',
'cooldown',
'guard',
'damage',
] as const;
export type SharedRuntimeItemFunctionalBias =
(typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number];
export const RUNTIME_ITEM_TONE_VALUES = [
'grim',
'mysterious',
'martial',
'ritual',
'survival',
] as const;
export type SharedRuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number];
export type RuntimeItemIntentRequest<
TContext = JsonObject,
TPlan = JsonObject,
> = {
context: TContext;
plans: TPlan[];
};
export type RuntimeItemIntentResponse<TIntent = JsonObject> = {
intents: TIntent[];
};
export type QuestGenerationRequest<
TState = JsonObject,
TEncounter = JsonObject,
> = {
state: TState;
encounter: TEncounter;
};

View File

@@ -0,0 +1,129 @@
/**
* RPG runtime story 动作层共享契约。
* 将 function id、动作请求与交互元数据从旧 story.ts 中单独收口。
*/
import type { JsonObject } from './common';
export type RuntimeAction<
TType extends string = string,
TPayload = JsonObject,
> = {
type: TType;
functionId?: string;
targetId?: string;
payload?: TPayload;
};
export type RuntimeActionRequest<
TAction extends RuntimeAction = RuntimeAction,
> = {
sessionId: string;
clientVersion?: number;
action: TAction;
};
export type RuntimeActionResponse<
TViewModel = JsonObject,
TPresentation = JsonObject,
TPatch = JsonObject,
> = {
sessionId: string;
serverVersion: number;
viewModel: TViewModel;
presentation: TPresentation;
patches: TPatch[];
};
export const TASK5_RUNTIME_FUNCTION_IDS = [
'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',
'battle_attack_basic',
'battle_use_skill',
'battle_all_in_crush',
'battle_escape_breakout',
'battle_feint_step',
'battle_finisher_window',
'battle_guard_break',
'battle_probe_pressure',
'battle_recover_breath',
'npc_chat',
'npc_fight',
'npc_help',
'npc_leave',
'npc_preview_talk',
'npc_recruit',
'npc_spar',
] as const;
export type Task5RuntimeFunctionId =
(typeof TASK5_RUNTIME_FUNCTION_IDS)[number];
export const TASK6_RUNTIME_FUNCTION_IDS = [
'equipment_equip',
'equipment_unequip',
'forge_craft',
'forge_dismantle',
'forge_reforge',
'inventory_use',
'npc_gift',
'npc_chat_quest_offer_abandon',
'npc_chat_quest_offer_replace',
'npc_chat_quest_offer_view',
'npc_quest_accept',
'npc_quest_turn_in',
'npc_trade',
] as const;
export type Task6RuntimeFunctionId =
(typeof TASK6_RUNTIME_FUNCTION_IDS)[number];
export const SERVER_RUNTIME_FUNCTION_IDS = [
...TASK5_RUNTIME_FUNCTION_IDS,
...TASK6_RUNTIME_FUNCTION_IDS,
] as const;
export type ServerRuntimeFunctionId =
(typeof SERVER_RUNTIME_FUNCTION_IDS)[number];
export const TASK5_RUNTIME_OPTION_SCOPES = ['story', 'combat', 'npc'] as const;
export type Task5RuntimeOptionScope =
(typeof TASK5_RUNTIME_OPTION_SCOPES)[number];
export type RuntimeStoryChoicePayload = JsonObject & {
optionText?: string;
note?: string;
releaseNpcId?: string;
preludeText?: string;
};
export type RuntimeStoryOptionInteraction =
| {
kind: 'npc';
npcId: string;
action:
| 'chat'
| 'help'
| 'fight'
| 'leave'
| 'quest_offer_abandon'
| 'quest_offer_replace'
| 'quest_offer_view'
| 'recruit'
| 'spar'
| 'trade'
| 'gift'
| 'quest_accept'
| 'quest_turn_in';
questId?: string;
};
export type RuntimeStoryChoiceAction = RuntimeAction<
'story_choice',
RuntimeStoryChoicePayload
> & {
functionId: string;
targetId?: string;
};

View File

@@ -0,0 +1,146 @@
/**
* RPG runtime story 状态与响应共享契约。
* 该文件只负责 view model、presentation、patch 与 snapshot 回包结构。
*/
import type { JsonObject } from './common';
import type { SavedGameSnapshot, SavedGameSnapshotInput } from './runtime';
import type {
RuntimeActionRequest,
RuntimeActionResponse,
RuntimeStoryChoiceAction,
RuntimeStoryChoicePayload,
RuntimeStoryOptionInteraction,
Task5RuntimeOptionScope,
} from './rpgRuntimeStoryAction';
export type RuntimeStoryOptionView = {
functionId: string;
actionText: string;
detailText?: string;
scope: Task5RuntimeOptionScope;
interaction?: RuntimeStoryOptionInteraction;
payload?: RuntimeStoryChoicePayload;
disabled?: boolean;
reason?: string;
};
export type RuntimeStoryPlayerViewModel = {
hp: number;
maxHp: number;
mana: number;
maxMana: number;
};
export type RuntimeStoryCompanionViewModel = {
npcId: string;
characterId?: string;
joinedAtAffinity: number;
};
export type RuntimeStoryEncounterViewModel = {
id: string;
kind: 'npc' | 'treasure';
npcName: string;
hostile: boolean;
affinity?: number;
recruited?: boolean;
interactionActive: boolean;
battleMode?: 'fight' | 'spar' | null;
};
export type RuntimeStoryStatusViewModel = {
inBattle: boolean;
npcInteractionActive: boolean;
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
};
export type RuntimeBattlePresentation = {
targetId?: string;
targetName?: string;
damageDealt?: number;
damageTaken?: number;
outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'escaped';
};
export type RuntimeStoryViewModel = {
player: RuntimeStoryPlayerViewModel;
encounter: RuntimeStoryEncounterViewModel | null;
companions: RuntimeStoryCompanionViewModel[];
availableOptions: RuntimeStoryOptionView[];
status: RuntimeStoryStatusViewModel;
};
export type RuntimeStoryPresentation = {
actionText: string;
resultText: string;
storyText: string;
options: RuntimeStoryOptionView[];
toast?: string | null;
battle?: RuntimeBattlePresentation | null;
};
export type RuntimeStoryPatch =
| {
type: 'story_history_append';
actionText: string;
resultText: string;
}
| {
type: 'npc_affinity_changed';
npcId: string;
previousAffinity: number;
nextAffinity: number;
}
| {
type: 'battle_resolved';
functionId: string;
targetId?: string;
damageDealt?: number;
damageTaken?: number;
outcome: 'ongoing' | 'victory' | 'spar_complete' | 'escaped';
}
| {
type: 'status_changed';
inBattle: boolean;
npcInteractionActive: boolean;
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
}
| {
type: 'encounter_changed';
encounterId: string | null;
};
export type RuntimeStoryActionRequest =
RuntimeActionRequest<RuntimeStoryChoiceAction> & {
snapshot?: SavedGameSnapshotInput;
};
export type RuntimeStoryStateRequest<
TSnapshotGameState = JsonObject,
TSnapshotCurrentStory = JsonObject,
> = {
sessionId: string;
clientVersion?: number;
snapshot?: SavedGameSnapshotInput<
TSnapshotGameState,
string,
TSnapshotCurrentStory
>;
};
export type RuntimeStoryActionResponse<
TSnapshotGameState = JsonObject,
TSnapshotCurrentStory = JsonObject,
> = RuntimeActionResponse<
RuntimeStoryViewModel,
RuntimeStoryPresentation,
RuntimeStoryPatch
> & {
snapshot: SavedGameSnapshot<
TSnapshotGameState,
string,
TSnapshotCurrentStory
>;
};

View File

@@ -0,0 +1,368 @@
import type { JsonObject } from './common';
export const SAVE_SNAPSHOT_VERSION = 2;
export const DEFAULT_MUSIC_VOLUME = 0.42;
export const DEFAULT_PLATFORM_THEME = 'light';
export const PLATFORM_THEMES = ['light', 'dark'] as const;
export type PlatformTheme = (typeof PLATFORM_THEMES)[number];
export type SavedGameSnapshot<
TGameState = unknown,
TBottomTab extends string = string,
TCurrentStory = unknown,
> = {
version: number;
savedAt: string;
gameState: TGameState;
bottomTab: TBottomTab;
currentStory: TCurrentStory | null;
};
export type SavedGameSnapshotInput<
TGameState = unknown,
TBottomTab extends string = string,
TCurrentStory = unknown,
> = Omit<
SavedGameSnapshot<TGameState, TBottomTab, TCurrentStory>,
'savedAt' | 'version'
> & {
savedAt?: string;
};
export type RuntimeSettings = {
musicVolume: number;
platformTheme: PlatformTheme;
};
export type BasicOkResult = {
ok: true;
};
export type ProfileDashboardCardKey = 'wallet' | 'playTime' | 'playedWorks';
export type ProfileDashboardSummary = {
walletBalance: number;
totalPlayTimeMs: number;
playedWorldCount: number;
updatedAt: string | null;
};
export type ProfileWalletLedgerEntry = {
id: string;
amountDelta: number;
balanceAfter: number;
sourceType:
| 'snapshot_sync'
| 'invite_inviter_reward'
| 'invite_invitee_reward'
| 'points_recharge';
createdAt: string;
};
export type ProfileWalletLedgerResponse = {
entries: ProfileWalletLedgerEntry[];
};
export type ProfileRechargeProductKind = 'points' | 'membership';
export type ProfileMembershipStatus = 'normal' | 'active';
export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year';
export type ProfileRechargeOrderStatus = 'paid';
export type ProfileRechargeProduct = {
productId: string;
title: string;
priceCents: number;
kind: ProfileRechargeProductKind;
pointsAmount: number;
bonusPoints: number;
durationDays: number;
badgeLabel: string;
description: string;
tier: ProfileMembershipTier;
};
export type ProfileMembershipBenefit = {
benefitName: string;
normalValue: string;
monthValue: string;
seasonValue: string;
yearValue: string;
};
export type ProfileMembership = {
status: ProfileMembershipStatus;
tier: ProfileMembershipTier;
startedAt: string | null;
expiresAt: string | null;
updatedAt: string | null;
};
export type ProfileRechargeOrder = {
orderId: string;
productId: string;
productTitle: string;
kind: ProfileRechargeProductKind;
amountCents: number;
status: ProfileRechargeOrderStatus;
paymentChannel: string;
paidAt: string;
createdAt: string;
pointsDelta: number;
membershipExpiresAt: string | null;
};
export type ProfileRechargeCenterResponse = {
walletBalance: number;
membership: ProfileMembership;
pointProducts: ProfileRechargeProduct[];
membershipProducts: ProfileRechargeProduct[];
benefits: ProfileMembershipBenefit[];
latestOrder: ProfileRechargeOrder | null;
hasPointsRecharged: boolean;
};
export type CreateProfileRechargeOrderRequest = {
productId: string;
paymentChannel?: string;
};
export type CreateProfileRechargeOrderResponse = {
order: ProfileRechargeOrder;
center: ProfileRechargeCenterResponse;
};
export type ProfileReferralInviteCenterResponse = {
inviteCode: string;
inviteLinkPath: string;
invitedCount: number;
rewardedInviteCount: number;
todayInviterRewardCount: number;
todayInviterRewardRemaining: number;
rewardPoints: number;
hasRedeemedCode: boolean;
boundInviterUserId: string | null;
boundAt: string | null;
updatedAt: string;
};
export type RedeemProfileReferralInviteCodeRequest = {
inviteCode: string;
};
export type RedeemProfileReferralInviteCodeResponse = {
center: ProfileReferralInviteCenterResponse;
inviteeRewardGranted: boolean;
inviterRewardGranted: boolean;
inviteeBalanceAfter: number;
inviterBalanceAfter: number;
};
export type ProfilePlayedWorkSummary = {
worldKey: string;
ownerUserId: string | null;
profileId: string | null;
worldType: string | null;
worldTitle: string;
worldSubtitle: string;
firstPlayedAt: string;
lastPlayedAt: string;
lastObservedPlayTimeMs: number;
};
export type ProfilePlayStatsResponse = {
totalPlayTimeMs: number;
playedWorks: ProfilePlayedWorkSummary[];
updatedAt: string | null;
};
export type ProfileSaveArchiveSummary = {
worldKey: string;
ownerUserId: string | null;
profileId: string | null;
worldType: string | null;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
lastPlayedAt: string;
};
export type ProfileSaveArchiveListResponse = {
entries: ProfileSaveArchiveSummary[];
};
export type ProfileSaveArchiveResumeResponse<
TGameState = unknown,
TBottomTab extends string = string,
TCurrentStory = unknown,
> = {
entry: ProfileSaveArchiveSummary;
snapshot: SavedGameSnapshot<TGameState, TBottomTab, TCurrentStory>;
};
export type CustomWorldPublicationStatus = 'draft' | 'published';
export type CustomWorldThemeMode =
| 'martial'
| 'arcane'
| 'machina'
| 'tide'
| 'rift'
| 'mythic';
export type CustomWorldProfileRecord = JsonObject & {
id?: string;
};
export type CustomWorldLibraryEntry<TProfile = CustomWorldProfileRecord> = {
ownerUserId: string;
profileId: string;
publicWorkCode: string | null;
authorPublicUserCode: string | null;
profile: TProfile;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldThemeMode;
playableNpcCount: number;
landmarkCount: number;
};
export type CustomWorldGalleryCard = Omit<
CustomWorldLibraryEntry<never>,
'profile'
>;
export type CustomWorldLibraryResponse<TProfile = CustomWorldProfileRecord> = {
entries: CustomWorldLibraryEntry<TProfile>[];
};
export type CustomWorldLibraryMutationResponse<
TProfile = CustomWorldProfileRecord,
> = {
entry: CustomWorldLibraryEntry<TProfile>;
entries: CustomWorldLibraryEntry<TProfile>[];
};
export type CustomWorldGalleryResponse = {
entries: CustomWorldGalleryCard[];
};
export type CustomWorldGalleryDetailResponse<
TProfile = CustomWorldProfileRecord,
> = {
entry: CustomWorldLibraryEntry<TProfile>;
};
export type PlatformBrowseHistoryEntry = {
ownerUserId: string;
profileId: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldThemeMode;
authorDisplayName: string;
visitedAt: string;
};
export type PlatformBrowseHistoryWriteEntry = Omit<
PlatformBrowseHistoryEntry,
'visitedAt'
> & {
visitedAt?: string;
};
export type PlatformBrowseHistoryResponse = {
entries: PlatformBrowseHistoryEntry[];
};
export type PlatformBrowseHistoryBatchSyncRequest = {
entries: PlatformBrowseHistoryWriteEntry[];
};
export const CUSTOM_WORLD_GENERATION_MODES = ['fast', 'full'] as const;
export type CustomWorldGenerationMode =
(typeof CUSTOM_WORLD_GENERATION_MODES)[number];
export const CUSTOM_WORLD_SESSION_STATUSES = [
'clarifying',
'ready_to_generate',
'generating',
'completed',
'generation_error',
] as const;
export type CustomWorldSessionStatus =
(typeof CUSTOM_WORLD_SESSION_STATUSES)[number];
export type CustomWorldQuestion = {
id: string;
label: string;
question: string;
answer?: string;
};
export type CustomWorldGenerationStep = {
id: string;
label: string;
detail: string;
completed: number;
total: number;
status: 'pending' | 'active' | 'completed';
};
export type CustomWorldGenerationProgress = {
phaseId: string;
phaseLabel: string;
phaseDetail: string;
batchLabel?: string;
overallProgress: number;
completedWeight: number;
totalWeight: number;
elapsedMs: number;
estimatedRemainingMs: number | null;
activeStepIndex: number;
steps: CustomWorldGenerationStep[];
};
export type GenerateCustomWorldProfileOptions = {
onProgress?: (progress: CustomWorldGenerationProgress) => void;
signal?: AbortSignal;
};
export type GenerateCustomWorldProfileInput = {
settingText: string;
creatorIntent?: JsonObject | null;
generationMode?: CustomWorldGenerationMode;
};
export type CreateCustomWorldSessionRequest = {
settingText: string;
creatorIntent?: JsonObject | null;
generationMode: CustomWorldGenerationMode;
};
export type AnswerCustomWorldSessionQuestionRequest = {
questionId: string;
answer: string;
};
export type CustomWorldSessionSummary = {
sessionId: string;
status: CustomWorldSessionStatus;
questions: CustomWorldQuestion[];
createdAt: string;
updatedAt: string;
};
export type CustomWorldSessionRecord = CustomWorldSessionSummary & {
settingText: string;
creatorIntent?: JsonObject | null;
generationMode: CustomWorldGenerationMode;
result?: JsonObject;
lastError?: string;
};

221
packages/shared/src/http.ts Normal file
View File

@@ -0,0 +1,221 @@
export const API_VERSION = '2026-04-08';
export const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
export const API_RESPONSE_ENVELOPE_VERSION = 'v1';
export type ApiErrorCode =
| 'BAD_REQUEST'
| 'INVALID_REQUEST'
| 'VALIDATION_ERROR'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'UPSTREAM_ERROR'
| 'INTERNAL_SERVER_ERROR'
| 'bad_request'
| 'validation_error'
| 'unauthorized'
| 'forbidden'
| 'not_found'
| 'conflict'
| 'upstream_error'
| 'internal_error'
| (string & {});
export type ApiErrorPayload = {
code: ApiErrorCode;
message: string;
details?: Record<string, unknown> | null;
};
export type ApiMeta = {
apiVersion: string;
requestId?: string;
routeVersion?: string;
operation?: string | null;
latencyMs?: number;
timestamp?: string;
};
export type ApiSuccessResponse<T> = {
ok: true;
data: T;
error: null;
meta: ApiMeta;
};
export type ApiErrorResponse = {
ok: false;
data: null;
error: ApiErrorPayload;
meta: ApiMeta;
};
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function buildApiMeta(meta: Partial<ApiMeta> = {}): ApiMeta {
return {
apiVersion: meta.apiVersion ?? API_VERSION,
requestId:
typeof meta.requestId === 'string' && meta.requestId.trim()
? meta.requestId.trim()
: undefined,
routeVersion:
typeof meta.routeVersion === 'string' && meta.routeVersion.trim()
? meta.routeVersion.trim()
: undefined,
operation:
typeof meta.operation === 'string' && meta.operation.trim()
? meta.operation.trim()
: meta.operation === null
? null
: undefined,
latencyMs:
typeof meta.latencyMs === 'number' && Number.isFinite(meta.latencyMs)
? meta.latencyMs
: undefined,
timestamp:
typeof meta.timestamp === 'string' && meta.timestamp.trim()
? meta.timestamp.trim()
: undefined,
};
}
export function createApiSuccess<T>(
data: T,
meta: Partial<ApiMeta> = {},
): ApiSuccessResponse<T> {
return {
ok: true,
data,
error: null,
meta: buildApiMeta(meta),
};
}
export function createApiError(
error: ApiErrorPayload,
meta: Partial<ApiMeta> = {},
): ApiErrorResponse {
return {
ok: false,
data: null,
error: {
code: error.code,
message: error.message,
details: error.details ?? null,
},
meta: buildApiMeta(meta),
};
}
export function isApiResponse<T>(value: unknown): value is ApiResponse<T> {
if (!isRecord(value) || typeof value.ok !== 'boolean' || !('meta' in value)) {
return false;
}
if (!isRecord(value.meta) || typeof value.meta.apiVersion !== 'string') {
return false;
}
if (value.ok) {
return 'data' in value && value.error === null;
}
return (
value.data === null &&
isRecord(value.error) &&
typeof value.error.code === 'string' &&
typeof value.error.message === 'string'
);
}
export function unwrapApiResponse<T>(value: ApiResponse<T> | T): T {
if (!isApiResponse<T>(value)) {
return value as T;
}
if (value.ok) {
return value.data;
}
throw new Error(getApiErrorDisplayMessage(value.error) || '请求失败');
}
function readTrimmedMessage(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
export function getApiErrorDisplayMessage(error: ApiErrorPayload) {
// 后端通用 message 常用于错误分类details.message 才是给用户定位问题的业务原因。
const detailMessage = isRecord(error.details)
? readTrimmedMessage(error.details.message)
: '';
return detailMessage || readTrimmedMessage(error.message);
}
export function parseApiErrorMessage(rawText: string, fallbackMessage: string) {
if (!rawText.trim()) {
return fallbackMessage;
}
try {
const parsed = JSON.parse(rawText) as
| ApiErrorResponse
| {
error?: {
message?: string;
code?: string;
details?: Record<string, unknown> | null;
};
message?: string;
code?: string;
};
const detailMessage = isRecord(parsed.error?.details)
? readTrimmedMessage(parsed.error.details.message)
: '';
if (detailMessage) {
return detailMessage;
}
if (
typeof parsed.error?.message === 'string' &&
parsed.error.message.trim()
) {
return parsed.error.message.trim();
}
const topLevelMessage =
'message' in parsed && typeof parsed.message === 'string'
? parsed.message.trim()
: '';
if (topLevelMessage) {
return topLevelMessage;
}
const errorCode =
typeof parsed.error?.code === 'string' && parsed.error.code.trim()
? parsed.error.code.trim()
: 'code' in parsed &&
typeof parsed.code === 'string' &&
parsed.code.trim()
? parsed.code.trim()
: '';
if (errorCode) {
return `${fallbackMessage}${errorCode}`;
}
} catch {
// Ignore malformed json responses.
}
return rawText.trim() || fallbackMessage;
}

View File

@@ -0,0 +1,26 @@
export * from './assets/qwenSprite';
export * from './contracts/auth';
export type * from './contracts/bigFish';
export * from './contracts/common';
export type * from './contracts/customWorldAgent';
export * from './contracts/rpgAgentActions';
export * from './contracts/rpgAgentAnchors';
export * from './contracts/rpgAgentDraft';
export * from './contracts/rpgAgentSession';
export * from './contracts/rpgCreationFixtures';
export * from './contracts/rpgCreationPreview';
export * from './contracts/rpgCreationWorkSummary';
export * from './contracts/puzzleAgentActions';
export * from './contracts/puzzleAgentDraft';
export * from './contracts/puzzleAgentSession';
export * from './contracts/puzzleResultPreview';
export * from './contracts/puzzleRuntimeSession';
export * from './contracts/puzzleWorkSummary';
export * from './contracts/rpgRuntimeChat';
export * from './contracts/rpgRuntimeQuestAssist';
export * from './contracts/rpgRuntimeStoryAction';
export * from './contracts/rpgRuntimeStoryState';
export * from './contracts/runtime';
export * from './http';
export * from './llm/narrativeLanguage';
export * from './llm/parsers';

View File

@@ -0,0 +1,66 @@
const CJK_CHAR_PATTERN = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/gu;
const LATIN_WORD_PATTERN = /[A-Za-z][A-Za-z'-]{1,}/g;
const LATIN_FRAGMENT_PATTERN =
/[A-Za-z][A-Za-z0-9'"()\-,:;!?/]*(?:\s+[A-Za-z0-9'"()\-,:;!?/]+)+/gu;
const SAFE_LATIN_TOKENS = new Set([
'act',
'ai',
'boss',
'cd',
'hp',
'json',
'llm',
'mp',
'npc',
'qa',
'rpg',
]);
function getCjkCharCount(text: string) {
return text.match(CJK_CHAR_PATTERN)?.length ?? 0;
}
function getSignificantLatinWords(text: string) {
return (text.match(LATIN_WORD_PATTERN) ?? [])
.map((word) => word.toLowerCase())
.filter((word) => word.length >= 4 && !SAFE_LATIN_TOKENS.has(word));
}
export function hasMixedNarrativeLanguage(text: string) {
const trimmed = text.trim();
if (!trimmed) {
return false;
}
const cjkCharCount = getCjkCharCount(trimmed);
const latinSentenceFragments = (trimmed.match(LATIN_FRAGMENT_PATTERN) ?? [])
.map((fragment) => fragment.trim())
.filter((fragment) => fragment.split(/\s+/u).length >= 2);
const significantLatinWords = getSignificantLatinWords(trimmed);
if (latinSentenceFragments.length > 0) {
return true;
}
if (cjkCharCount > 0 && significantLatinWords.length >= 2) {
return true;
}
return cjkCharCount === 0 && significantLatinWords.length >= 3;
}
export function sanitizePromptNarrativeText(
text: string | null | undefined,
fallback: string | null = null,
) {
if (typeof text !== 'string') {
return fallback;
}
const trimmed = text.trim();
if (!trimmed) {
return fallback;
}
return hasMixedNarrativeLanguage(trimmed) ? fallback : trimmed;
}

View File

@@ -0,0 +1,28 @@
export function parseJsonResponseText(text: string) {
const trimmed = text.trim();
if (!trimmed) {
throw new Error('LLM returned an empty response.');
}
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu);
if (fencedMatch?.[1]) {
return JSON.parse(fencedMatch[1].trim());
}
const firstBrace = trimmed.indexOf('{');
const lastBrace = trimmed.lastIndexOf('}');
if (firstBrace >= 0 && lastBrace > firstBrace) {
return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
}
return JSON.parse(trimmed);
}
export function parseLineListContent(text: string, maxItems = 3) {
return text
.replace(/\r/g, '')
.split('\n')
.map((line) => line.trim().replace(/^[-*\d.)\s]+/u, '').trim())
.filter(Boolean)
.slice(0, maxItems);
}

View File

@@ -0,0 +1,176 @@
/**
* 共享 sprite / 角色资产正式 prompt 模板。
*
* 这份脚本属于“正式模型 prompt 模板层”,不负责从角色卡里挑默认文本。
* 它的定位是:
* - 给后端角色主图生成链路提供标准主图 prompt 骨架
* - 给后端角色动作视频生成链路提供标准动作 prompt 骨架
*
* 当前角色资产主链中的关系是:
* 1. 前端或 Rust 后端先拿到一段较短的描述文本
* 2. 当前角色资产链路调用本文件 buildMasterPrompt / buildVideoActionPrompt
* 把短描述扩成正式给模型吃的 prompt
*
* 因此本文件不要承载“角色卡字段挑选”或“UI 默认值”职责,
* 只维护共享的正式 prompt 骨架与动作模板。
*/
export type QwenSpriteActionTemplateId =
| 'idle'
| 'run'
| 'attack_slash'
| 'hurt'
| 'die';
export type QwenSpriteActionTemplate = {
id: QwenSpriteActionTemplateId;
label: string;
loop: boolean;
defaultFps: number;
bodyTravel: string;
weaponRule: string;
sequenceLines: [string, string, string, string];
ending: string;
};
export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
{
id: 'idle',
label: '待机循环',
loop: true,
defaultFps: 8,
bodyTravel: '原地',
weaponRule: '武器始终在主手,位置稳定',
sequenceLines: [
'1-4 帧:稳定站姿,轻微呼吸起伏',
'5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化',
'9-12 帧:呼气回落,重心恢复',
'13-16 帧:逐渐回到与首帧接近的站姿',
],
ending: '第 16 帧自然衔接第 1 帧',
},
{
id: 'run',
label: '奔跑循环',
loop: true,
defaultFps: 12,
bodyTravel: '小幅前移但角色中心基本固定',
weaponRule: '武器始终在主手,不换手',
sequenceLines: [
'1-4 帧:右腿前摆,左腿后蹬,身体略前倾',
'5-8 帧:双腿交叉经过身体下方,手臂反向摆动',
'9-12 帧:左腿前摆,右腿后蹬,继续前倾',
'13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态',
],
ending: '第 16 帧能无缝接回第 1 帧',
},
{
id: 'attack_slash',
label: '横斩攻击',
loop: false,
defaultFps: 12,
bodyTravel: '中幅前探',
weaponRule: '右手持武器,始终右手,不换手',
sequenceLines: [
'1-4 帧:轻微收身蓄力,武器向后收',
'5-8 帧:重心前压,挥击开始',
'9-12 帧:斩击达到最大幅度,动作力量最强',
'13-16 帧:顺势收招,回到可接下一动作的稳定姿态',
],
ending: '第 16 帧停在收招后稳定姿态',
},
{
id: 'hurt',
label: '受击后仰',
loop: false,
defaultFps: 10,
bodyTravel: '原地或极小后仰',
weaponRule: '武器不要脱手,不要换手',
sequenceLines: [
'1-4 帧:突然受击,头肩后仰',
'5-8 帧:身体失衡最明显',
'9-12 帧:手臂和武器随惯性摆动',
'13-16 帧:逐渐恢复到勉强站稳的姿态',
],
ending: '第 16 帧能接回 idle 或下一个动作',
},
{
id: 'die',
label: '倒地死亡',
loop: false,
defaultFps: 8,
bodyTravel: '明显倒地位移',
weaponRule: '武器不可瞬间消失',
sequenceLines: [
'1-4 帧:受创失衡,重心被打断',
'5-8 帧:身体明显下坠或后仰',
'9-12 帧:倒地过程完成,动作幅度最大',
'13-16 帧:停在清晰的终止姿态',
],
ending: '第 16 帧停在死亡结束姿态,不需要循环',
},
];
const BODY_RATIO_TEXT =
'横版像素动作角色体型,头身比优先控制在 3 到 4 头身,头部只允许略大于写实比例,保留清楚的头、躯干、双臂和双腿轮廓,不要退化成软萌 Q版大头贴或儿童绘本比例。';
const PIXEL_STYLE_TEXT =
'明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,身体始终朝右,适合横版动作 sprite 资产。';
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
return (
QWEN_SPRITE_ACTION_TEMPLATES.find((template) => template.id === id) ??
QWEN_SPRITE_ACTION_TEMPLATES[0]
);
}
/**
* 正式角色主图 prompt 骨架。
*
* 输入应该是一段已经整理好的角色摘要或视觉描述,
* 这里会把它嵌进统一的 sprite 资产约束中,
* 输出真正发给图像模型的完整 prompt。
*/
export function buildMasterPrompt(characterBrief: string) {
return [
'单人2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
`画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
`风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,形体清晰,服装层次明确,优先体现像素动作角色感而不是软萌 Q版插画感便于后续连续动作生成。`,
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。',
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。',
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。',
characterBrief.trim(),
]
.filter(Boolean)
.join('\n');
}
/**
* 正式动作视频 prompt 骨架。
*
* 输入应该是已经整理好的动作细节与角色摘要,
* 这里负责统一拼装成 sprite 动作生成所需的正式 prompt
* 包括视角、像素风格、动作模板、绿幕约束等。
*/
export function buildVideoActionPrompt(options: {
actionTemplate: QwenSpriteActionTemplate;
actionDetailText: string;
useChromaKey: boolean;
characterBrief: string;
}) {
return [
`单人全身角色动作视频,动作英文名是 ${options.actionTemplate.id}`,
`角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰不要退化成完全 90 度纯右视图。`,
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
`画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
`风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,优先保证像素动作角色感,不要退化成只剩 Q 版比例的普通插画,便于后续连续动作生成。`,
`动作结构:${options.actionTemplate.sequenceLines.join('')}。结尾要求:${options.actionTemplate.ending}`,
options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
: '背景简洁纯净,无复杂场景。',
`动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`,
`角色设定:${options.characterBrief.trim()}`,
'目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。',
].join(' ');
}