1
91
.codex/skills/gpt-image-2-apimart/SKILL.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
name: gpt-image-2-apimart
|
||||||
|
description: Generate or inspect project image assets through this repository's APIMart OpenAI-compatible gpt-image-2 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs gpt-image-2 request body, dry-run image prompts, batch-generate local project thumbnails, or debug APIMART_BASE_URL / APIMART_API_KEY image-generation configuration without exposing secrets.
|
||||||
|
---
|
||||||
|
|
||||||
|
# gpt-image-2 APIMart
|
||||||
|
|
||||||
|
Use this skill for project-local image asset generation that must match the repository's `server-rs` APIMart `gpt-image-2` path.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Read the local task and decide whether the image is project-bound.
|
||||||
|
2. Prefer `scripts/generate-template-samples.mjs` for puzzle template thumbnails or small batches.
|
||||||
|
3. Run dry-run first:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node .codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
4. If dry-run looks correct and the user asked for real assets, run live generation with a small limit:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node .codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs --live --limit 6
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Save final project assets under `public/` or another explicitly requested workspace path.
|
||||||
|
6. Never print `APIMART_API_KEY`. Report only whether configuration exists.
|
||||||
|
|
||||||
|
## Request Contract
|
||||||
|
|
||||||
|
The repository image path uses:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST {APIMART_BASE_URL}/images/generations
|
||||||
|
Authorization: Bearer {APIMART_API_KEY}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Default body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "gpt-image-2",
|
||||||
|
"prompt": "<prompt>",
|
||||||
|
"n": 1,
|
||||||
|
"size": "1:1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For a reference image, add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"image_urls": ["data:image/png;base64,..."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Poll async responses with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET {APIMART_BASE_URL}/tasks/{task_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Accept image output from `data[].url`, `data[].b64_json`, direct nested `url` fields, or async task results.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Load environment values from process env first, then `.env.secrets.local`, `.env.local`, and `.env.example`.
|
||||||
|
|
||||||
|
Required for live generation:
|
||||||
|
|
||||||
|
- `APIMART_BASE_URL`
|
||||||
|
- `APIMART_API_KEY`
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `APIMART_IMAGE_REQUEST_TIMEOUT_MS`
|
||||||
|
|
||||||
|
If the key or base URL is missing, stop after dry-run or explain the missing configuration. Do not ask the user to paste the key in chat.
|
||||||
|
|
||||||
|
## Prompt Rules
|
||||||
|
|
||||||
|
- Use Chinese prompts when generating project puzzle templates.
|
||||||
|
- Keep template samples square, clear, image-only, and suitable for puzzle thumbnails.
|
||||||
|
- Avoid text, watermark, UI chrome, buttons, borders, and tutorial overlays.
|
||||||
|
- Include local negative constraints in the prompt instead of relying on provider-specific negative prompt fields.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- `scripts/generate-template-samples.mjs`: dry-run or live-generate puzzle template sample thumbnails.
|
||||||
|
- `assets/puzzle-template-prompts.json`: default prompt list consumed by the script.
|
||||||
7
.codex/skills/gpt-image-2-apimart/agents/openai.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "GPT Image 2 APIMart"
|
||||||
|
short_description: "Generate project thumbnails through APIMart"
|
||||||
|
brand_color: "#10B981"
|
||||||
|
default_prompt: "Use $gpt-image-2-apimart to dry-run or generate puzzle template thumbnails through APIMart."
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "couple-memory",
|
||||||
|
"title": "情侣合照拼图",
|
||||||
|
"prompt": "温暖自然光下的一对情侣纪念合照,城市咖啡馆窗边,桌面有花束和两杯热饮,人物神情自然,画面主体清晰,前中后景层次明确,适合切成拼图。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "family-keepsake",
|
||||||
|
"title": "家庭纪念拼图",
|
||||||
|
"prompt": "三代家人在客厅沙发前的家庭纪念合照,柔和午后阳光,孩子抱着生日蛋糕,长辈微笑,画面温暖完整,细节丰富但不杂乱。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "friends-party",
|
||||||
|
"title": "朋友聚会拼图",
|
||||||
|
"prompt": "朋友们在露台夜晚聚会,彩灯、桌上零食和举杯瞬间,人物分布有层次,中央焦点清楚,氛围轻松热闹,适合社交分享拼图。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "festival-card",
|
||||||
|
"title": "节日贺卡拼图",
|
||||||
|
"prompt": "节日餐桌与礼物布置,暖色灯光、彩带、蜡烛和窗外烟花,画面像无字贺卡,主体集中,边角细节可辨,适合节日拼图。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "knowledge-summary",
|
||||||
|
"title": "知识总结拼图",
|
||||||
|
"prompt": "一张无文字的知识学习主题插画,书桌上有打开的笔记本、便签、咖啡、台灯和思维导图式图形元素,构图整洁,重点明确,适合学习打卡拼图。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "product-detail",
|
||||||
|
"title": "商品细节拼图",
|
||||||
|
"prompt": "精致商品静物展示,一只高质感香水瓶放在丝绸与花瓣之间,玻璃反光清晰,包装和材质细节丰富,背景干净,适合作为电商细节拼图。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "healing-landscape",
|
||||||
|
"title": "治愈风景拼图",
|
||||||
|
"prompt": "治愈风景插画,清晨湖边、薄雾、远山、木栈道和一盏小灯,色彩柔和,层次清楚,局部元素可辨,适合长时间拼图。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cute-pet",
|
||||||
|
"title": "宠物可爱拼图",
|
||||||
|
"prompt": "一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净,适合萌宠拼图分享。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hot-topic-poster",
|
||||||
|
"title": "热点海报拼图",
|
||||||
|
"prompt": "电影感热点海报风插画,雨夜街头、霓虹反光、奔跑的人影和远处光束,强烈视觉焦点,画面无文字,适合热点话题拼图。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "event-invitation",
|
||||||
|
"title": "活动邀请拼图",
|
||||||
|
"prompt": "活动邀请主题插画,展厅入口、花艺装置、签到台和柔和灯带,人群剪影自然分布,画面高级干净,无文字,适合活动预热拼图。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-challenge",
|
||||||
|
"title": "每日挑战拼图",
|
||||||
|
"prompt": "每日挑战主题插画,清爽桌面上摆放相机、明信片、计时器和小奖章,色彩明亮,构图有趣,细节可拆解,适合平台每日拼图。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "children-learning",
|
||||||
|
"title": "儿童认知拼图",
|
||||||
|
"prompt": "儿童认知学习插画,木质桌面上有积木、彩色形状、动物玩偶和小书本,色彩明快,元素边界清晰,无文字,适合儿童教育拼图。"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const skillRoot = path.resolve(__dirname, '..');
|
||||||
|
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
|
||||||
|
const promptsPath = path.join(
|
||||||
|
skillRoot,
|
||||||
|
'assets',
|
||||||
|
'puzzle-template-prompts.json',
|
||||||
|
);
|
||||||
|
const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates');
|
||||||
|
const defaultTimeoutMs = 180000;
|
||||||
|
const pollDelayMs = 3000;
|
||||||
|
|
||||||
|
const args = new Map();
|
||||||
|
for (let index = 2; index < process.argv.length; index += 1) {
|
||||||
|
const raw = process.argv[index];
|
||||||
|
if (raw.startsWith('--')) {
|
||||||
|
const next = process.argv[index + 1];
|
||||||
|
if (next && !next.startsWith('--')) {
|
||||||
|
args.set(raw, next);
|
||||||
|
index += 1;
|
||||||
|
} else {
|
||||||
|
args.set(raw, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDotenv(fileName) {
|
||||||
|
const filePath = path.join(repoRoot, fileName);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = {};
|
||||||
|
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let value = match[2].trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
values[match[1]] = value;
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEnv() {
|
||||||
|
const loaded = {
|
||||||
|
...readDotenv('.env.example'),
|
||||||
|
...readDotenv('.env.local'),
|
||||||
|
...readDotenv('.env.secrets.local'),
|
||||||
|
...process.env,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
baseUrl: String(loaded.APIMART_BASE_URL || '').trim().replace(/\/+$/u, ''),
|
||||||
|
apiKey: String(loaded.APIMART_API_KEY || '').trim(),
|
||||||
|
timeoutMs: Number.parseInt(
|
||||||
|
String(loaded.APIMART_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt(template) {
|
||||||
|
return [
|
||||||
|
'请生成一张高清 1:1 方形插画,用作拼图创作模板样例图。',
|
||||||
|
`画面主体:${template.prompt}`,
|
||||||
|
'要求:主体清晰集中,前中后景层次明确,边角有可辨识细节,适合切成 3x3 到 7x7 拼图。',
|
||||||
|
'避免:文字、水印、边框、按钮、UI 元素、教程标注、低清晰度、过度模糊、杂乱构图。',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectStringsByKey(value, targetKey, output) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, nested] of Object.entries(value)) {
|
||||||
|
if (key === targetKey) {
|
||||||
|
if (typeof nested === 'string' && nested.trim()) {
|
||||||
|
output.push(nested.trim());
|
||||||
|
}
|
||||||
|
if (Array.isArray(nested)) {
|
||||||
|
nested.forEach((entry) => {
|
||||||
|
if (typeof entry === 'string' && entry.trim()) {
|
||||||
|
output.push(entry.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collectStringsByKey(nested, targetKey, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractImageUrls(payload) {
|
||||||
|
const urls = [];
|
||||||
|
collectStringsByKey(payload, 'url', urls);
|
||||||
|
collectStringsByKey(payload, 'image', urls);
|
||||||
|
collectStringsByKey(payload, 'image_url', urls);
|
||||||
|
return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBase64Images(payload) {
|
||||||
|
const values = [];
|
||||||
|
collectStringsByKey(payload, 'b64_json', values);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTaskId(payload) {
|
||||||
|
const ids = [];
|
||||||
|
collectStringsByKey(payload, 'task_id', ids);
|
||||||
|
collectStringsByKey(payload, 'taskId', ids);
|
||||||
|
collectStringsByKey(payload, 'id', ids);
|
||||||
|
return ids[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferExtensionFromContentType(contentType) {
|
||||||
|
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
||||||
|
if (normalized === 'image/png') {
|
||||||
|
return 'png';
|
||||||
|
}
|
||||||
|
if (normalized === 'image/webp') {
|
||||||
|
return 'webp';
|
||||||
|
}
|
||||||
|
if (normalized === 'image/gif') {
|
||||||
|
return 'gif';
|
||||||
|
}
|
||||||
|
return 'jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferExtensionFromBytes(bytes) {
|
||||||
|
if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) {
|
||||||
|
return 'png';
|
||||||
|
}
|
||||||
|
if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) {
|
||||||
|
return 'jpg';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
return 'webp';
|
||||||
|
}
|
||||||
|
return 'png';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url, options, timeoutMs) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`APIMart ${response.status}: ${text.slice(0, 600)}`);
|
||||||
|
}
|
||||||
|
return JSON.parse(text);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadUrl(url, timeoutMs) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: abortController.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`download ${response.status}`);
|
||||||
|
}
|
||||||
|
const bytes = Buffer.from(await response.arrayBuffer());
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
extension: inferExtensionFromContentType(
|
||||||
|
response.headers.get('content-type') || 'image/jpeg',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTask(env, taskId) {
|
||||||
|
const deadline = Date.now() + env.timeoutMs;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const payload = await fetchJson(
|
||||||
|
`${env.baseUrl}/tasks/${encodeURIComponent(taskId)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${env.apiKey}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
env.timeoutMs,
|
||||||
|
);
|
||||||
|
const statuses = [];
|
||||||
|
collectStringsByKey(payload, 'status', statuses);
|
||||||
|
collectStringsByKey(payload, 'task_status', statuses);
|
||||||
|
const status = String(statuses[0] || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
if (['completed', 'succeeded', 'success'].includes(status)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
if (['failed', 'error', 'canceled', 'cancelled', 'unknown'].includes(status)) {
|
||||||
|
throw new Error(`APIMart task ${taskId} failed: ${JSON.stringify(payload).slice(0, 600)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, pollDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`APIMart task ${taskId} timed out`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateOne(env, template, outDir) {
|
||||||
|
const requestBody = {
|
||||||
|
model: 'gpt-image-2',
|
||||||
|
prompt: buildPrompt(template),
|
||||||
|
n: 1,
|
||||||
|
size: '1:1',
|
||||||
|
};
|
||||||
|
const payload = await fetchJson(
|
||||||
|
`${env.baseUrl}/images/generations`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${env.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
},
|
||||||
|
env.timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedPayload =
|
||||||
|
extractImageUrls(payload).length || extractBase64Images(payload).length
|
||||||
|
? payload
|
||||||
|
: await waitForTask(env, extractTaskId(payload));
|
||||||
|
const urls = extractImageUrls(resolvedPayload);
|
||||||
|
const b64Images = extractBase64Images(resolvedPayload);
|
||||||
|
|
||||||
|
let image;
|
||||||
|
if (urls[0]) {
|
||||||
|
image = await downloadUrl(urls[0], env.timeoutMs);
|
||||||
|
} else if (b64Images[0]) {
|
||||||
|
const bytes = Buffer.from(b64Images[0], 'base64');
|
||||||
|
image = {
|
||||||
|
bytes,
|
||||||
|
extension: inferExtensionFromBytes(bytes),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error(`APIMart returned no image for ${template.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
const outputPath = path.join(outDir, `${template.id}.${image.extension}`);
|
||||||
|
writeFileSync(outputPath, image.bytes);
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dryRun = args.has('--dry-run') || !args.has('--live');
|
||||||
|
const outDir = path.resolve(String(args.get('--out-dir') || defaultOutDir));
|
||||||
|
const limit = Number.parseInt(String(args.get('--limit') || '0'), 10);
|
||||||
|
const onlyIds = String(args.get('--only') || '')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const templates = JSON.parse(readFileSync(promptsPath, 'utf8')).filter(
|
||||||
|
(template) => !onlyIds.length || onlyIds.includes(template.id),
|
||||||
|
);
|
||||||
|
const selectedTemplates = limit > 0 ? templates.slice(0, limit) : templates;
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mode: 'dry-run',
|
||||||
|
outDir,
|
||||||
|
count: selectedTemplates.length,
|
||||||
|
requests: selectedTemplates.map((template) => ({
|
||||||
|
id: template.id,
|
||||||
|
title: template.title,
|
||||||
|
body: {
|
||||||
|
model: 'gpt-image-2',
|
||||||
|
prompt: buildPrompt(template),
|
||||||
|
n: 1,
|
||||||
|
size: '1:1',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = resolveEnv();
|
||||||
|
if (!env.baseUrl || !env.apiKey) {
|
||||||
|
console.error(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
error: 'Missing APIMART_BASE_URL or APIMART_API_KEY',
|
||||||
|
hasBaseUrl: Boolean(env.baseUrl),
|
||||||
|
hasApiKey: Boolean(env.apiKey),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = [];
|
||||||
|
for (const template of selectedTemplates) {
|
||||||
|
console.log(`Generating ${template.id}...`);
|
||||||
|
generated.push(await generateOne(env, template, outDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
count: generated.length,
|
||||||
|
files: generated,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -85,6 +85,11 @@
|
|||||||
2. 用户返回成本更低
|
2. 用户返回成本更低
|
||||||
3. 操作像手游副面板,更符合预期
|
3. 操作像手游副面板,更符合预期
|
||||||
|
|
||||||
|
### 4.3.1 弹出确认面板不能透明
|
||||||
|
- 删除作品、发布后分享、确认离开等关键弹窗必须有实体面板底色,不能只靠透明背景、毛玻璃或遮罩承载内容。
|
||||||
|
- 通过 portal 挂到 `body` 的平台弹窗必须在遮罩层补齐平台主题类,否则主题变量会脱离页面容器,轻则颜色漂移,重则面板背景看起来透明。
|
||||||
|
- 移动端关键确认弹窗优先居中显示,并保留 `max-height + 内部滚动`,避免被底部导航、安全区或底部抽屉布局遮住。
|
||||||
|
|
||||||
### 4.4 图标优于文字按钮
|
### 4.4 图标优于文字按钮
|
||||||
- 在底部工具区,队伍/背包改成 icon 后更紧凑。
|
- 在底部工具区,队伍/背包改成 icon 后更紧凑。
|
||||||
- 但必须保留 `aria-label`,保证语义清晰、后续也方便测试。
|
- 但必须保留 `aria-label`,保证语义清晰、后续也方便测试。
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 抓大鹅创作入口开放与错误隔离 2026-05-01
|
# 抓大鹅创作入口开放与错误隔离 2026-05-01
|
||||||
|
|
||||||
|
> 2026-05-03 更新:抓大鹅创作端入口已按运营节奏暂时下线,`match3d.visible` 调整为 `false`。本文保留为 2026-05-01 入口开放阶段的历史记录;当前入口状态以 `NEW_WORK_ENTRY_CONFIG_2026-05-01.md` 和 `src/config/newWorkEntryConfig.ts` 为准。
|
||||||
|
|
||||||
## 1. 背景
|
## 1. 背景
|
||||||
|
|
||||||
抓大鹅 Match3D 玩法域已完成当前 demo 主链接入,本轮恢复创作页入口,使玩家可以从创作中心直接进入抓大鹅共创工作台。同时,平台首页会并行读取 RPG、拼图、抓大鹅等公开广场数据,公开广场接口未就绪、空表或临时失败不应污染创作入口错误态,也不应表现成登录异常。
|
抓大鹅 Match3D 玩法域已完成当前 demo 主链接入,本轮恢复创作页入口,使玩家可以从创作中心直接进入抓大鹅共创工作台。同时,平台首页会并行读取 RPG、拼图、抓大鹅等公开广场数据,公开广场接口未就绪、空表或临时失败不应污染创作入口错误态,也不应表现成登录异常。
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 抓大鹅 Match3D F1 创作入口与 Agent UI 落地记录 2026-04-30
|
# 抓大鹅 Match3D F1 创作入口与 Agent UI 落地记录 2026-04-30
|
||||||
|
|
||||||
|
> 2026-05-03 更新:抓大鹅创作端入口已暂时下线,当前 `match3d.visible` 为 `false`。本文件记录 F1 接入能力,入口是否展示以 `NEW_WORK_ENTRY_CONFIG_2026-05-01.md` 和 `src/config/newWorkEntryConfig.ts` 为准。
|
||||||
|
|
||||||
## 1. 阶段边界
|
## 1. 阶段边界
|
||||||
|
|
||||||
本文件承接《MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md》的 F1 包。
|
本文件承接《MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md》的 F1 包。
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
|
|
||||||
| 玩法 | 展示 | 开放 | 说明 |
|
| 玩法 | 展示 | 开放 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 角色扮演 | 是 | 是 | 点击后进入 RPG Agent 共创工作台 |
|
| 角色扮演 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 |
|
||||||
| 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 |
|
| 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 |
|
||||||
| 拼图 | 是 | 是 | 点击后进入拼图 Agent 共创工作台 |
|
| 拼图 | 是 | 是 | 点击后进入拼图 Agent 共创工作台 |
|
||||||
| 抓大鹅 | 是 | 是 | 点击后进入抓大鹅 Agent 共创工作台 |
|
| 抓大鹅 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 |
|
||||||
| AIRP | 是 | 否 | 保留入口,显示敬请期待 |
|
| AIRP | 是 | 否 | 保留入口,显示敬请期待 |
|
||||||
| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待 |
|
| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待 |
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进
|
|||||||
|
|
||||||
- 前端只负责展示生成进度与触发已有后端动作,不新增 server-node 或 PostgreSQL 链路。
|
- 前端只负责展示生成进度与触发已有后端动作,不新增 server-node 或 PostgreSQL 链路。
|
||||||
- 后端继续沿用 `server-rs` + `SpacetimeDB` 的会话、草稿与资产写入能力。
|
- 后端继续沿用 `server-rs` + `SpacetimeDB` 的会话、草稿与资产写入能力。
|
||||||
- 拼图生成草稿链路仍包含:结果页草稿、候选图生成、正式图确认。
|
- 拼图生成草稿链路仍包含:首关草稿编译、首关画面生成、正式草稿写入。
|
||||||
- 大鱼吃小鱼生成草稿链路只包含:玩法草稿、等级蓝图、背景蓝图与运行参数编译。
|
- 大鱼吃小鱼生成草稿链路只包含:玩法草稿、等级蓝图、背景蓝图与运行参数编译。
|
||||||
- 大鱼吃小鱼的主图、动作、背景都在结果页工坊单独触发,不再属于草稿编译阶段。
|
- 大鱼吃小鱼的主图、动作、背景都在结果页工坊单独触发,不再属于草稿编译阶段。
|
||||||
- 生成过程中展示的“角色描述、角色图片、动作”等,统一映射为锚点、草稿蓝图与资产步骤,不把规则说明类文本写成默认 UI 文案。
|
- 生成过程中展示的“角色描述、角色图片、动作”等,统一映射为锚点、草稿蓝图与资产步骤,不把规则说明类文本写成默认 UI 文案。
|
||||||
@@ -38,9 +38,9 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进
|
|||||||
|
|
||||||
### 拼图
|
### 拼图
|
||||||
|
|
||||||
- `compile_puzzle_draft`:在 `server-rs` 内整理主题、主体、构图与标签,写入结果页草稿。
|
- `compile_puzzle_draft`:在 `server-rs` 内根据入口画面描述生成首关名称和结果页草稿。
|
||||||
- `compile_puzzle_draft`:同一次后端 action 内根据草稿摘要生成候选图。
|
- `compile_puzzle_draft`:同一次后端 action 内根据画面描述、参考图和当前图片模型生成首关画面。
|
||||||
- `compile_puzzle_draft`:同一次后端 action 内自动选择第一张候选图作为正式图。
|
- `compile_puzzle_draft`:同一次后端 action 内自动把首图设为正式图,并同步到结果页草稿。
|
||||||
- `ready`:进入拼图结果页。
|
- `ready`:进入拼图结果页。
|
||||||
|
|
||||||
### 大鱼吃小鱼
|
### 大鱼吃小鱼
|
||||||
|
|||||||
@@ -4,8 +4,18 @@
|
|||||||
|
|
||||||
拼图创作入口不再使用 Agent 对话收集题材锚点。新流程让玩家填写作品名称、作品描述、画面描述三类信息,其中画面描述只服务首关画面生成与关卡画面语义,不再作为作品详情页的作品描述。画面描述支持上传参考图。玩家确认后直接进入草稿生成进度页,后续草稿生成、首图生成、正式图选择、结果页编辑和发布沿用现有后端编排。
|
拼图创作入口不再使用 Agent 对话收集题材锚点。新流程让玩家填写作品名称、作品描述、画面描述三类信息,其中画面描述只服务首关画面生成与关卡画面语义,不再作为作品详情页的作品描述。画面描述支持上传参考图。玩家确认后直接进入草稿生成进度页,后续草稿生成、首图生成、正式图选择、结果页编辑和发布沿用现有后端编排。
|
||||||
|
|
||||||
|
2026-05-03 后入口进一步收口为画面描述直创:入口表单只保留画面描述、参考图和图片模型选择;作品名称、作品描述、作品标签全部进入结果页补全。若本文件早期段落仍提到入口必填作品名称或作品描述,以 `PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md` 为准。
|
||||||
|
|
||||||
## 入口表单
|
## 入口表单
|
||||||
|
|
||||||
|
### 2026-05-03 画面描述直创补充
|
||||||
|
|
||||||
|
1. 入口表单只展示 `画面描述`、参考图和图片模型选择;`画面描述` 是唯一必填字段。
|
||||||
|
2. 表单自动保存只保存 `pictureDescription`,不再保存入口作品名称、作品描述或推断标签。
|
||||||
|
3. 点击“生成草稿”后进入生成进度页,步骤固定为“编译首关草稿 -> 生成首关画面 -> 写入正式草稿”。
|
||||||
|
4. 生成进度页“当前拼图信息”只展示画面描述;不得展示空作品名称、空作品描述或旧五锚点结构。
|
||||||
|
5. 结果页打开后,作品名称默认使用首关名称,作品描述与作品标签保持为空,等待用户在作品信息 Tab 补全或触发 AI 标签生成。
|
||||||
|
|
||||||
### 2026-04-30 初始表单草稿保存补充
|
### 2026-04-30 初始表单草稿保存补充
|
||||||
|
|
||||||
1. 玩家在创作页点击“拼图”入口时,前端必须立即创建一个新的拼图 Agent session,并同步生成一条 `publicationStatus = draft` 的拼图作品卡;此时不触发 `compile_puzzle_draft`,不生成图片,不进入生成进度页。
|
1. 玩家在创作页点击“拼图”入口时,前端必须立即创建一个新的拼图 Agent session,并同步生成一条 `publicationStatus = draft` 的拼图作品卡;此时不触发 `compile_puzzle_draft`,不生成图片,不进入生成进度页。
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 拼图画面描述直创与 AI 标签生成调整 2026-05-03
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
拼图创作入口继续保留填表式体验,但入口表单不再要求百梦主提前填写作品名称和作品描述。入口只收集“拼图画面描述”,后端用该描述完成首图生成和第一关关卡名生成;进入结果页后再补作品信息。
|
||||||
|
|
||||||
|
## 入口表单
|
||||||
|
|
||||||
|
1. 点击“开始创作”后的拼图表单只展示 `画面描述`、参考图和图片模型选择。
|
||||||
|
2. `画面描述` 是唯一必填字段,提交时写入 `pictureDescription`,并作为 `promptText` 传给 `compile_puzzle_draft`。
|
||||||
|
3. `workTitle`、`workDescription` 不再从入口表单传入;`seedText` 只由画面描述组成,格式为 `画面描述:...`。
|
||||||
|
4. 表单自动保存只保存画面描述,不生成图片,不消耗光点。
|
||||||
|
5. 生成进度页“当前拼图信息”只展示画面描述,不再展示空作品名称或空作品描述。
|
||||||
|
|
||||||
|
## 生成进度步骤
|
||||||
|
|
||||||
|
1. `compile` 展示为“编译首关草稿”:根据画面描述生成首关名称和结果页草稿,不在本步骤生成作品标签。
|
||||||
|
2. `puzzle-images` 展示为“生成首关画面”:按画面描述、参考图和当前图片模型生成第一张拼图图。
|
||||||
|
3. `puzzle-select-image` 展示为“写入正式草稿”:把首图设为第一关正式图,并同步到结果页草稿。
|
||||||
|
4. `ready` 文案提示进入结果页补作品信息;不得暗示作品名称、作品描述或作品标签已经完整生成。
|
||||||
|
|
||||||
|
## 草稿默认值
|
||||||
|
|
||||||
|
1. 后端先由 `module-puzzle` 生成可回滚的确定性草稿,再由 `api-server` 基于画面描述调用文本模型生成第一关关卡名;模型不可用或返回非法时才降级到确定性兜底名。
|
||||||
|
2. 第一关关卡名生成后,必须写回首关 `levelName`,并在入口直创默认场景下作为 `workTitle` 同步写入草稿和作品草稿卡。
|
||||||
|
3. `workDescription` 默认保持空字符串,不再回退为画面描述。
|
||||||
|
4. `themeTags` 默认保持空数组,不再由入口画面描述自动推断为正式作品标签。
|
||||||
|
5. `formDraft` 只保留 `pictureDescription`,`workTitle` 与 `workDescription` 为空。
|
||||||
|
|
||||||
|
## 作品标签
|
||||||
|
|
||||||
|
1. 作品信息 Tab 继续支持手动新增、删除标签。
|
||||||
|
2. 作品标签合法数量仍为 `3~6` 个,发布前和后端发布逻辑都要检查。
|
||||||
|
3. 新增 `generate_puzzle_tags` action:
|
||||||
|
- 前端点击 AI 生成标签时先检查作品名称和作品描述。
|
||||||
|
- 若任一为空,前端直接提示先填写,不请求后端。
|
||||||
|
- 两者都不为空时,后端基于作品名称和作品描述调用文本模型,生成 6 个中文短标签。
|
||||||
|
- 生成结果回写 session draft 与 puzzle work profile,前端直接使用返回 session 更新界面。
|
||||||
|
4. AI 标签生成失败时可以降级为确定性关键词标签,但仍必须返回去重后的 6 个标签,保证用户能继续编辑。
|
||||||
|
|
||||||
|
## 保存与发布
|
||||||
|
|
||||||
|
1. 用户在结果页修改作品名称、作品描述、作品标签、关卡名称或画面描述时,继续通过 `PUT /api/runtime/puzzle/works/{profileId}` 自动保存。
|
||||||
|
2. 自动保存允许标签为空,用于支持初始草稿和用户清空标签后的继续编辑。
|
||||||
|
3. 发布前必须检查:
|
||||||
|
- 每个关卡名称非空。
|
||||||
|
- 作品名称非空。
|
||||||
|
- 作品描述非空。
|
||||||
|
- 作品标签数量为 `3~6`。
|
||||||
|
- 每关正式图存在。
|
||||||
|
4. `publish_puzzle_work` 仍由 SpacetimeDB procedure 执行最终校验和发布,前端不能绕过后端门禁。
|
||||||
|
|
||||||
|
## 结果页返回
|
||||||
|
|
||||||
|
1. 从拼图草稿结果页点击左上角返回时,直接回到平台创作页。
|
||||||
|
2. 结果页返回不回到上一页填表工作区;表单页只作为发起新草稿或恢复纯表单草稿的入口。
|
||||||
|
3. 返回创作页时清理拼图生成态、运行态和临时操作态,保留后端已保存的草稿,用户后续从作品卡继续完善。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
1. 拼图入口表单不再出现作品名称和作品描述输入框。
|
||||||
|
2. 只填写画面描述即可生成草稿、图片和第一关关卡名。
|
||||||
|
3. 进入结果页后作品名称默认为模型生成的第一关关卡名,作品描述为空,作品标签为空。
|
||||||
|
4. 点击 AI 生成标签时,作品名称或作品描述为空会先提示补齐。
|
||||||
|
5. 作品名称和作品描述都不为空时,AI 生成 6 个作品标签,并自动保存到后端。
|
||||||
|
6. 手动增删标签仍可用,发布前标签必须至少 3 个且最多 6 个。
|
||||||
|
7. 拼图草稿结果页左上角返回直接回到创作页,不再显示上一页表单。
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 拼图创作模板表单与 gpt-image-2 Skill 封装 2026-05-03
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
拼图创作入口已经从对话式 Agent 收口为填表式表单。本次改版目标是让“点击拼图创作”后的表单更接近图像创作工具的单屏体验:先选创作模板,再补充提示词,最后直接生成首关草稿与首张拼图图。
|
||||||
|
|
||||||
|
## 落地范围
|
||||||
|
|
||||||
|
1. `src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`
|
||||||
|
- 改为顶部标题、模板横滑区、大输入框、底部操作区的布局。
|
||||||
|
- 保留参考图上传、模型切换和生成草稿。
|
||||||
|
- 不再提供输入框底部的 `try` 示例入口。
|
||||||
|
2. `src/components/puzzle-agent/puzzleCreationTemplates.ts`
|
||||||
|
- 新增拼图创作模板数据。
|
||||||
|
- 模板来源按社交、热点、职场学习、电商、治愈、营销、儿童教育等场景抽样。
|
||||||
|
- 点击模板后把模板提示词写入画面描述。
|
||||||
|
3. `public/puzzle-creation-templates/`
|
||||||
|
- 存放模板样例图。
|
||||||
|
- 样例图只用于创作模板缩略图,不作为正式拼图作品资产。
|
||||||
|
4. `.codex/skills/gpt-image-2-apimart/`
|
||||||
|
- 封装仓库内 `gpt-image-2` 的 APIMart OpenAI 兼容调用流程。
|
||||||
|
- Skill 默认读取本地环境变量,不把密钥写入代码、文档或前端。
|
||||||
|
|
||||||
|
## UI 规则
|
||||||
|
|
||||||
|
1. 顶部只展示“创建拼图”和轻量状态标识,不写玩法规则说明。
|
||||||
|
2. 模板区横向滚动,移动端优先;每个模板卡包含样例图、短标题和选中态。
|
||||||
|
3. 点击模板时:
|
||||||
|
- 立即选中该模板。
|
||||||
|
- 如果输入框为空,直接填入模板提示词。
|
||||||
|
- 如果输入框已有内容,替换为该模板提示词,避免追加后变得冗长。
|
||||||
|
4. 输入区保留:
|
||||||
|
- 参考图上传按钮。
|
||||||
|
- 图片模型切换按钮。
|
||||||
|
5. 输入区不保留:
|
||||||
|
- `try` 文本。
|
||||||
|
- 示例 prompt chip。
|
||||||
|
- 玩法规则说明。
|
||||||
|
|
||||||
|
## 模板抽样
|
||||||
|
|
||||||
|
首批模板不追求覆盖图二所有条目,而是选择高频且适合拼图主图的代表项:
|
||||||
|
|
||||||
|
1. 情侣合照拼图
|
||||||
|
2. 家庭纪念拼图
|
||||||
|
3. 朋友聚会拼图
|
||||||
|
4. 节日贺卡拼图
|
||||||
|
5. 知识点总结拼图
|
||||||
|
6. 商品细节拼图
|
||||||
|
7. 治愈风景拼图
|
||||||
|
8. 宠物可爱拼图
|
||||||
|
9. 热点海报拼图
|
||||||
|
10. 活动邀请拼图
|
||||||
|
11. 每日挑战拼图
|
||||||
|
12. 儿童认知拼图
|
||||||
|
|
||||||
|
模板提示词必须是可直接送入拼图生图链路的画面描述,不写 UI、按钮、教程、规则或营销解释。
|
||||||
|
|
||||||
|
## gpt-image-2 Skill 规则
|
||||||
|
|
||||||
|
Skill 封装仓库现有后端口径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST {APIMART_BASE_URL}/images/generations
|
||||||
|
Authorization: Bearer {APIMART_API_KEY}
|
||||||
|
model = gpt-image-2
|
||||||
|
size = 1:1
|
||||||
|
n = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
响应兼容:
|
||||||
|
|
||||||
|
1. `data[].url`
|
||||||
|
2. `data[].b64_json`
|
||||||
|
3. `task_id` 后续 `GET /tasks/{task_id}`
|
||||||
|
|
||||||
|
本次 Skill 只封装生成样例图和研发复用流程,不改变正式后端接口、扣费、OSS、SpacetimeDB 写入和发布链路。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
1. 点击拼图创作后,表单首屏呈现模板横滑区和大输入框。
|
||||||
|
2. 点击任一模板后,输入框填入该模板提示词。
|
||||||
|
3. 输入框里没有 `try` 示例功能。
|
||||||
|
4. 图片模型切换仍可打开并选择 `gpt-image-2` / `nanobanana2`。
|
||||||
|
5. 模板样例图文件存在,并能在创作表单缩略图中显示。
|
||||||
|
6. gpt-image-2 Skill 校验通过,且脚本 dry-run 能输出计划请求而不泄露密钥。
|
||||||
|
7. `npm run check:encoding` 通过。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# RPG 聊天退出后继续冒险过场方案(2026-05-03)
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
玩家退出 NPC 聊天后点击“继续冒险”,不能直接瞬间切到下一幕或下一场景。继续冒险必须先完成一段清晰的角色退场与入场演出,再让新对面角色主动开启对话。
|
||||||
|
|
||||||
|
## 2. 时序约束
|
||||||
|
|
||||||
|
点击“继续冒险”后的顺序固定为:
|
||||||
|
|
||||||
|
1. 保持旧场景画面,隐藏当前场景对面的所有角色。
|
||||||
|
2. 主角色与同行角色播放行走动画,向右走出屏幕。
|
||||||
|
3. 点击后可以先更新真实 `gameState/currentStory`,但画布继续使用过场模型缓存的旧可见态;退场完成前不得把新幕画面展示出来。
|
||||||
|
4. 新场景或新幕画面展示后,主角色从左侧走到默认站位。
|
||||||
|
5. 新场景对面角色从屏幕左侧走入到指定对面站位。
|
||||||
|
6. 入场完成后,如果后续选项里存在 `npc_preview_talk` 或 `npc_chat`,自动执行该选项,直接开启主角色与对面角色的对话。
|
||||||
|
|
||||||
|
## 3. 代码落点
|
||||||
|
|
||||||
|
1. `src/hooks/rpg-runtime-story/choiceActions.ts`
|
||||||
|
- 点击 `story_continue_adventure` 时只提交延迟状态与选项,不直接进入对话。
|
||||||
|
- 若延迟故事标记了自动执行,则把目标 option 放到新的 `deferredAutoChoice`。
|
||||||
|
|
||||||
|
2. `src/components/rpg-runtime-shell/useRpgSceneTransitionModel.ts`
|
||||||
|
- `story_continue_adventure` 也纳入 `content-change` 过场。
|
||||||
|
- 入场动画结束后触发 `deferredAutoChoice`,避免在角色尚未走到位前开聊。
|
||||||
|
- 自动触发时通过最新回调读取当前运行态,避免计时器拿到点击“继续冒险”前的旧状态。
|
||||||
|
|
||||||
|
3. `src/components/game-canvas/GameCanvasEntityLayer.tsx`
|
||||||
|
- 退场期隐藏旧对面角色。
|
||||||
|
- 入场期让新对面角色从左侧走入到右侧指定站位。
|
||||||
|
- 对面角色入场期使用移动动画,完成后恢复 idle 与对话气泡。
|
||||||
|
|
||||||
|
4. `src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.ts`
|
||||||
|
- `story_continue_adventure` 只要携带 `deferredRuntimeState` 或 `deferredAutoChoice`,就先进入过场,再交给 story choice 处理真实状态。
|
||||||
|
|
||||||
|
## 4. 验收标准
|
||||||
|
|
||||||
|
1. 退出 NPC 聊天后点击“继续冒险”,不会在同一帧瞬间切换到下一幕对话。
|
||||||
|
2. 退场时旧对面角色不可见,主角色向右走出画面。
|
||||||
|
3. 入场时新对面角色从左侧进入右侧站位。
|
||||||
|
4. 入场完成后自动进入新对面角色对话。
|
||||||
|
5. 移动端与桌面端都不新增说明类 UI 文案,只保留游戏内演出。
|
||||||
171
docs/technical/RPG_OPENING_CG_MANUAL_GENERATION_2026-05-03.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# RPG 世界草稿开局 CG 手动生成技术方案(2026-05-03)
|
||||||
|
|
||||||
|
## 1. 背景与本次口径
|
||||||
|
|
||||||
|
本方案落地“RPG 游戏开场 CG”第一版。它继承 `docs/prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md` 的资产化方向,但本次不采用旧 PRD 中“4 张关键帧 + 3 段视频拼接”的方案,而采用更短的两阶段链路:
|
||||||
|
|
||||||
|
```text
|
||||||
|
世界草稿
|
||||||
|
-> GPT Image 2 生成 3*4 故事板图,2k,16:9
|
||||||
|
-> Seedance 使用故事板作为参考图生成单段 15 秒视频,480p,16:9
|
||||||
|
-> OSS 保存故事板与成片
|
||||||
|
-> 前端把 openingCg 回写到当前世界草稿 profile
|
||||||
|
```
|
||||||
|
|
||||||
|
本次明确不在生成世界草稿时自动生成开局 CG。入口只放在世界草稿结果页的世界 Tab,由用户手动触发。
|
||||||
|
|
||||||
|
## 2. 用户体验
|
||||||
|
|
||||||
|
1. 世界 Tab 展示一个轻量的“开局 CG”资产槽。
|
||||||
|
2. 未生成时只提供手动生成按钮。
|
||||||
|
3. 生成中展示阶段状态和进度,不把规则说明长文写进 UI。
|
||||||
|
4. 生成成功后展示视频预览和重新生成按钮。
|
||||||
|
5. 每次点击生成扣 `80` 积分,失败自动退款。
|
||||||
|
6. UI 预计等待文案为 `预计 10 分钟`,真实等待由后端同步请求完成后返回。
|
||||||
|
7. 只在世界草稿中手动生成;世界底稿、角色图、幕背景图自动补齐流程不生成开局 CG。
|
||||||
|
|
||||||
|
## 3. 数据结构
|
||||||
|
|
||||||
|
在 `CustomWorldProfile` 新增可选字段:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CustomWorldOpeningCgStatus =
|
||||||
|
| 'not_started'
|
||||||
|
| 'storyboard_generating'
|
||||||
|
| 'video_generating'
|
||||||
|
| 'ready'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
type CustomWorldOpeningCgProfile = {
|
||||||
|
id: string;
|
||||||
|
status: CustomWorldOpeningCgStatus;
|
||||||
|
storyboardImageSrc?: string | null;
|
||||||
|
storyboardAssetId?: string | null;
|
||||||
|
videoSrc?: string | null;
|
||||||
|
videoAssetId?: string | null;
|
||||||
|
posterImageSrc?: string | null;
|
||||||
|
posterAssetId?: string | null;
|
||||||
|
storyboardPrompt?: string | null;
|
||||||
|
videoPrompt?: string | null;
|
||||||
|
imageModel: 'gpt-image-2';
|
||||||
|
videoModel: string;
|
||||||
|
aspectRatio: '16:9';
|
||||||
|
imageSize: '2k';
|
||||||
|
videoResolution: '480p';
|
||||||
|
durationSeconds: 15;
|
||||||
|
pointCost: 80;
|
||||||
|
estimatedWaitMinutes: 10;
|
||||||
|
generatedAt?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
该字段保存在现有 profile JSON 内,不新增 SpacetimeDB 表字段。发布与保存沿用当前 `profile_payload_json` 整包存储能力。
|
||||||
|
|
||||||
|
## 4. 后端接口
|
||||||
|
|
||||||
|
新增接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/runtime/custom-world/opening-cg
|
||||||
|
```
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type GenerateCustomWorldOpeningCgRequest = {
|
||||||
|
profile: CustomWorldProfile;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type GenerateCustomWorldOpeningCgResponse = {
|
||||||
|
openingCg: CustomWorldOpeningCgProfile;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
接口职责:
|
||||||
|
|
||||||
|
1. 校验登录态与 profile 基本结构。
|
||||||
|
2. 校验至少存在可扮演角色、世界基调、世界概述、核心冲突和首个场景第一幕背景图。
|
||||||
|
3. 使用 `execute_billable_asset_operation_with_cost(..., 80, ...)` 做预扣和失败退款。
|
||||||
|
4. 生成故事板图片并持久化为 `custom_world_opening_cg_storyboard` 资产。
|
||||||
|
5. 使用故事板图作为 Seedance 参考图生成视频并持久化为 `custom_world_opening_cg_video` 资产。
|
||||||
|
6. 返回可直接合并进 profile 的 `openingCg`。
|
||||||
|
|
||||||
|
## 5. 提示词
|
||||||
|
|
||||||
|
### 5.1 故事板
|
||||||
|
|
||||||
|
图片模型固定使用 `gpt-image-2`,尺寸语义为 `2k`、`16:9`,当前 APIMart/OpenAI 兼容入口用 `2048x1152` 作为下游 size。
|
||||||
|
|
||||||
|
模板:
|
||||||
|
|
||||||
|
```text
|
||||||
|
以3*4网格格式创建故事板,16:9。像素风角色扮演游戏开场动画CG。
|
||||||
|
|
||||||
|
故事流程:先展示角色,展示故事背景,然后表现核心冲突,最后衔接开局场景
|
||||||
|
故事基调:{世界草稿.tone}
|
||||||
|
|
||||||
|
玩家扮演:将玩家扮演角色作为角色参考图并引用世界草稿中的角色简介
|
||||||
|
故事背景:{世界草稿.summary}
|
||||||
|
核心冲突:{世界草稿.coreConflicts}
|
||||||
|
开局场景:将首个场景的第一幕背景图作为参考图
|
||||||
|
```
|
||||||
|
|
||||||
|
参考图:
|
||||||
|
|
||||||
|
1. 玩家扮演角色使用第一个可扮演角色的 `imageSrc`。
|
||||||
|
2. 开局场景使用 `sceneChapterBlueprints[0].acts[0].backgroundImageSrc`。
|
||||||
|
3. 若缺少任一参考图,返回可理解错误,不降级到无参考图生成。
|
||||||
|
|
||||||
|
### 5.2 视频
|
||||||
|
|
||||||
|
视频模型复用当前 Ark Seedance 配置,分辨率 `480p`,比例 `16:9`,时长 `15` 秒。
|
||||||
|
|
||||||
|
提示词固定:
|
||||||
|
|
||||||
|
```text
|
||||||
|
利用参考图作为故事板,生成一段连贯的动画,没有旁白
|
||||||
|
```
|
||||||
|
|
||||||
|
请求参数必须开启生成音频与联网搜索。若当前上游字段名存在差异,后端在 Ark 请求体中以 `audio` / `generate_audio` / `web_search` 的兼容布尔字段表达,保证不会影响现有角色动画接口。
|
||||||
|
|
||||||
|
开局 CG 视频链路的上游等待窗口不得低于 `10` 分钟,以匹配产品侧“预计 10 分钟”的展示口径。
|
||||||
|
|
||||||
|
## 6. 资产与计费
|
||||||
|
|
||||||
|
资产写入:
|
||||||
|
|
||||||
|
| 产物 | assetKind | entityKind | slot |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 故事板图 | `custom_world_opening_cg_storyboard` | `custom_world_profile` | `opening_cg_storyboard` |
|
||||||
|
| 成片视频 | `custom_world_opening_cg_video` | `custom_world_profile` | `opening_cg_video` |
|
||||||
|
|
||||||
|
计费:
|
||||||
|
|
||||||
|
1. 每次点击生成消耗 `80` 积分。
|
||||||
|
2. 故事板生成失败、视频任务创建失败、轮询失败、下载失败或 OSS 持久化失败都退款。
|
||||||
|
3. 扣费流水的 asset id 使用本次 opening CG id,避免同一次请求重试重复扣费。
|
||||||
|
|
||||||
|
## 7. 前端落点
|
||||||
|
|
||||||
|
1. `CustomWorldEntityCatalog` 在世界 Tab 增加开局 CG 槽。
|
||||||
|
2. `rpgCreationAssetClient` 新增 `generateOpeningCg`。
|
||||||
|
3. `RpgCreationResultViewImpl` 持有生成中状态,生成完成后 `onProfileChange({ ...profile, openingCg })`。
|
||||||
|
4. 视频展示使用签名 URL 读取组件,不把签名 URL 写入 profile。
|
||||||
|
5. 草稿生成时不调用该接口。
|
||||||
|
|
||||||
|
## 8. 验收点
|
||||||
|
|
||||||
|
1. 新草稿生成完成后 `openingCg` 为空或不存在。
|
||||||
|
2. 世界 Tab 可以手动生成开局 CG。
|
||||||
|
3. 生成请求 payload 包含角色参考图与开局第一幕背景图。
|
||||||
|
4. 故事板请求使用 `gpt-image-2`、`2048x1152`/`2k` 语义、`16:9`。
|
||||||
|
5. 视频请求使用 Seedance、`480p`、`16:9`、`15` 秒,并传入故事板参考图。
|
||||||
|
6. 单次生成扣 `80` 积分,任一失败路径退款。
|
||||||
|
7. 生成成功后 profile 内出现 `openingCg.videoSrc`,刷新/保存/发布后能保留。
|
||||||
|
8. 视频链路上游超时不低于 `10` 分钟,避免低于产品展示的预计等待时长。
|
||||||
@@ -20,6 +20,17 @@
|
|||||||
- 后端调用 `module-runtime-story` 纯规则结算动作,推进 `runtimeActionVersion`,写回 runtime snapshot,并用 `continue_story` 记录本轮 narrative event。
|
- 后端调用 `module-runtime-story` 纯规则结算动作,推进 `runtimeActionVersion`,写回 runtime snapshot,并用 `continue_story` 记录本轮 narrative event。
|
||||||
- 响应同样返回 `StoryRuntimeMutationResponse { projection }`,不返回旧 `viewModel / presentation / patches / snapshot` 组合。
|
- 响应同样返回 `StoryRuntimeMutationResponse { projection }`,不返回旧 `viewModel / presentation / patches / snapshot` 组合。
|
||||||
|
|
||||||
|
## 版本口径
|
||||||
|
|
||||||
|
`StoryRuntimeProjectionResponse.serverVersion` 只表示动作并发版本,必须与 `projection.gameState.runtimeActionVersion` 保持一致。前端点击运行时选项时把该值作为 `clientVersion` 提交,后端只用它防止基于旧动作快照重复结算。
|
||||||
|
|
||||||
|
以下字段不能参与 `serverVersion` 计算:
|
||||||
|
|
||||||
|
1. `runtime_snapshot.version`:这是保存快照结构版本,当前由 `SAVE_SNAPSHOT_VERSION` 固定维护,写快照不会把它当作动作轮次递增。
|
||||||
|
2. `story_session.version`:这是故事事件流版本,`continue_story` 会推进它,但它不一定等同于当前运行时动作快照版本。
|
||||||
|
|
||||||
|
读取 `/runtime-projection` 和写入 `/actions/resolve` 的回包都必须从持久化 `gameState.runtimeActionVersion` 解析 `serverVersion`。如果旧快照缺少该字段,才允许回退到 `storySession.version` 或本轮 resolver 输出版本,避免历史存档无法恢复;不得再使用 `runtime_snapshot.version.max(story_session.version)` 这类混合口径。
|
||||||
|
|
||||||
## 契约收口
|
## 契约收口
|
||||||
|
|
||||||
本轮新增 story contract 下的运行时写侧 DTO:
|
本轮新增 story contract 下的运行时写侧 DTO:
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# WP-SC Story runtime legacy option scope 兼容修复(2026-05-03)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`/api/story/sessions/{storySessionId}/runtime-projection` 读取侧在解析历史 `currentStory.options` 时,曾直接把 option JSON 反序列化为后端投影类型,并要求 `scope` 必填。
|
||||||
|
|
||||||
|
但旧快照里的 `currentStory.options` 只保证 `functionId` / `actionText` / `text` 等基础字段,`scope` 并不是历史存档的稳定字段。于是旧存档在读取 runtime inventory view 时会报:
|
||||||
|
|
||||||
|
`currentStory.options 无法映射为后端选项投影: missing field 'scope'`
|
||||||
|
|
||||||
|
## 修复口径
|
||||||
|
|
||||||
|
1. `spacetime-client` 的 story runtime projection 读取不再直接反序列化 `currentStory.options`。
|
||||||
|
2. 改为复用 `module-runtime-story::build_runtime_story_options(...)`,让历史快照通过领域 helper 统一补齐 `story / combat / npc` 作用域。
|
||||||
|
3. 保持 `StoryRuntimeProjectionSource` 与 `StoryRuntimeProjectionResponse` 输出结构不变,不改 SpacetimeDB schema,不改 reducer,不改 API route。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo test -p spacetime-client story_runtime --manifest-path server-rs\Cargo.toml
|
||||||
|
cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml
|
||||||
|
```
|
||||||
@@ -37,12 +37,14 @@
|
|||||||
|
|
||||||
本次只做前端分享引导,不接入微信、QQ、抖音的原生 SDK。点击渠道 icon 与主“分享”按钮保持一致,复制同一份分享文本。
|
本次只做前端分享引导,不接入微信、QQ、抖音的原生 SDK。点击渠道 icon 与主“分享”按钮保持一致,复制同一份分享文本。
|
||||||
|
|
||||||
仓库现有 `media/social-media-group/wechat.png` 与 `qq.png` 是社群二维码,不作为本面板渠道 icon 使用。渠道 icon 采用轻量圆形文字标识,避免误导用户进入社群。
|
仓库现有 `media/social-media-group/wechat.png` 与 `qq.png` 是社群二维码,不作为本面板渠道 icon 使用。渠道 icon 必须使用微信、QQ、抖音的品牌 SVG 轮廓,外层保持圆形触控底座;不能用通用聊天气泡、音乐符号或纯文字替代 logo。
|
||||||
|
|
||||||
## 面板样式约束
|
## 面板样式约束
|
||||||
|
|
||||||
分享面板通过 `UnifiedModal` portal 挂载到页面根部时,需要在遮罩层补齐当前平台主题类,避免主题变量脱离页面容器后丢失。面板外壳继续使用 `platform-modal-shell` 的 `--platform-modal-fill` 背景,并在移动端覆盖平台弹窗默认底部抽屉布局,保持居中显示。
|
分享面板通过 `UnifiedModal` portal 挂载到页面根部时,需要在遮罩层补齐当前平台主题类,避免主题变量脱离页面容器后丢失。面板外壳继续使用 `platform-modal-shell` 的 `--platform-modal-fill` 背景,并在移动端覆盖平台弹窗默认底部抽屉布局,保持居中显示。
|
||||||
|
|
||||||
|
同类平台弹窗,包括删除作品等确认面板,也必须遵守同一条约束:portal 挂载时遮罩层必须带 `platform-theme platform-theme--light/dark`,面板必须保留 `platform-modal-shell` 的实体背景,不能把主面板做成透明或只依赖 backdrop blur。移动端高风险确认弹窗必须显式居中显示,避免被底部导航、安全区或底部抽屉布局遮住。
|
||||||
|
|
||||||
## 接入范围
|
## 接入范围
|
||||||
|
|
||||||
- `RpgCreationResultActionBar`:RPG 发布成功后由父层回传分享数据并打开面板。
|
- `RpgCreationResultActionBar`:RPG 发布成功后由父层回传分享数据并打开面板。
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type PuzzleAgentSuggestedActionType =
|
|||||||
| 'request_summary'
|
| 'request_summary'
|
||||||
| 'compile_puzzle_draft'
|
| 'compile_puzzle_draft'
|
||||||
| 'generate_puzzle_images'
|
| 'generate_puzzle_images'
|
||||||
|
| 'generate_puzzle_tags'
|
||||||
| 'publish_puzzle_work';
|
| 'publish_puzzle_work';
|
||||||
|
|
||||||
export interface PuzzleAgentSuggestedAction {
|
export interface PuzzleAgentSuggestedAction {
|
||||||
@@ -16,6 +17,7 @@ export type PuzzleAgentActionType =
|
|||||||
| 'save_puzzle_form_draft'
|
| 'save_puzzle_form_draft'
|
||||||
| 'compile_puzzle_draft'
|
| 'compile_puzzle_draft'
|
||||||
| 'generate_puzzle_images'
|
| 'generate_puzzle_images'
|
||||||
|
| 'generate_puzzle_tags'
|
||||||
| 'select_puzzle_image'
|
| 'select_puzzle_image'
|
||||||
| 'publish_puzzle_work';
|
| 'publish_puzzle_work';
|
||||||
|
|
||||||
@@ -71,6 +73,15 @@ export type PuzzleAgentActionRequest =
|
|||||||
themeTags?: string[];
|
themeTags?: string[];
|
||||||
levelsJson?: string;
|
levelsJson?: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
action: 'generate_puzzle_tags';
|
||||||
|
workTitle: string;
|
||||||
|
workDescription: string;
|
||||||
|
levelName?: string;
|
||||||
|
summary?: string;
|
||||||
|
themeTags?: string[];
|
||||||
|
levelsJson?: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
action: 'select_puzzle_image';
|
action: 'select_puzzle_image';
|
||||||
levelId?: string | null;
|
levelId?: string | null;
|
||||||
|
|||||||
BIN
public/puzzle-creation-templates/children-learning.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
public/puzzle-creation-templates/couple-memory.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/puzzle-creation-templates/cute-pet.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/puzzle-creation-templates/daily-challenge.webp
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
public/puzzle-creation-templates/event-invitation.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/puzzle-creation-templates/family-keepsake.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/puzzle-creation-templates/festival-card.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
public/puzzle-creation-templates/friends-party.webp
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
public/puzzle-creation-templates/healing-landscape.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/puzzle-creation-templates/hot-topic-poster.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/puzzle-creation-templates/knowledge-summary.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/puzzle-creation-templates/product-detail.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
@@ -28,7 +28,9 @@ use webp::Encoder as WebpEncoder;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api_response::json_success_body,
|
api_response::json_success_body,
|
||||||
asset_billing::execute_billable_asset_operation,
|
asset_billing::{
|
||||||
|
execute_billable_asset_operation, execute_billable_asset_operation_with_cost,
|
||||||
|
},
|
||||||
auth::AuthenticatedAccessToken,
|
auth::AuthenticatedAccessToken,
|
||||||
custom_world_result_prompts::{
|
custom_world_result_prompts::{
|
||||||
build_result_entity_system_prompt, build_result_entity_user_prompt,
|
build_result_entity_system_prompt, build_result_entity_user_prompt,
|
||||||
@@ -115,6 +117,12 @@ pub(crate) struct CustomWorldCoverUploadRequest {
|
|||||||
crop_rect: CustomWorldCoverCropRect,
|
crop_rect: CustomWorldCoverCropRect,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct CustomWorldOpeningCgGenerateRequest {
|
||||||
|
profile: Value,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct GeneratedAssetResponse {
|
struct GeneratedAssetResponse {
|
||||||
@@ -133,6 +141,38 @@ struct GeneratedAssetResponse {
|
|||||||
actual_prompt: Option<String>,
|
actual_prompt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct GeneratedOpeningCgResponse {
|
||||||
|
opening_cg: CustomWorldOpeningCgProfileResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct CustomWorldOpeningCgProfileResponse {
|
||||||
|
id: String,
|
||||||
|
status: &'static str,
|
||||||
|
storyboard_image_src: String,
|
||||||
|
storyboard_asset_id: String,
|
||||||
|
video_src: String,
|
||||||
|
video_asset_id: String,
|
||||||
|
poster_image_src: Option<String>,
|
||||||
|
poster_asset_id: Option<String>,
|
||||||
|
storyboard_prompt: String,
|
||||||
|
video_prompt: String,
|
||||||
|
image_model: &'static str,
|
||||||
|
video_model: String,
|
||||||
|
aspect_ratio: &'static str,
|
||||||
|
image_size: &'static str,
|
||||||
|
video_resolution: &'static str,
|
||||||
|
duration_seconds: u32,
|
||||||
|
point_cost: u64,
|
||||||
|
estimated_wait_minutes: u32,
|
||||||
|
generated_at: String,
|
||||||
|
updated_at: String,
|
||||||
|
error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct GeneratedCustomWorldSceneImage {
|
pub(crate) struct GeneratedCustomWorldSceneImage {
|
||||||
pub image_src: String,
|
pub image_src: String,
|
||||||
@@ -317,6 +357,22 @@ struct DownloadedRemoteImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RPG_SCENE_IMAGE_MODEL: &str = GPT_IMAGE_2_MODEL;
|
const RPG_SCENE_IMAGE_MODEL: &str = GPT_IMAGE_2_MODEL;
|
||||||
|
const OPENING_CG_POINTS_COST: u64 = 80;
|
||||||
|
const OPENING_CG_ESTIMATED_WAIT_MINUTES: u32 = 10;
|
||||||
|
const OPENING_CG_IMAGE_SIZE_LABEL: &str = "2k";
|
||||||
|
const OPENING_CG_STORYBOARD_IMAGE_SIZE: &str = "2048x1152";
|
||||||
|
const OPENING_CG_VIDEO_PROMPT: &str = "利用参考图作为故事板,生成一段连贯的动画,没有旁白";
|
||||||
|
const OPENING_CG_VIDEO_RESOLUTION: &str = "480p";
|
||||||
|
const OPENING_CG_VIDEO_RATIO: &str = "16:9";
|
||||||
|
const OPENING_CG_VIDEO_DURATION_SECONDS: u32 = 15;
|
||||||
|
const OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS: u64 = 600_000;
|
||||||
|
const OPENING_CG_ASPECT_RATIO: &str = "16:9";
|
||||||
|
const OPENING_CG_STORYBOARD_ASSET_KIND: &str = "custom_world_opening_cg_storyboard";
|
||||||
|
const OPENING_CG_VIDEO_ASSET_KIND: &str = "custom_world_opening_cg_video";
|
||||||
|
const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile";
|
||||||
|
const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard";
|
||||||
|
const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video";
|
||||||
|
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
|
||||||
|
|
||||||
struct CoverPromptContext {
|
struct CoverPromptContext {
|
||||||
opening_act_title: String,
|
opening_act_title: String,
|
||||||
@@ -336,6 +392,39 @@ struct NormalizedSceneImageRequest {
|
|||||||
reference_image_src: Option<String>,
|
reference_image_src: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct NormalizedOpeningCgRequest {
|
||||||
|
profile_id: Option<String>,
|
||||||
|
world_name: String,
|
||||||
|
opening_cg_id: String,
|
||||||
|
storyboard_prompt: String,
|
||||||
|
video_prompt: String,
|
||||||
|
player_role_image_src: String,
|
||||||
|
opening_scene_image_src: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ArkVideoSettings {
|
||||||
|
base_url: String,
|
||||||
|
api_key: String,
|
||||||
|
request_timeout_ms: u64,
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GeneratedOpeningCgStoryboard {
|
||||||
|
image_src: String,
|
||||||
|
asset_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GeneratedOpeningCgVideo {
|
||||||
|
video_src: String,
|
||||||
|
asset_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DownloadedRemoteVideo {
|
||||||
|
mime_type: String,
|
||||||
|
extension: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct NormalizedCropRect {
|
struct NormalizedCropRect {
|
||||||
left: u32,
|
left: u32,
|
||||||
@@ -884,6 +973,119 @@ pub async fn upload_custom_world_cover_image(
|
|||||||
Ok(json_success_body(Some(&request_context), asset))
|
Ok(json_success_body(Some(&request_context), asset))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn generate_custom_world_opening_cg(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
payload: Result<Json<CustomWorldOpeningCgGenerateRequest>, JsonRejection>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
let Json(payload) = payload.map_err(|error| {
|
||||||
|
custom_world_ai_error_response(
|
||||||
|
&request_context,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "custom-world-opening-cg",
|
||||||
|
"message": error.body_text(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||||
|
let normalized = normalize_opening_cg_request(&payload.profile)
|
||||||
|
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||||
|
require_openai_image_settings(&state)
|
||||||
|
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||||
|
require_ark_video_settings(&state)
|
||||||
|
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||||
|
|
||||||
|
let opening_cg_id = normalized.opening_cg_id.clone();
|
||||||
|
let generated = execute_billable_asset_operation_with_cost(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
"custom_world_opening_cg",
|
||||||
|
opening_cg_id.as_str(),
|
||||||
|
OPENING_CG_POINTS_COST,
|
||||||
|
async {
|
||||||
|
let image_settings = require_openai_image_settings(&state)?;
|
||||||
|
let image_http_client = build_openai_image_http_client(&image_settings)?;
|
||||||
|
let video_settings = require_ark_video_settings(&state)?;
|
||||||
|
let video_http_client = build_upstream_http_client(video_settings.request_timeout_ms)?;
|
||||||
|
let player_role_reference = resolve_reference_image_as_data_url(
|
||||||
|
&state,
|
||||||
|
&image_http_client,
|
||||||
|
normalized.player_role_image_src.as_str(),
|
||||||
|
"playerRoleImageSrc",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let opening_scene_reference = resolve_reference_image_as_data_url(
|
||||||
|
&state,
|
||||||
|
&image_http_client,
|
||||||
|
normalized.opening_scene_image_src.as_str(),
|
||||||
|
"openingSceneImageSrc",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let storyboard = generate_opening_cg_storyboard(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
&image_http_client,
|
||||||
|
&image_settings,
|
||||||
|
&normalized,
|
||||||
|
&[player_role_reference, opening_scene_reference],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let storyboard_reference = resolve_reference_image_as_data_url(
|
||||||
|
&state,
|
||||||
|
&image_http_client,
|
||||||
|
storyboard.image_src.as_str(),
|
||||||
|
"storyboardImageSrc",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let video = generate_opening_cg_video(
|
||||||
|
&state,
|
||||||
|
&owner_user_id,
|
||||||
|
&video_http_client,
|
||||||
|
&video_settings,
|
||||||
|
&normalized,
|
||||||
|
storyboard_reference.as_str(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let generated_at = current_utc_iso_text();
|
||||||
|
|
||||||
|
Ok(CustomWorldOpeningCgProfileResponse {
|
||||||
|
id: opening_cg_id.clone(),
|
||||||
|
status: "ready",
|
||||||
|
storyboard_image_src: storyboard.image_src,
|
||||||
|
storyboard_asset_id: storyboard.asset_id,
|
||||||
|
video_src: video.video_src,
|
||||||
|
video_asset_id: video.asset_id,
|
||||||
|
poster_image_src: None,
|
||||||
|
poster_asset_id: None,
|
||||||
|
storyboard_prompt: normalized.storyboard_prompt.clone(),
|
||||||
|
video_prompt: normalized.video_prompt.clone(),
|
||||||
|
image_model: GPT_IMAGE_2_MODEL,
|
||||||
|
video_model: video_settings.model,
|
||||||
|
aspect_ratio: OPENING_CG_ASPECT_RATIO,
|
||||||
|
image_size: OPENING_CG_IMAGE_SIZE_LABEL,
|
||||||
|
video_resolution: OPENING_CG_VIDEO_RESOLUTION,
|
||||||
|
duration_seconds: OPENING_CG_VIDEO_DURATION_SECONDS,
|
||||||
|
point_cost: OPENING_CG_POINTS_COST,
|
||||||
|
estimated_wait_minutes: OPENING_CG_ESTIMATED_WAIT_MINUTES,
|
||||||
|
generated_at: generated_at.clone(),
|
||||||
|
updated_at: generated_at,
|
||||||
|
error_message: None,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||||
|
|
||||||
|
Ok(json_success_body(
|
||||||
|
Some(&request_context),
|
||||||
|
GeneratedOpeningCgResponse {
|
||||||
|
opening_cg: generated,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn persist_custom_world_asset(
|
async fn persist_custom_world_asset(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
owner_user_id: &str,
|
owner_user_id: &str,
|
||||||
@@ -974,6 +1176,337 @@ async fn persist_custom_world_asset(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn generate_opening_cg_storyboard(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
settings: &crate::openai_image_generation::OpenAiImageSettings,
|
||||||
|
normalized: &NormalizedOpeningCgRequest,
|
||||||
|
reference_images: &[String],
|
||||||
|
) -> Result<GeneratedOpeningCgStoryboard, AppError> {
|
||||||
|
let generated = create_openai_image_generation(
|
||||||
|
http_client,
|
||||||
|
settings,
|
||||||
|
normalized.storyboard_prompt.as_str(),
|
||||||
|
None,
|
||||||
|
OPENING_CG_STORYBOARD_IMAGE_SIZE,
|
||||||
|
1,
|
||||||
|
reference_images,
|
||||||
|
"开局 CG 故事板生成失败",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let downloaded = generated
|
||||||
|
.images
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(downloaded_openai_to_custom_world_image)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "apimart",
|
||||||
|
"message": "开局 CG 故事板生成成功但未返回图片。",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let asset_id = format!("opening-cg-storyboard-{}", current_utc_millis());
|
||||||
|
let upload = PreparedAssetUpload {
|
||||||
|
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||||
|
path_segments: vec![
|
||||||
|
sanitize_storage_segment(
|
||||||
|
normalized
|
||||||
|
.profile_id
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(normalized.world_name.as_str()),
|
||||||
|
"world",
|
||||||
|
),
|
||||||
|
"opening-cg".to_string(),
|
||||||
|
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
|
||||||
|
],
|
||||||
|
file_name: format!("storyboard.{}", downloaded.extension),
|
||||||
|
content_type: downloaded.mime_type,
|
||||||
|
body: downloaded.bytes,
|
||||||
|
asset_kind: OPENING_CG_STORYBOARD_ASSET_KIND,
|
||||||
|
entity_kind: OPENING_CG_ENTITY_KIND,
|
||||||
|
entity_id: normalized
|
||||||
|
.profile_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| normalized.world_name.clone()),
|
||||||
|
profile_id: normalized.profile_id.clone(),
|
||||||
|
slot: OPENING_CG_STORYBOARD_SLOT,
|
||||||
|
source_job_id: Some(generated.task_id.clone()),
|
||||||
|
};
|
||||||
|
let asset = persist_custom_world_asset(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
|
upload,
|
||||||
|
GeneratedAssetResponse {
|
||||||
|
image_src: String::new(),
|
||||||
|
asset_id: asset_id.clone(),
|
||||||
|
source_type: "generated".to_string(),
|
||||||
|
model: Some(GPT_IMAGE_2_MODEL.to_string()),
|
||||||
|
size: Some(OPENING_CG_STORYBOARD_IMAGE_SIZE.to_string()),
|
||||||
|
task_id: Some(generated.task_id.clone()),
|
||||||
|
prompt: Some(normalized.storyboard_prompt.clone()),
|
||||||
|
actual_prompt: generated.actual_prompt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(GeneratedOpeningCgStoryboard {
|
||||||
|
image_src: asset.image_src,
|
||||||
|
asset_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_opening_cg_video(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
settings: &ArkVideoSettings,
|
||||||
|
normalized: &NormalizedOpeningCgRequest,
|
||||||
|
storyboard_reference_data_url: &str,
|
||||||
|
) -> Result<GeneratedOpeningCgVideo, AppError> {
|
||||||
|
let upstream_task_id = create_ark_storyboard_to_video_task(
|
||||||
|
http_client,
|
||||||
|
settings,
|
||||||
|
normalized.video_prompt.as_str(),
|
||||||
|
storyboard_reference_data_url,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let video_url =
|
||||||
|
wait_for_ark_content_generation_task(http_client, settings, upstream_task_id.as_str())
|
||||||
|
.await?;
|
||||||
|
let downloaded =
|
||||||
|
download_generated_video(http_client, video_url.as_str(), "下载开局 CG 视频失败").await?;
|
||||||
|
let asset_id = format!("opening-cg-video-{}", current_utc_millis());
|
||||||
|
let video_src = persist_opening_cg_video_asset(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
|
normalized,
|
||||||
|
asset_id.as_str(),
|
||||||
|
Some(upstream_task_id.clone()),
|
||||||
|
downloaded,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(GeneratedOpeningCgVideo {
|
||||||
|
video_src,
|
||||||
|
asset_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_opening_cg_video_asset(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
normalized: &NormalizedOpeningCgRequest,
|
||||||
|
asset_id: &str,
|
||||||
|
source_job_id: Option<String>,
|
||||||
|
video: DownloadedRemoteVideo,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let upload = PreparedAssetUpload {
|
||||||
|
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||||
|
path_segments: vec![
|
||||||
|
sanitize_storage_segment(
|
||||||
|
normalized
|
||||||
|
.profile_id
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(normalized.world_name.as_str()),
|
||||||
|
"world",
|
||||||
|
),
|
||||||
|
"opening-cg".to_string(),
|
||||||
|
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
|
||||||
|
],
|
||||||
|
file_name: format!("opening.{}", video.extension),
|
||||||
|
content_type: video.mime_type,
|
||||||
|
body: video.bytes,
|
||||||
|
asset_kind: OPENING_CG_VIDEO_ASSET_KIND,
|
||||||
|
entity_kind: OPENING_CG_ENTITY_KIND,
|
||||||
|
entity_id: normalized
|
||||||
|
.profile_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| normalized.world_name.clone()),
|
||||||
|
profile_id: normalized.profile_id.clone(),
|
||||||
|
slot: OPENING_CG_VIDEO_SLOT,
|
||||||
|
source_job_id,
|
||||||
|
};
|
||||||
|
let asset = persist_custom_world_asset(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
|
upload,
|
||||||
|
GeneratedAssetResponse {
|
||||||
|
image_src: String::new(),
|
||||||
|
asset_id: asset_id.to_string(),
|
||||||
|
source_type: "generated".to_string(),
|
||||||
|
model: Some("ark-seedance".to_string()),
|
||||||
|
size: Some(format!(
|
||||||
|
"{}:{}:{}s",
|
||||||
|
OPENING_CG_VIDEO_RESOLUTION,
|
||||||
|
OPENING_CG_VIDEO_RATIO,
|
||||||
|
OPENING_CG_VIDEO_DURATION_SECONDS
|
||||||
|
)),
|
||||||
|
task_id: None,
|
||||||
|
prompt: Some(normalized.video_prompt.clone()),
|
||||||
|
actual_prompt: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(asset.image_src)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_ark_storyboard_to_video_task(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
settings: &ArkVideoSettings,
|
||||||
|
prompt: &str,
|
||||||
|
storyboard_reference_data_url: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let response = http_client
|
||||||
|
.post(format!("{}/contents/generations/tasks", settings.base_url))
|
||||||
|
.header(
|
||||||
|
reqwest::header::AUTHORIZATION,
|
||||||
|
format!("Bearer {}", settings.api_key),
|
||||||
|
)
|
||||||
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
|
.json(&json!({
|
||||||
|
"model": settings.model,
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": prompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": storyboard_reference_data_url,
|
||||||
|
},
|
||||||
|
"role": "reference_image",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resolution": OPENING_CG_VIDEO_RESOLUTION,
|
||||||
|
"ratio": OPENING_CG_VIDEO_RATIO,
|
||||||
|
"duration": OPENING_CG_VIDEO_DURATION_SECONDS,
|
||||||
|
"watermark": false,
|
||||||
|
"audio": true,
|
||||||
|
"generate_audio": true,
|
||||||
|
"web_search": true,
|
||||||
|
"enable_web_search": true,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| map_ark_video_request_error(format!("请求 Seedance 视频服务失败:{error}")))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await.map_err(|error| {
|
||||||
|
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
|
||||||
|
})?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(parse_ark_video_upstream_error(
|
||||||
|
text.as_str(),
|
||||||
|
"创建开局 CG 视频任务失败。",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let payload = parse_ark_video_json_payload(text.as_str(), "创建开局 CG 视频任务失败。")?;
|
||||||
|
extract_ark_task_id(&payload.payload).ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "ark",
|
||||||
|
"message": "开局 CG 视频任务未返回任务 id。",
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_ark_content_generation_task(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
settings: &ArkVideoSettings,
|
||||||
|
task_id: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let response = http_client
|
||||||
|
.get(format!(
|
||||||
|
"{}/contents/generations/tasks/{}",
|
||||||
|
settings.base_url, task_id
|
||||||
|
))
|
||||||
|
.header(
|
||||||
|
reqwest::header::AUTHORIZATION,
|
||||||
|
format!("Bearer {}", settings.api_key),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| map_ark_video_request_error(format!("查询 Seedance 视频任务失败:{error}")))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await.map_err(|error| {
|
||||||
|
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
|
||||||
|
})?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(parse_ark_video_upstream_error(
|
||||||
|
text.as_str(),
|
||||||
|
"查询开局 CG 视频任务失败。",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let payload = parse_ark_video_json_payload(text.as_str(), "查询开局 CG 视频任务失败。")?;
|
||||||
|
if let Some(video_url) = extract_video_url(&payload.payload) {
|
||||||
|
return Ok(video_url);
|
||||||
|
}
|
||||||
|
let normalized_status = normalize_generation_task_status(
|
||||||
|
extract_generation_task_status(&payload.payload).as_str(),
|
||||||
|
);
|
||||||
|
if is_completed_generation_task_status(normalized_status.as_str()) {
|
||||||
|
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "ark",
|
||||||
|
"message": "开局 CG 视频任务完成但没有返回 video_url。",
|
||||||
|
"taskId": task_id,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
if is_failed_generation_task_status(normalized_status.as_str()) {
|
||||||
|
return Err(parse_ark_video_upstream_error(
|
||||||
|
text.as_str(),
|
||||||
|
"开局 CG 视频任务执行失败。",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "ark",
|
||||||
|
"message": "开局 CG 视频生成超时,请稍后重试。",
|
||||||
|
"taskId": task_id,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_generated_video(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
video_url: &str,
|
||||||
|
fallback_message: &str,
|
||||||
|
) -> Result<DownloadedRemoteVideo, AppError> {
|
||||||
|
let response = http_client
|
||||||
|
.get(video_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}:{error}")))?;
|
||||||
|
let status = response.status();
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or("video/mp4")
|
||||||
|
.to_string();
|
||||||
|
let body = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}:{error}")))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "ark",
|
||||||
|
"message": fallback_message,
|
||||||
|
"status": status.as_u16(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
let normalized_mime_type = normalize_downloaded_video_mime_type(content_type.as_str());
|
||||||
|
|
||||||
|
Ok(DownloadedRemoteVideo {
|
||||||
|
extension: video_mime_to_extension(normalized_mime_type.as_str()).to_string(),
|
||||||
|
mime_type: normalized_mime_type,
|
||||||
|
bytes: body.to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn build_asset_metadata(
|
fn build_asset_metadata(
|
||||||
asset_kind: &str,
|
asset_kind: &str,
|
||||||
owner_user_id: &str,
|
owner_user_id: &str,
|
||||||
@@ -1225,6 +1758,176 @@ fn normalize_scene_image_request(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_opening_cg_request(profile: &Value) -> Result<NormalizedOpeningCgRequest, AppError> {
|
||||||
|
let object = profile.as_object().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "custom-world-opening-cg",
|
||||||
|
"message": "profile 必须是 JSON object",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let world_name = read_string_field(object, "name").unwrap_or_else(|| "未命名世界".to_string());
|
||||||
|
let profile_id = read_string_field(object, "id");
|
||||||
|
let world_tone = read_string_field(object, "tone").ok_or_else(|| {
|
||||||
|
missing_opening_cg_field_error("世界基调缺失,无法生成开局 CG。")
|
||||||
|
})?;
|
||||||
|
let world_summary = read_string_field(object, "summary").ok_or_else(|| {
|
||||||
|
missing_opening_cg_field_error("世界概述缺失,无法生成开局 CG。")
|
||||||
|
})?;
|
||||||
|
let core_conflicts = read_string_array_field(object, "coreConflicts");
|
||||||
|
if core_conflicts.is_empty() {
|
||||||
|
return Err(missing_opening_cg_field_error(
|
||||||
|
"核心冲突缺失,无法生成开局 CG。",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let player_role = object
|
||||||
|
.get("playableNpcs")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|roles| roles.first())
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.ok_or_else(|| missing_opening_cg_field_error("缺少玩家扮演角色。"))?;
|
||||||
|
let player_role_image_src = read_string_field(player_role, "imageSrc").ok_or_else(|| {
|
||||||
|
missing_opening_cg_field_error("玩家扮演角色缺少角色参考图。")
|
||||||
|
})?;
|
||||||
|
let player_role_brief = build_opening_cg_player_role_brief(player_role);
|
||||||
|
let opening_scene_image_src = profile
|
||||||
|
.pointer("/sceneChapterBlueprints/0/acts/0/backgroundImageSrc")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
missing_opening_cg_field_error("首个场景第一幕背景图缺失,无法生成开局 CG。")
|
||||||
|
})?;
|
||||||
|
let opening_cg_id = format!("opening-cg-{}", current_utc_millis());
|
||||||
|
let storyboard_prompt = build_opening_cg_storyboard_prompt(
|
||||||
|
world_tone.as_str(),
|
||||||
|
player_role_brief.as_str(),
|
||||||
|
world_summary.as_str(),
|
||||||
|
core_conflicts.as_slice(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(NormalizedOpeningCgRequest {
|
||||||
|
profile_id,
|
||||||
|
world_name,
|
||||||
|
opening_cg_id,
|
||||||
|
storyboard_prompt,
|
||||||
|
video_prompt: OPENING_CG_VIDEO_PROMPT.to_string(),
|
||||||
|
player_role_image_src,
|
||||||
|
opening_scene_image_src,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_opening_cg_field_error(message: &str) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "custom-world-opening-cg",
|
||||||
|
"message": message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_opening_cg_storyboard_prompt(
|
||||||
|
world_tone: &str,
|
||||||
|
player_role_brief: &str,
|
||||||
|
world_summary: &str,
|
||||||
|
core_conflicts: &[String],
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"以3*4网格格式创建故事板,16:9。像素风角色扮演游戏开场动画CG。\n\n故事流程:先展示角色,展示故事背景,然后表现核心冲突,最后衔接开局场景\n故事基调:{}\n\n玩家扮演:将玩家扮演角色作为角色参考图并引用世界草稿中的角色简介:{}\n故事背景:{}\n核心冲突:{}\n开局场景:将首个场景的第一幕背景图作为参考图",
|
||||||
|
clamp_opening_cg_prompt_text(world_tone, 160),
|
||||||
|
clamp_opening_cg_prompt_text(player_role_brief, 320),
|
||||||
|
clamp_opening_cg_prompt_text(world_summary, 420),
|
||||||
|
clamp_opening_cg_prompt_text(core_conflicts.join(";").as_str(), 360),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_opening_cg_player_role_brief(role: &Map<String, Value>) -> String {
|
||||||
|
[
|
||||||
|
read_string_field(role, "name")
|
||||||
|
.map(|value| format!("姓名:{value}"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
read_string_field(role, "role")
|
||||||
|
.map(|value| format!("身份:{value}"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
read_string_field(role, "description")
|
||||||
|
.map(|value| format!("简介:{value}"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
read_string_field(role, "visualDescription")
|
||||||
|
.map(|value| format!("形象:{value}"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_string_array_field(object: &Map<String, Value>, key: &str) -> Vec<String> {
|
||||||
|
object
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|entries| {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_opening_cg_prompt_text(value: &str, max_length: usize) -> String {
|
||||||
|
clamp_text(value, max_length, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_ark_video_settings(state: &AppState) -> Result<ArkVideoSettings, AppError> {
|
||||||
|
let base_url = state
|
||||||
|
.config
|
||||||
|
.ark_character_video_base_url
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches('/');
|
||||||
|
if base_url.is_empty() {
|
||||||
|
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
|
"provider": "ark",
|
||||||
|
"reason": "ARK_CHARACTER_VIDEO_BASE_URL 未配置",
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
let api_key = state
|
||||||
|
.config
|
||||||
|
.ark_character_video_api_key
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
|
"provider": "ark",
|
||||||
|
"reason": "ARK_CHARACTER_VIDEO_API_KEY 未配置",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(ArkVideoSettings {
|
||||||
|
base_url: base_url.to_string(),
|
||||||
|
api_key: api_key.to_string(),
|
||||||
|
request_timeout_ms: state
|
||||||
|
.config
|
||||||
|
.ark_character_video_request_timeout_ms
|
||||||
|
.max(OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS),
|
||||||
|
model: state.config.ark_character_video_model.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_upstream_http_client(timeout_ms: u64) -> Result<reqwest::Client, AppError> {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_millis(timeout_ms))
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||||
|
"provider": "custom-world-opening-cg",
|
||||||
|
"message": format!("构造上游 HTTP 客户端失败:{error}"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
|
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
|
||||||
// Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。
|
// Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。
|
||||||
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
|
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
|
||||||
@@ -2143,6 +2846,20 @@ fn parse_json_payload(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_ark_video_json_payload(
|
||||||
|
raw_text: &str,
|
||||||
|
fallback_message: &str,
|
||||||
|
) -> Result<ParsedJsonPayload, AppError> {
|
||||||
|
serde_json::from_str::<Value>(raw_text)
|
||||||
|
.map(|payload| ParsedJsonPayload { payload })
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "ark",
|
||||||
|
"message": format!("{fallback_message}:解析响应失败:{error}"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||||
if raw_text.trim().is_empty() {
|
if raw_text.trim().is_empty() {
|
||||||
return fallback_message.to_string();
|
return fallback_message.to_string();
|
||||||
@@ -2193,6 +2910,13 @@ fn map_dashscope_request_error(message: String) -> AppError {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_ark_video_request_error(message: String) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "ark",
|
||||||
|
"message": message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
|
fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "dashscope",
|
"provider": "dashscope",
|
||||||
@@ -2200,6 +2924,13 @@ fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppEr
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_ark_video_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "ark",
|
||||||
|
"message": parse_api_error_message(raw_text, fallback_message),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
|
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||||
match value {
|
match value {
|
||||||
Value::Array(entries) => {
|
Value::Array(entries) => {
|
||||||
@@ -2236,6 +2967,61 @@ fn extract_task_id(payload: &Value) -> Option<String> {
|
|||||||
find_first_string_by_key(payload, "task_id")
|
find_first_string_by_key(payload, "task_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_ark_task_id(payload: &Value) -> Option<String> {
|
||||||
|
payload
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| find_first_string_by_key(payload, "task_id"))
|
||||||
|
.or_else(|| find_first_string_by_key(payload, "taskId"))
|
||||||
|
.or_else(|| find_first_string_by_key(payload, "id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_video_url(payload: &Value) -> Option<String> {
|
||||||
|
find_first_string_by_key(payload, "video_url")
|
||||||
|
.or_else(|| find_first_string_by_key(payload, "videoUrl"))
|
||||||
|
.or_else(|| find_first_string_by_key(payload, "url"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_generation_task_status(payload: &Value) -> String {
|
||||||
|
payload
|
||||||
|
.get("status")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| find_first_string_by_key(payload, "task_status"))
|
||||||
|
.or_else(|| find_first_string_by_key(payload, "status"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_generation_task_status(value: &str) -> String {
|
||||||
|
value.trim().to_ascii_lowercase().replace(' ', "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_completed_generation_task_status(status: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
status,
|
||||||
|
"completed" | "complete" | "done" | "finished" | "success" | "succeeded" | "succeed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_failed_generation_task_status(status: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
status,
|
||||||
|
"failed"
|
||||||
|
| "canceled"
|
||||||
|
| "cancelled"
|
||||||
|
| "error"
|
||||||
|
| "aborted"
|
||||||
|
| "rejected"
|
||||||
|
| "expired"
|
||||||
|
| "unknown"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_image_urls(payload: &Value) -> Vec<String> {
|
fn extract_image_urls(payload: &Value) -> Vec<String> {
|
||||||
let mut urls = Vec::new();
|
let mut urls = Vec::new();
|
||||||
collect_strings_by_key(payload, "image", &mut urls);
|
collect_strings_by_key(payload, "image", &mut urls);
|
||||||
@@ -2263,6 +3049,20 @@ fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_downloaded_video_mime_type(content_type: &str) -> String {
|
||||||
|
let mime_type = content_type
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("video/mp4");
|
||||||
|
match mime_type {
|
||||||
|
"video/mp4" | "video/quicktime" | "video/webm" | "video/x-msvideo" => {
|
||||||
|
mime_type.to_string()
|
||||||
|
}
|
||||||
|
_ => "video/mp4".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn mime_to_extension(mime_type: &str) -> &str {
|
fn mime_to_extension(mime_type: &str) -> &str {
|
||||||
match mime_type {
|
match mime_type {
|
||||||
"image/png" => "png",
|
"image/png" => "png",
|
||||||
@@ -2272,6 +3072,15 @@ fn mime_to_extension(mime_type: &str) -> &str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn video_mime_to_extension(mime_type: &str) -> &str {
|
||||||
|
match mime_type {
|
||||||
|
"video/quicktime" => "mov",
|
||||||
|
"video/webm" => "webm",
|
||||||
|
"video/x-msvideo" => "avi",
|
||||||
|
_ => "mp4",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn conditional_prompt_line(prefix: &str, value: &str) -> String {
|
fn conditional_prompt_line(prefix: &str, value: &str) -> String {
|
||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -2391,6 +3200,12 @@ fn current_utc_micros() -> i64 {
|
|||||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_utc_iso_text() -> String {
|
||||||
|
time::OffsetDateTime::now_utc()
|
||||||
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.unwrap_or_else(|_| format!("{}.000000Z", current_utc_millis()))
|
||||||
|
}
|
||||||
|
|
||||||
fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||||
error.into_response_with_context(Some(request_context))
|
error.into_response_with_context(Some(request_context))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,15 +58,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn form_seed_prompt_keeps_only_user_visible_fields() {
|
fn form_seed_prompt_keeps_only_user_visible_fields() {
|
||||||
let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||||
title: Some(" 暖灯猫街 "),
|
title: None,
|
||||||
work_description: Some("雨夜礼物拼图"),
|
work_description: None,
|
||||||
picture_description: Some("猫咪在灯牌下回头"),
|
picture_description: Some("猫咪在灯牌下回头"),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(prompt, "画面描述:猫咪在灯牌下回头");
|
||||||
prompt,
|
|
||||||
"作品名称:暖灯猫街\n作品描述:雨夜礼物拼图\n画面描述:猫咪在灯牌下回头"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
35
server-rs/crates/api-server/src/prompt/puzzle/level_name.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/// 拼图首关关卡名生成提示词。
|
||||||
|
///
|
||||||
|
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名;写回草稿和作品卡由业务路由处理。
|
||||||
|
pub(crate) const PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT: &str = r#"你是一个中文拼图关卡命名编辑。
|
||||||
|
|
||||||
|
你会收到拼图第一关的画面描述。请生成 1 个适合直接展示在游戏关卡卡片上的中文关卡名。
|
||||||
|
|
||||||
|
硬约束:
|
||||||
|
1. 只输出 JSON,不要输出 Markdown、解释或代码块。
|
||||||
|
2. JSON 格式必须是 {"levelName":"关卡名"}。
|
||||||
|
3. levelName 必须是 2 到 8 个中文字符为主。
|
||||||
|
4. 不要输出“第一关”“画面”“拼图”“作品”等泛词。
|
||||||
|
5. 不要输出标点、引号、编号、英文、emoji 或空白。
|
||||||
|
6. 关卡名要抓住画面主体、场景和氛围,读起来像一个具体可玩的关卡。
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub(crate) fn build_puzzle_first_level_name_user_prompt(picture_description: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"画面描述:{picture_description}\n\n请生成第一关关卡名。",
|
||||||
|
picture_description = picture_description.trim(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn level_name_prompt_contains_picture_description() {
|
||||||
|
let prompt = build_puzzle_first_level_name_user_prompt("一只猫在雨夜灯牌下回头。");
|
||||||
|
|
||||||
|
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
|
||||||
|
assert!(prompt.contains("第一关关卡名"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
pub(crate) mod agent_chat;
|
pub(crate) mod agent_chat;
|
||||||
pub(crate) mod draft;
|
pub(crate) mod draft;
|
||||||
pub(crate) mod image;
|
pub(crate) mod image;
|
||||||
|
pub(crate) mod level_name;
|
||||||
|
pub(crate) mod tags;
|
||||||
|
|||||||
40
server-rs/crates/api-server/src/prompt/puzzle/tags.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/// 拼图作品标签生成提示词。
|
||||||
|
///
|
||||||
|
/// 这里只负责标签生成的文本契约,业务路由负责调用 LLM、解析结果和写回草稿。
|
||||||
|
pub(crate) const PUZZLE_TAG_GENERATION_SYSTEM_PROMPT: &str = r#"你是一个中文内容标签编辑。
|
||||||
|
|
||||||
|
你会收到拼图作品名称和作品描述。请生成 6 个适合作品广场检索和相似推荐的中文短标签。
|
||||||
|
|
||||||
|
硬约束:
|
||||||
|
1. 只输出 JSON,不要输出 Markdown、解释或代码块。
|
||||||
|
2. JSON 格式必须是 {"tags":["标签1","标签2","标签3","标签4","标签5","标签6"]}。
|
||||||
|
3. tags 必须正好 6 个。
|
||||||
|
4. 每个标签 2 到 6 个中文字符为主,不要整句描述。
|
||||||
|
5. 不要输出空标签、重复标签、英文标签、编号、标点或井号。
|
||||||
|
6. 标签要覆盖题材、主体、氛围、场景、风格和拼图辨识点。
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub(crate) fn build_puzzle_tag_generation_user_prompt(
|
||||||
|
work_title: &str,
|
||||||
|
work_description: &str,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"作品名称:{work_title}\n作品描述:{work_description}\n\n请生成 6 个作品标签。",
|
||||||
|
work_title = work_title.trim(),
|
||||||
|
work_description = work_description.trim(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_prompt_contains_title_and_description() {
|
||||||
|
let prompt = build_puzzle_tag_generation_user_prompt("雨夜猫街", "一套暖灯街角主题拼图。");
|
||||||
|
|
||||||
|
assert!(prompt.contains("作品名称:雨夜猫街"));
|
||||||
|
assert!(prompt.contains("作品描述:一套暖灯街角主题拼图。"));
|
||||||
|
assert!(prompt.contains("6 个作品标签"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ use module_assets::{
|
|||||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||||
};
|
};
|
||||||
use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
|
use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
|
||||||
|
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||||
use platform_oss::{
|
use platform_oss::{
|
||||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||||
OssSignedGetObjectUrlRequest,
|
OssSignedGetObjectUrlRequest,
|
||||||
@@ -76,6 +77,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
auth::AuthenticatedAccessToken,
|
auth::AuthenticatedAccessToken,
|
||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
|
llm_model_routing::CREATION_TEMPLATE_LLM_MODEL,
|
||||||
platform_errors::map_oss_error,
|
platform_errors::map_oss_error,
|
||||||
prompt::puzzle::{
|
prompt::puzzle::{
|
||||||
draft::{
|
draft::{
|
||||||
@@ -83,6 +85,10 @@ use crate::{
|
|||||||
resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt,
|
resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt,
|
||||||
},
|
},
|
||||||
image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
||||||
|
level_name::{
|
||||||
|
PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT, build_puzzle_first_level_name_user_prompt,
|
||||||
|
},
|
||||||
|
tags::{PUZZLE_TAG_GENERATION_SYSTEM_PROMPT, build_puzzle_tag_generation_user_prompt},
|
||||||
},
|
},
|
||||||
puzzle_agent_turn::{
|
puzzle_agent_turn::{
|
||||||
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||||
@@ -527,15 +533,15 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
});
|
});
|
||||||
(
|
(
|
||||||
"compile_puzzle_draft",
|
"compile_puzzle_draft",
|
||||||
"完整拼图草稿",
|
"首关拼图草稿",
|
||||||
"已编译草稿、生成拼图图片并应用为正式图。",
|
"已编译首关草稿、生成首关画面并写入正式草稿。",
|
||||||
session,
|
session,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"save_puzzle_form_draft" => {
|
"save_puzzle_form_draft" => {
|
||||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||||
payload.work_title.as_deref(),
|
None,
|
||||||
payload.work_description.as_deref(),
|
None,
|
||||||
payload
|
payload
|
||||||
.picture_description
|
.picture_description
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -705,6 +711,66 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
session,
|
session,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
"generate_puzzle_tags" => {
|
||||||
|
let work_title = payload
|
||||||
|
.work_title
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
puzzle_bad_request(
|
||||||
|
&request_context,
|
||||||
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"作品名称不能为空",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let work_description = payload
|
||||||
|
.work_description
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
puzzle_bad_request(
|
||||||
|
&request_context,
|
||||||
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"作品描述不能为空",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let levels_json = normalize_puzzle_levels_json_for_module(
|
||||||
|
payload.levels_json.as_deref(),
|
||||||
|
)
|
||||||
|
.map_err(|message| {
|
||||||
|
puzzle_error_response(
|
||||||
|
&request_context,
|
||||||
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"message": message,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let generated_tags =
|
||||||
|
generate_puzzle_work_tags(&state, work_title, work_description).await;
|
||||||
|
let session = save_generated_puzzle_tags_to_session(
|
||||||
|
&state,
|
||||||
|
&session_id,
|
||||||
|
&owner_user_id,
|
||||||
|
&payload,
|
||||||
|
generated_tags,
|
||||||
|
levels_json,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||||
|
});
|
||||||
|
(
|
||||||
|
"generate_puzzle_tags",
|
||||||
|
"作品标签生成",
|
||||||
|
"已生成 6 个作品标签。",
|
||||||
|
session,
|
||||||
|
)
|
||||||
|
}
|
||||||
"select_puzzle_image" => {
|
"select_puzzle_image" => {
|
||||||
let candidate_id = payload
|
let candidate_id = payload
|
||||||
.candidate_id
|
.candidate_id
|
||||||
@@ -2058,12 +2124,12 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String {
|
|||||||
|
|
||||||
fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
|
fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
|
||||||
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||||
title: payload
|
title: None,
|
||||||
.work_title
|
work_description: None,
|
||||||
|
picture_description: payload
|
||||||
|
.picture_description
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.or(payload.seed_text.as_deref()),
|
.or(payload.seed_text.as_deref()),
|
||||||
work_description: payload.work_description.as_deref(),
|
|
||||||
picture_description: payload.picture_description.as_deref(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2088,8 +2154,8 @@ async fn save_puzzle_form_payload_before_compile(
|
|||||||
now: i64,
|
now: i64,
|
||||||
) -> Result<String, Response> {
|
) -> Result<String, Response> {
|
||||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||||
payload.work_title.as_deref(),
|
None,
|
||||||
payload.work_description.as_deref(),
|
None,
|
||||||
payload
|
payload
|
||||||
.picture_description
|
.picture_description
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -2486,6 +2552,176 @@ fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn generate_puzzle_first_level_name(state: &AppState, picture_description: &str) -> String {
|
||||||
|
if let Some(llm_client) = state.llm_client() {
|
||||||
|
let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description);
|
||||||
|
let response = llm_client
|
||||||
|
.request_text(
|
||||||
|
LlmTextRequest::new(vec![
|
||||||
|
LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT),
|
||||||
|
LlmMessage::user(user_prompt),
|
||||||
|
])
|
||||||
|
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
||||||
|
.with_responses_api(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match response {
|
||||||
|
Ok(response) => {
|
||||||
|
if let Some(level_name) =
|
||||||
|
parse_puzzle_first_level_name_from_text(response.content.as_str())
|
||||||
|
{
|
||||||
|
return level_name;
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
picture_chars = picture_description.chars().count(),
|
||||||
|
"拼图首关名模型返回非法,降级使用关键词名"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
picture_chars = picture_description.chars().count(),
|
||||||
|
error = %error,
|
||||||
|
"拼图首关名生成失败,降级使用关键词名"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build_fallback_puzzle_first_level_name(picture_description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
let json_text = if let Some(start) = trimmed.find('{')
|
||||||
|
&& let Some(end) = trimmed.rfind('}')
|
||||||
|
&& end > start
|
||||||
|
{
|
||||||
|
&trimmed[start..=end]
|
||||||
|
} else {
|
||||||
|
trimmed
|
||||||
|
};
|
||||||
|
let parsed = serde_json::from_str::<Value>(json_text).ok();
|
||||||
|
let raw_name = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("levelName").and_then(Value::as_str))
|
||||||
|
.or_else(|| {
|
||||||
|
parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("level_name").and_then(Value::as_str))
|
||||||
|
})
|
||||||
|
.unwrap_or(trimmed);
|
||||||
|
normalize_puzzle_first_level_name(raw_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
|
||||||
|
let normalized = value
|
||||||
|
.trim()
|
||||||
|
.trim_matches(|ch: char| {
|
||||||
|
ch.is_ascii_punctuation()
|
||||||
|
|| matches!(
|
||||||
|
ch,
|
||||||
|
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')'))
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| {
|
||||||
|
!matches!(
|
||||||
|
ch,
|
||||||
|
'#' | '"'
|
||||||
|
| '\''
|
||||||
|
| '`'
|
||||||
|
| ' '
|
||||||
|
| '\t'
|
||||||
|
| '\r'
|
||||||
|
| '\n'
|
||||||
|
| ','
|
||||||
|
| '。'
|
||||||
|
| '、'
|
||||||
|
| ';'
|
||||||
|
| ':'
|
||||||
|
| '!'
|
||||||
|
| '?'
|
||||||
|
| '“'
|
||||||
|
| '”'
|
||||||
|
| '《'
|
||||||
|
| '》'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.take(12)
|
||||||
|
.collect::<String>();
|
||||||
|
let normalized = strip_puzzle_level_name_generic_words(normalized);
|
||||||
|
if normalized.chars().count() >= 2
|
||||||
|
&& !matches!(
|
||||||
|
normalized.as_str(),
|
||||||
|
"第一关" | "画面" | "拼图" | "作品" | "关卡"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Some(normalized)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_puzzle_level_name_generic_words(mut value: String) -> String {
|
||||||
|
for prefix in ["第一关", "关卡名", "关卡"] {
|
||||||
|
value = value.trim_start_matches(prefix).to_string();
|
||||||
|
}
|
||||||
|
for suffix in ["第一关", "关卡名", "关卡", "画面", "拼图", "作品"] {
|
||||||
|
value = value.trim_end_matches(suffix).to_string();
|
||||||
|
}
|
||||||
|
value.chars().take(8).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String {
|
||||||
|
let source = picture_description.trim();
|
||||||
|
if source.contains("猫") && (source.contains("雨夜") || source.contains('雨')) {
|
||||||
|
return "雨夜猫街".to_string();
|
||||||
|
}
|
||||||
|
if source.contains("猫") && source.contains('灯') {
|
||||||
|
return "暖灯猫街".to_string();
|
||||||
|
}
|
||||||
|
for (keyword, level_name) in [
|
||||||
|
("雨夜", "雨夜灯街"),
|
||||||
|
("猫", "暖灯猫街"),
|
||||||
|
("狗", "花园小狗"),
|
||||||
|
("神庙", "神庙遗光"),
|
||||||
|
("遗迹", "遗迹谜光"),
|
||||||
|
("森林", "森林秘境"),
|
||||||
|
("城市", "霓虹城市"),
|
||||||
|
("机械", "机械迷城"),
|
||||||
|
("蒸汽", "蒸汽街区"),
|
||||||
|
("海", "海岸微光"),
|
||||||
|
("花", "花园晨光"),
|
||||||
|
("雪", "雪境小径"),
|
||||||
|
("龙", "龙影高塔"),
|
||||||
|
("灯", "暖灯街角"),
|
||||||
|
("塔", "塔顶星光"),
|
||||||
|
] {
|
||||||
|
if source.contains(keyword) {
|
||||||
|
return level_name.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"奇境初见".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_puzzle_levels_with_primary_name(
|
||||||
|
draft: &PuzzleResultDraftRecord,
|
||||||
|
target_level: &PuzzleDraftLevelRecord,
|
||||||
|
) -> Vec<PuzzleDraftLevelRecord> {
|
||||||
|
let mut levels = draft.levels.clone();
|
||||||
|
if let Some(index) = levels
|
||||||
|
.iter()
|
||||||
|
.position(|level| level.level_id == target_level.level_id)
|
||||||
|
.or_else(|| (!levels.is_empty()).then_some(0))
|
||||||
|
{
|
||||||
|
levels[index].level_name = target_level.level_name.clone();
|
||||||
|
}
|
||||||
|
levels
|
||||||
|
}
|
||||||
|
|
||||||
async fn compile_puzzle_draft_with_initial_cover(
|
async fn compile_puzzle_draft_with_initial_cover(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@@ -2506,7 +2742,14 @@ async fn compile_puzzle_draft_with_initial_cover(
|
|||||||
"message": "拼图结果页草稿尚未生成",
|
"message": "拼图结果页草稿尚未生成",
|
||||||
}))
|
}))
|
||||||
})?;
|
})?;
|
||||||
let target_level = select_puzzle_level_for_api(&draft, None)?;
|
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
|
||||||
|
let fallback_level_name = target_level.level_name.clone();
|
||||||
|
let generated_level_name =
|
||||||
|
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
|
||||||
|
target_level.level_name = generated_level_name.clone();
|
||||||
|
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
|
||||||
|
&build_puzzle_levels_with_primary_name(&draft, &target_level),
|
||||||
|
)?);
|
||||||
let image_prompt = resolve_puzzle_draft_cover_prompt(
|
let image_prompt = resolve_puzzle_draft_cover_prompt(
|
||||||
prompt_text,
|
prompt_text,
|
||||||
&target_level.picture_description,
|
&target_level.picture_description,
|
||||||
@@ -2554,7 +2797,7 @@ async fn compile_puzzle_draft_with_initial_cover(
|
|||||||
session_id: compiled_session.session_id.clone(),
|
session_id: compiled_session.session_id.clone(),
|
||||||
owner_user_id: owner_user_id.clone(),
|
owner_user_id: owner_user_id.clone(),
|
||||||
level_id: Some(target_level.level_id.clone()),
|
level_id: Some(target_level.level_id.clone()),
|
||||||
levels_json: None,
|
levels_json: levels_json_with_generated_name,
|
||||||
candidates_json,
|
candidates_json,
|
||||||
saved_at_micros: current_utc_micros(),
|
saved_at_micros: current_utc_micros(),
|
||||||
})
|
})
|
||||||
@@ -2572,7 +2815,13 @@ async fn compile_puzzle_draft_with_initial_cover(
|
|||||||
"拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
|
"拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
|
||||||
);
|
);
|
||||||
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||||
compiled_session.clone(),
|
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||||
|
compiled_session.clone(),
|
||||||
|
target_level.level_id.as_str(),
|
||||||
|
generated_level_name.as_str(),
|
||||||
|
fallback_level_name.as_str(),
|
||||||
|
now,
|
||||||
|
),
|
||||||
target_level.level_id.as_str(),
|
target_level.level_id.as_str(),
|
||||||
candidates.clone(),
|
candidates.clone(),
|
||||||
now,
|
now,
|
||||||
@@ -2655,6 +2904,39 @@ fn apply_generated_puzzle_candidates_to_session_snapshot(
|
|||||||
session
|
session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||||
|
mut session: PuzzleAgentSessionRecord,
|
||||||
|
target_level_id: &str,
|
||||||
|
level_name: &str,
|
||||||
|
previous_level_name: &str,
|
||||||
|
updated_at_micros: i64,
|
||||||
|
) -> PuzzleAgentSessionRecord {
|
||||||
|
let Some(draft) = session.draft.as_mut() else {
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
let normalized_name = level_name.trim();
|
||||||
|
if normalized_name.is_empty() {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
let Some(target_index) = draft
|
||||||
|
.levels
|
||||||
|
.iter()
|
||||||
|
.position(|level| level.level_id == target_level_id)
|
||||||
|
.or_else(|| (!draft.levels.is_empty()).then_some(0))
|
||||||
|
else {
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
draft.levels[target_index].level_name = normalized_name.to_string();
|
||||||
|
let should_default_work_title =
|
||||||
|
draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim();
|
||||||
|
if target_index == 0 && should_default_work_title {
|
||||||
|
draft.work_title = normalized_name.to_string();
|
||||||
|
}
|
||||||
|
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||||
|
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) {
|
fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) {
|
||||||
let Some(primary_level) = draft.levels.first() else {
|
let Some(primary_level) = draft.levels.first() else {
|
||||||
return;
|
return;
|
||||||
@@ -2677,6 +2959,305 @@ fn replace_puzzle_session_draft_snapshot(
|
|||||||
session
|
session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn generate_puzzle_work_tags(
|
||||||
|
state: &AppState,
|
||||||
|
work_title: &str,
|
||||||
|
work_description: &str,
|
||||||
|
) -> Vec<String> {
|
||||||
|
if let Some(llm_client) = state.llm_client() {
|
||||||
|
let user_prompt = build_puzzle_tag_generation_user_prompt(work_title, work_description);
|
||||||
|
let response = llm_client
|
||||||
|
.request_text(
|
||||||
|
LlmTextRequest::new(vec![
|
||||||
|
LlmMessage::system(PUZZLE_TAG_GENERATION_SYSTEM_PROMPT),
|
||||||
|
LlmMessage::user(user_prompt),
|
||||||
|
])
|
||||||
|
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
||||||
|
.with_responses_api(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match response {
|
||||||
|
Ok(response) => {
|
||||||
|
let tags = normalize_puzzle_tag_candidates(parse_puzzle_tags_from_text(
|
||||||
|
response.content.as_str(),
|
||||||
|
));
|
||||||
|
if tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
work_title,
|
||||||
|
"拼图 AI 标签数量不足,降级使用关键词补齐"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
work_title,
|
||||||
|
error = %error,
|
||||||
|
"拼图 AI 标签生成失败,降级使用关键词标签"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_puzzle_tag_candidates(build_fallback_puzzle_tags(work_title, work_description))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_puzzle_tags_from_text(text: &str) -> Vec<String> {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
let json_text = if let Some(start) = trimmed.find('{')
|
||||||
|
&& let Some(end) = trimmed.rfind('}')
|
||||||
|
&& end > start
|
||||||
|
{
|
||||||
|
&trimmed[start..=end]
|
||||||
|
} else {
|
||||||
|
trimmed
|
||||||
|
};
|
||||||
|
let Ok(value) = serde_json::from_str::<Value>(json_text) else {
|
||||||
|
return normalize_puzzle_tag_candidates(trimmed.split([',', ',', '、', '\n']));
|
||||||
|
};
|
||||||
|
let Some(tags) = value.get("tags").and_then(Value::as_array) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
normalize_puzzle_tag_candidates(tags.iter().filter_map(Value::as_str))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_puzzle_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
let mut tags = Vec::new();
|
||||||
|
for candidate in candidates {
|
||||||
|
let normalized = normalize_puzzle_tag(candidate.as_ref());
|
||||||
|
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tags.push(normalized);
|
||||||
|
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for fallback in ["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"] {
|
||||||
|
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !tags.iter().any(|tag| tag == fallback) {
|
||||||
|
tags.push(fallback.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_puzzle_tag(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.trim_matches(|ch: char| {
|
||||||
|
ch.is_ascii_punctuation()
|
||||||
|
|| matches!(
|
||||||
|
ch,
|
||||||
|
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')'))
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
|
||||||
|
.take(6)
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_fallback_puzzle_tags(work_title: &str, work_description: &str) -> Vec<&'static str> {
|
||||||
|
let source = format!("{work_title} {work_description}");
|
||||||
|
let mut tags = Vec::new();
|
||||||
|
for (keyword, tag) in [
|
||||||
|
("猫", "猫咪"),
|
||||||
|
("狗", "小狗"),
|
||||||
|
("神庙", "神庙遗迹"),
|
||||||
|
("遗迹", "神庙遗迹"),
|
||||||
|
("森林", "童话森林"),
|
||||||
|
("雨", "雨夜"),
|
||||||
|
("夜", "夜景"),
|
||||||
|
("城市", "城市奇景"),
|
||||||
|
("蒸汽", "蒸汽城市"),
|
||||||
|
("机械", "机械幻想"),
|
||||||
|
("海", "海岸"),
|
||||||
|
("花", "花园"),
|
||||||
|
("雪", "雪景"),
|
||||||
|
("龙", "幻想生物"),
|
||||||
|
("灯", "暖灯"),
|
||||||
|
("塔", "高塔"),
|
||||||
|
] {
|
||||||
|
if source.contains(keyword) && !tags.contains(&tag) {
|
||||||
|
tags.push(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags.extend(["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"]);
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_generated_puzzle_tags_to_session(
|
||||||
|
state: &AppState,
|
||||||
|
session_id: &str,
|
||||||
|
owner_user_id: &str,
|
||||||
|
payload: &ExecutePuzzleAgentActionRequest,
|
||||||
|
generated_tags: Vec<String>,
|
||||||
|
levels_json: Option<String>,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||||
|
let session = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string())
|
||||||
|
.await
|
||||||
|
.map_err(map_puzzle_client_error)?;
|
||||||
|
let draft = session.draft.clone().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"message": "拼图结果页草稿尚未生成",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let mut levels = if let Some(levels_json) = levels_json.as_deref() {
|
||||||
|
parse_puzzle_level_records_from_module_json(levels_json)?
|
||||||
|
} else {
|
||||||
|
draft.levels.clone()
|
||||||
|
};
|
||||||
|
if levels.is_empty() {
|
||||||
|
levels = draft.levels.clone();
|
||||||
|
}
|
||||||
|
let first_level = levels.first().cloned().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"message": "拼图草稿缺少可编辑关卡",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let work_title = payload
|
||||||
|
.work_title
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(draft.work_title.as_str())
|
||||||
|
.to_string();
|
||||||
|
let work_description = payload
|
||||||
|
.work_description
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(draft.work_description.as_str())
|
||||||
|
.to_string();
|
||||||
|
let levels_json = Some(serialize_puzzle_level_records_for_module(&levels)?);
|
||||||
|
let (_, profile_id) = build_stable_puzzle_work_ids(session_id);
|
||||||
|
state
|
||||||
|
.spacetime_client()
|
||||||
|
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||||||
|
profile_id,
|
||||||
|
owner_user_id: owner_user_id.to_string(),
|
||||||
|
work_title: work_title.clone(),
|
||||||
|
work_description: work_description.clone(),
|
||||||
|
level_name: first_level.level_name.clone(),
|
||||||
|
summary: work_description.clone(),
|
||||||
|
theme_tags: generated_tags.clone(),
|
||||||
|
cover_image_src: first_level.cover_image_src.clone(),
|
||||||
|
cover_asset_id: first_level.cover_asset_id.clone(),
|
||||||
|
levels_json,
|
||||||
|
updated_at_micros: now,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(map_puzzle_client_error)?;
|
||||||
|
|
||||||
|
Ok(apply_generated_puzzle_tags_to_session_snapshot(
|
||||||
|
session,
|
||||||
|
generated_tags,
|
||||||
|
work_title,
|
||||||
|
work_description,
|
||||||
|
levels,
|
||||||
|
now,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_generated_puzzle_tags_to_session_snapshot(
|
||||||
|
mut session: PuzzleAgentSessionRecord,
|
||||||
|
generated_tags: Vec<String>,
|
||||||
|
work_title: String,
|
||||||
|
work_description: String,
|
||||||
|
levels: Vec<PuzzleDraftLevelRecord>,
|
||||||
|
updated_at_micros: i64,
|
||||||
|
) -> PuzzleAgentSessionRecord {
|
||||||
|
let Some(draft) = session.draft.as_mut() else {
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
draft.work_title = work_title;
|
||||||
|
draft.work_description = work_description.clone();
|
||||||
|
draft.summary = work_description;
|
||||||
|
draft.theme_tags = generated_tags;
|
||||||
|
draft.levels = levels;
|
||||||
|
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||||
|
session.progress_percent = session.progress_percent.max(96);
|
||||||
|
session.stage = if is_puzzle_session_snapshot_publish_ready(draft) {
|
||||||
|
"ready_to_publish".to_string()
|
||||||
|
} else {
|
||||||
|
"image_refining".to_string()
|
||||||
|
};
|
||||||
|
session.last_assistant_reply = Some("作品标签已生成。".to_string());
|
||||||
|
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool {
|
||||||
|
!draft.work_title.trim().is_empty()
|
||||||
|
&& !draft.work_description.trim().is_empty()
|
||||||
|
&& draft.theme_tags.len() >= module_puzzle::PUZZLE_MIN_TAG_COUNT
|
||||||
|
&& draft.theme_tags.len() <= module_puzzle::PUZZLE_MAX_TAG_COUNT
|
||||||
|
&& !draft.levels.is_empty()
|
||||||
|
&& draft.levels.iter().all(|level| {
|
||||||
|
!level.level_name.trim().is_empty()
|
||||||
|
&& level
|
||||||
|
.cover_image_src
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| !value.is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_puzzle_level_records_for_module(
|
||||||
|
levels: &[PuzzleDraftLevelRecord],
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let payload = levels
|
||||||
|
.iter()
|
||||||
|
.map(|level| {
|
||||||
|
json!({
|
||||||
|
"level_id": level.level_id,
|
||||||
|
"level_name": level.level_name,
|
||||||
|
"picture_description": level.picture_description,
|
||||||
|
"candidates": level
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| {
|
||||||
|
json!({
|
||||||
|
"candidate_id": candidate.candidate_id,
|
||||||
|
"image_src": candidate.image_src,
|
||||||
|
"asset_id": candidate.asset_id,
|
||||||
|
"prompt": candidate.prompt,
|
||||||
|
"actual_prompt": candidate.actual_prompt,
|
||||||
|
"source_type": candidate.source_type,
|
||||||
|
"selected": candidate.selected,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
"selected_candidate_id": level.selected_candidate_id,
|
||||||
|
"cover_image_src": level.cover_image_src,
|
||||||
|
"cover_asset_id": level.cover_asset_id,
|
||||||
|
"generation_status": level.generation_status,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
serde_json::to_string(&payload).map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"message": format!("拼图关卡列表序列化失败:{error}"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn is_spacetimedb_connectivity_app_error(error: &AppError) -> bool {
|
fn is_spacetimedb_connectivity_app_error(error: &AppError) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
error.status_code(),
|
error.status_code(),
|
||||||
@@ -3069,6 +3650,84 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#),
|
||||||
|
Some("雨夜猫街".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"),
|
||||||
|
Some("暖灯猫街".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#),
|
||||||
|
Some("雨夜猫街".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_first_level_name_fallback_uses_picture_keywords() {
|
||||||
|
assert_eq!(
|
||||||
|
build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"),
|
||||||
|
"雨夜猫街"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"),
|
||||||
|
"奇境初见"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_first_level_name_snapshot_defaults_work_title() {
|
||||||
|
let levels_json = serde_json::to_string(&vec![json!({
|
||||||
|
"level_id": "puzzle-level-1",
|
||||||
|
"level_name": "猫画面",
|
||||||
|
"picture_description": "一只猫在雨夜灯牌下回头。",
|
||||||
|
"candidates": [],
|
||||||
|
"selected_candidate_id": null,
|
||||||
|
"cover_image_src": null,
|
||||||
|
"cover_asset_id": null,
|
||||||
|
"generation_status": "idle",
|
||||||
|
})])
|
||||||
|
.expect("levels json");
|
||||||
|
let payload = ExecutePuzzleAgentActionRequest {
|
||||||
|
action: "generate_puzzle_images".to_string(),
|
||||||
|
prompt_text: None,
|
||||||
|
reference_image_src: None,
|
||||||
|
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||||
|
candidate_count: Some(1),
|
||||||
|
candidate_id: None,
|
||||||
|
level_id: Some("puzzle-level-1".to_string()),
|
||||||
|
work_title: Some("猫画面".to_string()),
|
||||||
|
work_description: None,
|
||||||
|
picture_description: None,
|
||||||
|
level_name: None,
|
||||||
|
summary: None,
|
||||||
|
theme_tags: Some(vec![]),
|
||||||
|
levels_json: Some(levels_json.clone()),
|
||||||
|
};
|
||||||
|
let session = build_puzzle_session_snapshot_from_action_payload(
|
||||||
|
"puzzle-session-1",
|
||||||
|
&payload,
|
||||||
|
Some(levels_json.as_str()),
|
||||||
|
1_713_686_401_234_567,
|
||||||
|
)
|
||||||
|
.expect("fallback session");
|
||||||
|
|
||||||
|
let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||||
|
session,
|
||||||
|
"puzzle-level-1",
|
||||||
|
"雨夜猫街",
|
||||||
|
"猫画面",
|
||||||
|
1_713_686_401_234_568,
|
||||||
|
);
|
||||||
|
let draft = renamed.draft.expect("draft");
|
||||||
|
assert_eq!(draft.level_name, "雨夜猫街");
|
||||||
|
assert_eq!(draft.work_title, "雨夜猫街");
|
||||||
|
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
|
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
|
||||||
let invalid_operation =
|
let invalid_operation =
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ pub async fn begin_story_runtime_session(
|
|||||||
story_session_payload_from_record(story_result.session),
|
story_session_payload_from_record(story_result.session),
|
||||||
vec![story_event_payload_from_record(story_result.event)],
|
vec![story_event_payload_from_record(story_result.event)],
|
||||||
&persisted,
|
&persisted,
|
||||||
persisted.version,
|
None,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
@@ -257,7 +257,7 @@ pub async fn resolve_story_runtime_action(
|
|||||||
story_session_payload_from_record(story_result.session),
|
story_session_payload_from_record(story_result.session),
|
||||||
vec![story_event_payload_from_record(story_result.event)],
|
vec![story_event_payload_from_record(story_result.event)],
|
||||||
&persisted,
|
&persisted,
|
||||||
resolved.server_version.max(persisted.version),
|
Some(resolved.server_version),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
@@ -395,7 +395,7 @@ fn build_story_runtime_projection_from_persisted(
|
|||||||
story_session: StorySessionPayload,
|
story_session: StorySessionPayload,
|
||||||
story_events: Vec<StoryEventPayload>,
|
story_events: Vec<StoryEventPayload>,
|
||||||
record: &RuntimeSnapshotRecord,
|
record: &RuntimeSnapshotRecord,
|
||||||
server_version: u32,
|
resolved_version: Option<u32>,
|
||||||
) -> shared_contracts::story::StoryRuntimeProjectionResponse {
|
) -> shared_contracts::story::StoryRuntimeProjectionResponse {
|
||||||
let snapshot = story_runtime_snapshot_payload_from_record(record);
|
let snapshot = story_runtime_snapshot_payload_from_record(record);
|
||||||
let current_story = snapshot.current_story.as_ref();
|
let current_story = snapshot.current_story.as_ref();
|
||||||
@@ -405,6 +405,8 @@ fn build_story_runtime_projection_from_persisted(
|
|||||||
.or_else(|| Some(story_session.latest_narrative_text.clone()));
|
.or_else(|| Some(story_session.latest_narrative_text.clone()));
|
||||||
let action_result_text = read_story_runtime_current_field(current_story, "resultText");
|
let action_result_text = read_story_runtime_current_field(current_story, "resultText");
|
||||||
let toast = read_story_runtime_current_field(current_story, "toast");
|
let toast = read_story_runtime_current_field(current_story, "toast");
|
||||||
|
let server_version =
|
||||||
|
resolve_story_runtime_projection_version(&snapshot.game_state, resolved_version);
|
||||||
|
|
||||||
module_runtime_story::build_story_runtime_projection(
|
module_runtime_story::build_story_runtime_projection(
|
||||||
module_runtime_story::StoryRuntimeProjectionSource {
|
module_runtime_story::StoryRuntimeProjectionSource {
|
||||||
@@ -420,6 +422,15 @@ fn build_story_runtime_projection_from_persisted(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_story_runtime_projection_version(
|
||||||
|
game_state: &Value,
|
||||||
|
resolved_version: Option<u32>,
|
||||||
|
) -> u32 {
|
||||||
|
module_runtime_story::read_u32_field(game_state, "runtimeActionVersion")
|
||||||
|
.or(resolved_version)
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_story_runtime_current_text(current_story: Option<&Value>) -> Option<String> {
|
fn read_story_runtime_current_text(current_story: Option<&Value>) -> Option<String> {
|
||||||
read_story_runtime_current_field(current_story, "text")
|
read_story_runtime_current_field(current_story, "text")
|
||||||
.or_else(|| read_story_runtime_current_field(current_story, "storyText"))
|
.or_else(|| read_story_runtime_current_field(current_story, "storyText"))
|
||||||
@@ -619,10 +630,12 @@ mod tests {
|
|||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
use super::require_story_session_owner;
|
use super::{build_story_runtime_projection_from_persisted, require_story_session_owner};
|
||||||
use crate::{
|
use crate::{
|
||||||
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
|
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
|
||||||
};
|
};
|
||||||
|
use module_runtime::RuntimeSnapshotRecord;
|
||||||
|
use shared_contracts::story::StorySessionPayload;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn begin_story_session_requires_authentication() {
|
async fn begin_story_session_requires_authentication() {
|
||||||
@@ -1028,6 +1041,56 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn story_runtime_projection_version_prefers_runtime_action_version() {
|
||||||
|
let projection = build_story_runtime_projection_from_persisted(
|
||||||
|
StorySessionPayload {
|
||||||
|
story_session_id: "storysess_001".to_string(),
|
||||||
|
runtime_session_id: "runtime_001".to_string(),
|
||||||
|
actor_user_id: "user_1".to_string(),
|
||||||
|
world_profile_id: "profile_1".to_string(),
|
||||||
|
initial_prompt: "进入营地".to_string(),
|
||||||
|
opening_summary: Some("营地开场".to_string()),
|
||||||
|
latest_narrative_text: "最新故事".to_string(),
|
||||||
|
latest_choice_function_id: Some("npc_chat".to_string()),
|
||||||
|
status: "active".to_string(),
|
||||||
|
version: 9,
|
||||||
|
created_at: "1.000000Z".to_string(),
|
||||||
|
updated_at: "3.000000Z".to_string(),
|
||||||
|
},
|
||||||
|
vec![],
|
||||||
|
&RuntimeSnapshotRecord {
|
||||||
|
user_id: "user_1".to_string(),
|
||||||
|
version: 2,
|
||||||
|
saved_at: "3.000000Z".to_string(),
|
||||||
|
saved_at_micros: 3,
|
||||||
|
bottom_tab: "adventure".to_string(),
|
||||||
|
game_state: json!({
|
||||||
|
"runtimeSessionId": "runtime_001",
|
||||||
|
"runtimeActionVersion": 7,
|
||||||
|
"playerHp": 30,
|
||||||
|
"playerMaxHp": 40,
|
||||||
|
"playerMana": 10,
|
||||||
|
"playerMaxMana": 20,
|
||||||
|
"playerCurrency": 0,
|
||||||
|
"playerInventory": [],
|
||||||
|
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
|
||||||
|
"inBattle": false,
|
||||||
|
"npcInteractionActive": false,
|
||||||
|
"storyHistory": []
|
||||||
|
}),
|
||||||
|
current_story: None,
|
||||||
|
game_state_json: "{}".to_string(),
|
||||||
|
current_story_json: None,
|
||||||
|
created_at_micros: 1,
|
||||||
|
updated_at_micros: 3,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(projection.server_version, 7);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn story_session_owner_guard_rejects_mismatched_actor() {
|
fn story_session_owner_guard_rejects_mismatched_actor() {
|
||||||
let context = RequestContext::new(
|
let context = RequestContext::new(
|
||||||
|
|||||||
@@ -125,10 +125,7 @@ pub fn build_form_anchor_pack(title: &str, picture_description: &str) -> PuzzleA
|
|||||||
pack.visual_mood.status = PuzzleAnchorStatus::Inferred;
|
pack.visual_mood.status = PuzzleAnchorStatus::Inferred;
|
||||||
pack.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string();
|
pack.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string();
|
||||||
pack.composition_hooks.status = PuzzleAnchorStatus::Inferred;
|
pack.composition_hooks.status = PuzzleAnchorStatus::Inferred;
|
||||||
pack.tags_and_forbidden.value = build_form_tags_and_forbidden(
|
pack.tags_and_forbidden.value = build_form_tags_and_forbidden(title, picture_description);
|
||||||
normalized_title.as_deref().unwrap_or(""),
|
|
||||||
normalized_description.as_deref().unwrap_or(""),
|
|
||||||
);
|
|
||||||
pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred;
|
pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred;
|
||||||
|
|
||||||
pack
|
pack
|
||||||
@@ -178,12 +175,12 @@ pub fn compile_result_draft_from_seed(
|
|||||||
seed_text: Option<&str>,
|
seed_text: Option<&str>,
|
||||||
) -> PuzzleResultDraft {
|
) -> PuzzleResultDraft {
|
||||||
let creator_intent = build_creator_intent(anchor_pack, messages);
|
let creator_intent = build_creator_intent(anchor_pack, messages);
|
||||||
let normalized_tags = normalize_theme_tags(creator_intent.theme_tags.clone());
|
let normalized_tags = resolve_initial_theme_tags(seed_text, &creator_intent);
|
||||||
let work_title = build_work_title(anchor_pack);
|
|
||||||
let work_description = resolve_work_description(seed_text, anchor_pack);
|
let work_description = resolve_work_description(seed_text, anchor_pack);
|
||||||
let picture_description = fallback_text(&anchor_pack.visual_subject.value, "画面主体");
|
let picture_description = fallback_text(&anchor_pack.visual_subject.value, "画面主体");
|
||||||
let level_name =
|
let level_name =
|
||||||
build_level_name_from_picture(picture_description.as_str(), &normalized_tags, 1);
|
build_level_name_from_picture(picture_description.as_str(), &normalized_tags, 1);
|
||||||
|
let work_title = resolve_work_title(seed_text, anchor_pack, &level_name);
|
||||||
let level = PuzzleDraftLevel {
|
let level = PuzzleDraftLevel {
|
||||||
level_id: "puzzle-level-1".to_string(),
|
level_id: "puzzle-level-1".to_string(),
|
||||||
level_name: level_name.clone(),
|
level_name: level_name.clone(),
|
||||||
@@ -238,16 +235,6 @@ pub fn build_form_draft_from_parts(
|
|||||||
let work_description = work_description.and_then(|value| normalize_required_string(&value));
|
let work_description = work_description.and_then(|value| normalize_required_string(&value));
|
||||||
let picture_description =
|
let picture_description =
|
||||||
picture_description.and_then(|value| normalize_required_string(&value));
|
picture_description.and_then(|value| normalize_required_string(&value));
|
||||||
let title_for_tags = work_title.as_deref().unwrap_or("");
|
|
||||||
let picture_for_tags = picture_description.as_deref().unwrap_or("");
|
|
||||||
let mut tags = normalize_theme_tags(derive_form_theme_tags(title_for_tags, picture_for_tags));
|
|
||||||
if tags.is_empty() {
|
|
||||||
tags = vec![
|
|
||||||
"拼图".to_string(),
|
|
||||||
"插画".to_string(),
|
|
||||||
"清晰构图".to_string(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
let summary = work_description.clone().unwrap_or_default();
|
let summary = work_description.clone().unwrap_or_default();
|
||||||
let level = PuzzleDraftLevel {
|
let level = PuzzleDraftLevel {
|
||||||
level_id: "puzzle-level-1".to_string(),
|
level_id: "puzzle-level-1".to_string(),
|
||||||
@@ -266,7 +253,7 @@ pub fn build_form_draft_from_parts(
|
|||||||
work_description: summary.clone(),
|
work_description: summary.clone(),
|
||||||
level_name: String::new(),
|
level_name: String::new(),
|
||||||
summary,
|
summary,
|
||||||
theme_tags: tags,
|
theme_tags: Vec::new(),
|
||||||
forbidden_directives: Vec::new(),
|
forbidden_directives: Vec::new(),
|
||||||
creator_intent: None,
|
creator_intent: None,
|
||||||
anchor_pack: anchor_pack.clone(),
|
anchor_pack: anchor_pack.clone(),
|
||||||
@@ -349,12 +336,6 @@ pub fn apply_selected_candidate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft {
|
pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft {
|
||||||
if draft.work_title.trim().is_empty() {
|
|
||||||
draft.work_title = fallback_text(&draft.anchor_pack.theme_promise.value, &draft.level_name);
|
|
||||||
}
|
|
||||||
if draft.work_description.trim().is_empty() {
|
|
||||||
draft.work_description = draft.summary.clone();
|
|
||||||
}
|
|
||||||
if draft.levels.is_empty() {
|
if draft.levels.is_empty() {
|
||||||
draft.levels = vec![PuzzleDraftLevel {
|
draft.levels = vec![PuzzleDraftLevel {
|
||||||
level_id: "puzzle-level-1".to_string(),
|
level_id: "puzzle-level-1".to_string(),
|
||||||
@@ -383,9 +364,6 @@ pub fn sync_primary_level_fields(draft: &mut PuzzleResultDraft) {
|
|||||||
draft.cover_asset_id = primary_level.cover_asset_id.clone();
|
draft.cover_asset_id = primary_level.cover_asset_id.clone();
|
||||||
draft.generation_status = primary_level.generation_status.clone();
|
draft.generation_status = primary_level.generation_status.clone();
|
||||||
}
|
}
|
||||||
if draft.work_description.trim().is_empty() {
|
|
||||||
draft.work_description = draft.summary.clone();
|
|
||||||
}
|
|
||||||
draft.summary = draft.work_description.clone();
|
draft.summary = draft.work_description.clone();
|
||||||
if draft.form_draft.is_some() {
|
if draft.form_draft.is_some() {
|
||||||
draft.form_draft = Some(PuzzleFormDraft {
|
draft.form_draft = Some(PuzzleFormDraft {
|
||||||
@@ -642,23 +620,19 @@ pub fn apply_publish_overrides_to_draft(
|
|||||||
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
|
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
|
||||||
let mut next_draft = normalize_puzzle_draft(draft.clone());
|
let mut next_draft = normalize_puzzle_draft(draft.clone());
|
||||||
|
|
||||||
if let Some(next_work_title) = work_title
|
if let Some(next_work_title) = work_title {
|
||||||
&& let Some(normalized_work_title) = normalize_required_string(&next_work_title)
|
next_draft.work_title = normalize_required_string(&next_work_title).unwrap_or_default();
|
||||||
{
|
|
||||||
next_draft.work_title = normalized_work_title;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(next_work_description) = work_description
|
if let Some(next_work_description) = work_description {
|
||||||
&& let Some(normalized_work_description) = normalize_required_string(&next_work_description)
|
next_draft.work_description =
|
||||||
{
|
normalize_required_string(&next_work_description).unwrap_or_default();
|
||||||
next_draft.work_description = normalized_work_description;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(next_level_name) = level_name
|
if let Some(next_level_name) = level_name {
|
||||||
&& let Some(normalized_level_name) = normalize_required_string(&next_level_name)
|
|
||||||
{
|
|
||||||
if let Some(primary_level) = next_draft.levels.first_mut() {
|
if let Some(primary_level) = next_draft.levels.first_mut() {
|
||||||
primary_level.level_name = normalized_level_name;
|
primary_level.level_name =
|
||||||
|
normalize_required_string(&next_level_name).unwrap_or_default();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,7 +663,7 @@ pub fn apply_publish_overrides_to_draft(
|
|||||||
|
|
||||||
pub fn normalize_puzzle_levels(
|
pub fn normalize_puzzle_levels(
|
||||||
levels: Vec<PuzzleDraftLevel>,
|
levels: Vec<PuzzleDraftLevel>,
|
||||||
theme_tags: &[String],
|
_theme_tags: &[String],
|
||||||
) -> Result<Vec<PuzzleDraftLevel>, PuzzleFieldError> {
|
) -> Result<Vec<PuzzleDraftLevel>, PuzzleFieldError> {
|
||||||
let mut normalized_levels = Vec::new();
|
let mut normalized_levels = Vec::new();
|
||||||
for (index, mut level) in levels.into_iter().enumerate() {
|
for (index, mut level) in levels.into_iter().enumerate() {
|
||||||
@@ -697,9 +671,7 @@ pub fn normalize_puzzle_levels(
|
|||||||
.unwrap_or_else(|| format!("puzzle-level-{}", index + 1));
|
.unwrap_or_else(|| format!("puzzle-level-{}", index + 1));
|
||||||
let picture_description = normalize_required_string(&level.picture_description)
|
let picture_description = normalize_required_string(&level.picture_description)
|
||||||
.unwrap_or_else(|| format!("第{}关画面", index + 1));
|
.unwrap_or_else(|| format!("第{}关画面", index + 1));
|
||||||
let level_name = normalize_required_string(&level.level_name).unwrap_or_else(|| {
|
let level_name = normalize_required_string(&level.level_name).unwrap_or_default();
|
||||||
build_level_name_from_picture(picture_description.as_str(), theme_tags, index + 1)
|
|
||||||
});
|
|
||||||
level.level_id = level_id;
|
level.level_id = level_id;
|
||||||
level.level_name = level_name;
|
level.level_name = level_name;
|
||||||
level.picture_description = picture_description;
|
level.picture_description = picture_description;
|
||||||
@@ -1959,21 +1931,67 @@ fn build_result_summary(anchor_pack: &PuzzleAnchorPack) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_work_description(seed_text: Option<&str>, anchor_pack: &PuzzleAnchorPack) -> String {
|
fn resolve_work_description(seed_text: Option<&str>, anchor_pack: &PuzzleAnchorPack) -> String {
|
||||||
seed_text
|
if let Some(parts) = seed_text.and_then(parse_form_seed_text) {
|
||||||
.and_then(parse_form_seed_text)
|
if parts.picture_description.is_some()
|
||||||
.and_then(|parts| {
|
&& parts.work_title.is_none()
|
||||||
parts
|
&& parts.work_description.is_none()
|
||||||
.work_description
|
{
|
||||||
.or(parts.picture_description)
|
return String::new();
|
||||||
.or(parts.work_title)
|
}
|
||||||
})
|
return parts
|
||||||
.unwrap_or_else(|| build_result_summary(anchor_pack))
|
.work_description
|
||||||
|
.unwrap_or_else(|| build_result_summary(anchor_pack));
|
||||||
|
}
|
||||||
|
build_result_summary(anchor_pack)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_work_title(anchor_pack: &PuzzleAnchorPack) -> String {
|
fn build_work_title(anchor_pack: &PuzzleAnchorPack) -> String {
|
||||||
fallback_text(&anchor_pack.theme_promise.value, "奇景拼图")
|
fallback_text(&anchor_pack.theme_promise.value, "奇景拼图")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_work_title(
|
||||||
|
seed_text: Option<&str>,
|
||||||
|
anchor_pack: &PuzzleAnchorPack,
|
||||||
|
level_name: &str,
|
||||||
|
) -> String {
|
||||||
|
seed_text
|
||||||
|
.and_then(parse_form_seed_text)
|
||||||
|
.and_then(|parts| {
|
||||||
|
parts
|
||||||
|
.work_title
|
||||||
|
.or_else(|| normalize_required_string(level_name))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| build_work_title(anchor_pack))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_initial_theme_tags(
|
||||||
|
seed_text: Option<&str>,
|
||||||
|
creator_intent: &PuzzleCreatorIntent,
|
||||||
|
) -> Vec<String> {
|
||||||
|
if let Some(parts) = seed_text.and_then(parse_form_seed_text) {
|
||||||
|
if parts.picture_description.is_some()
|
||||||
|
&& parts.work_title.is_none()
|
||||||
|
&& parts.work_description.is_none()
|
||||||
|
{
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let derived_tags = normalize_theme_tags(derive_form_theme_tags(
|
||||||
|
parts
|
||||||
|
.work_title
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(creator_intent.theme_promise.as_str()),
|
||||||
|
parts
|
||||||
|
.picture_description
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(creator_intent.visual_subject.as_str()),
|
||||||
|
));
|
||||||
|
if !derived_tags.is_empty() {
|
||||||
|
return derived_tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalize_theme_tags(creator_intent.theme_tags.clone())
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_forbidden_directive(source: &str) -> String {
|
fn extract_forbidden_directive(source: &str) -> String {
|
||||||
if let Some((_, tail)) = source.split_once(';') {
|
if let Some((_, tail)) = source.split_once(';') {
|
||||||
return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string());
|
return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string());
|
||||||
@@ -1996,7 +2014,7 @@ fn build_level_name_from_picture(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(tag) = normalized_tags.first() {
|
if let Some(tag) = normalized_tags.first() {
|
||||||
return format!("{tag}第{level_index}关");
|
return format!("{tag}画面");
|
||||||
}
|
}
|
||||||
format!("第{level_index}关")
|
format!("第{level_index}关")
|
||||||
}
|
}
|
||||||
@@ -2912,6 +2930,23 @@ mod tests {
|
|||||||
assert!(draft.theme_tags.len() >= PUZZLE_MIN_TAG_COUNT);
|
assert!(draft.theme_tags.len() >= PUZZLE_MIN_TAG_COUNT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picture_only_form_seed_uses_level_name_as_work_title_and_empty_metadata() {
|
||||||
|
let seed_text = "画面描述:一只猫在雨夜灯牌下回头。";
|
||||||
|
let anchor_pack = infer_anchor_pack(seed_text, None);
|
||||||
|
let draft = compile_result_draft_from_seed(&anchor_pack, &[], Some(seed_text));
|
||||||
|
|
||||||
|
assert_eq!(draft.level_name, "猫画面");
|
||||||
|
assert_eq!(draft.work_title, "猫画面");
|
||||||
|
assert_eq!(draft.work_description, "");
|
||||||
|
assert_eq!(draft.summary, "");
|
||||||
|
assert!(draft.theme_tags.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
draft.levels[0].picture_description,
|
||||||
|
"一只猫在雨夜灯牌下回头。"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn form_seed_keeps_multiline_picture_description() {
|
fn form_seed_keeps_multiline_picture_description() {
|
||||||
let anchor_pack = infer_anchor_pack(
|
let anchor_pack = infer_anchor_pack(
|
||||||
@@ -3452,4 +3487,34 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(error, PuzzleFieldError::InvalidTagCount);
|
assert_eq!(error, PuzzleFieldError::InvalidTagCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_publish_overrides_preserves_empty_level_name_for_publish_gate() {
|
||||||
|
let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙"));
|
||||||
|
let draft = compile_result_draft(&anchor_pack, &[]);
|
||||||
|
let mut levels = draft.levels.clone();
|
||||||
|
levels[0].level_name = " ".to_string();
|
||||||
|
|
||||||
|
let updated = apply_publish_overrides_to_draft(
|
||||||
|
&draft,
|
||||||
|
Some("雨夜猫塔作品".to_string()),
|
||||||
|
Some("作品描述。".to_string()),
|
||||||
|
Some("".to_string()),
|
||||||
|
Some("作品描述。".to_string()),
|
||||||
|
Some(vec![
|
||||||
|
"雨夜".to_string(),
|
||||||
|
"猫咪".to_string(),
|
||||||
|
"遗迹".to_string(),
|
||||||
|
]),
|
||||||
|
Some(levels),
|
||||||
|
)
|
||||||
|
.expect("empty level name should remain editable before publish gate");
|
||||||
|
|
||||||
|
assert_eq!(updated.levels[0].level_name, "");
|
||||||
|
assert!(
|
||||||
|
validate_publish_requirements(&updated, Some("玩家"))
|
||||||
|
.iter()
|
||||||
|
.any(|blocker| blocker.code == "MISSING_LEVEL_NAME")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
4. 生成绑定到 BFF record / module record 的 row snapshot mapper 已集中在 `mapper.rs`。
|
4. 生成绑定到 BFF record / module record 的 row snapshot mapper 已集中在 `mapper.rs`。
|
||||||
5. SDK 调用错误、reducer 业务错误、procedure 业务错误、缺快照错误和本地输入校验错误已统一收口到 `SpacetimeClientError` helper。
|
5. SDK 调用错误、reducer 业务错误、procedure 业务错误、缺快照错误和本地输入校验错误已统一收口到 `SpacetimeClientError` helper。
|
||||||
6. Story runtime projection source 已复用 runtime inventory typed facade,读取投影不再只依赖 runtime snapshot 中的历史背包 JSON 副本。
|
6. Story runtime projection source 已复用 runtime inventory typed facade,读取投影不再只依赖 runtime snapshot 中的历史背包 JSON 副本。
|
||||||
|
7. Story runtime 投影读取会对历史 `currentStory.options` 做兼容推断:若旧快照缺少 `scope`,仍会按 `functionId` 通过 `module-runtime-story` 的 option helper 还原为 `story / combat / npc` 作用域,避免旧存档把读取链路卡死。
|
||||||
|
|
||||||
`confirm_asset_object_and_return` 与 `bind_asset_object_to_entity_and_return` 的调用必须等到 SDK `on_connect` 回调后再发起。`DbConnection::build()` 只代表 WebSocket 已经初始化,不代表 SpacetimeDB 身份握手完成;如果过早调用 procedure,本地联调会表现为连接建立但请求长期没有回调,最终等到 idle timeout。
|
`confirm_asset_object_and_return` 与 `bind_asset_object_to_entity_and_return` 的调用必须等到 SDK `on_connect` 回调后再发起。`DbConnection::build()` 只代表 WebSocket 已经初始化,不代表 SpacetimeDB 身份握手完成;如果过早调用 procedure,本地联调会表现为连接建立但请求长期没有回调,最终等到 idle timeout。
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
use module_inventory::{RuntimeInventorySlotRecord, RuntimeInventoryStateRecord};
|
use module_inventory::{RuntimeInventorySlotRecord, RuntimeInventoryStateRecord};
|
||||||
use module_runtime_story::StoryRuntimeProjectionSource;
|
use module_runtime_story::StoryRuntimeProjectionSource;
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
use shared_contracts::{
|
use shared_contracts::story::{StoryEventPayload, StorySessionPayload};
|
||||||
runtime_story::RuntimeStoryOptionView,
|
|
||||||
story::{StoryEventPayload, StorySessionPayload},
|
|
||||||
};
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -43,7 +40,10 @@ impl SpacetimeClient {
|
|||||||
)?;
|
)?;
|
||||||
let current_story = runtime_snapshot.current_story.as_ref();
|
let current_story = runtime_snapshot.current_story.as_ref();
|
||||||
let latest_narrative_text = story_state.session.latest_narrative_text.clone();
|
let latest_narrative_text = story_state.session.latest_narrative_text.clone();
|
||||||
let server_version = runtime_snapshot.version.max(story_state.session.version);
|
let server_version =
|
||||||
|
resolve_story_runtime_server_version(&game_state, story_state.session.version);
|
||||||
|
|
||||||
|
let options = module_runtime_story::build_runtime_story_options(current_story, &game_state);
|
||||||
|
|
||||||
Ok(StoryRuntimeProjectionSource {
|
Ok(StoryRuntimeProjectionSource {
|
||||||
story_session: build_story_session_payload(story_state.session),
|
story_session: build_story_session_payload(story_state.session),
|
||||||
@@ -53,7 +53,7 @@ impl SpacetimeClient {
|
|||||||
.map(build_story_event_payload)
|
.map(build_story_event_payload)
|
||||||
.collect(),
|
.collect(),
|
||||||
game_state,
|
game_state,
|
||||||
options: read_runtime_story_options(current_story)?,
|
options,
|
||||||
server_version,
|
server_version,
|
||||||
current_narrative_text: read_current_story_text(current_story)
|
current_narrative_text: read_current_story_text(current_story)
|
||||||
.or(Some(latest_narrative_text)),
|
.or(Some(latest_narrative_text)),
|
||||||
@@ -311,20 +311,6 @@ fn build_story_event_payload(record: StoryEventRecord) -> StoryEventPayload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_runtime_story_options(
|
|
||||||
current_story: Option<&Value>,
|
|
||||||
) -> Result<Vec<RuntimeStoryOptionView>, SpacetimeClientError> {
|
|
||||||
let Some(options) = current_story.and_then(|story| story.get("options")) else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
|
|
||||||
serde_json::from_value::<Vec<RuntimeStoryOptionView>>(options.clone()).map_err(|error| {
|
|
||||||
SpacetimeClientError::Runtime(format!(
|
|
||||||
"currentStory.options 无法映射为后端选项投影: {error}"
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_current_story_text(current_story: Option<&Value>) -> Option<String> {
|
fn read_current_story_text(current_story: Option<&Value>) -> Option<String> {
|
||||||
read_current_story_string(current_story, "text")
|
read_current_story_string(current_story, "text")
|
||||||
.or_else(|| read_current_story_string(current_story, "storyText"))
|
.or_else(|| read_current_story_string(current_story, "storyText"))
|
||||||
@@ -340,6 +326,20 @@ fn read_current_story_string(current_story: Option<&Value>, field: &str) -> Opti
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_current_runtime_action_version(game_state: &Value) -> Option<u32> {
|
||||||
|
game_state
|
||||||
|
.as_object()?
|
||||||
|
.get("runtimeActionVersion")?
|
||||||
|
.as_u64()
|
||||||
|
.and_then(|value| u32::try_from(value).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_story_runtime_server_version(game_state: &Value, story_session_version: u32) -> u32 {
|
||||||
|
read_current_runtime_action_version(game_state)
|
||||||
|
.or(Some(story_session_version))
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -434,16 +434,26 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn current_story_options_parse_runtime_story_options() {
|
fn runtime_projection_source_uses_runtime_action_version() {
|
||||||
let options = read_runtime_story_options(Some(&json!({
|
let game_state = json!({
|
||||||
|
"runtimeSessionId": "runtime_1",
|
||||||
|
"runtimeActionVersion": 1
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(resolve_story_runtime_server_version(&game_state, 3), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_story_options_infer_scope_for_legacy_story_options() {
|
||||||
|
let current_story = json!({
|
||||||
"text": "守火人抬眼看着你。",
|
"text": "守火人抬眼看着你。",
|
||||||
"options": [{
|
"options": [{
|
||||||
"functionId": "npc_chat",
|
"functionId": "npc_chat",
|
||||||
"actionText": "继续交谈",
|
"actionText": "继续交谈"
|
||||||
"scope": "npc"
|
|
||||||
}]
|
}]
|
||||||
})))
|
});
|
||||||
.expect("options should parse");
|
let options =
|
||||||
|
module_runtime_story::build_runtime_story_options(Some(¤t_story), &json!({}));
|
||||||
|
|
||||||
assert_eq!(options[0].function_id, "npc_chat");
|
assert_eq!(options[0].function_id, "npc_chat");
|
||||||
assert_eq!(options[0].action_text, "继续交谈");
|
assert_eq!(options[0].action_text, "继续交谈");
|
||||||
|
|||||||
@@ -946,10 +946,18 @@ fn save_puzzle_generated_images_tx(
|
|||||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||||
let mut draft = deserialize_draft_required(&row.draft_json)?;
|
let mut draft = deserialize_draft_required(&row.draft_json)?;
|
||||||
|
let previous_primary_level_name = draft.level_name.clone();
|
||||||
|
let previous_work_title = draft.work_title.clone();
|
||||||
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
|
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
|
||||||
// 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。
|
// 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。
|
||||||
draft.levels = levels;
|
draft.levels = levels;
|
||||||
module_puzzle::sync_primary_level_fields(&mut draft);
|
module_puzzle::sync_primary_level_fields(&mut draft);
|
||||||
|
// 中文注释:入口直创会在 api-server 生成首关名后随 levels_json 写入;作品名仍是旧首关名或空值时才跟随首关名,避免覆盖用户手动命名。
|
||||||
|
sync_generated_primary_level_name_as_default_work_title(
|
||||||
|
&mut draft,
|
||||||
|
&previous_work_title,
|
||||||
|
&previous_primary_level_name,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let candidates: Vec<PuzzleGeneratedImageCandidate> = json_from_str(&input.candidates_json)
|
let candidates: Vec<PuzzleGeneratedImageCandidate> = json_from_str(&input.candidates_json)
|
||||||
.map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?;
|
.map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?;
|
||||||
@@ -1014,6 +1022,18 @@ fn save_puzzle_generated_images_tx(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_generated_primary_level_name_as_default_work_title(
|
||||||
|
draft: &mut PuzzleResultDraft,
|
||||||
|
previous_work_title: &str,
|
||||||
|
previous_primary_level_name: &str,
|
||||||
|
) {
|
||||||
|
if previous_work_title.trim().is_empty()
|
||||||
|
|| previous_work_title.trim() == previous_primary_level_name.trim()
|
||||||
|
{
|
||||||
|
draft.work_title = draft.level_name.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn select_puzzle_cover_image_tx(
|
fn select_puzzle_cover_image_tx(
|
||||||
ctx: &TxContext,
|
ctx: &TxContext,
|
||||||
input: PuzzleSelectCoverImageInput,
|
input: PuzzleSelectCoverImageInput,
|
||||||
@@ -1189,7 +1209,7 @@ fn update_puzzle_work_tx(
|
|||||||
return Err("无权修改该拼图作品".to_string());
|
return Err("无权修改该拼图作品".to_string());
|
||||||
}
|
}
|
||||||
let theme_tags = normalize_theme_tags(input.theme_tags);
|
let theme_tags = normalize_theme_tags(input.theme_tags);
|
||||||
if theme_tags.is_empty() || theme_tags.len() > PUZZLE_MAX_TAG_COUNT {
|
if theme_tags.len() > PUZZLE_MAX_TAG_COUNT {
|
||||||
return Err("拼图标签数量不合法".to_string());
|
return Err("拼图标签数量不合法".to_string());
|
||||||
}
|
}
|
||||||
let levels = deserialize_optional_levels_input(input.levels_json.as_deref())?
|
let levels = deserialize_optional_levels_input(input.levels_json.as_deref())?
|
||||||
@@ -1251,6 +1271,7 @@ fn update_puzzle_work_tx(
|
|||||||
published_at: row.published_at,
|
published_at: row.published_at,
|
||||||
};
|
};
|
||||||
replace_puzzle_work_profile(ctx, &row, next_row);
|
replace_puzzle_work_profile(ctx, &row, next_row);
|
||||||
|
sync_puzzle_source_session_draft_from_work(ctx, &row, &preview_draft, input.updated_at_micros)?;
|
||||||
get_puzzle_work_detail_tx(
|
get_puzzle_work_detail_tx(
|
||||||
ctx,
|
ctx,
|
||||||
PuzzleWorkGetInput {
|
PuzzleWorkGetInput {
|
||||||
@@ -1259,6 +1280,53 @@ fn update_puzzle_work_tx(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_puzzle_source_session_draft_from_work(
|
||||||
|
ctx: &TxContext,
|
||||||
|
work_row: &PuzzleWorkProfileRow,
|
||||||
|
draft: &PuzzleResultDraft,
|
||||||
|
updated_at_micros: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let Some(session_id) = work_row.source_session_id.as_ref() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(session_row) = ctx.db.puzzle_agent_session().session_id().find(session_id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if session_row.owner_user_id != work_row.owner_user_id {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let normalized_draft = normalize_puzzle_draft(draft.clone());
|
||||||
|
let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros);
|
||||||
|
let next_stage = if session_row.stage == PuzzleAgentStage::Published {
|
||||||
|
PuzzleAgentStage::Published
|
||||||
|
} else if build_result_preview(&normalized_draft, Some(&work_row.author_display_name))
|
||||||
|
.publish_ready
|
||||||
|
{
|
||||||
|
PuzzleAgentStage::ReadyToPublish
|
||||||
|
} else {
|
||||||
|
PuzzleAgentStage::ImageRefining
|
||||||
|
};
|
||||||
|
replace_puzzle_agent_session(
|
||||||
|
ctx,
|
||||||
|
&session_row,
|
||||||
|
PuzzleAgentSessionRow {
|
||||||
|
session_id: session_row.session_id.clone(),
|
||||||
|
owner_user_id: session_row.owner_user_id.clone(),
|
||||||
|
seed_text: session_row.seed_text.clone(),
|
||||||
|
current_turn: session_row.current_turn,
|
||||||
|
progress_percent: session_row.progress_percent.max(94),
|
||||||
|
stage: next_stage,
|
||||||
|
anchor_pack_json: session_row.anchor_pack_json.clone(),
|
||||||
|
draft_json: Some(serialize_json(&normalized_draft)),
|
||||||
|
last_assistant_reply: session_row.last_assistant_reply.clone(),
|
||||||
|
published_profile_id: session_row.published_profile_id.clone(),
|
||||||
|
created_at: session_row.created_at,
|
||||||
|
updated_at,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn delete_puzzle_work_tx(
|
fn delete_puzzle_work_tx(
|
||||||
ctx: &TxContext,
|
ctx: &TxContext,
|
||||||
input: PuzzleWorkDeleteInput,
|
input: PuzzleWorkDeleteInput,
|
||||||
@@ -3298,6 +3366,53 @@ mod tests {
|
|||||||
assert!(draft.candidates[0].selected);
|
assert!(draft.candidates[0].selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_first_level_name_defaults_work_title_when_previous_title_is_fallback() {
|
||||||
|
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
|
||||||
|
let mut draft = compile_result_draft_from_seed(
|
||||||
|
&anchor_pack,
|
||||||
|
&[],
|
||||||
|
Some("画面描述:一只猫在雨夜灯牌下回头。"),
|
||||||
|
);
|
||||||
|
let previous_level_name = draft.level_name.clone();
|
||||||
|
let previous_work_title = draft.work_title.clone();
|
||||||
|
draft.levels[0].level_name = "雨夜猫街".to_string();
|
||||||
|
module_puzzle::sync_primary_level_fields(&mut draft);
|
||||||
|
|
||||||
|
sync_generated_primary_level_name_as_default_work_title(
|
||||||
|
&mut draft,
|
||||||
|
&previous_work_title,
|
||||||
|
&previous_level_name,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(draft.level_name, "雨夜猫街");
|
||||||
|
assert_eq!(draft.work_title, "雨夜猫街");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_first_level_name_keeps_manual_work_title() {
|
||||||
|
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
|
||||||
|
let mut draft = compile_result_draft_from_seed(
|
||||||
|
&anchor_pack,
|
||||||
|
&[],
|
||||||
|
Some("画面描述:一只猫在雨夜灯牌下回头。"),
|
||||||
|
);
|
||||||
|
let previous_level_name = draft.level_name.clone();
|
||||||
|
let previous_work_title = "我的猫街合集".to_string();
|
||||||
|
draft.work_title = previous_work_title.clone();
|
||||||
|
draft.levels[0].level_name = "雨夜猫街".to_string();
|
||||||
|
module_puzzle::sync_primary_level_fields(&mut draft);
|
||||||
|
|
||||||
|
sync_generated_primary_level_name_as_default_work_title(
|
||||||
|
&mut draft,
|
||||||
|
&previous_work_title,
|
||||||
|
&previous_level_name,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(draft.level_name, "雨夜猫街");
|
||||||
|
assert_eq!(draft.work_title, "我的猫街合集");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn puzzle_recommendation_score_prefers_same_author_weight() {
|
fn puzzle_recommendation_score_prefers_same_author_weight() {
|
||||||
let left = PuzzleWorkProfile {
|
let left = PuzzleWorkProfile {
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ import {
|
|||||||
AnimationState,
|
AnimationState,
|
||||||
type Character,
|
type Character,
|
||||||
type CustomWorldProfile,
|
type CustomWorldProfile,
|
||||||
|
type CustomWorldOpeningCgProfile,
|
||||||
type SceneActBlueprint,
|
type SceneActBlueprint,
|
||||||
type SceneChapterBlueprint,
|
type SceneChapterBlueprint,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { CharacterAnimator } from './CharacterAnimator';
|
import { CharacterAnimator } from './CharacterAnimator';
|
||||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||||
|
import { ResolvedAssetVideo } from './ResolvedAssetVideo';
|
||||||
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
|
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
|
||||||
|
|
||||||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||||||
@@ -50,6 +52,10 @@ interface CustomWorldEntityCatalogProps {
|
|||||||
createActionLabel?: string;
|
createActionLabel?: string;
|
||||||
onCreateAction?: () => void;
|
onCreateAction?: () => void;
|
||||||
createActionDisabled?: boolean;
|
createActionDisabled?: boolean;
|
||||||
|
openingCgGenerating?: boolean;
|
||||||
|
openingCgPhaseLabel?: string | null;
|
||||||
|
openingCgGenerateDisabled?: boolean;
|
||||||
|
onGenerateOpeningCg?: () => void;
|
||||||
pendingGeneratedEntity?: PendingGeneratedEntity | null;
|
pendingGeneratedEntity?: PendingGeneratedEntity | null;
|
||||||
recentGeneratedIds?: RecentGeneratedIds;
|
recentGeneratedIds?: RecentGeneratedIds;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -240,6 +246,85 @@ function PendingEntityCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OpeningCgPreview({
|
||||||
|
openingCg,
|
||||||
|
isGenerating,
|
||||||
|
phaseLabel,
|
||||||
|
generateDisabled,
|
||||||
|
readOnly,
|
||||||
|
onGenerate,
|
||||||
|
}: {
|
||||||
|
openingCg?: CustomWorldOpeningCgProfile | null;
|
||||||
|
isGenerating: boolean;
|
||||||
|
phaseLabel?: string | null;
|
||||||
|
generateDisabled?: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
|
onGenerate?: () => void;
|
||||||
|
}) {
|
||||||
|
const hasVideo = Boolean(openingCg?.videoSrc?.trim());
|
||||||
|
const buttonLabel = hasVideo ? '重新生成' : '生成';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-black/35 aspect-video">
|
||||||
|
{hasVideo ? (
|
||||||
|
<ResolvedAssetVideo
|
||||||
|
src={openingCg?.videoSrc}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
) : openingCg?.storyboardImageSrc ? (
|
||||||
|
<ResolvedAssetImage
|
||||||
|
src={openingCg.storyboardImageSrc}
|
||||||
|
alt="开局 CG 故事板"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-sm font-semibold tracking-[0.18em] text-zinc-500">
|
||||||
|
开局 CG
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||||
|
80 积分
|
||||||
|
</span>
|
||||||
|
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||||
|
预计 10 分钟
|
||||||
|
</span>
|
||||||
|
{hasVideo ? (
|
||||||
|
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
|
||||||
|
已生成
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!readOnly && onGenerate ? (
|
||||||
|
<div className="ml-auto">
|
||||||
|
<SmallButton
|
||||||
|
onClick={onGenerate}
|
||||||
|
tone="sky"
|
||||||
|
disabled={isGenerating || generateDisabled}
|
||||||
|
>
|
||||||
|
{isGenerating ? (phaseLabel ?? '生成中') : buttonLabel}
|
||||||
|
</SmallButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{isGenerating ? (
|
||||||
|
<div className="platform-progress-track h-2 overflow-hidden rounded-full">
|
||||||
|
<div className="h-full w-2/3 animate-pulse bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_52%,#ffd2a6_100%)]" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{openingCg?.status === 'failed' && openingCg.errorMessage ? (
|
||||||
|
<div className="platform-banner platform-banner--danger rounded-2xl px-3 py-2 text-xs leading-5">
|
||||||
|
{openingCg.errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildSceneActParticipantText(
|
function buildSceneActParticipantText(
|
||||||
act: SceneActBlueprint,
|
act: SceneActBlueprint,
|
||||||
roleById: Map<
|
roleById: Map<
|
||||||
@@ -557,6 +642,10 @@ export function CustomWorldEntityCatalog({
|
|||||||
createActionLabel,
|
createActionLabel,
|
||||||
onCreateAction,
|
onCreateAction,
|
||||||
createActionDisabled = false,
|
createActionDisabled = false,
|
||||||
|
openingCgGenerating = false,
|
||||||
|
openingCgPhaseLabel = null,
|
||||||
|
openingCgGenerateDisabled = false,
|
||||||
|
onGenerateOpeningCg,
|
||||||
pendingGeneratedEntity = null,
|
pendingGeneratedEntity = null,
|
||||||
recentGeneratedIds = {
|
recentGeneratedIds = {
|
||||||
playable: [],
|
playable: [],
|
||||||
@@ -916,6 +1005,17 @@ export function CustomWorldEntityCatalog({
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="开局 CG">
|
||||||
|
<OpeningCgPreview
|
||||||
|
openingCg={profile.openingCg}
|
||||||
|
isGenerating={openingCgGenerating}
|
||||||
|
phaseLabel={openingCgPhaseLabel}
|
||||||
|
generateDisabled={openingCgGenerateDisabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onGenerate={onGenerateOpeningCg}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
title="世界概述"
|
title="世界概述"
|
||||||
actions={
|
actions={
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
|
|||||||
const generateLandmark = vi.fn();
|
const generateLandmark = vi.fn();
|
||||||
const generateSceneImage = vi.fn();
|
const generateSceneImage = vi.fn();
|
||||||
const generateSceneNpc = vi.fn();
|
const generateSceneNpc = vi.fn();
|
||||||
|
const generateOpeningCg = vi.fn();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rpgCreationAssetClient: {
|
rpgCreationAssetClient: {
|
||||||
@@ -23,6 +24,7 @@ vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
|
|||||||
generateLandmark,
|
generateLandmark,
|
||||||
generateSceneImage,
|
generateSceneImage,
|
||||||
generateSceneNpc,
|
generateSceneNpc,
|
||||||
|
generateOpeningCg,
|
||||||
},
|
},
|
||||||
generateCustomWorldPlayableNpc: generatePlayableNpc,
|
generateCustomWorldPlayableNpc: generatePlayableNpc,
|
||||||
generateCustomWorldStoryNpc: generateStoryNpc,
|
generateCustomWorldStoryNpc: generateStoryNpc,
|
||||||
@@ -343,6 +345,46 @@ test('world basic setting renders eight anchor fields and hides legacy parsed/so
|
|||||||
expect(screen.getByText(/沉钟异动和旧案灭口是同一条线/u)).toBeTruthy();
|
expect(screen.getByText(/沉钟异动和旧案灭口是同一条线/u)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('world tab generates opening cg only after manual click and writes it back to profile', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({
|
||||||
|
id: 'opening-cg-1',
|
||||||
|
status: 'ready',
|
||||||
|
storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png',
|
||||||
|
storyboardAssetId: 'storyboard-1',
|
||||||
|
videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4',
|
||||||
|
videoAssetId: 'video-1',
|
||||||
|
imageModel: 'gpt-image-2',
|
||||||
|
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||||
|
aspectRatio: '16:9',
|
||||||
|
imageSize: '2k',
|
||||||
|
videoResolution: '480p',
|
||||||
|
durationSeconds: 15,
|
||||||
|
pointCost: 80,
|
||||||
|
estimatedWaitMinutes: 10,
|
||||||
|
updatedAt: '2026-05-03T00:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResultViewHarness />);
|
||||||
|
|
||||||
|
expect(mockedRpgCreationAssetClient.generateOpeningCg).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '生成' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedRpgCreationAssetClient.generateOpeningCg).toHaveBeenCalledTimes(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
document.querySelector(
|
||||||
|
'video[src="/generated-custom-world-scenes/world/opening/opening.mp4"]',
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
|
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const profile = {
|
const profile = {
|
||||||
|
|||||||
30
src/components/ResolvedAssetVideo.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { VideoHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||||
|
|
||||||
|
type ResolvedAssetVideoProps = Omit<
|
||||||
|
VideoHTMLAttributes<HTMLVideoElement>,
|
||||||
|
'src'
|
||||||
|
> & {
|
||||||
|
src?: string | null;
|
||||||
|
fallbackSrc?: string | null;
|
||||||
|
refreshKey?: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResolvedAssetVideo({
|
||||||
|
src,
|
||||||
|
fallbackSrc,
|
||||||
|
refreshKey,
|
||||||
|
...rest
|
||||||
|
}: ResolvedAssetVideoProps) {
|
||||||
|
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
|
||||||
|
refreshKey,
|
||||||
|
});
|
||||||
|
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
|
||||||
|
|
||||||
|
if (!finalSrc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <video {...rest} src={finalSrc} />;
|
||||||
|
}
|
||||||
@@ -57,6 +57,13 @@ describe('PublishShareModal', () => {
|
|||||||
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
|
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
|
||||||
expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy();
|
expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy();
|
||||||
expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy();
|
expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByTestId('share-channel-logo-wechat'),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(within(dialog).getByTestId('share-channel-logo-qq')).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByTestId('share-channel-logo-douyin'),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(within(dialog).getByRole('button', { name: '分享' }));
|
fireEvent.click(within(dialog).getByRole('button', { name: '分享' }));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Check, Copy, MessageCircle, Music2 } from 'lucide-react';
|
import { Check, Copy } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { copyTextToClipboard } from '../../services/clipboard';
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
@@ -15,26 +15,74 @@ type PublishShareModalProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ShareChannelId = 'wechat' | 'qq' | 'douyin';
|
||||||
|
|
||||||
|
type ShareChannel = {
|
||||||
|
id: ShareChannelId;
|
||||||
|
label: string;
|
||||||
|
iconClassName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 中文注释:渠道图标只承载品牌轮廓,不复用社群二维码或通用聊天图标。
|
||||||
|
const SHARE_CHANNEL_ICON_PATHS: Record<ShareChannelId, string> = {
|
||||||
|
wechat:
|
||||||
|
'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z',
|
||||||
|
qq: 'M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673',
|
||||||
|
douyin:
|
||||||
|
'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z',
|
||||||
|
};
|
||||||
|
|
||||||
const SHARE_CHANNELS = [
|
const SHARE_CHANNELS = [
|
||||||
{
|
{
|
||||||
id: 'wechat',
|
id: 'wechat',
|
||||||
label: '微信',
|
label: '微信',
|
||||||
icon: MessageCircle,
|
iconClassName: 'bg-[#07c160] text-white',
|
||||||
className: 'bg-emerald-500 text-white',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'qq',
|
id: 'qq',
|
||||||
label: 'QQ',
|
label: 'QQ',
|
||||||
icon: MessageCircle,
|
iconClassName: 'bg-[#12b7f5] text-white',
|
||||||
className: 'bg-sky-500 text-white',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'douyin',
|
id: 'douyin',
|
||||||
label: '抖音',
|
label: '抖音',
|
||||||
icon: Music2,
|
iconClassName: 'bg-black text-white',
|
||||||
className: 'bg-slate-950 text-white',
|
|
||||||
},
|
},
|
||||||
] as const;
|
] as const satisfies readonly ShareChannel[];
|
||||||
|
|
||||||
|
function ShareChannelLogo({ channel }: { channel: ShareChannel }) {
|
||||||
|
const iconPath = SHARE_CHANNEL_ICON_PATHS[channel.id];
|
||||||
|
|
||||||
|
if (channel.id === 'douyin') {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="-1 -1 26 26"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
className="h-6 w-6 overflow-visible"
|
||||||
|
data-share-channel-logo={channel.id}
|
||||||
|
data-testid={`share-channel-logo-${channel.id}`}
|
||||||
|
>
|
||||||
|
<path d={iconPath} fill="#25f4ee" transform="translate(-0.75 0.45)" />
|
||||||
|
<path d={iconPath} fill="#fe2c55" transform="translate(0.75 -0.45)" />
|
||||||
|
<path d={iconPath} fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
className="h-6 w-6"
|
||||||
|
data-share-channel-logo={channel.id}
|
||||||
|
data-testid={`share-channel-logo-${channel.id}`}
|
||||||
|
>
|
||||||
|
<path d={iconPath} fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布完成后的分享弹窗。
|
* 发布完成后的分享弹窗。
|
||||||
@@ -98,8 +146,6 @@ export function PublishShareModal({
|
|||||||
footer={
|
footer={
|
||||||
<div className="grid w-full grid-cols-3 gap-3">
|
<div className="grid w-full grid-cols-3 gap-3">
|
||||||
{SHARE_CHANNELS.map((channel) => {
|
{SHARE_CHANNELS.map((channel) => {
|
||||||
const Icon = channel.icon;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
@@ -110,9 +156,9 @@ export function PublishShareModal({
|
|||||||
title={channel.label}
|
title={channel.label}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.className}`}
|
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.iconClassName}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
<ShareChannelLogo channel={channel} />
|
||||||
</span>
|
</span>
|
||||||
<span>{channel.label}</span>
|
<span>{channel.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { render, screen, within } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -104,20 +104,12 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
|||||||
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
|
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
|
||||||
expect(screen.queryByText('角色 3')).toBeNull();
|
expect(screen.queryByText('角色 3')).toBeNull();
|
||||||
expect(screen.queryByText('地点 4')).toBeNull();
|
expect(screen.queryByText('地点 4')).toBeNull();
|
||||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
|
||||||
const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
||||||
const match3dButton = screen.getByRole('button', { name: /抓大鹅/u });
|
|
||||||
expect(
|
|
||||||
rpgButton.compareDocumentPosition(puzzleButton) &
|
|
||||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
||||||
).toBeTruthy();
|
|
||||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
|
||||||
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
|
|
||||||
expect(
|
|
||||||
within(match3dButton).getAllByText('经典消除玩法').length,
|
|
||||||
).toBeGreaterThan(0);
|
|
||||||
expect(puzzleButton).toBeTruthy();
|
expect(puzzleButton).toBeTruthy();
|
||||||
|
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', { name: /抓大鹅/u })).toBeNull();
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<CustomWorldCreationHub
|
<CustomWorldCreationHub
|
||||||
|
|||||||
@@ -42,10 +42,11 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
|||||||
expect(html).toContain('一个被潮雾切开的列岛世界');
|
expect(html).toContain('一个被潮雾切开的列岛世界');
|
||||||
expect(html).toContain('玩家是失职返乡的守灯人');
|
expect(html).toContain('玩家是失职返乡的守灯人');
|
||||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||||
expect(html).toContain('角色扮演');
|
|
||||||
expect(html).toContain('拼图');
|
expect(html).toContain('拼图');
|
||||||
expect(html).toContain('创意礼物,生活分享');
|
expect(html).toContain('创意礼物,生活分享');
|
||||||
|
expect(html).not.toContain('角色扮演');
|
||||||
expect(html).not.toContain('大鱼吃小鱼');
|
expect(html).not.toContain('大鱼吃小鱼');
|
||||||
|
expect(html).not.toContain('抓大鹅');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
|
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
getEncounterCharacterBottomOffsetPx,
|
getEncounterCharacterBottomOffsetPx,
|
||||||
getEncounterCharacterOpponentBottom,
|
getEncounterCharacterOpponentBottom,
|
||||||
getHostileNpcSceneBottomOffsetPx,
|
getHostileNpcSceneBottomOffsetPx,
|
||||||
getMonsterWorldLeft,
|
|
||||||
getMirroredStageEntityLeft,
|
getMirroredStageEntityLeft,
|
||||||
|
getMonsterWorldLeft,
|
||||||
getNpcCombatHpTop,
|
getNpcCombatHpTop,
|
||||||
getSceneNpcVisualBottomOffsetPx,
|
getSceneNpcVisualBottomOffsetPx,
|
||||||
MONSTER_COMBAT_HP_TOP_PX,
|
MONSTER_COMBAT_HP_TOP_PX,
|
||||||
@@ -387,6 +387,53 @@ describe('GameCanvasEntityLayer', () => {
|
|||||||
expect(html).toContain('查看后排乙详情');
|
expect(html).toContain('查看后排乙详情');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides opposite scene actors while the player exits for a scene transition', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<GameCanvasEntityLayer
|
||||||
|
companions={[]}
|
||||||
|
sceneActAmbientEncounters={[
|
||||||
|
createEncounter({ id: 'npc-back-1', npcName: '后排甲' }),
|
||||||
|
]}
|
||||||
|
currentScenePreset={null}
|
||||||
|
sceneTransitionToken={1}
|
||||||
|
isSceneTransitionEntering={false}
|
||||||
|
isSceneTransitionExiting={true}
|
||||||
|
transitionSweepPx={320}
|
||||||
|
sceneTransitionExitDurationS={0.2}
|
||||||
|
sceneTransitionEntryDurationS={0.2}
|
||||||
|
companionAnchorLeft="10%"
|
||||||
|
companionAnchorBottom="20%"
|
||||||
|
playerBottomOffsetPx={0}
|
||||||
|
sceneTransitionPhase="exiting"
|
||||||
|
inBattle={false}
|
||||||
|
onEntitySelect={null}
|
||||||
|
playerLeft="20%"
|
||||||
|
playerCharacter={createCharacter()}
|
||||||
|
playerHp={100}
|
||||||
|
playerMaxHp={100}
|
||||||
|
effectivePlayerFacing="right"
|
||||||
|
effectivePlayerAnimationState={AnimationState.RUN}
|
||||||
|
shouldShowPlayerDialogueIcon={false}
|
||||||
|
dialogueIndicator={null}
|
||||||
|
npcAffinityEffect={null}
|
||||||
|
sceneCombatants={[createHostileNpc({ name: '旧场景敌人' })]}
|
||||||
|
monsters={[]}
|
||||||
|
getHostileNpcOuterLeft={() => '70%'}
|
||||||
|
groundBottom="18%"
|
||||||
|
stageLiftPx={68}
|
||||||
|
encounter={createEncounter({ id: 'npc-primary', npcName: '主角色' })}
|
||||||
|
sideAnchor="15%"
|
||||||
|
cameraAnchorX={0}
|
||||||
|
monsterAnchorMeters={3.2}
|
||||||
|
playerX={0}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).not.toContain('查看旧场景敌人详情');
|
||||||
|
expect(html).not.toContain('查看主角色详情');
|
||||||
|
expect(html).not.toContain('查看后排甲详情');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps hostile combatant identity stable while attack position changes', () => {
|
it('keeps hostile combatant identity stable while attack position changes', () => {
|
||||||
const sideAnchor = '15%';
|
const sideAnchor = '15%';
|
||||||
const cameraAnchorX = 0;
|
const cameraAnchorX = 0;
|
||||||
|
|||||||
@@ -114,6 +114,22 @@ function addCssPxOffset(value: string, offsetPx: number) {
|
|||||||
return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`;
|
return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSceneTransitionMotionConfig(
|
||||||
|
isEntering: boolean,
|
||||||
|
isExiting: boolean,
|
||||||
|
transitionSweepPx: number,
|
||||||
|
durationS: number,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
initial: isEntering ? {x: -transitionSweepPx} : false,
|
||||||
|
animate: {x: isExiting ? transitionSweepPx : 0},
|
||||||
|
transition: {
|
||||||
|
duration: isExiting ? durationS : isEntering ? durationS : 0.18,
|
||||||
|
ease: 'linear' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function CombatFloatingNumber({
|
function CombatFloatingNumber({
|
||||||
event,
|
event,
|
||||||
onDone,
|
onDone,
|
||||||
@@ -451,7 +467,9 @@ export function GameCanvasEntityLayer({
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{sceneCombatants.map((hostileNpc, index) => {
|
{sceneTransitionPhase === 'exiting'
|
||||||
|
? null
|
||||||
|
: sceneCombatants.map((hostileNpc, index) => {
|
||||||
const npcEncounter = hostileNpc.encounter ?? buildFallbackCombatEncounter(hostileNpc);
|
const npcEncounter = hostileNpc.encounter ?? buildFallbackCombatEncounter(hostileNpc);
|
||||||
const hostileRenderKey = [
|
const hostileRenderKey = [
|
||||||
hostileNpc.id,
|
hostileNpc.id,
|
||||||
@@ -465,9 +483,15 @@ export function GameCanvasEntityLayer({
|
|||||||
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
|
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
|
||||||
: null;
|
: null;
|
||||||
const npcSceneSpriteFacing =
|
const npcSceneSpriteFacing =
|
||||||
npcCharacter
|
isSceneTransitionEntering
|
||||||
? hostileNpc.facing
|
? 'right'
|
||||||
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
|
: npcCharacter
|
||||||
|
? hostileNpc.facing
|
||||||
|
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
|
||||||
|
const hostileNpcAnimation =
|
||||||
|
isSceneTransitionEntering
|
||||||
|
? ('move' as const)
|
||||||
|
: hostileNpc.animation;
|
||||||
const npcCombatHpTop = getNpcCombatHpTop(
|
const npcCombatHpTop = getNpcCombatHpTop(
|
||||||
npcCharacter ? npcEncounter?.characterId : null,
|
npcCharacter ? npcEncounter?.characterId : null,
|
||||||
npcCharacter ? null : npcEncounter?.monsterPresetId,
|
npcCharacter ? null : npcEncounter?.monsterPresetId,
|
||||||
@@ -498,10 +522,20 @@ export function GameCanvasEntityLayer({
|
|||||||
)
|
)
|
||||||
: stageLiftPx + (hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx;
|
: stageLiftPx + (hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx;
|
||||||
|
|
||||||
|
const motionConfig = getSceneTransitionMotionConfig(
|
||||||
|
isSceneTransitionEntering,
|
||||||
|
isSceneTransitionExiting,
|
||||||
|
transitionSweepPx,
|
||||||
|
sceneTransitionEntryDurationS,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<motion.div
|
||||||
key={hostileRenderKey}
|
key={hostileRenderKey}
|
||||||
className="absolute"
|
className="absolute"
|
||||||
|
initial={motionConfig.initial}
|
||||||
|
animate={motionConfig.animate}
|
||||||
|
transition={motionConfig.transition}
|
||||||
style={{
|
style={{
|
||||||
left: getHostileNpcOuterLeft(hostileNpc),
|
left: getHostileNpcOuterLeft(hostileNpc),
|
||||||
bottom: entityBottom,
|
bottom: entityBottom,
|
||||||
@@ -526,7 +560,11 @@ export function GameCanvasEntityLayer({
|
|||||||
<CombatReactiveSpriteFrame events={feedbackEvents} facing={npcSceneSpriteFacing}>
|
<CombatReactiveSpriteFrame events={feedbackEvents} facing={npcSceneSpriteFacing}>
|
||||||
{npcCharacter ? (
|
{npcCharacter ? (
|
||||||
<RoleCharacterSprite
|
<RoleCharacterSprite
|
||||||
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
|
state={
|
||||||
|
isSceneTransitionEntering
|
||||||
|
? AnimationState.RUN
|
||||||
|
: hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)
|
||||||
|
}
|
||||||
character={npcCharacter}
|
character={npcCharacter}
|
||||||
facing={npcSceneSpriteFacing}
|
facing={npcSceneSpriteFacing}
|
||||||
/>
|
/>
|
||||||
@@ -534,8 +572,8 @@ export function GameCanvasEntityLayer({
|
|||||||
<div style={{transform: `translate(${renderOffset.x}px, ${renderOffset.y}px)`}}>
|
<div style={{transform: `translate(${renderOffset.x}px, ${renderOffset.y}px)`}}>
|
||||||
<HostileNpcAnimator
|
<HostileNpcAnimator
|
||||||
hostileNpc={npcMonsterConfig}
|
hostileNpc={npcMonsterConfig}
|
||||||
animation={hostileNpc.animation}
|
animation={hostileNpcAnimation}
|
||||||
flip={hostileNpc.facing === 'right'}
|
flip={npcSceneSpriteFacing === 'right'}
|
||||||
className="scale-[1.82] origin-bottom"
|
className="scale-[1.82] origin-bottom"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -561,11 +599,11 @@ export function GameCanvasEntityLayer({
|
|||||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||||
) : null}
|
) : null}
|
||||||
</SceneEntityButton>
|
</SceneEntityButton>
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{shouldRenderPeacefulEncounter &&
|
{sceneTransitionPhase !== 'exiting' && shouldRenderPeacefulEncounter &&
|
||||||
(() => {
|
(() => {
|
||||||
if (!encounter) {
|
if (!encounter) {
|
||||||
return null;
|
return null;
|
||||||
@@ -594,11 +632,23 @@ export function GameCanvasEntityLayer({
|
|||||||
const peacefulBottomOffsetPx = peacefulResolvedCharacter
|
const peacefulBottomOffsetPx = peacefulResolvedCharacter
|
||||||
? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, peacefulResolvedCharacter)
|
? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, peacefulResolvedCharacter)
|
||||||
: stageLiftPx + peacefulHostileBottomOffsetPx;
|
: stageLiftPx + peacefulHostileBottomOffsetPx;
|
||||||
const peacefulNpcSpriteFacing = towardPeacefulPlayer;
|
const peacefulNpcSpriteFacing = isSceneTransitionEntering
|
||||||
|
? 'right'
|
||||||
|
: towardPeacefulPlayer;
|
||||||
|
|
||||||
|
const motionConfig = getSceneTransitionMotionConfig(
|
||||||
|
isSceneTransitionEntering,
|
||||||
|
isSceneTransitionExiting,
|
||||||
|
transitionSweepPx,
|
||||||
|
sceneTransitionEntryDurationS,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<motion.div
|
||||||
className="absolute"
|
className="absolute"
|
||||||
|
initial={motionConfig.initial}
|
||||||
|
animate={motionConfig.animate}
|
||||||
|
transition={motionConfig.transition}
|
||||||
style={{
|
style={{
|
||||||
left: getMonsterWorldLeft(
|
left: getMonsterWorldLeft(
|
||||||
sideAnchor,
|
sideAnchor,
|
||||||
@@ -639,7 +689,7 @@ export function GameCanvasEntityLayer({
|
|||||||
!encounter.visual &&
|
!encounter.visual &&
|
||||||
!encounter.imageSrc?.trim() ? (
|
!encounter.imageSrc?.trim() ? (
|
||||||
<RoleCharacterSprite
|
<RoleCharacterSprite
|
||||||
state={AnimationState.IDLE}
|
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||||
character={peacefulResolvedCharacter}
|
character={peacefulResolvedCharacter}
|
||||||
facing={peacefulNpcSpriteFacing}
|
facing={peacefulNpcSpriteFacing}
|
||||||
/>
|
/>
|
||||||
@@ -647,13 +697,13 @@ export function GameCanvasEntityLayer({
|
|||||||
<HostileNpcAnimator
|
<HostileNpcAnimator
|
||||||
hostileNpc={peacefulMonsterConfig}
|
hostileNpc={peacefulMonsterConfig}
|
||||||
animation={isPeacefulEncounterMoving ? 'move' : 'idle'}
|
animation={isPeacefulEncounterMoving ? 'move' : 'idle'}
|
||||||
flip={towardPeacefulPlayer === 'right'}
|
flip={peacefulNpcSpriteFacing === 'right'}
|
||||||
className="scale-[1.82] origin-bottom"
|
className="scale-[1.82] origin-bottom"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SceneEncounterNpcSprite
|
<SceneEncounterNpcSprite
|
||||||
encounter={encounter}
|
encounter={encounter}
|
||||||
state={AnimationState.IDLE}
|
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||||
facing={peacefulNpcSpriteFacing}
|
facing={peacefulNpcSpriteFacing}
|
||||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
||||||
/>
|
/>
|
||||||
@@ -672,11 +722,12 @@ export function GameCanvasEntityLayer({
|
|||||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||||
) : null}
|
) : null}
|
||||||
</SceneEntityButton>
|
</SceneEntityButton>
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{!inBattle &&
|
{!inBattle &&
|
||||||
|
sceneTransitionPhase !== 'exiting' &&
|
||||||
sceneActAmbientEncounters.map((ambientEncounter, index) => {
|
sceneActAmbientEncounters.map((ambientEncounter, index) => {
|
||||||
const ambientOffsetPx = SCENE_ACT_BACK_ROW_OFFSET_PX[index];
|
const ambientOffsetPx = SCENE_ACT_BACK_ROW_OFFSET_PX[index];
|
||||||
if (ambientOffsetPx === undefined) {
|
if (ambientOffsetPx === undefined) {
|
||||||
@@ -708,6 +759,9 @@ export function GameCanvasEntityLayer({
|
|||||||
SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
|
SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
|
||||||
playerX,
|
playerX,
|
||||||
);
|
);
|
||||||
|
const ambientSpriteFacing = isSceneTransitionEntering
|
||||||
|
? 'right'
|
||||||
|
: ambientFacing;
|
||||||
const ambientBottom = ambientEncounter.characterId
|
const ambientBottom = ambientEncounter.characterId
|
||||||
? getEncounterCharacterOpponentBottom(
|
? getEncounterCharacterOpponentBottom(
|
||||||
groundBottom,
|
groundBottom,
|
||||||
@@ -717,10 +771,20 @@ export function GameCanvasEntityLayer({
|
|||||||
)
|
)
|
||||||
: `calc(${groundBottom} + ${stageLiftPx + ambientHostileBottomOffsetPx}px)`;
|
: `calc(${groundBottom} + ${stageLiftPx + ambientHostileBottomOffsetPx}px)`;
|
||||||
|
|
||||||
|
const motionConfig = getSceneTransitionMotionConfig(
|
||||||
|
isSceneTransitionEntering,
|
||||||
|
isSceneTransitionExiting,
|
||||||
|
transitionSweepPx,
|
||||||
|
sceneTransitionEntryDurationS,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<motion.div
|
||||||
key={`scene-act-ambient-${ambientEncounter.id ?? ambientEncounter.npcName}-${index}`}
|
key={`scene-act-ambient-${ambientEncounter.id ?? ambientEncounter.npcName}-${index}`}
|
||||||
className="absolute"
|
className="absolute"
|
||||||
|
initial={motionConfig.initial}
|
||||||
|
animate={motionConfig.animate}
|
||||||
|
transition={motionConfig.transition}
|
||||||
style={{
|
style={{
|
||||||
left: getMonsterWorldLeft(
|
left: getMonsterWorldLeft(
|
||||||
sideAnchor,
|
sideAnchor,
|
||||||
@@ -751,22 +815,22 @@ export function GameCanvasEntityLayer({
|
|||||||
!ambientEncounter.visual &&
|
!ambientEncounter.visual &&
|
||||||
!ambientEncounter.imageSrc?.trim() ? (
|
!ambientEncounter.imageSrc?.trim() ? (
|
||||||
<RoleCharacterSprite
|
<RoleCharacterSprite
|
||||||
state={AnimationState.IDLE}
|
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||||
character={ambientResolvedCharacter}
|
character={ambientResolvedCharacter}
|
||||||
facing={ambientFacing}
|
facing={ambientSpriteFacing}
|
||||||
/>
|
/>
|
||||||
) : ambientMonsterConfig ? (
|
) : ambientMonsterConfig ? (
|
||||||
<HostileNpcAnimator
|
<HostileNpcAnimator
|
||||||
hostileNpc={ambientMonsterConfig}
|
hostileNpc={ambientMonsterConfig}
|
||||||
animation="idle"
|
animation="idle"
|
||||||
flip={ambientFacing === 'right'}
|
flip={ambientSpriteFacing === 'right'}
|
||||||
className="scale-[1.82] origin-bottom"
|
className="scale-[1.82] origin-bottom"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SceneEncounterNpcSprite
|
<SceneEncounterNpcSprite
|
||||||
encounter={ambientEncounter}
|
encounter={ambientEncounter}
|
||||||
state={AnimationState.IDLE}
|
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||||
facing={ambientFacing}
|
facing={ambientSpriteFacing}
|
||||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.32)]"
|
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.32)]"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -777,7 +841,7 @@ export function GameCanvasEntityLayer({
|
|||||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||||
) : null}
|
) : null}
|
||||||
</SceneEntityButton>
|
</SceneEntityButton>
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -659,15 +659,12 @@ function buildPuzzleResultProfileId(sessionId: string | null | undefined) {
|
|||||||
function buildPuzzleCompileActionFromFormPayload(
|
function buildPuzzleCompileActionFromFormPayload(
|
||||||
payload: CreatePuzzleAgentSessionRequest | null,
|
payload: CreatePuzzleAgentSessionRequest | null,
|
||||||
): PuzzleAgentActionRequest {
|
): PuzzleAgentActionRequest {
|
||||||
const workTitle = payload?.workTitle?.trim() || payload?.seedText?.trim();
|
const pictureDescription =
|
||||||
const workDescription = payload?.workDescription?.trim();
|
payload?.pictureDescription?.trim() || payload?.seedText?.trim();
|
||||||
const pictureDescription = payload?.pictureDescription?.trim();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: 'compile_puzzle_draft',
|
action: 'compile_puzzle_draft',
|
||||||
promptText: pictureDescription || workTitle,
|
promptText: pictureDescription,
|
||||||
...(workTitle ? { workTitle } : {}),
|
|
||||||
...(workDescription ? { workDescription } : {}),
|
|
||||||
...(pictureDescription ? { pictureDescription } : {}),
|
...(pictureDescription ? { pictureDescription } : {}),
|
||||||
referenceImageSrc: payload?.referenceImageSrc || null,
|
referenceImageSrc: payload?.referenceImageSrc || null,
|
||||||
imageModel: payload?.imageModel ?? null,
|
imageModel: payload?.imageModel ?? null,
|
||||||
@@ -679,28 +676,15 @@ function buildPuzzleFormPayloadFromSession(
|
|||||||
session: PuzzleAgentSessionSnapshot,
|
session: PuzzleAgentSessionSnapshot,
|
||||||
): CreatePuzzleAgentSessionRequest {
|
): CreatePuzzleAgentSessionRequest {
|
||||||
const formDraft = session.draft?.formDraft;
|
const formDraft = session.draft?.formDraft;
|
||||||
const workTitle =
|
|
||||||
formDraft?.workTitle?.trim() ||
|
|
||||||
session.draft?.workTitle?.trim() ||
|
|
||||||
session.draft?.levelName?.trim() ||
|
|
||||||
session.anchorPack.themePromise.value.trim() ||
|
|
||||||
session.seedText?.trim() ||
|
|
||||||
'';
|
|
||||||
const workDescription =
|
|
||||||
formDraft?.workDescription?.trim() ||
|
|
||||||
session.draft?.workDescription?.trim() ||
|
|
||||||
session.draft?.summary?.trim() ||
|
|
||||||
'';
|
|
||||||
const pictureDescription =
|
const pictureDescription =
|
||||||
formDraft?.pictureDescription?.trim() ||
|
formDraft?.pictureDescription?.trim() ||
|
||||||
session.draft?.levels?.[0]?.pictureDescription?.trim() ||
|
session.draft?.levels?.[0]?.pictureDescription?.trim() ||
|
||||||
session.anchorPack.visualSubject.value.trim() ||
|
session.anchorPack.visualSubject.value.trim() ||
|
||||||
|
session.seedText?.trim() ||
|
||||||
'';
|
'';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
seedText: workTitle,
|
seedText: pictureDescription,
|
||||||
workTitle,
|
|
||||||
workDescription,
|
|
||||||
pictureDescription,
|
pictureDescription,
|
||||||
referenceImageSrc: null,
|
referenceImageSrc: null,
|
||||||
imageModel: null,
|
imageModel: null,
|
||||||
@@ -723,9 +707,9 @@ function buildPuzzleFormPayloadFromAction(
|
|||||||
payload.pictureDescription?.trim() || payload.promptText?.trim() || '';
|
payload.pictureDescription?.trim() || payload.promptText?.trim() || '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
seedText: workTitle,
|
seedText: pictureDescription,
|
||||||
workTitle,
|
...(workTitle ? { workTitle } : {}),
|
||||||
workDescription,
|
...(workDescription ? { workDescription } : {}),
|
||||||
pictureDescription,
|
pictureDescription,
|
||||||
referenceImageSrc:
|
referenceImageSrc:
|
||||||
payload.action === 'compile_puzzle_draft'
|
payload.action === 'compile_puzzle_draft'
|
||||||
@@ -920,6 +904,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
initialPublicWorkCode,
|
initialPublicWorkCode,
|
||||||
}: PlatformEntryFlowShellProps) {
|
}: PlatformEntryFlowShellProps) {
|
||||||
const authUi = useAuthUi();
|
const authUi = useAuthUi();
|
||||||
|
const platformThemeClass =
|
||||||
|
authUi?.platformTheme === 'dark'
|
||||||
|
? 'platform-theme--dark'
|
||||||
|
: 'platform-theme--light';
|
||||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||||
@@ -1662,7 +1650,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPuzzleFormDraftPayload(formPayload);
|
setPuzzleFormDraftPayload(formPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.action === 'publish_puzzle_work') {
|
if (
|
||||||
|
payload.action === 'publish_puzzle_work' ||
|
||||||
|
payload.action === 'generate_puzzle_tags'
|
||||||
|
) {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
refreshPuzzleShelf(),
|
refreshPuzzleShelf(),
|
||||||
refreshPuzzleGallery(),
|
refreshPuzzleGallery(),
|
||||||
@@ -1836,8 +1827,6 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const response = await executePuzzleAgentAction(session.sessionId, {
|
const response = await executePuzzleAgentAction(session.sessionId, {
|
||||||
action: 'save_puzzle_form_draft',
|
action: 'save_puzzle_form_draft',
|
||||||
promptText: payload.pictureDescription ?? null,
|
promptText: payload.pictureDescription ?? null,
|
||||||
workTitle: payload.workTitle ?? payload.seedText ?? '',
|
|
||||||
workDescription: payload.workDescription ?? '',
|
|
||||||
pictureDescription: payload.pictureDescription ?? '',
|
pictureDescription: payload.pictureDescription ?? '',
|
||||||
imageModel: payload.imageModel ?? null,
|
imageModel: payload.imageModel ?? null,
|
||||||
});
|
});
|
||||||
@@ -5057,9 +5046,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
isBusy={isPuzzleBusy}
|
isBusy={isPuzzleBusy}
|
||||||
error={puzzleError}
|
error={puzzleError}
|
||||||
onBack={() => {
|
onBack={leavePuzzleFlow}
|
||||||
setSelectionStage('puzzle-agent-workspace');
|
|
||||||
}}
|
|
||||||
onExecuteAction={(payload) => {
|
onExecuteAction={(payload) => {
|
||||||
void executePuzzleAction(payload);
|
void executePuzzleAction(payload);
|
||||||
}}
|
}}
|
||||||
@@ -5463,6 +5450,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
closeDisabled={Boolean(deletingCreationWorkId)}
|
closeDisabled={Boolean(deletingCreationWorkId)}
|
||||||
closeOnBackdrop={!deletingCreationWorkId}
|
closeOnBackdrop={!deletingCreationWorkId}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||||
|
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -28,13 +28,15 @@ test('platform creation types are derived from new work entry config', () => {
|
|||||||
test('new work entry config controls visibility and open order', () => {
|
test('new work entry config controls visibility and open order', () => {
|
||||||
const visibleIds = getVisiblePlatformCreationTypes().map((item) => item.id);
|
const visibleIds = getVisiblePlatformCreationTypes().map((item) => item.id);
|
||||||
|
|
||||||
|
expect(isPlatformCreationTypeVisible('rpg')).toBe(false);
|
||||||
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
|
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
|
||||||
|
expect(isPlatformCreationTypeVisible('match3d')).toBe(false);
|
||||||
|
expect(visibleIds).not.toContain('rpg');
|
||||||
expect(visibleIds).not.toContain('big-fish');
|
expect(visibleIds).not.toContain('big-fish');
|
||||||
expect(visibleIds[0]).toBe('rpg');
|
expect(visibleIds).not.toContain('match3d');
|
||||||
|
expect(visibleIds[0]).toBe('puzzle');
|
||||||
expect(visibleIds).toEqual([
|
expect(visibleIds).toEqual([
|
||||||
'rpg',
|
|
||||||
'puzzle',
|
'puzzle',
|
||||||
'match3d',
|
|
||||||
'airp',
|
'airp',
|
||||||
'visual-novel',
|
'visual-novel',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -83,21 +83,18 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('作品名称'), {
|
expect(screen.queryByLabelText('作品名称')).toBeNull();
|
||||||
target: { value: '暖灯猫街' },
|
expect(screen.queryByLabelText('作品描述')).toBeNull();
|
||||||
});
|
expect(screen.getByText('创建拼图')).toBeTruthy();
|
||||||
fireEvent.change(screen.getByLabelText('作品描述'), {
|
expect(screen.queryByText('try')).toBeNull();
|
||||||
target: { value: '一套雨夜猫街主题拼图。' },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
||||||
|
|
||||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||||
seedText: '暖灯猫街',
|
seedText: '一只猫在雨夜灯牌下回头。',
|
||||||
workTitle: '暖灯猫街',
|
|
||||||
workDescription: '一套雨夜猫街主题拼图。',
|
|
||||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||||
referenceImageSrc: null,
|
referenceImageSrc: null,
|
||||||
imageModel: 'gpt-image-2',
|
imageModel: 'gpt-image-2',
|
||||||
@@ -107,6 +104,35 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
|||||||
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
|
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('puzzle workspace applies a creation template prompt', () => {
|
||||||
|
const onCreateFromForm = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PuzzleAgentWorkspace
|
||||||
|
session={null}
|
||||||
|
onBack={() => {}}
|
||||||
|
onSubmitMessage={() => {}}
|
||||||
|
onExecuteAction={() => {}}
|
||||||
|
onCreateFromForm={onCreateFromForm}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '宠物可爱拼图模板' }));
|
||||||
|
|
||||||
|
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
|
||||||
|
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净,适合萌宠拼图分享。',
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText('宠物可爱拼图').length).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
||||||
|
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
pictureDescription:
|
||||||
|
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净,适合萌宠拼图分享。',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('puzzle workspace falls back to compile action for restored sessions', () => {
|
test('puzzle workspace falls back to compile action for restored sessions', () => {
|
||||||
const onExecuteAction = vi.fn();
|
const onExecuteAction = vi.fn();
|
||||||
const onCreateFromForm = vi.fn();
|
const onCreateFromForm = vi.fn();
|
||||||
@@ -126,10 +152,8 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
|
|||||||
expect(onCreateFromForm).not.toHaveBeenCalled();
|
expect(onCreateFromForm).not.toHaveBeenCalled();
|
||||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||||
action: 'compile_puzzle_draft',
|
action: 'compile_puzzle_draft',
|
||||||
promptText: '潮雾中的灯塔与断桥',
|
|
||||||
workTitle: '雾港遗迹拼图',
|
|
||||||
workDescription: '雾港遗迹拼图',
|
|
||||||
pictureDescription: '潮雾中的灯塔与断桥',
|
pictureDescription: '潮雾中的灯塔与断桥',
|
||||||
|
promptText: '潮雾中的灯塔与断桥',
|
||||||
referenceImageSrc: null,
|
referenceImageSrc: null,
|
||||||
imageModel: 'gpt-image-2',
|
imageModel: 'gpt-image-2',
|
||||||
candidateCount: 1,
|
candidateCount: 1,
|
||||||
@@ -149,12 +173,6 @@ test('puzzle workspace switches the image model from the description box', () =>
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('作品名称'), {
|
|
||||||
target: { value: '暖灯猫街' },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText('作品描述'), {
|
|
||||||
target: { value: '一套雨夜猫街主题拼图。' },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||||
});
|
});
|
||||||
@@ -175,8 +193,7 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
|||||||
const onAutoSaveForm = vi.fn();
|
const onAutoSaveForm = vi.fn();
|
||||||
const formDraftSession: PuzzleAgentSessionSnapshot = {
|
const formDraftSession: PuzzleAgentSessionSnapshot = {
|
||||||
...baseSession,
|
...baseSession,
|
||||||
seedText:
|
seedText: '画面描述:旧街灯牌下的猫。',
|
||||||
'作品名称:旧街拼图\n作品描述:旧街雨夜的拼图草稿。\n画面描述:旧街灯牌下的猫。',
|
|
||||||
draft: {
|
draft: {
|
||||||
workTitle: '旧街拼图',
|
workTitle: '旧街拼图',
|
||||||
workDescription: '旧街雨夜的拼图草稿。',
|
workDescription: '旧街雨夜的拼图草稿。',
|
||||||
@@ -204,8 +221,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
formDraft: {
|
formDraft: {
|
||||||
workTitle: '旧街拼图',
|
|
||||||
workDescription: '旧街雨夜的拼图草稿。',
|
|
||||||
pictureDescription: '旧街灯牌下的猫。',
|
pictureDescription: '旧街灯牌下的猫。',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -221,12 +236,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect((screen.getByLabelText('作品名称') as HTMLInputElement).value).toBe(
|
|
||||||
'旧街拼图',
|
|
||||||
);
|
|
||||||
expect((screen.getByLabelText('作品描述') as HTMLTextAreaElement).value).toBe(
|
|
||||||
'旧街雨夜的拼图草稿。',
|
|
||||||
);
|
|
||||||
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
|
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
|
||||||
'旧街灯牌下的猫。',
|
'旧街灯牌下的猫。',
|
||||||
);
|
);
|
||||||
@@ -240,9 +249,7 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(onAutoSaveForm).toHaveBeenCalledWith({
|
expect(onAutoSaveForm).toHaveBeenCalledWith({
|
||||||
seedText: '旧街拼图',
|
seedText: '旧街灯牌下的猫和发光雨伞。',
|
||||||
workTitle: '旧街拼图',
|
|
||||||
workDescription: '旧街雨夜的拼图草稿。',
|
|
||||||
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
|
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
|
||||||
referenceImageSrc: null,
|
referenceImageSrc: null,
|
||||||
imageModel: 'gpt-image-2',
|
imageModel: 'gpt-image-2',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
SendPuzzleAgentMessageRequest,
|
SendPuzzleAgentMessageRequest,
|
||||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||||
|
import { PUZZLE_CREATION_TEMPLATES } from './puzzleCreationTemplates';
|
||||||
import {
|
import {
|
||||||
normalizePuzzleImageModel,
|
normalizePuzzleImageModel,
|
||||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||||
@@ -28,8 +29,6 @@ type PuzzleAgentWorkspaceProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PuzzleFormState = {
|
type PuzzleFormState = {
|
||||||
workTitle: string;
|
|
||||||
workDescription: string;
|
|
||||||
pictureDescription: string;
|
pictureDescription: string;
|
||||||
referenceImageSrc: string;
|
referenceImageSrc: string;
|
||||||
referenceImageLabel: string;
|
referenceImageLabel: string;
|
||||||
@@ -37,8 +36,6 @@ type PuzzleFormState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_FORM_STATE: PuzzleFormState = {
|
const EMPTY_FORM_STATE: PuzzleFormState = {
|
||||||
workTitle: '',
|
|
||||||
workDescription: '',
|
|
||||||
pictureDescription: '',
|
pictureDescription: '',
|
||||||
referenceImageSrc: '',
|
referenceImageSrc: '',
|
||||||
referenceImageLabel: '',
|
referenceImageLabel: '',
|
||||||
@@ -52,8 +49,6 @@ function resolveInitialFormState(
|
|||||||
const formDraft = session?.draft?.formDraft;
|
const formDraft = session?.draft?.formDraft;
|
||||||
if (formDraft) {
|
if (formDraft) {
|
||||||
return {
|
return {
|
||||||
workTitle: formDraft.workTitle ?? '',
|
|
||||||
workDescription: formDraft.workDescription ?? '',
|
|
||||||
pictureDescription: formDraft.pictureDescription ?? '',
|
pictureDescription: formDraft.pictureDescription ?? '',
|
||||||
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
|
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
|
||||||
referenceImageLabel: initialFormPayload?.referenceImageSrc
|
referenceImageLabel: initialFormPayload?.referenceImageSrc
|
||||||
@@ -65,10 +60,10 @@ function resolveInitialFormState(
|
|||||||
|
|
||||||
if (initialFormPayload) {
|
if (initialFormPayload) {
|
||||||
return {
|
return {
|
||||||
workTitle:
|
pictureDescription:
|
||||||
initialFormPayload.workTitle ?? initialFormPayload.seedText ?? '',
|
initialFormPayload.pictureDescription ??
|
||||||
workDescription: initialFormPayload.workDescription ?? '',
|
initialFormPayload.seedText ??
|
||||||
pictureDescription: initialFormPayload.pictureDescription ?? '',
|
'',
|
||||||
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
|
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
|
||||||
referenceImageLabel: initialFormPayload.referenceImageSrc
|
referenceImageLabel: initialFormPayload.referenceImageSrc
|
||||||
? '已选择参考图'
|
? '已选择参考图'
|
||||||
@@ -82,19 +77,12 @@ function resolveInitialFormState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workTitle:
|
|
||||||
session.draft?.workTitle ||
|
|
||||||
session.draft?.levelName ||
|
|
||||||
session.seedText ||
|
|
||||||
session.anchorPack.themePromise.value ||
|
|
||||||
session.messages.find((message) => message.role === 'user')?.text ||
|
|
||||||
'',
|
|
||||||
workDescription:
|
|
||||||
session.draft?.workDescription ||
|
|
||||||
session.anchorPack.themePromise.value ||
|
|
||||||
'',
|
|
||||||
pictureDescription:
|
pictureDescription:
|
||||||
session.draft?.summary || session.anchorPack.visualSubject.value || '',
|
session.draft?.formDraft?.pictureDescription ||
|
||||||
|
session.draft?.levels?.[0]?.pictureDescription ||
|
||||||
|
session.anchorPack.visualSubject.value ||
|
||||||
|
session.seedText ||
|
||||||
|
'',
|
||||||
referenceImageSrc: '',
|
referenceImageSrc: '',
|
||||||
referenceImageLabel: '',
|
referenceImageLabel: '',
|
||||||
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||||
@@ -121,6 +109,9 @@ export function PuzzleAgentWorkspace({
|
|||||||
const [referenceImageError, setReferenceImageError] = useState<string | null>(
|
const [referenceImageError, setReferenceImageError] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState(
|
||||||
|
PUZZLE_CREATION_TEMPLATES[0]?.id ?? '',
|
||||||
|
);
|
||||||
const previousSessionIdRef = useRef<string | null>(
|
const previousSessionIdRef = useRef<string | null>(
|
||||||
session?.sessionId ?? null,
|
session?.sessionId ?? null,
|
||||||
);
|
);
|
||||||
@@ -148,18 +139,13 @@ export function PuzzleAgentWorkspace({
|
|||||||
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
||||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||||
setReferenceImageError(null);
|
setReferenceImageError(null);
|
||||||
}, [initialFormPayload, session?.sessionId]);
|
}, [initialFormPayload, session]);
|
||||||
|
|
||||||
const workTitle = formState.workTitle.trim();
|
|
||||||
const workDescription = formState.workDescription.trim();
|
|
||||||
const pictureDescription = formState.pictureDescription.trim();
|
const pictureDescription = formState.pictureDescription.trim();
|
||||||
const canSubmit =
|
const canSubmit = Boolean(pictureDescription) && !isBusy;
|
||||||
Boolean(workTitle && workDescription && pictureDescription) && !isBusy;
|
|
||||||
const autosavePayload = useMemo(
|
const autosavePayload = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
seedText: workTitle,
|
seedText: pictureDescription,
|
||||||
workTitle,
|
|
||||||
workDescription,
|
|
||||||
pictureDescription,
|
pictureDescription,
|
||||||
referenceImageSrc: formState.referenceImageSrc || null,
|
referenceImageSrc: formState.referenceImageSrc || null,
|
||||||
imageModel: formState.imageModel,
|
imageModel: formState.imageModel,
|
||||||
@@ -168,13 +154,9 @@ export function PuzzleAgentWorkspace({
|
|||||||
formState.referenceImageSrc,
|
formState.referenceImageSrc,
|
||||||
formState.imageModel,
|
formState.imageModel,
|
||||||
pictureDescription,
|
pictureDescription,
|
||||||
workDescription,
|
|
||||||
workTitle,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const autosaveSignature = JSON.stringify([
|
const autosaveSignature = JSON.stringify([
|
||||||
autosavePayload.workTitle,
|
|
||||||
autosavePayload.workDescription,
|
|
||||||
autosavePayload.pictureDescription,
|
autosavePayload.pictureDescription,
|
||||||
autosavePayload.imageModel,
|
autosavePayload.imageModel,
|
||||||
]);
|
]);
|
||||||
@@ -189,7 +171,7 @@ export function PuzzleAgentWorkspace({
|
|||||||
|
|
||||||
autosaveSessionIdRef.current = currentSessionId;
|
autosaveSessionIdRef.current = currentSessionId;
|
||||||
lastAutosaveSignatureRef.current = autosaveSignature;
|
lastAutosaveSignatureRef.current = autosaveSignature;
|
||||||
}, [autosaveSignature, session?.sessionId]);
|
}, [autosaveSignature, session]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -214,7 +196,7 @@ export function PuzzleAgentWorkspace({
|
|||||||
onAutoSaveForm,
|
onAutoSaveForm,
|
||||||
session?.draft?.formDraft,
|
session?.draft?.formDraft,
|
||||||
session?.stage,
|
session?.stage,
|
||||||
session?.sessionId,
|
session,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleReferenceImageChange = async (
|
const handleReferenceImageChange = async (
|
||||||
@@ -243,15 +225,28 @@ export function PuzzleAgentWorkspace({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyTemplatePrompt = (templateId: string) => {
|
||||||
|
const template = PUZZLE_CREATION_TEMPLATES.find(
|
||||||
|
(item) => item.id === templateId,
|
||||||
|
);
|
||||||
|
if (!template) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedTemplateId(template.id);
|
||||||
|
setFormState((current) => ({
|
||||||
|
...current,
|
||||||
|
pictureDescription: template.prompt,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const submitForm = () => {
|
const submitForm = () => {
|
||||||
if (!canSubmit) {
|
if (!canSubmit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
seedText: workTitle,
|
seedText: pictureDescription,
|
||||||
workTitle,
|
|
||||||
workDescription,
|
|
||||||
pictureDescription,
|
pictureDescription,
|
||||||
referenceImageSrc: formState.referenceImageSrc || null,
|
referenceImageSrc: formState.referenceImageSrc || null,
|
||||||
imageModel: formState.imageModel,
|
imageModel: formState.imageModel,
|
||||||
@@ -265,8 +260,6 @@ export function PuzzleAgentWorkspace({
|
|||||||
onExecuteAction({
|
onExecuteAction({
|
||||||
action: 'compile_puzzle_draft',
|
action: 'compile_puzzle_draft',
|
||||||
promptText: pictureDescription,
|
promptText: pictureDescription,
|
||||||
workTitle,
|
|
||||||
workDescription,
|
|
||||||
pictureDescription,
|
pictureDescription,
|
||||||
referenceImageSrc: formState.referenceImageSrc || null,
|
referenceImageSrc: formState.referenceImageSrc || null,
|
||||||
imageModel: formState.imageModel,
|
imageModel: formState.imageModel,
|
||||||
@@ -275,7 +268,7 @@ export function PuzzleAgentWorkspace({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
|
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -291,61 +284,107 @@ export function PuzzleAgentWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
|
<div className="mb-5">
|
||||||
<div className="space-y-5">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<label className="block">
|
<h1 className="m-0 text-5xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
创建拼图
|
||||||
作品名称
|
</h1>
|
||||||
|
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||||
|
BETA
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="platform-subpanel overflow-hidden rounded-[1.5rem] p-4 sm:p-5">
|
||||||
|
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3 sm:p-4">
|
||||||
|
<div className="mb-3 flex min-h-6 items-center justify-between gap-3">
|
||||||
|
<span className="text-xs font-black text-[var(--platform-text-soft)]">
|
||||||
|
Template
|
||||||
|
</span>
|
||||||
|
<span className="max-w-[11rem] truncate text-xs font-black text-[var(--platform-text-strong)]">
|
||||||
|
{PUZZLE_CREATION_TEMPLATES.find(
|
||||||
|
(item) => item.id === selectedTemplateId,
|
||||||
|
)?.title ?? PUZZLE_CREATION_TEMPLATES[0]?.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex gap-3 overflow-x-auto pb-2"
|
||||||
|
aria-label="拼图创作模板"
|
||||||
|
>
|
||||||
|
{PUZZLE_CREATION_TEMPLATES.map((template) => {
|
||||||
|
const selected = template.id === selectedTemplateId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => applyTemplatePrompt(template.id)}
|
||||||
|
className={`min-h-[10.2rem] w-[7.45rem] shrink-0 rounded-[1rem] border p-2 text-left transition ${
|
||||||
|
selected
|
||||||
|
? 'border-emerald-300 bg-emerald-50/86 shadow-[0_0_0_1px_rgba(16,185,129,0.18)]'
|
||||||
|
: 'border-[var(--platform-subpanel-border)] bg-white/82 hover:bg-white'
|
||||||
|
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={`${template.title}模板`}
|
||||||
|
>
|
||||||
|
<span className="block aspect-square overflow-hidden rounded-[0.8rem] bg-[var(--platform-subpanel-fill)]">
|
||||||
|
<img
|
||||||
|
src={template.imageSrc}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 block min-h-8 overflow-hidden text-ellipsis text-xs font-black leading-4 text-[var(--platform-text-strong)]">
|
||||||
|
{template.title}
|
||||||
|
</span>
|
||||||
|
{selected ? (
|
||||||
|
<span className="mt-2 inline-flex max-w-full rounded-full bg-emerald-100 px-2 py-1 text-[10px] font-black text-emerald-700">
|
||||||
|
已选择
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<label
|
||||||
|
className={`inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 px-4 text-sm font-black text-[var(--platform-text-strong)] shadow-sm transition hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||||
|
title={formState.referenceImageSrc ? '更换参考图' : '添加参考图'}
|
||||||
|
>
|
||||||
|
<ImagePlus className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{formState.referenceImageSrc ? '更换参考图' : '上传参考图'}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
value={formState.workTitle}
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setFormState((current) => ({
|
void handleReferenceImageChange(event);
|
||||||
...current,
|
}}
|
||||||
workTitle: event.target.value,
|
className="hidden"
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
|
|
||||||
aria-label="作品名称"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
<span className="sr-only">画面描述</span>
|
||||||
作品描述
|
<div className="relative">
|
||||||
</span>
|
|
||||||
<textarea
|
|
||||||
value={formState.workDescription}
|
|
||||||
disabled={isBusy}
|
|
||||||
rows={4}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
workDescription: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
|
||||||
aria-label="作品描述"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
|
||||||
画面描述
|
|
||||||
</span>
|
|
||||||
<div className="relative mt-2">
|
|
||||||
<textarea
|
<textarea
|
||||||
value={formState.pictureDescription}
|
value={formState.pictureDescription}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
rows={10}
|
rows={10}
|
||||||
|
placeholder="一只猫在雨夜灯牌下回头,霓虹反光清晰,街角有花店和小伞,适合切成拼图。"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setFormState((current) => ({
|
setFormState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
pictureDescription: event.target.value,
|
pictureDescription: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
className="min-h-[18rem] w-full resize-none rounded-[1.35rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-4 pb-16 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:min-h-[20rem]"
|
||||||
aria-label="画面描述"
|
aria-label="画面描述"
|
||||||
/>
|
/>
|
||||||
<PuzzleImageModelPicker
|
<PuzzleImageModelPicker
|
||||||
@@ -358,26 +397,6 @@ export function PuzzleAgentWorkspace({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<label
|
|
||||||
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
|
||||||
title={
|
|
||||||
formState.referenceImageSrc ? '更换参考图' : '添加参考图'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ImagePlus className="h-4 w-4" />
|
|
||||||
<span className="sr-only">
|
|
||||||
{formState.referenceImageSrc ? '更换参考图' : '添加参考图'}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/webp"
|
|
||||||
disabled={isBusy}
|
|
||||||
onChange={(event) => {
|
|
||||||
void handleReferenceImageChange(event);
|
|
||||||
}}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
94
src/components/puzzle-agent/puzzleCreationTemplates.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
export type PuzzleCreationTemplate = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
imageSrc: string;
|
||||||
|
prompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 中文注释:模板只服务入口快速填词,正式作品信息仍在结果页补全。
|
||||||
|
export const PUZZLE_CREATION_TEMPLATES: PuzzleCreationTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 'couple-memory',
|
||||||
|
title: '情侣合照拼图',
|
||||||
|
imageSrc: '/puzzle-creation-templates/couple-memory.webp',
|
||||||
|
prompt:
|
||||||
|
'温暖自然光下的一对情侣纪念合照,城市咖啡馆窗边,桌面有花束和两杯热饮,人物神情自然,画面主体清晰,前中后景层次明确。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'family-keepsake',
|
||||||
|
title: '家庭纪念拼图',
|
||||||
|
imageSrc: '/puzzle-creation-templates/family-keepsake.webp',
|
||||||
|
prompt:
|
||||||
|
'三代家人在客厅沙发前的家庭纪念合照,柔和午后阳光,孩子抱着生日蛋糕,长辈微笑,画面温暖完整,细节丰富但不杂乱。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'friends-party',
|
||||||
|
title: '朋友聚会拼图',
|
||||||
|
imageSrc: '/puzzle-creation-templates/friends-party.webp',
|
||||||
|
prompt:
|
||||||
|
'朋友们在露台夜晚聚会,彩灯、桌上零食和举杯瞬间,人物分布有层次,中央焦点清楚,氛围轻松热闹。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'festival-card',
|
||||||
|
title: '节日贺卡拼图',
|
||||||
|
imageSrc: '/puzzle-creation-templates/festival-card.webp',
|
||||||
|
prompt:
|
||||||
|
'节日餐桌与礼物布置,暖色灯光、彩带、蜡烛和窗外烟花,画面像无字贺卡,主体集中,边角细节可辨。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'knowledge-summary',
|
||||||
|
title: '知识总结拼图',
|
||||||
|
imageSrc: '/puzzle-creation-templates/knowledge-summary.webp',
|
||||||
|
prompt:
|
||||||
|
'一张无文字的知识学习主题插画,书桌上有打开的笔记本、便签、咖啡、台灯和思维导图式图形元素,构图整洁,重点明确。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'product-detail',
|
||||||
|
title: '商品细节拼图',
|
||||||
|
imageSrc: '/puzzle-creation-templates/product-detail.webp',
|
||||||
|
prompt:
|
||||||
|
'精致商品静物展示,一只高质感香水瓶放在丝绸与花瓣之间,玻璃反光清晰,包装和材质细节丰富,背景干净。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'healing-landscape',
|
||||||
|
title: '治愈风景拼图',
|
||||||
|
imageSrc: '/puzzle-creation-templates/healing-landscape.webp',
|
||||||
|
prompt:
|
||||||
|
'治愈风景插画,清晨湖边、薄雾、远山、木栈道和一盏小灯,色彩柔和。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cute-pet',
|
||||||
|
title: '宠物可爱拼图',
|
||||||
|
imageSrc: '/puzzle-creation-templates/cute-pet.webp',
|
||||||
|
prompt:
|
||||||
|
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hot-topic-poster',
|
||||||
|
title: '热点海报拼图',
|
||||||
|
imageSrc: '/puzzle-creation-templates/hot-topic-poster.webp',
|
||||||
|
prompt:
|
||||||
|
'电影感热点海报风插画,雨夜街头、霓虹反光、奔跑的人影和远处光束,强烈视觉焦点,画面无文字。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-invitation',
|
||||||
|
title: '活动邀请拼图',
|
||||||
|
imageSrc: '/puzzle-creation-templates/event-invitation.webp',
|
||||||
|
prompt:
|
||||||
|
'活动邀请主题插画,展厅入口、花艺装置、签到台和柔和灯带,人群剪影自然分布,画面高级干净,无文字。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'daily-challenge',
|
||||||
|
title: '每日挑战拼图',
|
||||||
|
imageSrc: '/puzzle-creation-templates/daily-challenge.webp',
|
||||||
|
prompt:
|
||||||
|
'每日挑战主题插画,清爽桌面上摆放相机、明信片、计时器和小奖章,色彩明亮,构图有趣,细节可拆解。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'children-learning',
|
||||||
|
title: '儿童认知拼图',
|
||||||
|
imageSrc: '/puzzle-creation-templates/children-learning.webp',
|
||||||
|
prompt:
|
||||||
|
'儿童认知学习插画,木质桌面上有积木、彩色形状、动物玩偶和小书本,色彩明快,元素边界清晰,无文字。',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -104,11 +104,12 @@ function createSession(
|
|||||||
stage: 'ready_to_publish',
|
stage: 'ready_to_publish',
|
||||||
anchorPack,
|
anchorPack,
|
||||||
draft: {
|
draft: {
|
||||||
workTitle: '暖灯猫街作品',
|
workTitle: overrides.draft?.workTitle ?? '暖灯猫街作品',
|
||||||
workDescription: '一套雨夜猫街主题拼图。',
|
workDescription:
|
||||||
|
overrides.draft?.workDescription ?? '一套雨夜猫街主题拼图。',
|
||||||
levelName: level.levelName,
|
levelName: level.levelName,
|
||||||
summary: level.pictureDescription,
|
summary: level.pictureDescription,
|
||||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
themeTags: overrides.draft?.themeTags ?? ['猫咪', '雨夜', '暖灯'],
|
||||||
forbiddenDirectives: [],
|
forbiddenDirectives: [],
|
||||||
creatorIntent: null,
|
creatorIntent: null,
|
||||||
anchorPack,
|
anchorPack,
|
||||||
@@ -119,6 +120,7 @@ function createSession(
|
|||||||
generationStatus: 'ready',
|
generationStatus: 'ready',
|
||||||
levels: [level],
|
levels: [level],
|
||||||
metadata: null,
|
metadata: null,
|
||||||
|
...overrides.draft,
|
||||||
},
|
},
|
||||||
messages: [],
|
messages: [],
|
||||||
lastAssistantReply: null,
|
lastAssistantReply: null,
|
||||||
@@ -199,7 +201,7 @@ describe('PuzzleResultView', () => {
|
|||||||
workTitle: '暖灯猫街合集',
|
workTitle: '暖灯猫街合集',
|
||||||
workDescription: '一套雨夜猫街主题拼图。',
|
workDescription: '一套雨夜猫街主题拼图。',
|
||||||
levelName: '雨夜猫街',
|
levelName: '雨夜猫街',
|
||||||
summary: '屋檐下的猫与暖灯街角。',
|
summary: '一套雨夜猫街主题拼图。',
|
||||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||||
levels: expect.arrayContaining([
|
levels: expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -250,7 +252,7 @@ describe('PuzzleResultView', () => {
|
|||||||
candidateCount: 1,
|
candidateCount: 1,
|
||||||
workTitle: '暖灯猫街作品',
|
workTitle: '暖灯猫街作品',
|
||||||
workDescription: '一套雨夜猫街主题拼图。',
|
workDescription: '一套雨夜猫街主题拼图。',
|
||||||
summary: '一只猫在雨夜灯牌下回头。',
|
summary: '一套雨夜猫街主题拼图。',
|
||||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||||
levelsJson: expect.any(String),
|
levelsJson: expect.any(String),
|
||||||
});
|
});
|
||||||
@@ -280,7 +282,7 @@ describe('PuzzleResultView', () => {
|
|||||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
levelName: '暖灯猫街',
|
levelName: '暖灯猫街',
|
||||||
summary: '一只猫在雨夜灯牌下回头。',
|
summary: '一套雨夜猫街主题拼图。',
|
||||||
levels: [
|
levels: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
levelId: 'puzzle-level-1',
|
levelId: 'puzzle-level-1',
|
||||||
@@ -386,7 +388,7 @@ describe('PuzzleResultView', () => {
|
|||||||
candidateCount: 1,
|
candidateCount: 1,
|
||||||
workTitle: '暖灯猫街作品',
|
workTitle: '暖灯猫街作品',
|
||||||
workDescription: '一套雨夜猫街主题拼图。',
|
workDescription: '一套雨夜猫街主题拼图。',
|
||||||
summary: '新关卡里有一座发光钟楼。',
|
summary: '一套雨夜猫街主题拼图。',
|
||||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||||
levelsJson: expect.any(String),
|
levelsJson: expect.any(String),
|
||||||
});
|
});
|
||||||
@@ -427,7 +429,7 @@ describe('PuzzleResultView', () => {
|
|||||||
workTitle: '暖灯猫街作品',
|
workTitle: '暖灯猫街作品',
|
||||||
workDescription: '一套雨夜猫街主题拼图。',
|
workDescription: '一套雨夜猫街主题拼图。',
|
||||||
levelName: '雨夜猫街',
|
levelName: '雨夜猫街',
|
||||||
summary: '屋檐下的猫与暖灯街角。',
|
summary: '一套雨夜猫街主题拼图。',
|
||||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -440,6 +442,57 @@ describe('PuzzleResultView', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('generates six tags after work title and description are filled', () => {
|
||||||
|
const onExecuteAction = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PuzzleResultView
|
||||||
|
session={createSession({
|
||||||
|
draft: {
|
||||||
|
...createSession().draft!,
|
||||||
|
workTitle: '雨夜猫街',
|
||||||
|
workDescription: '',
|
||||||
|
themeTags: [],
|
||||||
|
},
|
||||||
|
resultPreview: {
|
||||||
|
draft: createSession().draft!,
|
||||||
|
publishReady: false,
|
||||||
|
blockers: [
|
||||||
|
{
|
||||||
|
id: 'invalid-tag-count',
|
||||||
|
code: 'INVALID_TAG_COUNT',
|
||||||
|
message: '正式标签数量必须在 3 到 6 之间',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
qualityFindings: [],
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
onBack={() => {}}
|
||||||
|
onExecuteAction={onExecuteAction}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
|
||||||
|
expect(screen.getByText('请先填写作品名称和作品描述。')).toBeTruthy();
|
||||||
|
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('作品描述'), {
|
||||||
|
target: { value: '一套雨夜猫街主题拼图。' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
|
||||||
|
|
||||||
|
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||||
|
action: 'generate_puzzle_tags',
|
||||||
|
workTitle: '雨夜猫街',
|
||||||
|
workDescription: '一套雨夜猫街主题拼图。',
|
||||||
|
levelName: '雨夜猫街',
|
||||||
|
summary: '一套雨夜猫街主题拼图。',
|
||||||
|
themeTags: [],
|
||||||
|
levelsJson: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('selects a history puzzle asset as reference image for the selected level', async () => {
|
test('selects a history puzzle asset as reference image for the selected level', async () => {
|
||||||
const onExecuteAction = vi.fn();
|
const onExecuteAction = vi.fn();
|
||||||
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
|
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
|
||||||
@@ -496,7 +549,7 @@ describe('PuzzleResultView', () => {
|
|||||||
candidateCount: 1,
|
candidateCount: 1,
|
||||||
workTitle: '暖灯猫街作品',
|
workTitle: '暖灯猫街作品',
|
||||||
workDescription: '一套雨夜猫街主题拼图。',
|
workDescription: '一套雨夜猫街主题拼图。',
|
||||||
summary: '屋檐下的猫与暖灯街角。',
|
summary: '一套雨夜猫街主题拼图。',
|
||||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||||
levelsJson: expect.any(String),
|
levelsJson: expect.any(String),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ function syncDraftFromEditState(
|
|||||||
const primaryLevel = levels[0] ?? buildFallbackLevelFromDraft(draft);
|
const primaryLevel = levels[0] ?? buildFallbackLevelFromDraft(draft);
|
||||||
return {
|
return {
|
||||||
...draft,
|
...draft,
|
||||||
workTitle: editState.workTitle.trim() || draft.workTitle,
|
workTitle: editState.workTitle.trim(),
|
||||||
workDescription: editState.workDescription.trim(),
|
workDescription: editState.workDescription.trim(),
|
||||||
levelName: primaryLevel.levelName,
|
levelName: primaryLevel.levelName,
|
||||||
summary: editState.workDescription.trim(),
|
summary: editState.workDescription.trim(),
|
||||||
@@ -145,8 +145,8 @@ function syncDraftFromEditState(
|
|||||||
|
|
||||||
function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
|
function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
|
||||||
return {
|
return {
|
||||||
workTitle: draft.workTitle || draft.levelName,
|
workTitle: draft.workTitle ?? '',
|
||||||
workDescription: draft.workDescription || '',
|
workDescription: draft.workDescription ?? '',
|
||||||
themeTags: normalizeThemeTagInput(draft.themeTags.join(',')),
|
themeTags: normalizeThemeTagInput(draft.themeTags.join(',')),
|
||||||
levels: normalizeDraftLevels(draft),
|
levels: normalizeDraftLevels(draft),
|
||||||
};
|
};
|
||||||
@@ -219,16 +219,7 @@ function buildPublishReady(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
blockers: [...new Set(blockers.filter(Boolean))],
|
blockers: [...new Set(blockers.filter(Boolean))],
|
||||||
publishReady:
|
publishReady: blockers.filter(Boolean).length === 0,
|
||||||
Boolean(session.resultPreview?.publishReady) &&
|
|
||||||
Boolean(editState.workTitle.trim()) &&
|
|
||||||
Boolean(editState.workDescription.trim()) &&
|
|
||||||
editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
|
|
||||||
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT &&
|
|
||||||
levels.length > 0 &&
|
|
||||||
levels.every(
|
|
||||||
(level) => level.levelName.trim() && resolveLevelFormalImageSrc(level),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,11 +299,15 @@ function PuzzleResultTabs({
|
|||||||
function PuzzleThemeTagEditor({
|
function PuzzleThemeTagEditor({
|
||||||
editState,
|
editState,
|
||||||
isBusy,
|
isBusy,
|
||||||
|
error,
|
||||||
onChange,
|
onChange,
|
||||||
|
onGenerateTags,
|
||||||
}: {
|
}: {
|
||||||
editState: DraftEditState;
|
editState: DraftEditState;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
error: string | null;
|
||||||
onChange: (nextState: DraftEditState) => void;
|
onChange: (nextState: DraftEditState) => void;
|
||||||
|
onGenerateTags: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [newTagText, setNewTagText] = useState('');
|
const [newTagText, setNewTagText] = useState('');
|
||||||
const [isAddingTag, setIsAddingTag] = useState(false);
|
const [isAddingTag, setIsAddingTag] = useState(false);
|
||||||
@@ -339,18 +334,34 @@ function PuzzleThemeTagEditor({
|
|||||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||||
作品标签
|
作品标签
|
||||||
</div>
|
</div>
|
||||||
{!isAddingTag ? (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => setIsAddingTag(true)}
|
onClick={onGenerateTags}
|
||||||
className="platform-icon-button h-9 w-9"
|
className="platform-icon-button h-9 w-9"
|
||||||
aria-label="新增作品标签"
|
aria-label="AI生成作品标签"
|
||||||
title="新增作品标签"
|
title="AI生成作品标签"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
{isBusy ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
{!isAddingTag ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => setIsAddingTag(true)}
|
||||||
|
className="platform-icon-button h-9 w-9"
|
||||||
|
aria-label="新增作品标签"
|
||||||
|
title="新增作品标签"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
@@ -430,6 +441,11 @@ function PuzzleThemeTagEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1191,12 +1207,16 @@ function PuzzleLevelListTab({
|
|||||||
|
|
||||||
function PuzzleWorkInfoTab({
|
function PuzzleWorkInfoTab({
|
||||||
editState,
|
editState,
|
||||||
|
tagGenerationError,
|
||||||
isBusy,
|
isBusy,
|
||||||
onChange,
|
onChange,
|
||||||
|
onGenerateTags,
|
||||||
}: {
|
}: {
|
||||||
editState: DraftEditState;
|
editState: DraftEditState;
|
||||||
|
tagGenerationError: string | null;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onChange: (nextState: DraftEditState) => void;
|
onChange: (nextState: DraftEditState) => void;
|
||||||
|
onGenerateTags: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -1233,8 +1253,10 @@ function PuzzleWorkInfoTab({
|
|||||||
|
|
||||||
<PuzzleThemeTagEditor
|
<PuzzleThemeTagEditor
|
||||||
editState={editState}
|
editState={editState}
|
||||||
|
error={tagGenerationError}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onGenerateTags={onGenerateTags}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1304,6 +1326,9 @@ export function PuzzleResultView({
|
|||||||
const [autoSaveState, setAutoSaveState] =
|
const [autoSaveState, setAutoSaveState] =
|
||||||
useState<PuzzleAutoSaveState>('idle');
|
useState<PuzzleAutoSaveState>('idle');
|
||||||
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||||
|
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const savedEditStateRef = useRef<DraftEditState | null>(
|
const savedEditStateRef = useRef<DraftEditState | null>(
|
||||||
draft ? createDraftEditState(draft) : null,
|
draft ? createDraftEditState(draft) : null,
|
||||||
);
|
);
|
||||||
@@ -1314,6 +1339,7 @@ export function PuzzleResultView({
|
|||||||
setActiveLevelId(null);
|
setActiveLevelId(null);
|
||||||
setAutoSaveState('idle');
|
setAutoSaveState('idle');
|
||||||
setAutoSaveError(null);
|
setAutoSaveError(null);
|
||||||
|
setTagGenerationError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextState = createDraftEditState(draft);
|
const nextState = createDraftEditState(draft);
|
||||||
@@ -1327,6 +1353,7 @@ export function PuzzleResultView({
|
|||||||
);
|
);
|
||||||
setAutoSaveState('idle');
|
setAutoSaveState('idle');
|
||||||
setAutoSaveError(null);
|
setAutoSaveError(null);
|
||||||
|
setTagGenerationError(null);
|
||||||
}, [draft]);
|
}, [draft]);
|
||||||
|
|
||||||
const syncedDraft = useMemo(() => {
|
const syncedDraft = useMemo(() => {
|
||||||
@@ -1445,7 +1472,7 @@ export function PuzzleResultView({
|
|||||||
const buildLevelDraft = (level: PuzzleDraftLevel): PuzzleResultDraft => ({
|
const buildLevelDraft = (level: PuzzleDraftLevel): PuzzleResultDraft => ({
|
||||||
...syncedDraft,
|
...syncedDraft,
|
||||||
levelName: level.levelName,
|
levelName: level.levelName,
|
||||||
summary: level.pictureDescription,
|
summary: editState.workDescription.trim(),
|
||||||
candidates: level.candidates,
|
candidates: level.candidates,
|
||||||
selectedCandidateId: level.selectedCandidateId,
|
selectedCandidateId: level.selectedCandidateId,
|
||||||
coverImageSrc: resolveLevelFormalImageSrc(level) || level.coverImageSrc,
|
coverImageSrc: resolveLevelFormalImageSrc(level) || level.coverImageSrc,
|
||||||
@@ -1498,8 +1525,28 @@ export function PuzzleResultView({
|
|||||||
) : (
|
) : (
|
||||||
<PuzzleWorkInfoTab
|
<PuzzleWorkInfoTab
|
||||||
editState={editState}
|
editState={editState}
|
||||||
|
tagGenerationError={tagGenerationError}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
onChange={setEditState}
|
onChange={setEditState}
|
||||||
|
onGenerateTags={() => {
|
||||||
|
const workTitle = editState.workTitle.trim();
|
||||||
|
const workDescription = editState.workDescription.trim();
|
||||||
|
if (!workTitle || !workDescription) {
|
||||||
|
setTagGenerationError('请先填写作品名称和作品描述。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTagGenerationError(null);
|
||||||
|
const firstLevel = editState.levels[0] ?? null;
|
||||||
|
onExecuteAction({
|
||||||
|
action: 'generate_puzzle_tags',
|
||||||
|
workTitle,
|
||||||
|
workDescription,
|
||||||
|
levelName: firstLevel?.levelName.trim(),
|
||||||
|
summary: workDescription,
|
||||||
|
themeTags: editState.themeTags,
|
||||||
|
levelsJson: JSON.stringify(editState.levels),
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { Character, CustomWorldProfile } from '../../types';
|
import type { Character, CustomWorldProfile } from '../../types';
|
||||||
|
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
|
||||||
import {
|
import {
|
||||||
CustomWorldEntityCatalog,
|
CustomWorldEntityCatalog,
|
||||||
type ResultTab,
|
type ResultTab,
|
||||||
@@ -91,10 +92,19 @@ export function RpgCreationResultView({
|
|||||||
qualityFindings = [],
|
qualityFindings = [],
|
||||||
}: RpgCreationResultViewProps) {
|
}: RpgCreationResultViewProps) {
|
||||||
const [activeTab, setActiveTab] = useState<ResultTab>('world');
|
const [activeTab, setActiveTab] = useState<ResultTab>('world');
|
||||||
|
const [openingCgGenerating, setOpeningCgGenerating] = useState(false);
|
||||||
|
const [openingCgGenerationError, setOpeningCgGenerationError] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const latestProfileRef = useRef(profile);
|
||||||
const assetDebugEnabled = useMemo(
|
const assetDebugEnabled = useMemo(
|
||||||
() => shouldEnableRpgCreationAssetDebugPanel(),
|
() => shouldEnableRpgCreationAssetDebugPanel(),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestProfileRef.current = profile;
|
||||||
|
}, [profile]);
|
||||||
const {
|
const {
|
||||||
closeEditorTarget,
|
closeEditorTarget,
|
||||||
createLabel,
|
createLabel,
|
||||||
@@ -133,6 +143,32 @@ export function RpgCreationResultView({
|
|||||||
}
|
}
|
||||||
: handleDeleteLandmarks;
|
: handleDeleteLandmarks;
|
||||||
|
|
||||||
|
const handleGenerateOpeningCg = async () => {
|
||||||
|
if (readOnly || isGenerating || openingCgGenerating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpeningCgGenerating(true);
|
||||||
|
setOpeningCgGenerationError(null);
|
||||||
|
try {
|
||||||
|
const openingCg = await rpgCreationAssetClient.generateOpeningCg({
|
||||||
|
profile: latestProfileRef.current,
|
||||||
|
});
|
||||||
|
onProfileChange({
|
||||||
|
...latestProfileRef.current,
|
||||||
|
openingCg,
|
||||||
|
});
|
||||||
|
} catch (generationError) {
|
||||||
|
setOpeningCgGenerationError(
|
||||||
|
generationError instanceof Error
|
||||||
|
? generationError.message
|
||||||
|
: '生成开局 CG 失败',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setOpeningCgGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
|
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
|
||||||
<RpgCreationResultHeader
|
<RpgCreationResultHeader
|
||||||
@@ -152,6 +188,14 @@ export function RpgCreationResultView({
|
|||||||
onProfileChange={onProfileChange}
|
onProfileChange={onProfileChange}
|
||||||
onDeleteStoryNpcs={deleteStoryNpcs}
|
onDeleteStoryNpcs={deleteStoryNpcs}
|
||||||
onDeleteLandmarks={deleteLandmarks}
|
onDeleteLandmarks={deleteLandmarks}
|
||||||
|
openingCgGenerating={openingCgGenerating}
|
||||||
|
openingCgPhaseLabel={
|
||||||
|
openingCgGenerating ? '正在生成开局 CG' : null
|
||||||
|
}
|
||||||
|
openingCgGenerateDisabled={isGenerating}
|
||||||
|
onGenerateOpeningCg={
|
||||||
|
readOnly ? undefined : () => void handleGenerateOpeningCg()
|
||||||
|
}
|
||||||
createActionLabel={
|
createActionLabel={
|
||||||
readOnly || (compactAgentResultMode && !onGenerateEntity)
|
readOnly || (compactAgentResultMode && !onGenerateEntity)
|
||||||
? undefined
|
? undefined
|
||||||
@@ -227,6 +271,11 @@ export function RpgCreationResultView({
|
|||||||
{localGenerationError}
|
{localGenerationError}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!error && openingCgGenerationError ? (
|
||||||
|
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||||
|
{openingCgGenerationError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{assetDebugEnabled ? (
|
{assetDebugEnabled ? (
|
||||||
<RpgCreationAssetDebugPanel profile={profile} />
|
<RpgCreationAssetDebugPanel profile={profile} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -138,7 +138,9 @@ async function clickFirstAsyncButtonByName(
|
|||||||
|
|
||||||
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
|
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
|
||||||
await clickFirstButtonByName(user, '创作');
|
await clickFirstButtonByName(user, '创作');
|
||||||
expect(await screen.findByText('角色扮演')).toBeTruthy();
|
expect(
|
||||||
|
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||||
|
).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openExistingRpgDraft(
|
async function openExistingRpgDraft(
|
||||||
@@ -1867,7 +1869,7 @@ beforeEach(() => {
|
|||||||
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
|
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create hub opens RPG while keeping AIRP and visual novel locked', async () => {
|
test('create hub hides RPG and Match3D while keeping AIRP and visual novel locked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<TestWrapper withAuth />);
|
render(<TestWrapper withAuth />);
|
||||||
@@ -1881,15 +1883,10 @@ test('create hub opens RPG while keeping AIRP and visual novel locked', async ()
|
|||||||
|
|
||||||
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
|
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
|
||||||
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
|
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
|
||||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', { name: /抓大鹅/u })).toBeNull();
|
||||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||||
await user.click(rpgButton);
|
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
|
|
||||||
expect(
|
|
||||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
|
||||||
).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('platform create hub does not prefetch hidden big fish platform data', async () => {
|
test('platform create hub does not prefetch hidden big fish platform data', async () => {
|
||||||
@@ -1900,7 +1897,7 @@ test('platform create hub does not prefetch hidden big fish platform data', asyn
|
|||||||
await openCreationHub(user);
|
await openCreationHub(user);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('button', { name: /角色扮演/u }),
|
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||||
expect(listBigFishWorks).not.toHaveBeenCalled();
|
expect(listBigFishWorks).not.toHaveBeenCalled();
|
||||||
@@ -2643,7 +2640,7 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('selecting RPG creation while logged out routes through requireAuth', async () => {
|
test('selecting puzzle creation while logged out routes through requireAuth', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const requireAuth = vi.fn();
|
const requireAuth = vi.fn();
|
||||||
|
|
||||||
@@ -2658,12 +2655,15 @@ test('selecting RPG creation while logged out routes through requireAuth', async
|
|||||||
);
|
);
|
||||||
|
|
||||||
await openCreationHub(user);
|
await openCreationHub(user);
|
||||||
const rpgButton = await screen.findByRole('button', { name: /角色扮演/u });
|
const puzzleButton = await screen.findByRole('button', {
|
||||||
|
name: /拼图.*创意礼物/u,
|
||||||
|
});
|
||||||
|
|
||||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||||
await user.click(rpgButton);
|
await user.click(puzzleButton);
|
||||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||||
|
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => {
|
test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => {
|
||||||
@@ -2772,10 +2772,10 @@ test('refreshing RPG agent path restores stored agent workspace pointer', async
|
|||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('new creation entry maps raw bearer token errors to user-facing auth copy', async () => {
|
test('new puzzle creation entry maps raw bearer token errors to user-facing auth copy', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
vi.mocked(createRpgCreationSession).mockRejectedValueOnce(
|
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
|
||||||
new ApiClientError({
|
new ApiClientError({
|
||||||
message: '缺少 Authorization Bearer Token',
|
message: '缺少 Authorization Bearer Token',
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -2786,13 +2786,15 @@ test('new creation entry maps raw bearer token errors to user-facing auth copy',
|
|||||||
render(<TestWrapper withAuth />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
await openCreationHub(user);
|
await openCreationHub(user);
|
||||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
const puzzleButton = screen.getByRole('button', {
|
||||||
|
name: /拼图.*创意礼物/u,
|
||||||
|
});
|
||||||
|
|
||||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||||
await user.click(rpgButton);
|
await user.click(puzzleButton);
|
||||||
|
|
||||||
expect(listPuzzleWorks).toHaveBeenCalled();
|
expect(listPuzzleWorks).toHaveBeenCalled();
|
||||||
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
|
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
|
||||||
expect(
|
expect(
|
||||||
await within(getPlatformTabPanel('create')).findByText(
|
await within(getPlatformTabPanel('create')).findByText(
|
||||||
'当前登录状态已失效,请重新登录后继续。',
|
'当前登录状态已失效,请重新登录后继续。',
|
||||||
@@ -2839,7 +2841,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
|
|||||||
expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull();
|
expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('match3d creation card opens workspace even when public galleries fail', async () => {
|
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const match3dSession = buildMockMatch3DAgentSession();
|
const match3dSession = buildMockMatch3DAgentSession();
|
||||||
|
|
||||||
@@ -2858,20 +2860,13 @@ test('match3d creation card opens workspace even when public galleries fail', as
|
|||||||
await openCreationHub(user);
|
await openCreationHub(user);
|
||||||
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
||||||
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
||||||
|
expect(
|
||||||
const button = screen.getByRole('button', {
|
screen.queryByRole('button', { name: /抓大鹅.*经典消除玩法/u }),
|
||||||
name: /抓大鹅.*经典消除玩法/u,
|
).toBeNull();
|
||||||
});
|
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||||
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
|
|
||||||
await user.click(button);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(match3dCreationClient.createSession).toHaveBeenCalledWith({});
|
|
||||||
});
|
|
||||||
expect(await screen.findByText('抓大鹅工作区:match3d-agent-session-1')).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('puzzle draft card restores the bound agent session and opens the result view', async () => {
|
test('puzzle draft result back button returns to creation hub', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||||
@@ -2913,9 +2908,12 @@ test('puzzle draft card restores the bound agent session and opens the result vi
|
|||||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
|
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(screen.queryByText('拼图玩法共创')).toBeNull();
|
expect(
|
||||||
|
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
|
||||||
|
).toBeNull();
|
||||||
|
expect(screen.queryByText('拼图结果页')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('published puzzle work card restores its source session for editing', async () => {
|
test('published puzzle work card restores its source session for editing', async () => {
|
||||||
@@ -4361,7 +4359,9 @@ test('agent draft result back button returns to creation hub without syncing res
|
|||||||
await user.click(screen.getByRole('button', { name: /返回创作/u }));
|
await user.click(screen.getByRole('button', { name: /返回创作/u }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('角色扮演')).toBeTruthy();
|
expect(
|
||||||
|
screen.getByRole('button', { name: /拼图.*创意礼物/u }),
|
||||||
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -4677,13 +4677,17 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
|
|||||||
render(<TestWrapper withAuth />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
await clickFirstButtonByName(user, '创作');
|
await clickFirstButtonByName(user, '创作');
|
||||||
expect(await screen.findByText('角色扮演')).toBeTruthy();
|
expect(
|
||||||
|
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
resolveGalleryRequest([]);
|
resolveGalleryRequest([]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
within(getPlatformTabPanel('create')).getByText('角色扮演'),
|
within(getPlatformTabPanel('create')).getByRole('button', {
|
||||||
|
name: /拼图.*创意礼物/u,
|
||||||
|
}),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5045,9 +5049,22 @@ test('creation hub published work card keeps delete action guarded by detail flo
|
|||||||
|
|
||||||
render(<TestWrapper withAuth />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
await openCreationHub(user);
|
await clickFirstButtonByName(user, '创作');
|
||||||
|
|
||||||
expect(await screen.findByRole('button', { name: /查看详情/u })).toBeTruthy();
|
expect(await screen.findByRole('button', { name: /查看详情/u })).toBeTruthy();
|
||||||
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
|
await user.click(screen.getByRole('button', { name: '删除' }));
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
|
||||||
|
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||||
|
expect(dialog.parentElement?.className).toContain('!items-center');
|
||||||
|
expect(dialog.className).toContain('platform-modal-shell');
|
||||||
|
expect(dialog.className).toContain('platform-remap-surface');
|
||||||
|
expect(dialog.className).toContain('rounded-[1.75rem]');
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText('确认删除《潮雾列岛》吗?'),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole('button', { name: '确认删除' }),
|
||||||
|
).toBeTruthy();
|
||||||
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
|
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export function useRpgRuntimeShellViewModel(
|
|||||||
gameState,
|
gameState,
|
||||||
currentStory,
|
currentStory,
|
||||||
openingCampSceneId,
|
openingCampSceneId,
|
||||||
|
onDeferredAutoChoice: (option) => handleChoice(option),
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
visibleGameState,
|
visibleGameState,
|
||||||
@@ -222,12 +223,24 @@ export function useRpgRuntimeShellViewModel(
|
|||||||
const handleSceneTransitionChoice = useCallback(
|
const handleSceneTransitionChoice = useCallback(
|
||||||
(option: StoryOption) => {
|
(option: StoryOption) => {
|
||||||
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
|
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
|
||||||
if (transitionMode) {
|
const shouldBeginTransition =
|
||||||
|
transitionMode &&
|
||||||
|
(option.functionId !== 'story_continue_adventure' ||
|
||||||
|
Boolean(
|
||||||
|
currentStory?.deferredAutoChoice ||
|
||||||
|
currentStory?.deferredRuntimeState,
|
||||||
|
));
|
||||||
|
if (shouldBeginTransition) {
|
||||||
beginSceneTransition(transitionMode);
|
beginSceneTransition(transitionMode);
|
||||||
}
|
}
|
||||||
handleChoice(option);
|
handleChoice(option);
|
||||||
},
|
},
|
||||||
[beginSceneTransition, handleChoice],
|
[
|
||||||
|
beginSceneTransition,
|
||||||
|
currentStory?.deferredAutoChoice,
|
||||||
|
currentStory?.deferredRuntimeState,
|
||||||
|
handleChoice,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AnimationState,
|
||||||
|
type GameState,
|
||||||
|
type StoryMoment,
|
||||||
|
type StoryOption,
|
||||||
|
WorldType,
|
||||||
|
} from '../../types';
|
||||||
|
import { useRpgSceneTransitionModel } from './useRpgSceneTransitionModel';
|
||||||
|
|
||||||
|
function createGameState(actId: string): GameState {
|
||||||
|
return {
|
||||||
|
worldType: WorldType.WUXIA,
|
||||||
|
customWorldProfile: null,
|
||||||
|
playerCharacter: {
|
||||||
|
id: 'hero',
|
||||||
|
name: '测试主角',
|
||||||
|
title: '游侠',
|
||||||
|
description: '测试角色',
|
||||||
|
backstory: '测试背景',
|
||||||
|
avatar: '',
|
||||||
|
portrait: '',
|
||||||
|
assetFolder: '',
|
||||||
|
assetVariant: '',
|
||||||
|
attributes: {
|
||||||
|
strength: 10,
|
||||||
|
agility: 10,
|
||||||
|
intelligence: 10,
|
||||||
|
spirit: 10,
|
||||||
|
},
|
||||||
|
personality: '沉稳',
|
||||||
|
skills: [],
|
||||||
|
adventureOpenings: {},
|
||||||
|
},
|
||||||
|
runtimeStats: {
|
||||||
|
playTimeMs: 0,
|
||||||
|
lastPlayTickAt: null,
|
||||||
|
hostileNpcsDefeated: 0,
|
||||||
|
questsAccepted: 0,
|
||||||
|
itemsUsed: 0,
|
||||||
|
scenesTraveled: 0,
|
||||||
|
},
|
||||||
|
currentScene: 'Story',
|
||||||
|
storyHistory: [{ text: '旧幕', options: [] }],
|
||||||
|
storyEngineMemory: {
|
||||||
|
discoveredFactIds: [],
|
||||||
|
activeThreadIds: [],
|
||||||
|
resolvedScarIds: [],
|
||||||
|
recentCarrierIds: [],
|
||||||
|
currentSceneActState: {
|
||||||
|
sceneId: 'scene-1',
|
||||||
|
chapterId: 'chapter-1',
|
||||||
|
currentActId: actId,
|
||||||
|
currentActIndex: actId === 'act-1' ? 0 : 1,
|
||||||
|
completedActIds: actId === 'act-1' ? [] : ['act-1'],
|
||||||
|
visitedActIds: [actId],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
characterChats: {},
|
||||||
|
animationState: AnimationState.IDLE,
|
||||||
|
currentEncounter: null,
|
||||||
|
npcInteractionActive: false,
|
||||||
|
currentScenePreset: {
|
||||||
|
id: 'scene-1',
|
||||||
|
name: '断桥旧哨',
|
||||||
|
description: '测试场景',
|
||||||
|
imageSrc: '/scene.png',
|
||||||
|
treasureHints: [],
|
||||||
|
npcs: [],
|
||||||
|
},
|
||||||
|
sceneHostileNpcs: [],
|
||||||
|
playerX: 0,
|
||||||
|
playerOffsetY: 0,
|
||||||
|
playerFacing: 'right',
|
||||||
|
playerActionMode: 'idle',
|
||||||
|
scrollWorld: false,
|
||||||
|
inBattle: false,
|
||||||
|
playerHp: 100,
|
||||||
|
playerMaxHp: 100,
|
||||||
|
playerMana: 20,
|
||||||
|
playerMaxMana: 20,
|
||||||
|
playerSkillCooldowns: {},
|
||||||
|
activeCombatEffects: [],
|
||||||
|
playerCurrency: 0,
|
||||||
|
playerInventory: [],
|
||||||
|
playerEquipment: {
|
||||||
|
weapon: null,
|
||||||
|
armor: null,
|
||||||
|
relic: null,
|
||||||
|
},
|
||||||
|
npcStates: {},
|
||||||
|
quests: [],
|
||||||
|
roster: [],
|
||||||
|
companions: [],
|
||||||
|
currentBattleNpcId: null,
|
||||||
|
currentNpcBattleMode: null,
|
||||||
|
currentNpcBattleOutcome: null,
|
||||||
|
sparReturnEncounter: null,
|
||||||
|
sparPlayerHpBefore: null,
|
||||||
|
sparPlayerMaxHpBefore: null,
|
||||||
|
sparStoryHistoryBefore: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStory(
|
||||||
|
text: string,
|
||||||
|
options: StoryOption[] = [],
|
||||||
|
deferredAutoChoice?: StoryOption,
|
||||||
|
): StoryMoment {
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
options,
|
||||||
|
deferredAutoChoice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useRpgSceneTransitionModel', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires deferred auto choice only after entry and through the latest callback', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const autoChoice: StoryOption = {
|
||||||
|
functionId: 'npc_preview_talk',
|
||||||
|
actionText: '与新角色交谈',
|
||||||
|
text: '与新角色交谈',
|
||||||
|
visuals: {
|
||||||
|
playerAnimation: AnimationState.IDLE,
|
||||||
|
playerMoveMeters: 0,
|
||||||
|
playerOffsetY: 0,
|
||||||
|
playerFacing: 'right',
|
||||||
|
scrollWorld: false,
|
||||||
|
monsterChanges: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const firstCallback = vi.fn();
|
||||||
|
const latestCallback = vi.fn();
|
||||||
|
const initialState = createGameState('act-1');
|
||||||
|
const initialStory = createStory('旧幕收束', [
|
||||||
|
{
|
||||||
|
functionId: 'story_continue_adventure',
|
||||||
|
actionText: '继续冒险',
|
||||||
|
text: '继续冒险',
|
||||||
|
visuals: autoChoice.visuals,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const nextStory = createStory('新幕入口', [autoChoice], autoChoice);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
(props: {
|
||||||
|
gameState: GameState;
|
||||||
|
currentStory: StoryMoment;
|
||||||
|
onDeferredAutoChoice: (option: StoryOption) => void;
|
||||||
|
}) =>
|
||||||
|
useRpgSceneTransitionModel({
|
||||||
|
gameState: props.gameState,
|
||||||
|
currentStory: props.currentStory,
|
||||||
|
openingCampSceneId: null,
|
||||||
|
onDeferredAutoChoice: props.onDeferredAutoChoice,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
initialProps: {
|
||||||
|
gameState: initialState,
|
||||||
|
currentStory: initialStory,
|
||||||
|
onDeferredAutoChoice: firstCallback,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setSceneTransitionDurations({ exitMs: 20, entryMs: 30 });
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.beginSceneTransition('content-change');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sceneTransitionPhase).toBe('exiting');
|
||||||
|
|
||||||
|
rerender({
|
||||||
|
gameState: createGameState('act-2'),
|
||||||
|
currentStory: nextStory,
|
||||||
|
onDeferredAutoChoice: latestCallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sceneTransitionPhase).toBe('entering');
|
||||||
|
expect(latestCallback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sceneTransitionPhase).toBe('idle');
|
||||||
|
expect(firstCallback).not.toHaveBeenCalled();
|
||||||
|
expect(latestCallback).toHaveBeenCalledWith(autoChoice);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { GameState, StoryMoment } from '../../types';
|
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||||
|
|
||||||
export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering';
|
export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering';
|
||||||
export type SceneTransitionTriggerMode = 'scene-change' | 'content-change';
|
export type SceneTransitionTriggerMode = 'scene-change' | 'content-change';
|
||||||
@@ -18,6 +18,7 @@ const DEFAULT_SCENE_SWITCH_ENTRY_MS = 5930;
|
|||||||
export const SCENE_TRANSITION_FUNCTION_MODES: Partial<
|
export const SCENE_TRANSITION_FUNCTION_MODES: Partial<
|
||||||
Record<string, SceneTransitionTriggerMode>
|
Record<string, SceneTransitionTriggerMode>
|
||||||
> = {
|
> = {
|
||||||
|
story_continue_adventure: 'content-change',
|
||||||
idle_travel_next_scene: 'scene-change',
|
idle_travel_next_scene: 'scene-change',
|
||||||
camp_travel_home_scene: 'scene-change',
|
camp_travel_home_scene: 'scene-change',
|
||||||
idle_explore_forward: 'content-change',
|
idle_explore_forward: 'content-change',
|
||||||
@@ -29,6 +30,9 @@ function buildSceneTransitionContentKey(
|
|||||||
currentStory: StoryMoment | null,
|
currentStory: StoryMoment | null,
|
||||||
) {
|
) {
|
||||||
const sceneId = gameState.currentScenePreset?.id ?? 'scene:none';
|
const sceneId = gameState.currentScenePreset?.id ?? 'scene:none';
|
||||||
|
const sceneActId =
|
||||||
|
gameState.storyEngineMemory?.currentSceneActState?.currentActId ??
|
||||||
|
'act:none';
|
||||||
const encounterKey = gameState.currentEncounter
|
const encounterKey = gameState.currentEncounter
|
||||||
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
|
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
|
||||||
: 'encounter:none';
|
: 'encounter:none';
|
||||||
@@ -39,9 +43,9 @@ function buildSceneTransitionContentKey(
|
|||||||
)
|
)
|
||||||
.join('|');
|
.join('|');
|
||||||
const storyKey = currentStory
|
const storyKey = currentStory
|
||||||
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}`
|
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}:${currentStory.options.map((option) => option.functionId).join('|')}:${currentStory.deferredAutoChoice?.functionId ?? 'auto:none'}`
|
||||||
: 'story:none';
|
: 'story:none';
|
||||||
return [sceneId, encounterKey, monsterKey, storyKey].join('::');
|
return [sceneId, sceneActId, encounterKey, monsterKey, storyKey].join('::');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,8 +56,14 @@ export function useRpgSceneTransitionModel(params: {
|
|||||||
gameState: GameState;
|
gameState: GameState;
|
||||||
currentStory: StoryMoment | null;
|
currentStory: StoryMoment | null;
|
||||||
openingCampSceneId: string | null;
|
openingCampSceneId: string | null;
|
||||||
|
onDeferredAutoChoice?: ((option: StoryOption) => void) | null;
|
||||||
}) {
|
}) {
|
||||||
const { gameState, currentStory, openingCampSceneId } = params;
|
const {
|
||||||
|
gameState,
|
||||||
|
currentStory,
|
||||||
|
openingCampSceneId,
|
||||||
|
onDeferredAutoChoice = null,
|
||||||
|
} = params;
|
||||||
const [renderGameState, setRenderGameState] = useState(gameState);
|
const [renderGameState, setRenderGameState] = useState(gameState);
|
||||||
const [renderCurrentStory, setRenderCurrentStory] = useState(currentStory);
|
const [renderCurrentStory, setRenderCurrentStory] = useState(currentStory);
|
||||||
const [sceneTransitionPhase, setSceneTransitionPhase] =
|
const [sceneTransitionPhase, setSceneTransitionPhase] =
|
||||||
@@ -73,6 +83,13 @@ export function useRpgSceneTransitionModel(params: {
|
|||||||
});
|
});
|
||||||
const sceneTransitionTimerIdsRef = useRef<number[]>([]);
|
const sceneTransitionTimerIdsRef = useRef<number[]>([]);
|
||||||
const sceneTransitionRequestRef = useRef<SceneTransitionRequest | null>(null);
|
const sceneTransitionRequestRef = useRef<SceneTransitionRequest | null>(null);
|
||||||
|
const pendingDeferredAutoChoiceRef =
|
||||||
|
useRef<StoryOption | null>(null);
|
||||||
|
const onDeferredAutoChoiceRef = useRef(onDeferredAutoChoice);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDeferredAutoChoiceRef.current = onDeferredAutoChoice;
|
||||||
|
}, [onDeferredAutoChoice]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -81,6 +98,7 @@ export function useRpgSceneTransitionModel(params: {
|
|||||||
);
|
);
|
||||||
sceneTransitionTimerIdsRef.current = [];
|
sceneTransitionTimerIdsRef.current = [];
|
||||||
sceneTransitionRequestRef.current = null;
|
sceneTransitionRequestRef.current = null;
|
||||||
|
pendingDeferredAutoChoiceRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -98,6 +116,15 @@ export function useRpgSceneTransitionModel(params: {
|
|||||||
|
|
||||||
const entryTimerId = window.setTimeout(() => {
|
const entryTimerId = window.setTimeout(() => {
|
||||||
setSceneTransitionPhase('idle');
|
setSceneTransitionPhase('idle');
|
||||||
|
const autoChoice =
|
||||||
|
payload.currentStory?.deferredAutoChoice ??
|
||||||
|
pendingDeferredAutoChoiceRef.current;
|
||||||
|
if (autoChoice) {
|
||||||
|
pendingDeferredAutoChoiceRef.current = null;
|
||||||
|
// 中文注释:入场计时器可能跨过一次 currentStory/gameState 更新,
|
||||||
|
// 必须读取最新回调,避免用点击“继续冒险”前的旧状态自动开聊。
|
||||||
|
onDeferredAutoChoiceRef.current?.(autoChoice);
|
||||||
|
}
|
||||||
}, sceneTransitionDurations.entryMs);
|
}, sceneTransitionDurations.entryMs);
|
||||||
sceneTransitionTimerIdsRef.current.push(entryTimerId);
|
sceneTransitionTimerIdsRef.current.push(entryTimerId);
|
||||||
},
|
},
|
||||||
@@ -109,6 +136,7 @@ export function useRpgSceneTransitionModel(params: {
|
|||||||
if (sceneTransitionPhase !== 'idle') return;
|
if (sceneTransitionPhase !== 'idle') return;
|
||||||
|
|
||||||
pendingScenePayloadRef.current = { gameState, currentStory };
|
pendingScenePayloadRef.current = { gameState, currentStory };
|
||||||
|
pendingDeferredAutoChoiceRef.current = null;
|
||||||
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
|
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
|
||||||
window.clearTimeout(timerId),
|
window.clearTimeout(timerId),
|
||||||
);
|
);
|
||||||
@@ -170,6 +198,8 @@ export function useRpgSceneTransitionModel(params: {
|
|||||||
: buildSceneTransitionContentKey(gameState, currentStory) !==
|
: buildSceneTransitionContentKey(gameState, currentStory) !==
|
||||||
request.baselineContentKey;
|
request.baselineContentKey;
|
||||||
if (isReady) {
|
if (isReady) {
|
||||||
|
pendingDeferredAutoChoiceRef.current =
|
||||||
|
currentStory?.deferredAutoChoice ?? null;
|
||||||
startSceneEntering({ gameState, currentStory });
|
startSceneEntering({ gameState, currentStory });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const NEW_WORK_ENTRY_CONFIG = {
|
|||||||
title: '角色扮演',
|
title: '角色扮演',
|
||||||
subtitle: '敬请期待',
|
subtitle: '敬请期待',
|
||||||
badge: '敬请期待',
|
badge: '敬请期待',
|
||||||
visible: true,
|
visible: false,
|
||||||
open: true,
|
open: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -43,7 +43,7 @@ export const NEW_WORK_ENTRY_CONFIG = {
|
|||||||
title: '抓大鹅',
|
title: '抓大鹅',
|
||||||
subtitle: '经典消除玩法',
|
subtitle: '经典消除玩法',
|
||||||
badge: '可创建',
|
badge: '可创建',
|
||||||
visible: true,
|
visible: false,
|
||||||
open: true,
|
open: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -398,6 +398,88 @@ describe('createStoryChoiceActions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the deferred auto choice for the scene transition model to trigger after entry', async () => {
|
||||||
|
const state = {
|
||||||
|
...createBaseState(),
|
||||||
|
inBattle: false,
|
||||||
|
currentBattleNpcId: null,
|
||||||
|
currentNpcBattleMode: null,
|
||||||
|
};
|
||||||
|
const autoChoice = createBattleOption('npc_preview_talk');
|
||||||
|
const continueOption: StoryOption = {
|
||||||
|
functionId: 'story_continue_adventure',
|
||||||
|
actionText: '继续冒险',
|
||||||
|
text: '继续冒险',
|
||||||
|
visuals: {
|
||||||
|
playerAnimation: AnimationState.RUN,
|
||||||
|
playerMoveMeters: 0,
|
||||||
|
playerOffsetY: 0,
|
||||||
|
playerFacing: 'right' as const,
|
||||||
|
scrollWorld: false,
|
||||||
|
monsterChanges: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const currentStory: StoryMoment = {
|
||||||
|
text: '对话已经完成',
|
||||||
|
options: [continueOption],
|
||||||
|
deferredOptions: [autoChoice],
|
||||||
|
deferredAutoChoice: autoChoice,
|
||||||
|
};
|
||||||
|
const setCurrentStory = vi.fn();
|
||||||
|
|
||||||
|
const { handleChoice } = createStoryChoiceActions({
|
||||||
|
gameState: state,
|
||||||
|
currentStory,
|
||||||
|
isLoading: false,
|
||||||
|
setGameState: vi.fn(),
|
||||||
|
setCurrentStory,
|
||||||
|
setAiError: vi.fn(),
|
||||||
|
setIsLoading: vi.fn(),
|
||||||
|
setBattleReward: vi.fn(),
|
||||||
|
buildResolvedChoiceState: vi.fn(),
|
||||||
|
playResolvedChoice: vi.fn(),
|
||||||
|
buildStoryContextFromState: vi.fn(),
|
||||||
|
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||||
|
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||||
|
generateStoryForState: vi.fn(),
|
||||||
|
getAvailableOptionsForState: vi.fn(() => null),
|
||||||
|
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||||
|
getResolvedSceneHostileNpcs: vi.fn(
|
||||||
|
(inputState: GameState) => inputState.sceneHostileNpcs,
|
||||||
|
),
|
||||||
|
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||||
|
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||||
|
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||||
|
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||||
|
getCampCompanionTravelScene: vi.fn(() => null),
|
||||||
|
enterNpcInteraction: vi.fn(() => false),
|
||||||
|
handleNpcInteraction: vi.fn(),
|
||||||
|
handleTreasureInteraction: vi.fn(() => false),
|
||||||
|
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||||
|
finalizeNpcBattleResult: vi.fn(() => null),
|
||||||
|
isContinueAdventureOption: vi.fn(
|
||||||
|
(option: StoryOption) =>
|
||||||
|
option.functionId === 'story_continue_adventure',
|
||||||
|
),
|
||||||
|
isCampTravelHomeOption: vi.fn(() => false),
|
||||||
|
isRegularNpcEncounter: neverNpcEncounter,
|
||||||
|
isNpcEncounter: neverNpcEncounter,
|
||||||
|
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||||
|
fallbackCompanionName: '同伴',
|
||||||
|
turnVisualMs: 820,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleChoice(continueOption);
|
||||||
|
|
||||||
|
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
options: [autoChoice],
|
||||||
|
deferredOptions: undefined,
|
||||||
|
deferredAutoChoice: autoChoice,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
|
it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
|
||||||
const state = createBaseState();
|
const state = createBaseState();
|
||||||
const option = createBattleOption('npc_chat');
|
const option = createBattleOption('npc_chat');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
|
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
|
||||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||||
import { isServerRuntimeFunctionId } from '../../services/rpg-runtime';
|
import { isServerRuntimeFunctionId } from '../../services/rpg-runtime';
|
||||||
import {
|
import {
|
||||||
@@ -185,8 +186,13 @@ export function createStoryChoiceActions({
|
|||||||
currentStory?.deferredOptions?.length &&
|
currentStory?.deferredOptions?.length &&
|
||||||
isContinueAdventureOption(option)
|
isContinueAdventureOption(option)
|
||||||
) {
|
) {
|
||||||
|
const deferredAutoChoice =
|
||||||
|
currentStory.deferredAutoChoice &&
|
||||||
|
currentStory.deferredOptions.includes(currentStory.deferredAutoChoice)
|
||||||
|
? currentStory.deferredAutoChoice
|
||||||
|
: undefined;
|
||||||
if (currentStory.deferredRuntimeState) {
|
if (currentStory.deferredRuntimeState) {
|
||||||
setGameState({
|
const restoredState = ensureSceneEncounterPreview({
|
||||||
...gameState,
|
...gameState,
|
||||||
currentEncounter: null,
|
currentEncounter: null,
|
||||||
npcInteractionActive: false,
|
npcInteractionActive: false,
|
||||||
@@ -202,12 +208,15 @@ export function createStoryChoiceActions({
|
|||||||
currentStory.deferredRuntimeState.storyEngineMemory ??
|
currentStory.deferredRuntimeState.storyEngineMemory ??
|
||||||
gameState.storyEngineMemory,
|
gameState.storyEngineMemory,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setGameState(restoredState);
|
||||||
}
|
}
|
||||||
setCurrentStory({
|
setCurrentStory({
|
||||||
...currentStory,
|
...currentStory,
|
||||||
options: currentStory.deferredOptions,
|
options: currentStory.deferredOptions,
|
||||||
deferredOptions: undefined,
|
deferredOptions: undefined,
|
||||||
deferredRuntimeState: undefined,
|
deferredRuntimeState: undefined,
|
||||||
|
deferredAutoChoice,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator';
|
|||||||
type RpgRuntimeInteractionFlowParams = {
|
type RpgRuntimeInteractionFlowParams = {
|
||||||
gameState: GameState;
|
gameState: GameState;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
|
||||||
interactionConfig: StoryInteractionCoordinatorConfig;
|
interactionConfig: StoryInteractionCoordinatorConfig;
|
||||||
runtimeSupport: StoryRuntimeSupport;
|
runtimeSupport: StoryRuntimeSupport;
|
||||||
buildResolvedChoiceState: (
|
buildResolvedChoiceState: (
|
||||||
@@ -76,6 +77,7 @@ export function createClearStoryInteractionUi(params: {
|
|||||||
export function useRpgRuntimeInteractionFlow({
|
export function useRpgRuntimeInteractionFlow({
|
||||||
gameState,
|
gameState,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
sceneTransitionPhase = 'idle',
|
||||||
interactionConfig,
|
interactionConfig,
|
||||||
runtimeSupport,
|
runtimeSupport,
|
||||||
buildResolvedChoiceState,
|
buildResolvedChoiceState,
|
||||||
@@ -117,7 +119,15 @@ export function useRpgRuntimeInteractionFlow({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || gameState.inBattle || gameState.npcInteractionActive) {
|
const pendingAutoChoice =
|
||||||
|
interactionConfig.npcEncounterActions.currentStory?.deferredAutoChoice;
|
||||||
|
if (
|
||||||
|
isLoading ||
|
||||||
|
sceneTransitionPhase !== 'idle' ||
|
||||||
|
pendingAutoChoice ||
|
||||||
|
gameState.inBattle ||
|
||||||
|
gameState.npcInteractionActive
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,8 +144,10 @@ export function useRpgRuntimeInteractionFlow({
|
|||||||
gameState.currentEncounter,
|
gameState.currentEncounter,
|
||||||
gameState.inBattle,
|
gameState.inBattle,
|
||||||
gameState.npcInteractionActive,
|
gameState.npcInteractionActive,
|
||||||
|
interactionConfig.npcEncounterActions.currentStory?.deferredAutoChoice,
|
||||||
isLoading,
|
isLoading,
|
||||||
isNpcEncounter,
|
isNpcEncounter,
|
||||||
|
sceneTransitionPhase,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const choiceRuntimeController: Parameters<
|
const choiceRuntimeController: Parameters<
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import type { StoryGenerationContext } from '../../services/aiTypes';
|
|||||||
import {
|
import {
|
||||||
advanceSceneActRuntimeState,
|
advanceSceneActRuntimeState,
|
||||||
getSceneConnectionDirectionText,
|
getSceneConnectionDirectionText,
|
||||||
resolveSceneActProgression,
|
|
||||||
resolveLimitedPrimaryNpcChatState,
|
resolveLimitedPrimaryNpcChatState,
|
||||||
|
resolveSceneActProgression,
|
||||||
} from '../../services/customWorldSceneActRuntime';
|
} from '../../services/customWorldSceneActRuntime';
|
||||||
import { normalizeStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
import { normalizeStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||||
import type {
|
import type {
|
||||||
@@ -1730,6 +1730,14 @@ export function createStoryNpcEncounterActions({
|
|||||||
deferredOptions: progressionResult?.options,
|
deferredOptions: progressionResult?.options,
|
||||||
deferredRuntimeState:
|
deferredRuntimeState:
|
||||||
progressionResult?.deferredRuntimeState ?? undefined,
|
progressionResult?.deferredRuntimeState ?? undefined,
|
||||||
|
deferredAutoChoice:
|
||||||
|
progressionResult?.options.find(
|
||||||
|
(option) => option.functionId === 'npc_preview_talk',
|
||||||
|
) ??
|
||||||
|
progressionResult?.options.find(
|
||||||
|
(option) => option.functionId === 'npc_chat',
|
||||||
|
) ??
|
||||||
|
undefined,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,11 +56,13 @@ export type {
|
|||||||
export function useRpgRuntimeStory({
|
export function useRpgRuntimeStory({
|
||||||
gameState,
|
gameState,
|
||||||
setGameState,
|
setGameState,
|
||||||
|
sceneTransitionPhase = 'idle',
|
||||||
buildResolvedChoiceState,
|
buildResolvedChoiceState,
|
||||||
playResolvedChoice,
|
playResolvedChoice,
|
||||||
}: {
|
}: {
|
||||||
gameState: GameState;
|
gameState: GameState;
|
||||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||||
|
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
|
||||||
buildResolvedChoiceState: (
|
buildResolvedChoiceState: (
|
||||||
state: GameState,
|
state: GameState,
|
||||||
option: StoryOption,
|
option: StoryOption,
|
||||||
@@ -108,6 +110,7 @@ export function useRpgRuntimeStory({
|
|||||||
} = useRpgRuntimeStoryFlow({
|
} = useRpgRuntimeStoryFlow({
|
||||||
gameState,
|
gameState,
|
||||||
setGameState,
|
setGameState,
|
||||||
|
sceneTransitionPhase,
|
||||||
buildResolvedChoiceState,
|
buildResolvedChoiceState,
|
||||||
playResolvedChoice,
|
playResolvedChoice,
|
||||||
getStoryGenerationHostileNpcs,
|
getStoryGenerationHostileNpcs,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useStoryGoalOptionCoordinator } from './useStoryGoalOptionCoordinator';
|
|||||||
type RpgRuntimeStoryFlowParams = {
|
type RpgRuntimeStoryFlowParams = {
|
||||||
gameState: GameState;
|
gameState: GameState;
|
||||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||||
|
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
|
||||||
buildResolvedChoiceState: (
|
buildResolvedChoiceState: (
|
||||||
state: GameState,
|
state: GameState,
|
||||||
option: StoryOption,
|
option: StoryOption,
|
||||||
@@ -61,6 +62,7 @@ type RpgRuntimeStoryFlowParams = {
|
|||||||
export function useRpgRuntimeStoryFlow({
|
export function useRpgRuntimeStoryFlow({
|
||||||
gameState,
|
gameState,
|
||||||
setGameState,
|
setGameState,
|
||||||
|
sceneTransitionPhase = 'idle',
|
||||||
buildResolvedChoiceState,
|
buildResolvedChoiceState,
|
||||||
playResolvedChoice,
|
playResolvedChoice,
|
||||||
getStoryGenerationHostileNpcs,
|
getStoryGenerationHostileNpcs,
|
||||||
@@ -148,6 +150,7 @@ export function useRpgRuntimeStoryFlow({
|
|||||||
} = useRpgRuntimeInteractionFlow({
|
} = useRpgRuntimeInteractionFlow({
|
||||||
gameState,
|
gameState,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
sceneTransitionPhase,
|
||||||
interactionConfig,
|
interactionConfig,
|
||||||
runtimeSupport,
|
runtimeSupport,
|
||||||
buildResolvedChoiceState,
|
buildResolvedChoiceState,
|
||||||
|
|||||||
@@ -7,6 +7,46 @@ import {
|
|||||||
} from './miniGameDraftGenerationProgress';
|
} from './miniGameDraftGenerationProgress';
|
||||||
|
|
||||||
describe('miniGameDraftGenerationProgress', () => {
|
describe('miniGameDraftGenerationProgress', () => {
|
||||||
|
test('puzzle draft generation follows picture-only creation steps', () => {
|
||||||
|
const state: MiniGameDraftGenerationState = {
|
||||||
|
kind: 'puzzle',
|
||||||
|
phase: 'compile',
|
||||||
|
startedAtMs: 1000,
|
||||||
|
completedAssetCount: 0,
|
||||||
|
totalAssetCount: 0,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = buildMiniGameDraftGenerationProgress(state, 1500);
|
||||||
|
|
||||||
|
expect(progress?.steps.map((step) => step.label)).toEqual([
|
||||||
|
'编译首关草稿',
|
||||||
|
'生成首关画面',
|
||||||
|
'写入正式草稿',
|
||||||
|
]);
|
||||||
|
expect(progress?.phaseLabel).toBe('编译首关草稿');
|
||||||
|
expect(progress?.steps[0]?.detail).toBe(
|
||||||
|
'根据画面描述生成首关名称和结果页草稿。',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('puzzle ready copy points to result page work info completion', () => {
|
||||||
|
const state: MiniGameDraftGenerationState = {
|
||||||
|
kind: 'puzzle',
|
||||||
|
phase: 'ready',
|
||||||
|
startedAtMs: 1000,
|
||||||
|
completedAssetCount: 1,
|
||||||
|
totalAssetCount: 1,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = buildMiniGameDraftGenerationProgress(state, 2000);
|
||||||
|
|
||||||
|
expect(progress?.phaseDetail).toBe(
|
||||||
|
'首关草稿与正式图已准备完成,可进入结果页补作品信息。',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('big fish draft generation exposes multiple draft steps', () => {
|
test('big fish draft generation exposes multiple draft steps', () => {
|
||||||
const state: MiniGameDraftGenerationState = {
|
const state: MiniGameDraftGenerationState = {
|
||||||
kind: 'big-fish',
|
kind: 'big-fish',
|
||||||
@@ -111,24 +151,12 @@ describe('miniGameDraftGenerationProgress', () => {
|
|||||||
resultPreview: null,
|
resultPreview: null,
|
||||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||||
}, {
|
}, {
|
||||||
seedText: '表单作品名',
|
seedText: '一只猫在雨夜灯牌下回头。',
|
||||||
workTitle: '暖灯猫街',
|
|
||||||
workDescription: '一套雨夜猫街主题拼图。',
|
|
||||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||||
referenceImageSrc: null,
|
referenceImageSrc: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(entries).toEqual([
|
expect(entries).toEqual([
|
||||||
{
|
|
||||||
id: 'puzzle-title',
|
|
||||||
label: '作品名称',
|
|
||||||
value: '暖灯猫街',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'work-description',
|
|
||||||
label: '作品描述',
|
|
||||||
value: '一套雨夜猫街主题拼图。',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'picture-description',
|
id: 'picture-description',
|
||||||
label: '画面描述',
|
label: '画面描述',
|
||||||
|
|||||||
@@ -47,20 +47,20 @@ type MiniGameAnchorSource = {
|
|||||||
const PUZZLE_STEPS = [
|
const PUZZLE_STEPS = [
|
||||||
{
|
{
|
||||||
id: 'compile',
|
id: 'compile',
|
||||||
label: '编译拼图草稿',
|
label: '编译首关草稿',
|
||||||
detail: '整理主题、主体、构图与标签。',
|
detail: '根据画面描述生成首关名称和结果页草稿。',
|
||||||
weight: 34,
|
weight: 34,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'puzzle-images',
|
id: 'puzzle-images',
|
||||||
label: '生成拼图图片',
|
label: '生成首关画面',
|
||||||
detail: '根据草稿生成候选图。',
|
detail: '按画面描述和参考图生成第一张拼图图。',
|
||||||
weight: 33,
|
weight: 33,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'puzzle-select-image',
|
id: 'puzzle-select-image',
|
||||||
label: '确认正式图片',
|
label: '写入正式草稿',
|
||||||
detail: '选择候选图写入结果页。',
|
detail: '把首图设为正式图并同步到结果页。',
|
||||||
weight: 33,
|
weight: 33,
|
||||||
},
|
},
|
||||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||||
@@ -211,7 +211,7 @@ export function buildMiniGameDraftGenerationProgress(
|
|||||||
(normalizedState.phase === 'ready'
|
(normalizedState.phase === 'ready'
|
||||||
? normalizedState.kind === 'big-fish'
|
? normalizedState.kind === 'big-fish'
|
||||||
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
|
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
|
||||||
: '完整草稿与资产已准备完成。'
|
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||||
: activeStep.detail),
|
: activeStep.detail),
|
||||||
batchLabel: activeStep.label,
|
batchLabel: activeStep.label,
|
||||||
overallProgress: clampProgress(overallProgress),
|
overallProgress: clampProgress(overallProgress),
|
||||||
@@ -238,28 +238,12 @@ export function buildPuzzleGenerationAnchorEntries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entries: Array<MiniGameAnchorSource | null> = [
|
const entries: Array<MiniGameAnchorSource | null> = [
|
||||||
{
|
|
||||||
key: 'puzzle-title',
|
|
||||||
label: '作品名称',
|
|
||||||
value:
|
|
||||||
formPayload?.workTitle?.trim() ||
|
|
||||||
formPayload?.seedText?.trim() ||
|
|
||||||
session.draft?.workTitle ||
|
|
||||||
session.anchorPack.themePromise.value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'work-description',
|
|
||||||
label: '作品描述',
|
|
||||||
value:
|
|
||||||
formPayload?.workDescription?.trim() ||
|
|
||||||
session.draft?.workDescription ||
|
|
||||||
'',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'picture-description',
|
key: 'picture-description',
|
||||||
label: '画面描述',
|
label: '画面描述',
|
||||||
value:
|
value:
|
||||||
formPayload?.pictureDescription?.trim() ||
|
formPayload?.pictureDescription?.trim() ||
|
||||||
|
formPayload?.seedText?.trim() ||
|
||||||
session.draft?.levels?.[0]?.pictureDescription ||
|
session.draft?.levels?.[0]?.pictureDescription ||
|
||||||
session.anchorPack.visualSubject.value,
|
session.anchorPack.visualSubject.value,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
generateRpgWorldOpeningCg,
|
||||||
generateRpgWorldLandmark,
|
generateRpgWorldLandmark,
|
||||||
generateRpgWorldSceneImage,
|
generateRpgWorldSceneImage,
|
||||||
generateRpgWorldSceneNpc,
|
generateRpgWorldSceneNpc,
|
||||||
@@ -23,6 +24,11 @@ describe('rpgCreationAssetClient', () => {
|
|||||||
entity: { id: 'landmark-1', name: '雾港' },
|
entity: { id: 'landmark-1', name: '雾港' },
|
||||||
imageSrc: '/generated-custom-world-scenes/profile/scene/image.webp',
|
imageSrc: '/generated-custom-world-scenes/profile/scene/image.webp',
|
||||||
npc: { id: 'npc-1', name: '守灯人' },
|
npc: { id: 'npc-1', name: '守灯人' },
|
||||||
|
openingCg: {
|
||||||
|
id: 'opening-cg-1',
|
||||||
|
status: 'ready',
|
||||||
|
videoSrc: '/generated-custom-world-scenes/profile/opening.mp4',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,4 +95,24 @@ describe('rpgCreationAssetClient', () => {
|
|||||||
'生成场景 NPC 失败',
|
'生成场景 NPC 失败',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('posts opening cg generation to the runtime custom world asset route', async () => {
|
||||||
|
const openingCg = await generateRpgWorldOpeningCg({
|
||||||
|
profile: {
|
||||||
|
id: 'profile-1',
|
||||||
|
name: '雾海群岛',
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openingCg.videoSrc).toBe(
|
||||||
|
'/generated-custom-world-scenes/profile/opening.mp4',
|
||||||
|
);
|
||||||
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||||
|
'/api/runtime/custom-world/opening-cg',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
'生成开局 CG 失败',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
|
|||||||
import type {
|
import type {
|
||||||
CustomWorldLandmark,
|
CustomWorldLandmark,
|
||||||
CustomWorldNpc,
|
CustomWorldNpc,
|
||||||
|
CustomWorldOpeningCgProfile,
|
||||||
CustomWorldPlayableNpc,
|
CustomWorldPlayableNpc,
|
||||||
CustomWorldProfile,
|
CustomWorldProfile,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
@@ -132,6 +133,20 @@ export async function generateRpgWorldLandmark(payload: {
|
|||||||
return response.entity;
|
return response.entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateRpgWorldOpeningCg(payload: {
|
||||||
|
profile: CustomWorldProfile;
|
||||||
|
}) {
|
||||||
|
const response = await requestRpgCreationPostJson<{
|
||||||
|
openingCg: CustomWorldOpeningCgProfile;
|
||||||
|
}>(
|
||||||
|
`${RPG_CREATION_ASSET_API_BASE}/opening-cg`,
|
||||||
|
payload,
|
||||||
|
'生成开局 CG 失败',
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.openingCg;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作包 D 把结果页与编辑器依赖的资产请求迁入 RPG 创作域 client,
|
* 工作包 D 把结果页与编辑器依赖的资产请求迁入 RPG 创作域 client,
|
||||||
* 保留封面资产服务的既有边界,不把逻辑重新塞回 `aiService.ts`。
|
* 保留封面资产服务的既有边界,不把逻辑重新塞回 `aiService.ts`。
|
||||||
@@ -143,6 +158,7 @@ export const rpgCreationAssetClient = {
|
|||||||
generatePlayableNpc: generateRpgWorldPlayableNpc,
|
generatePlayableNpc: generateRpgWorldPlayableNpc,
|
||||||
generateStoryNpc: generateRpgWorldStoryNpc,
|
generateStoryNpc: generateRpgWorldStoryNpc,
|
||||||
generateLandmark: generateRpgWorldLandmark,
|
generateLandmark: generateRpgWorldLandmark,
|
||||||
|
generateOpeningCg: generateRpgWorldOpeningCg,
|
||||||
generateCoverImage: generateCustomWorldCoverImage,
|
generateCoverImage: generateCustomWorldCoverImage,
|
||||||
uploadCoverImage: uploadCustomWorldCoverImage,
|
uploadCoverImage: uploadCustomWorldCoverImage,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ function createRuntimeProjection(
|
|||||||
overrides: RuntimeProjectionOverrides = {},
|
overrides: RuntimeProjectionOverrides = {},
|
||||||
): StoryRuntimeProjectionResponse {
|
): StoryRuntimeProjectionResponse {
|
||||||
const storySession = createStorySession(overrides.storySession);
|
const storySession = createStorySession(overrides.storySession);
|
||||||
const serverVersion = overrides.serverVersion ?? storySession.version;
|
const serverVersion =
|
||||||
|
overrides.serverVersion ?? storySession.version ?? 1;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storySession,
|
storySession,
|
||||||
|
|||||||
@@ -46,6 +46,37 @@ export interface CustomWorldCoverCropRect {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CustomWorldOpeningCgStatus =
|
||||||
|
| 'not_started'
|
||||||
|
| 'storyboard_generating'
|
||||||
|
| 'video_generating'
|
||||||
|
| 'ready'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
export interface CustomWorldOpeningCgProfile {
|
||||||
|
id: string;
|
||||||
|
status: CustomWorldOpeningCgStatus;
|
||||||
|
storyboardImageSrc?: string | null;
|
||||||
|
storyboardAssetId?: string | null;
|
||||||
|
videoSrc?: string | null;
|
||||||
|
videoAssetId?: string | null;
|
||||||
|
posterImageSrc?: string | null;
|
||||||
|
posterAssetId?: string | null;
|
||||||
|
storyboardPrompt?: string | null;
|
||||||
|
videoPrompt?: string | null;
|
||||||
|
imageModel: 'gpt-image-2';
|
||||||
|
videoModel: string;
|
||||||
|
aspectRatio: '16:9';
|
||||||
|
imageSize: '2k';
|
||||||
|
videoResolution: '480p';
|
||||||
|
durationSeconds: 15;
|
||||||
|
pointCost: 80;
|
||||||
|
estimatedWaitMinutes: 10;
|
||||||
|
generatedAt?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreatorFactionSeed {
|
export interface CreatorFactionSeed {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -411,6 +442,7 @@ export interface CustomWorldProfile {
|
|||||||
*/
|
*/
|
||||||
playerPremise?: string | null;
|
playerPremise?: string | null;
|
||||||
cover?: CustomWorldCoverProfile | null;
|
cover?: CustomWorldCoverProfile | null;
|
||||||
|
openingCg?: CustomWorldOpeningCgProfile | null;
|
||||||
templateWorldType: WorldTemplateType;
|
templateWorldType: WorldTemplateType;
|
||||||
compatibilityTemplateWorldType?: WorldTemplateType | null;
|
compatibilityTemplateWorldType?: WorldTemplateType | null;
|
||||||
majorFactions: string[];
|
majorFactions: string[];
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export interface StoryMoment {
|
|||||||
currentScenePreset?: ScenePresetInfo | null;
|
currentScenePreset?: ScenePresetInfo | null;
|
||||||
storyEngineMemory?: StoryEngineMemoryState;
|
storyEngineMemory?: StoryEngineMemoryState;
|
||||||
};
|
};
|
||||||
|
// 中文注释:用于“继续冒险”过场完成后自动执行下一幕入口,避免角色尚未走到位就开聊。
|
||||||
|
deferredAutoChoice?: StoryOption;
|
||||||
historyRole?: StoryHistoryRole;
|
historyRole?: StoryHistoryRole;
|
||||||
npcChatState?: StoryNpcChatState;
|
npcChatState?: StoryNpcChatState;
|
||||||
npcAffinityEffect?: StoryNpcAffinityEffect | null;
|
npcAffinityEffect?: StoryNpcAffinityEffect | null;
|
||||||
|
|||||||