This commit is contained in:
6
packages/shared/package.json
Normal file
6
packages/shared/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@genarrative/shared",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module"
|
||||
}
|
||||
478
packages/shared/src/assets/chromaKey.ts
Normal file
478
packages/shared/src/assets/chromaKey.ts
Normal 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;
|
||||
}
|
||||
1
packages/shared/src/assets/qwenSprite.ts
Normal file
1
packages/shared/src/assets/qwenSprite.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../prompts/qwenSprite.js';
|
||||
199
packages/shared/src/contracts/auth.ts
Normal file
199
packages/shared/src/contracts/auth.ts
Normal 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;
|
||||
};
|
||||
191
packages/shared/src/contracts/bigFish.ts
Normal file
191
packages/shared/src/contracts/bigFish.ts
Normal 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;
|
||||
};
|
||||
21
packages/shared/src/contracts/bigFishWorkSummary.ts
Normal file
21
packages/shared/src/contracts/bigFishWorkSummary.ts
Normal 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[];
|
||||
}
|
||||
3
packages/shared/src/contracts/common.ts
Normal file
3
packages/shared/src/contracts/common.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export type JsonArray = unknown[];
|
||||
16
packages/shared/src/contracts/creationAgentDocumentInput.ts
Normal file
16
packages/shared/src/contracts/creationAgentDocumentInput.ts
Normal 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;
|
||||
}
|
||||
12
packages/shared/src/contracts/customWorldAgent.ts
Normal file
12
packages/shared/src/contracts/customWorldAgent.ts
Normal 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';
|
||||
14
packages/shared/src/contracts/customWorldAgentActions.ts
Normal file
14
packages/shared/src/contracts/customWorldAgentActions.ts
Normal 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';
|
||||
9
packages/shared/src/contracts/customWorldAgentAnchors.ts
Normal file
9
packages/shared/src/contracts/customWorldAgentAnchors.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 旧 custom world 八锚点兼容出口。
|
||||
* 这里只保留旧命名到 RPG 创作域新契约的映射,便于旧导入渐进迁移。
|
||||
*/
|
||||
|
||||
export type {
|
||||
RpgCreationAnchorText as AnchorTextValue,
|
||||
RpgCreationAnchorContent as EightAnchorContent,
|
||||
} from './rpgAgentAnchors';
|
||||
29
packages/shared/src/contracts/customWorldAgentDraft.ts
Normal file
29
packages/shared/src/contracts/customWorldAgentDraft.ts
Normal 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';
|
||||
20
packages/shared/src/contracts/customWorldAgentSession.ts
Normal file
20
packages/shared/src/contracts/customWorldAgentSession.ts
Normal 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';
|
||||
12
packages/shared/src/contracts/customWorldResultPreview.ts
Normal file
12
packages/shared/src/contracts/customWorldResultPreview.ts
Normal 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';
|
||||
11
packages/shared/src/contracts/customWorldWorkSummary.ts
Normal file
11
packages/shared/src/contracts/customWorldWorkSummary.ts
Normal 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';
|
||||
65
packages/shared/src/contracts/puzzleAgentActions.ts
Normal file
65
packages/shared/src/contracts/puzzleAgentActions.ts
Normal 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;
|
||||
}
|
||||
58
packages/shared/src/contracts/puzzleAgentDraft.ts
Normal file
58
packages/shared/src/contracts/puzzleAgentDraft.ts
Normal 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;
|
||||
}
|
||||
59
packages/shared/src/contracts/puzzleAgentSession.ts
Normal file
59
packages/shared/src/contracts/puzzleAgentSession.ts
Normal 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;
|
||||
}
|
||||
21
packages/shared/src/contracts/puzzleResultPreview.ts
Normal file
21
packages/shared/src/contracts/puzzleResultPreview.ts
Normal 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;
|
||||
}
|
||||
79
packages/shared/src/contracts/puzzleRuntimeSession.ts
Normal file
79
packages/shared/src/contracts/puzzleRuntimeSession.ts
Normal 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;
|
||||
}
|
||||
39
packages/shared/src/contracts/puzzleWorkSummary.ts
Normal file
39
packages/shared/src/contracts/puzzleWorkSummary.ts
Normal 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;
|
||||
}
|
||||
132
packages/shared/src/contracts/rpgAgentActions.ts
Normal file
132
packages/shared/src/contracts/rpgAgentActions.ts
Normal 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;
|
||||
}
|
||||
17
packages/shared/src/contracts/rpgAgentAnchors.ts
Normal file
17
packages/shared/src/contracts/rpgAgentAnchors.ts
Normal 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;
|
||||
}
|
||||
279
packages/shared/src/contracts/rpgAgentDraft.ts
Normal file
279
packages/shared/src/contracts/rpgAgentDraft.ts
Normal 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;
|
||||
}
|
||||
134
packages/shared/src/contracts/rpgAgentSession.ts
Normal file
134
packages/shared/src/contracts/rpgAgentSession.ts
Normal 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;
|
||||
}
|
||||
146
packages/shared/src/contracts/rpgContracts.test.ts
Normal file
146
packages/shared/src/contracts/rpgContracts.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
766
packages/shared/src/contracts/rpgCreationFixtures.ts
Normal file
766
packages/shared/src/contracts/rpgCreationFixtures.ts
Normal 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);
|
||||
}
|
||||
40
packages/shared/src/contracts/rpgCreationPreview.ts
Normal file
40
packages/shared/src/contracts/rpgCreationPreview.ts
Normal 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;
|
||||
}
|
||||
38
packages/shared/src/contracts/rpgCreationWorkSummary.ts
Normal file
38
packages/shared/src/contracts/rpgCreationWorkSummary.ts
Normal 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[];
|
||||
}
|
||||
206
packages/shared/src/contracts/rpgRuntimeChat.ts
Normal file
206
packages/shared/src/contracts/rpgRuntimeChat.ts
Normal 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;
|
||||
};
|
||||
51
packages/shared/src/contracts/rpgRuntimeContracts.test.ts
Normal file
51
packages/shared/src/contracts/rpgRuntimeContracts.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
83
packages/shared/src/contracts/rpgRuntimeQuestAssist.ts
Normal file
83
packages/shared/src/contracts/rpgRuntimeQuestAssist.ts
Normal 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;
|
||||
};
|
||||
129
packages/shared/src/contracts/rpgRuntimeStoryAction.ts
Normal file
129
packages/shared/src/contracts/rpgRuntimeStoryAction.ts
Normal 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;
|
||||
};
|
||||
146
packages/shared/src/contracts/rpgRuntimeStoryState.ts
Normal file
146
packages/shared/src/contracts/rpgRuntimeStoryState.ts
Normal 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
|
||||
>;
|
||||
};
|
||||
368
packages/shared/src/contracts/runtime.ts
Normal file
368
packages/shared/src/contracts/runtime.ts
Normal 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
221
packages/shared/src/http.ts
Normal 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;
|
||||
}
|
||||
26
packages/shared/src/index.ts
Normal file
26
packages/shared/src/index.ts
Normal 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';
|
||||
66
packages/shared/src/llm/narrativeLanguage.ts
Normal file
66
packages/shared/src/llm/narrativeLanguage.ts
Normal 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;
|
||||
}
|
||||
28
packages/shared/src/llm/parsers.ts
Normal file
28
packages/shared/src/llm/parsers.ts
Normal 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);
|
||||
}
|
||||
176
packages/shared/src/prompts/qwenSprite.ts
Normal file
176
packages/shared/src/prompts/qwenSprite.ts
Normal 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(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user