Compare commits
4 Commits
995661e7cc
...
06b8b46530
| Author | SHA1 | Date | |
|---|---|---|---|
| 06b8b46530 | |||
| 07e777fef8 | |||
|
|
e847fcea6f | ||
|
|
46d240e37d |
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. 用户返回成本更低
|
||||
3. 操作像手游副面板,更符合预期
|
||||
|
||||
### 4.3.1 弹出确认面板不能透明
|
||||
- 删除作品、发布后分享、确认离开等关键弹窗必须有实体面板底色,不能只靠透明背景、毛玻璃或遮罩承载内容。
|
||||
- 通过 portal 挂到 `body` 的平台弹窗必须在遮罩层补齐平台主题类,否则主题变量会脱离页面容器,轻则颜色漂移,重则面板背景看起来透明。
|
||||
- 移动端关键确认弹窗优先居中显示,并保留 `max-height + 内部滚动`,避免被底部导航、安全区或底部抽屉布局遮住。
|
||||
|
||||
### 4.4 图标优于文字按钮
|
||||
- 在底部工具区,队伍/背包改成 icon 后更紧凑。
|
||||
- 但必须保留 `aria-label`,保证语义清晰、后续也方便测试。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 抓大鹅创作入口开放与错误隔离 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. 背景
|
||||
|
||||
抓大鹅 Match3D 玩法域已完成当前 demo 主链接入,本轮恢复创作页入口,使玩家可以从创作中心直接进入抓大鹅共创工作台。同时,平台首页会并行读取 RPG、拼图、抓大鹅等公开广场数据,公开广场接口未就绪、空表或临时失败不应污染创作入口错误态,也不应表现成登录异常。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 抓大鹅 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. 阶段边界
|
||||
|
||||
本文件承接《MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md》的 F1 包。
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
|
||||
| 玩法 | 展示 | 开放 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 角色扮演 | 是 | 是 | 点击后进入 RPG Agent 共创工作台 |
|
||||
| 角色扮演 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 |
|
||||
| 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 |
|
||||
| 拼图 | 是 | 是 | 点击后进入拼图 Agent 共创工作台 |
|
||||
| 抓大鹅 | 是 | 是 | 点击后进入抓大鹅 Agent 共创工作台 |
|
||||
| 抓大鹅 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 |
|
||||
| 方洞挑战 | 是 | 是 | 点击后进入方洞挑战 Agent 共创工作台,支持草稿、结果页、发布、试玩、作品架与广场 |
|
||||
| AIRP | 是 | 否 | 保留入口,显示敬请期待 |
|
||||
| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待 |
|
||||
|
||||
@@ -20,7 +20,7 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进
|
||||
|
||||
- 前端只负责展示生成进度与触发已有后端动作,不新增 server-node 或 PostgreSQL 链路。
|
||||
- 后端继续沿用 `server-rs` + `SpacetimeDB` 的会话、草稿与资产写入能力。
|
||||
- 拼图生成草稿链路仍包含:结果页草稿、候选图生成、正式图确认。
|
||||
- 拼图生成草稿链路仍包含:首关草稿编译、首关画面生成、正式草稿写入。
|
||||
- 大鱼吃小鱼生成草稿链路只包含:玩法草稿、等级蓝图、背景蓝图与运行参数编译。
|
||||
- 大鱼吃小鱼的主图、动作、背景都在结果页工坊单独触发,不再属于草稿编译阶段。
|
||||
- 生成过程中展示的“角色描述、角色图片、动作”等,统一映射为锚点、草稿蓝图与资产步骤,不把规则说明类文本写成默认 UI 文案。
|
||||
@@ -38,9 +38,9 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进
|
||||
|
||||
### 拼图
|
||||
|
||||
- `compile_puzzle_draft`:在 `server-rs` 内整理主题、主体、构图与标签,写入结果页草稿。
|
||||
- `compile_puzzle_draft`:同一次后端 action 内根据草稿摘要生成候选图。
|
||||
- `compile_puzzle_draft`:同一次后端 action 内自动选择第一张候选图作为正式图。
|
||||
- `compile_puzzle_draft`:在 `server-rs` 内根据入口画面描述生成首关名称和结果页草稿。
|
||||
- `compile_puzzle_draft`:同一次后端 action 内根据画面描述、参考图和当前图片模型生成首关画面。
|
||||
- `compile_puzzle_draft`:同一次后端 action 内自动把首图设为正式图,并同步到结果页草稿。
|
||||
- `ready`:进入拼图结果页。
|
||||
|
||||
### 大鱼吃小鱼
|
||||
|
||||
@@ -4,8 +4,18 @@
|
||||
|
||||
拼图创作入口不再使用 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 初始表单草稿保存补充
|
||||
|
||||
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` 通过。
|
||||
@@ -82,6 +82,7 @@
|
||||
- [BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md](./BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md):记录大鱼吃小鱼等级主图与动作关键帧正式图在 Rust 后端复用 RPG 角色主图透明背景 alpha 后处理的对齐口径,并明确场地背景不走该处理。
|
||||
- [PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md](./PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md):记录拼图生成图片回到 1:1,运行时拖动、交换、合并与拆分由前端即时裁决,以及移动端棋盘贴近屏幕边缘的落地边界。
|
||||
- [PUZZLE_FORM_CREATION_FLOW_2026-04-29.md](./PUZZLE_FORM_CREATION_FLOW_2026-04-29.md):冻结拼图填表式创作入口、初始表单自动保存草稿、生成前退出后的表单恢复,以及草稿编译/首图生成的前后端边界。
|
||||
- [PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md](./PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md):记录拼图入口只填写画面描述、首关名默认作品名、作品描述和标签初始为空、AI 生成 6 个作品标签以及发布前校验的落地规则。
|
||||
- [PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md](./PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md):记录拼图第二关排行榜提交以前端当前关卡为准、不被 SpacetimeDB 旧 run 快照误杀,以及 RPG 创作入口改为敬请期待的落地边界。
|
||||
- [PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md](./PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md):记录拼图通关后优先同作品下一关、无下一关时按 RPG/build 标签语义相似度返回三个候选作品,并在跨作品时只切换到候选作品第 1 张图、运行时关卡序号继续累进的落地规则。
|
||||
- [PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md](./PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md):记录拼图失败后重新开始/付费续时,以及进入作品与过关后同步存档页投影的落地规则。
|
||||
|
||||
@@ -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。
|
||||
- 响应同样返回 `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:
|
||||
|
||||
@@ -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 与主“分享”按钮保持一致,复制同一份分享文本。
|
||||
|
||||
仓库现有 `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` 背景,并在移动端覆盖平台弹窗默认底部抽屉布局,保持居中显示。
|
||||
|
||||
同类平台弹窗,包括删除作品等确认面板,也必须遵守同一条约束:portal 挂载时遮罩层必须带 `platform-theme platform-theme--light/dark`,面板必须保留 `platform-modal-shell` 的实体背景,不能把主面板做成透明或只依赖 backdrop blur。移动端高风险确认弹窗必须显式居中显示,避免被底部导航、安全区或底部抽屉布局遮住。
|
||||
|
||||
## 接入范围
|
||||
|
||||
- `RpgCreationResultActionBar`:RPG 发布成功后由父层回传分享数据并打开面板。
|
||||
|
||||
@@ -4,6 +4,7 @@ export type PuzzleAgentSuggestedActionType =
|
||||
| 'request_summary'
|
||||
| 'compile_puzzle_draft'
|
||||
| 'generate_puzzle_images'
|
||||
| 'generate_puzzle_tags'
|
||||
| 'publish_puzzle_work';
|
||||
|
||||
export interface PuzzleAgentSuggestedAction {
|
||||
@@ -16,6 +17,7 @@ export type PuzzleAgentActionType =
|
||||
| 'save_puzzle_form_draft'
|
||||
| 'compile_puzzle_draft'
|
||||
| 'generate_puzzle_images'
|
||||
| 'generate_puzzle_tags'
|
||||
| 'select_puzzle_image'
|
||||
| 'publish_puzzle_work';
|
||||
|
||||
@@ -71,6 +73,15 @@ export type PuzzleAgentActionRequest =
|
||||
themeTags?: string[];
|
||||
levelsJson?: string;
|
||||
}
|
||||
| {
|
||||
action: 'generate_puzzle_tags';
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
levelName?: string;
|
||||
summary?: string;
|
||||
themeTags?: string[];
|
||||
levelsJson?: string;
|
||||
}
|
||||
| {
|
||||
action: 'select_puzzle_image';
|
||||
levelId?: string | null;
|
||||
|
||||
@@ -387,6 +387,38 @@ export type CustomWorldThemeMode =
|
||||
|
||||
export type CustomWorldProfileRecord = JsonObject & {
|
||||
id?: string;
|
||||
openingCg?: CustomWorldOpeningCgProfile | null;
|
||||
};
|
||||
|
||||
export type CustomWorldOpeningCgStatus =
|
||||
| 'not_started'
|
||||
| 'storyboard_generating'
|
||||
| 'video_generating'
|
||||
| 'ready'
|
||||
| 'failed';
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export type CustomWorldLibraryEntry<TProfile = CustomWorldProfileRecord> = {
|
||||
|
||||
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 |
@@ -61,8 +61,8 @@ use crate::{
|
||||
},
|
||||
custom_world_ai::{
|
||||
generate_custom_world_cover_image, generate_custom_world_entity,
|
||||
generate_custom_world_scene_image, generate_custom_world_scene_npc,
|
||||
upload_custom_world_cover_image,
|
||||
generate_custom_world_opening_cg, generate_custom_world_scene_image,
|
||||
generate_custom_world_scene_npc, upload_custom_world_cover_image,
|
||||
},
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
@@ -1136,6 +1136,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/opening-cg",
|
||||
post(generate_custom_world_opening_cg).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/browse-history",
|
||||
get(get_runtime_browse_history)
|
||||
|
||||
@@ -28,7 +28,9 @@ use webp::Encoder as WebpEncoder;
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
custom_world_result_prompts::{
|
||||
build_result_entity_system_prompt, build_result_entity_user_prompt,
|
||||
@@ -115,6 +117,12 @@ pub(crate) struct CustomWorldCoverUploadRequest {
|
||||
crop_rect: CustomWorldCoverCropRect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CustomWorldOpeningCgGenerateRequest {
|
||||
profile: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeneratedAssetResponse {
|
||||
@@ -133,6 +141,38 @@ struct GeneratedAssetResponse {
|
||||
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)]
|
||||
pub(crate) struct GeneratedCustomWorldSceneImage {
|
||||
pub image_src: String,
|
||||
@@ -317,6 +357,22 @@ struct DownloadedRemoteImage {
|
||||
}
|
||||
|
||||
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 {
|
||||
opening_act_title: String,
|
||||
@@ -336,6 +392,39 @@ struct NormalizedSceneImageRequest {
|
||||
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)]
|
||||
struct NormalizedCropRect {
|
||||
left: u32,
|
||||
@@ -884,6 +973,119 @@ pub async fn upload_custom_world_cover_image(
|
||||
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(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -974,6 +1176,337 @@ async fn persist_custom_world_asset(
|
||||
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(
|
||||
asset_kind: &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> {
|
||||
// Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。
|
||||
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 {
|
||||
if raw_text.trim().is_empty() {
|
||||
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 {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"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>) {
|
||||
match value {
|
||||
Value::Array(entries) => {
|
||||
@@ -2236,6 +2967,61 @@ fn extract_task_id(payload: &Value) -> Option<String> {
|
||||
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> {
|
||||
let mut urls = Vec::new();
|
||||
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 {
|
||||
match mime_type {
|
||||
"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 {
|
||||
if value.is_empty() {
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
@@ -58,15 +58,12 @@ mod tests {
|
||||
#[test]
|
||||
fn form_seed_prompt_keeps_only_user_visible_fields() {
|
||||
let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title: Some(" 暖灯猫街 "),
|
||||
work_description: Some("雨夜礼物拼图"),
|
||||
title: None,
|
||||
work_description: None,
|
||||
picture_description: Some("猫咪在灯牌下回头"),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"作品名称:暖灯猫街\n作品描述:雨夜礼物拼图\n画面描述:猫咪在灯牌下回头"
|
||||
);
|
||||
assert_eq!(prompt, "画面描述:猫咪在灯牌下回头");
|
||||
}
|
||||
|
||||
#[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 draft;
|
||||
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,
|
||||
};
|
||||
use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
|
||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
@@ -76,6 +77,7 @@ use crate::{
|
||||
},
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
llm_model_routing::CREATION_TEMPLATE_LLM_MODEL,
|
||||
platform_errors::map_oss_error,
|
||||
prompt::puzzle::{
|
||||
draft::{
|
||||
@@ -83,6 +85,10 @@ use crate::{
|
||||
resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_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::{
|
||||
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||
@@ -527,15 +533,15 @@ pub async fn execute_puzzle_agent_action(
|
||||
});
|
||||
(
|
||||
"compile_puzzle_draft",
|
||||
"完整拼图草稿",
|
||||
"已编译草稿、生成拼图图片并应用为正式图。",
|
||||
"首关拼图草稿",
|
||||
"已编译首关草稿、生成首关画面并写入正式草稿。",
|
||||
session,
|
||||
)
|
||||
}
|
||||
"save_puzzle_form_draft" => {
|
||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||
payload.work_title.as_deref(),
|
||||
payload.work_description.as_deref(),
|
||||
None,
|
||||
None,
|
||||
payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
@@ -705,6 +711,66 @@ pub async fn execute_puzzle_agent_action(
|
||||
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" => {
|
||||
let candidate_id = payload
|
||||
.candidate_id
|
||||
@@ -2058,12 +2124,12 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String {
|
||||
|
||||
fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
|
||||
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title: payload
|
||||
.work_title
|
||||
title: None,
|
||||
work_description: None,
|
||||
picture_description: payload
|
||||
.picture_description
|
||||
.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,
|
||||
) -> Result<String, Response> {
|
||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||
payload.work_title.as_deref(),
|
||||
payload.work_description.as_deref(),
|
||||
None,
|
||||
None,
|
||||
payload
|
||||
.picture_description
|
||||
.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(
|
||||
state: &AppState,
|
||||
session_id: String,
|
||||
@@ -2506,7 +2742,14 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
"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(
|
||||
prompt_text,
|
||||
&target_level.picture_description,
|
||||
@@ -2554,7 +2797,7 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json: None,
|
||||
levels_json: levels_json_with_generated_name,
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
})
|
||||
@@ -2572,7 +2815,13 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
"拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
|
||||
);
|
||||
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(),
|
||||
candidates.clone(),
|
||||
now,
|
||||
@@ -2655,6 +2904,39 @@ fn apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
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) {
|
||||
let Some(primary_level) = draft.levels.first() else {
|
||||
return;
|
||||
@@ -2677,6 +2959,305 @@ fn replace_puzzle_session_draft_snapshot(
|
||||
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 {
|
||||
matches!(
|
||||
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]
|
||||
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
|
||||
let invalid_operation =
|
||||
|
||||
@@ -115,7 +115,7 @@ pub async fn begin_story_runtime_session(
|
||||
story_session_payload_from_record(story_result.session),
|
||||
vec![story_event_payload_from_record(story_result.event)],
|
||||
&persisted,
|
||||
persisted.version,
|
||||
None,
|
||||
),
|
||||
},
|
||||
))
|
||||
@@ -257,7 +257,7 @@ pub async fn resolve_story_runtime_action(
|
||||
story_session_payload_from_record(story_result.session),
|
||||
vec![story_event_payload_from_record(story_result.event)],
|
||||
&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_events: Vec<StoryEventPayload>,
|
||||
record: &RuntimeSnapshotRecord,
|
||||
server_version: u32,
|
||||
resolved_version: Option<u32>,
|
||||
) -> shared_contracts::story::StoryRuntimeProjectionResponse {
|
||||
let snapshot = story_runtime_snapshot_payload_from_record(record);
|
||||
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()));
|
||||
let action_result_text = read_story_runtime_current_field(current_story, "resultText");
|
||||
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::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> {
|
||||
read_story_runtime_current_field(current_story, "text")
|
||||
.or_else(|| read_story_runtime_current_field(current_story, "storyText"))
|
||||
@@ -619,10 +630,12 @@ mod tests {
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::require_story_session_owner;
|
||||
use super::{build_story_runtime_projection_from_persisted, require_story_session_owner};
|
||||
use crate::{
|
||||
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
|
||||
};
|
||||
use module_runtime::RuntimeSnapshotRecord;
|
||||
use shared_contracts::story::StorySessionPayload;
|
||||
|
||||
#[tokio::test]
|
||||
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]
|
||||
fn story_session_owner_guard_rejects_mismatched_actor() {
|
||||
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.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string();
|
||||
pack.composition_hooks.status = PuzzleAnchorStatus::Inferred;
|
||||
pack.tags_and_forbidden.value = build_form_tags_and_forbidden(
|
||||
normalized_title.as_deref().unwrap_or(""),
|
||||
normalized_description.as_deref().unwrap_or(""),
|
||||
);
|
||||
pack.tags_and_forbidden.value = build_form_tags_and_forbidden(title, picture_description);
|
||||
pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred;
|
||||
|
||||
pack
|
||||
@@ -178,12 +175,12 @@ pub fn compile_result_draft_from_seed(
|
||||
seed_text: Option<&str>,
|
||||
) -> PuzzleResultDraft {
|
||||
let creator_intent = build_creator_intent(anchor_pack, messages);
|
||||
let normalized_tags = normalize_theme_tags(creator_intent.theme_tags.clone());
|
||||
let work_title = build_work_title(anchor_pack);
|
||||
let normalized_tags = resolve_initial_theme_tags(seed_text, &creator_intent);
|
||||
let work_description = resolve_work_description(seed_text, anchor_pack);
|
||||
let picture_description = fallback_text(&anchor_pack.visual_subject.value, "画面主体");
|
||||
let level_name =
|
||||
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 {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
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 picture_description =
|
||||
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 level = PuzzleDraftLevel {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
@@ -266,7 +253,7 @@ pub fn build_form_draft_from_parts(
|
||||
work_description: summary.clone(),
|
||||
level_name: String::new(),
|
||||
summary,
|
||||
theme_tags: tags,
|
||||
theme_tags: Vec::new(),
|
||||
forbidden_directives: Vec::new(),
|
||||
creator_intent: None,
|
||||
anchor_pack: anchor_pack.clone(),
|
||||
@@ -349,12 +336,6 @@ pub fn apply_selected_candidate(
|
||||
}
|
||||
|
||||
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() {
|
||||
draft.levels = vec![PuzzleDraftLevel {
|
||||
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.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();
|
||||
if draft.form_draft.is_some() {
|
||||
draft.form_draft = Some(PuzzleFormDraft {
|
||||
@@ -642,23 +620,19 @@ pub fn apply_publish_overrides_to_draft(
|
||||
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
|
||||
let mut next_draft = normalize_puzzle_draft(draft.clone());
|
||||
|
||||
if let Some(next_work_title) = work_title
|
||||
&& let Some(normalized_work_title) = normalize_required_string(&next_work_title)
|
||||
{
|
||||
next_draft.work_title = normalized_work_title;
|
||||
if let Some(next_work_title) = work_title {
|
||||
next_draft.work_title = normalize_required_string(&next_work_title).unwrap_or_default();
|
||||
}
|
||||
|
||||
if let Some(next_work_description) = work_description
|
||||
&& let Some(normalized_work_description) = normalize_required_string(&next_work_description)
|
||||
{
|
||||
next_draft.work_description = normalized_work_description;
|
||||
if let Some(next_work_description) = work_description {
|
||||
next_draft.work_description =
|
||||
normalize_required_string(&next_work_description).unwrap_or_default();
|
||||
}
|
||||
|
||||
if let Some(next_level_name) = level_name
|
||||
&& let Some(normalized_level_name) = normalize_required_string(&next_level_name)
|
||||
{
|
||||
if let Some(next_level_name) = level_name {
|
||||
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(
|
||||
levels: Vec<PuzzleDraftLevel>,
|
||||
theme_tags: &[String],
|
||||
_theme_tags: &[String],
|
||||
) -> Result<Vec<PuzzleDraftLevel>, PuzzleFieldError> {
|
||||
let mut normalized_levels = Vec::new();
|
||||
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));
|
||||
let picture_description = normalize_required_string(&level.picture_description)
|
||||
.unwrap_or_else(|| format!("第{}关画面", index + 1));
|
||||
let level_name = normalize_required_string(&level.level_name).unwrap_or_else(|| {
|
||||
build_level_name_from_picture(picture_description.as_str(), theme_tags, index + 1)
|
||||
});
|
||||
let level_name = normalize_required_string(&level.level_name).unwrap_or_default();
|
||||
level.level_id = level_id;
|
||||
level.level_name = level_name;
|
||||
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 {
|
||||
seed_text
|
||||
.and_then(parse_form_seed_text)
|
||||
.and_then(|parts| {
|
||||
parts
|
||||
.work_description
|
||||
.or(parts.picture_description)
|
||||
.or(parts.work_title)
|
||||
})
|
||||
.unwrap_or_else(|| build_result_summary(anchor_pack))
|
||||
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 String::new();
|
||||
}
|
||||
return parts
|
||||
.work_description
|
||||
.unwrap_or_else(|| build_result_summary(anchor_pack));
|
||||
}
|
||||
build_result_summary(anchor_pack)
|
||||
}
|
||||
|
||||
fn build_work_title(anchor_pack: &PuzzleAnchorPack) -> String {
|
||||
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 {
|
||||
if let Some((_, tail)) = source.split_once(';') {
|
||||
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() {
|
||||
return format!("{tag}第{level_index}关");
|
||||
return format!("{tag}画面");
|
||||
}
|
||||
format!("第{level_index}关")
|
||||
}
|
||||
@@ -2912,6 +2930,23 @@ mod tests {
|
||||
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]
|
||||
fn form_seed_keeps_multiline_picture_description() {
|
||||
let anchor_pack = infer_anchor_pack(
|
||||
@@ -3452,4 +3487,34 @@ mod tests {
|
||||
|
||||
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`。
|
||||
5. SDK 调用错误、reducer 业务错误、procedure 业务错误、缺快照错误和本地输入校验错误已统一收口到 `SpacetimeClientError` helper。
|
||||
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。
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use module_inventory::{RuntimeInventorySlotRecord, RuntimeInventoryStateRecord};
|
||||
use module_runtime_story::StoryRuntimeProjectionSource;
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::{
|
||||
runtime_story::RuntimeStoryOptionView,
|
||||
story::{StoryEventPayload, StorySessionPayload},
|
||||
};
|
||||
use shared_contracts::story::{StoryEventPayload, StorySessionPayload};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
@@ -43,7 +40,10 @@ impl SpacetimeClient {
|
||||
)?;
|
||||
let current_story = runtime_snapshot.current_story.as_ref();
|
||||
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 {
|
||||
story_session: build_story_session_payload(story_state.session),
|
||||
@@ -53,7 +53,7 @@ impl SpacetimeClient {
|
||||
.map(build_story_event_payload)
|
||||
.collect(),
|
||||
game_state,
|
||||
options: read_runtime_story_options(current_story)?,
|
||||
options,
|
||||
server_version,
|
||||
current_narrative_text: read_current_story_text(current_story)
|
||||
.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> {
|
||||
read_current_story_string(current_story, "text")
|
||||
.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)
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
@@ -434,16 +434,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_story_options_parse_runtime_story_options() {
|
||||
let options = read_runtime_story_options(Some(&json!({
|
||||
fn runtime_projection_source_uses_runtime_action_version() {
|
||||
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": "守火人抬眼看着你。",
|
||||
"options": [{
|
||||
"functionId": "npc_chat",
|
||||
"actionText": "继续交谈",
|
||||
"scope": "npc"
|
||||
"actionText": "继续交谈"
|
||||
}]
|
||||
})))
|
||||
.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].action_text, "继续交谈");
|
||||
|
||||
@@ -946,10 +946,18 @@ fn save_puzzle_generated_images_tx(
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
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())? {
|
||||
// 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。
|
||||
draft.levels = levels;
|
||||
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)
|
||||
.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(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleSelectCoverImageInput,
|
||||
@@ -1189,7 +1209,7 @@ fn update_puzzle_work_tx(
|
||||
return Err("无权修改该拼图作品".to_string());
|
||||
}
|
||||
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());
|
||||
}
|
||||
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,
|
||||
};
|
||||
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(
|
||||
ctx,
|
||||
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(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleWorkDeleteInput,
|
||||
@@ -3298,6 +3366,53 @@ mod tests {
|
||||
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]
|
||||
fn puzzle_recommendation_score_prefers_same_author_weight() {
|
||||
let left = PuzzleWorkProfile {
|
||||
|
||||
@@ -18,12 +18,14 @@ import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type CustomWorldOpeningCgProfile,
|
||||
type SceneActBlueprint,
|
||||
type SceneChapterBlueprint,
|
||||
} from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
import { ResolvedAssetVideo } from './ResolvedAssetVideo';
|
||||
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
|
||||
|
||||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||||
@@ -50,6 +52,10 @@ interface CustomWorldEntityCatalogProps {
|
||||
createActionLabel?: string;
|
||||
onCreateAction?: () => void;
|
||||
createActionDisabled?: boolean;
|
||||
openingCgGenerating?: boolean;
|
||||
openingCgPhaseLabel?: string | null;
|
||||
openingCgGenerateDisabled?: boolean;
|
||||
onGenerateOpeningCg?: () => void;
|
||||
pendingGeneratedEntity?: PendingGeneratedEntity | null;
|
||||
recentGeneratedIds?: RecentGeneratedIds;
|
||||
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(
|
||||
act: SceneActBlueprint,
|
||||
roleById: Map<
|
||||
@@ -557,6 +642,10 @@ export function CustomWorldEntityCatalog({
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
createActionDisabled = false,
|
||||
openingCgGenerating = false,
|
||||
openingCgPhaseLabel = null,
|
||||
openingCgGenerateDisabled = false,
|
||||
onGenerateOpeningCg,
|
||||
pendingGeneratedEntity = null,
|
||||
recentGeneratedIds = {
|
||||
playable: [],
|
||||
@@ -916,6 +1005,17 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="开局 CG">
|
||||
<OpeningCgPreview
|
||||
openingCg={profile.openingCg}
|
||||
isGenerating={openingCgGenerating}
|
||||
phaseLabel={openingCgPhaseLabel}
|
||||
generateDisabled={openingCgGenerateDisabled}
|
||||
readOnly={readOnly}
|
||||
onGenerate={onGenerateOpeningCg}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="世界概述"
|
||||
actions={
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
|
||||
const generateLandmark = vi.fn();
|
||||
const generateSceneImage = vi.fn();
|
||||
const generateSceneNpc = vi.fn();
|
||||
const generateOpeningCg = vi.fn();
|
||||
|
||||
return {
|
||||
rpgCreationAssetClient: {
|
||||
@@ -23,6 +24,7 @@ vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
|
||||
generateLandmark,
|
||||
generateSceneImage,
|
||||
generateSceneNpc,
|
||||
generateOpeningCg,
|
||||
},
|
||||
generateCustomWorldPlayableNpc: generatePlayableNpc,
|
||||
generateCustomWorldStoryNpc: generateStoryNpc,
|
||||
@@ -343,6 +345,46 @@ test('world basic setting renders eight anchor fields and hides legacy parsed/so
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
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: '分享到QQ' })).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: '分享' }));
|
||||
|
||||
|
||||
@@ -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 { copyTextToClipboard } from '../../services/clipboard';
|
||||
@@ -15,26 +15,74 @@ type PublishShareModalProps = {
|
||||
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 = [
|
||||
{
|
||||
id: 'wechat',
|
||||
label: '微信',
|
||||
icon: MessageCircle,
|
||||
className: 'bg-emerald-500 text-white',
|
||||
iconClassName: 'bg-[#07c160] text-white',
|
||||
},
|
||||
{
|
||||
id: 'qq',
|
||||
label: 'QQ',
|
||||
icon: MessageCircle,
|
||||
className: 'bg-sky-500 text-white',
|
||||
iconClassName: 'bg-[#12b7f5] text-white',
|
||||
},
|
||||
{
|
||||
id: 'douyin',
|
||||
label: '抖音',
|
||||
icon: Music2,
|
||||
className: 'bg-slate-950 text-white',
|
||||
iconClassName: 'bg-black 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={
|
||||
<div className="grid w-full grid-cols-3 gap-3">
|
||||
{SHARE_CHANNELS.map((channel) => {
|
||||
const Icon = channel.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
@@ -110,9 +156,9 @@ export function PublishShareModal({
|
||||
title={channel.label}
|
||||
>
|
||||
<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>{channel.label}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @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 { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -104,25 +104,15 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
|
||||
expect(screen.queryByText('角色 3')).toBeNull();
|
||||
expect(screen.queryByText('地点 4')).toBeNull();
|
||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
||||
const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
||||
const match3dButton = screen.getByRole('button', { name: /抓大鹅/u });
|
||||
const squareHoleButton = 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((squareHoleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(
|
||||
within(squareHoleButton).getAllByText('反直觉形状分拣').length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
within(match3dButton).getAllByText('经典消除玩法').length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(puzzleButton).toBeTruthy();
|
||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.getByText('反直觉形状分拣')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /抓大鹅/u })).toBeNull();
|
||||
|
||||
rerender(
|
||||
<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).not.toContain('角色扮演');
|
||||
expect(html).not.toContain('大鱼吃小鱼');
|
||||
expect(html).not.toContain('抓大鹅');
|
||||
});
|
||||
|
||||
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
getHostileNpcSceneBottomOffsetPx,
|
||||
getMonsterWorldLeft,
|
||||
getMirroredStageEntityLeft,
|
||||
getMonsterWorldLeft,
|
||||
getNpcCombatHpTop,
|
||||
getSceneNpcVisualBottomOffsetPx,
|
||||
MONSTER_COMBAT_HP_TOP_PX,
|
||||
@@ -387,6 +387,53 @@ describe('GameCanvasEntityLayer', () => {
|
||||
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', () => {
|
||||
const sideAnchor = '15%';
|
||||
const cameraAnchorX = 0;
|
||||
|
||||
@@ -114,6 +114,22 @@ function addCssPxOffset(value: string, offsetPx: number) {
|
||||
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({
|
||||
event,
|
||||
onDone,
|
||||
@@ -451,7 +467,9 @@ export function GameCanvasEntityLayer({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{sceneCombatants.map((hostileNpc, index) => {
|
||||
{sceneTransitionPhase === 'exiting'
|
||||
? null
|
||||
: sceneCombatants.map((hostileNpc, index) => {
|
||||
const npcEncounter = hostileNpc.encounter ?? buildFallbackCombatEncounter(hostileNpc);
|
||||
const hostileRenderKey = [
|
||||
hostileNpc.id,
|
||||
@@ -465,9 +483,15 @@ export function GameCanvasEntityLayer({
|
||||
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
|
||||
: null;
|
||||
const npcSceneSpriteFacing =
|
||||
npcCharacter
|
||||
? hostileNpc.facing
|
||||
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
|
||||
isSceneTransitionEntering
|
||||
? 'right'
|
||||
: npcCharacter
|
||||
? hostileNpc.facing
|
||||
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
|
||||
const hostileNpcAnimation =
|
||||
isSceneTransitionEntering
|
||||
? ('move' as const)
|
||||
: hostileNpc.animation;
|
||||
const npcCombatHpTop = getNpcCombatHpTop(
|
||||
npcCharacter ? npcEncounter?.characterId : null,
|
||||
npcCharacter ? null : npcEncounter?.monsterPresetId,
|
||||
@@ -498,10 +522,20 @@ export function GameCanvasEntityLayer({
|
||||
)
|
||||
: stageLiftPx + (hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx;
|
||||
|
||||
const motionConfig = getSceneTransitionMotionConfig(
|
||||
isSceneTransitionEntering,
|
||||
isSceneTransitionExiting,
|
||||
transitionSweepPx,
|
||||
sceneTransitionEntryDurationS,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={hostileRenderKey}
|
||||
className="absolute"
|
||||
initial={motionConfig.initial}
|
||||
animate={motionConfig.animate}
|
||||
transition={motionConfig.transition}
|
||||
style={{
|
||||
left: getHostileNpcOuterLeft(hostileNpc),
|
||||
bottom: entityBottom,
|
||||
@@ -526,7 +560,11 @@ export function GameCanvasEntityLayer({
|
||||
<CombatReactiveSpriteFrame events={feedbackEvents} facing={npcSceneSpriteFacing}>
|
||||
{npcCharacter ? (
|
||||
<RoleCharacterSprite
|
||||
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
|
||||
state={
|
||||
isSceneTransitionEntering
|
||||
? AnimationState.RUN
|
||||
: hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)
|
||||
}
|
||||
character={npcCharacter}
|
||||
facing={npcSceneSpriteFacing}
|
||||
/>
|
||||
@@ -534,8 +572,8 @@ export function GameCanvasEntityLayer({
|
||||
<div style={{transform: `translate(${renderOffset.x}px, ${renderOffset.y}px)`}}>
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={npcMonsterConfig}
|
||||
animation={hostileNpc.animation}
|
||||
flip={hostileNpc.facing === 'right'}
|
||||
animation={hostileNpcAnimation}
|
||||
flip={npcSceneSpriteFacing === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
</div>
|
||||
@@ -561,11 +599,11 @@ export function GameCanvasEntityLayer({
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
{shouldRenderPeacefulEncounter &&
|
||||
{sceneTransitionPhase !== 'exiting' && shouldRenderPeacefulEncounter &&
|
||||
(() => {
|
||||
if (!encounter) {
|
||||
return null;
|
||||
@@ -594,11 +632,23 @@ export function GameCanvasEntityLayer({
|
||||
const peacefulBottomOffsetPx = peacefulResolvedCharacter
|
||||
? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, peacefulResolvedCharacter)
|
||||
: stageLiftPx + peacefulHostileBottomOffsetPx;
|
||||
const peacefulNpcSpriteFacing = towardPeacefulPlayer;
|
||||
const peacefulNpcSpriteFacing = isSceneTransitionEntering
|
||||
? 'right'
|
||||
: towardPeacefulPlayer;
|
||||
|
||||
const motionConfig = getSceneTransitionMotionConfig(
|
||||
isSceneTransitionEntering,
|
||||
isSceneTransitionExiting,
|
||||
transitionSweepPx,
|
||||
sceneTransitionEntryDurationS,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
className="absolute"
|
||||
initial={motionConfig.initial}
|
||||
animate={motionConfig.animate}
|
||||
transition={motionConfig.transition}
|
||||
style={{
|
||||
left: getMonsterWorldLeft(
|
||||
sideAnchor,
|
||||
@@ -639,7 +689,7 @@ export function GameCanvasEntityLayer({
|
||||
!encounter.visual &&
|
||||
!encounter.imageSrc?.trim() ? (
|
||||
<RoleCharacterSprite
|
||||
state={AnimationState.IDLE}
|
||||
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||
character={peacefulResolvedCharacter}
|
||||
facing={peacefulNpcSpriteFacing}
|
||||
/>
|
||||
@@ -647,13 +697,13 @@ export function GameCanvasEntityLayer({
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={peacefulMonsterConfig}
|
||||
animation={isPeacefulEncounterMoving ? 'move' : 'idle'}
|
||||
flip={towardPeacefulPlayer === 'right'}
|
||||
flip={peacefulNpcSpriteFacing === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
) : (
|
||||
<SceneEncounterNpcSprite
|
||||
encounter={encounter}
|
||||
state={AnimationState.IDLE}
|
||||
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||
facing={peacefulNpcSpriteFacing}
|
||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
||||
/>
|
||||
@@ -672,11 +722,12 @@ export function GameCanvasEntityLayer({
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{!inBattle &&
|
||||
sceneTransitionPhase !== 'exiting' &&
|
||||
sceneActAmbientEncounters.map((ambientEncounter, index) => {
|
||||
const ambientOffsetPx = SCENE_ACT_BACK_ROW_OFFSET_PX[index];
|
||||
if (ambientOffsetPx === undefined) {
|
||||
@@ -708,6 +759,9 @@ export function GameCanvasEntityLayer({
|
||||
SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
|
||||
playerX,
|
||||
);
|
||||
const ambientSpriteFacing = isSceneTransitionEntering
|
||||
? 'right'
|
||||
: ambientFacing;
|
||||
const ambientBottom = ambientEncounter.characterId
|
||||
? getEncounterCharacterOpponentBottom(
|
||||
groundBottom,
|
||||
@@ -717,10 +771,20 @@ export function GameCanvasEntityLayer({
|
||||
)
|
||||
: `calc(${groundBottom} + ${stageLiftPx + ambientHostileBottomOffsetPx}px)`;
|
||||
|
||||
const motionConfig = getSceneTransitionMotionConfig(
|
||||
isSceneTransitionEntering,
|
||||
isSceneTransitionExiting,
|
||||
transitionSweepPx,
|
||||
sceneTransitionEntryDurationS,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={`scene-act-ambient-${ambientEncounter.id ?? ambientEncounter.npcName}-${index}`}
|
||||
className="absolute"
|
||||
initial={motionConfig.initial}
|
||||
animate={motionConfig.animate}
|
||||
transition={motionConfig.transition}
|
||||
style={{
|
||||
left: getMonsterWorldLeft(
|
||||
sideAnchor,
|
||||
@@ -751,22 +815,22 @@ export function GameCanvasEntityLayer({
|
||||
!ambientEncounter.visual &&
|
||||
!ambientEncounter.imageSrc?.trim() ? (
|
||||
<RoleCharacterSprite
|
||||
state={AnimationState.IDLE}
|
||||
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||
character={ambientResolvedCharacter}
|
||||
facing={ambientFacing}
|
||||
facing={ambientSpriteFacing}
|
||||
/>
|
||||
) : ambientMonsterConfig ? (
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={ambientMonsterConfig}
|
||||
animation="idle"
|
||||
flip={ambientFacing === 'right'}
|
||||
flip={ambientSpriteFacing === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
) : (
|
||||
<SceneEncounterNpcSprite
|
||||
encounter={ambientEncounter}
|
||||
state={AnimationState.IDLE}
|
||||
facing={ambientFacing}
|
||||
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||
facing={ambientSpriteFacing}
|
||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.32)]"
|
||||
/>
|
||||
)}
|
||||
@@ -777,7 +841,7 @@ export function GameCanvasEntityLayer({
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -773,15 +773,12 @@ function buildPuzzleResultProfileId(sessionId: string | null | undefined) {
|
||||
function buildPuzzleCompileActionFromFormPayload(
|
||||
payload: CreatePuzzleAgentSessionRequest | null,
|
||||
): PuzzleAgentActionRequest {
|
||||
const workTitle = payload?.workTitle?.trim() || payload?.seedText?.trim();
|
||||
const workDescription = payload?.workDescription?.trim();
|
||||
const pictureDescription = payload?.pictureDescription?.trim();
|
||||
const pictureDescription =
|
||||
payload?.pictureDescription?.trim() || payload?.seedText?.trim();
|
||||
|
||||
return {
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: pictureDescription || workTitle,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
promptText: pictureDescription,
|
||||
...(pictureDescription ? { pictureDescription } : {}),
|
||||
referenceImageSrc: payload?.referenceImageSrc || null,
|
||||
imageModel: payload?.imageModel ?? null,
|
||||
@@ -793,28 +790,15 @@ function buildPuzzleFormPayloadFromSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): CreatePuzzleAgentSessionRequest {
|
||||
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 =
|
||||
formDraft?.pictureDescription?.trim() ||
|
||||
session.draft?.levels?.[0]?.pictureDescription?.trim() ||
|
||||
session.anchorPack.visualSubject.value.trim() ||
|
||||
session.seedText?.trim() ||
|
||||
'';
|
||||
|
||||
return {
|
||||
seedText: workTitle,
|
||||
workTitle,
|
||||
workDescription,
|
||||
seedText: pictureDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: null,
|
||||
imageModel: null,
|
||||
@@ -837,9 +821,9 @@ function buildPuzzleFormPayloadFromAction(
|
||||
payload.pictureDescription?.trim() || payload.promptText?.trim() || '';
|
||||
|
||||
return {
|
||||
seedText: workTitle,
|
||||
workTitle,
|
||||
workDescription,
|
||||
seedText: pictureDescription,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
pictureDescription,
|
||||
referenceImageSrc:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
@@ -1057,6 +1041,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
initialPublicWorkCode,
|
||||
}: PlatformEntryFlowShellProps) {
|
||||
const authUi = useAuthUi();
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||
@@ -1909,7 +1897,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleFormDraftPayload(formPayload);
|
||||
}
|
||||
|
||||
if (payload.action === 'publish_puzzle_work') {
|
||||
if (
|
||||
payload.action === 'publish_puzzle_work' ||
|
||||
payload.action === 'generate_puzzle_tags'
|
||||
) {
|
||||
await Promise.allSettled([
|
||||
refreshPuzzleShelf(),
|
||||
refreshPuzzleGallery(),
|
||||
@@ -2113,8 +2104,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
const response = await executePuzzleAgentAction(session.sessionId, {
|
||||
action: 'save_puzzle_form_draft',
|
||||
promptText: payload.pictureDescription ?? null,
|
||||
workTitle: payload.workTitle ?? payload.seedText ?? '',
|
||||
workDescription: payload.workDescription ?? '',
|
||||
pictureDescription: payload.pictureDescription ?? '',
|
||||
imageModel: payload.imageModel ?? null,
|
||||
});
|
||||
@@ -5829,9 +5818,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
isBusy={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage('puzzle-agent-workspace');
|
||||
}}
|
||||
onBack={leavePuzzleFlow}
|
||||
onExecuteAction={(payload) => {
|
||||
void executePuzzleAction(payload);
|
||||
}}
|
||||
@@ -6243,6 +6230,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
closeDisabled={Boolean(deletingCreationWorkId)}
|
||||
closeOnBackdrop={!deletingCreationWorkId}
|
||||
size="sm"
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
footer={
|
||||
<>
|
||||
<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', () => {
|
||||
const visibleIds = getVisiblePlatformCreationTypes().map((item) => item.id);
|
||||
|
||||
expect(isPlatformCreationTypeVisible('rpg')).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[0]).toBe('rpg');
|
||||
expect(visibleIds).not.toContain('match3d');
|
||||
expect(visibleIds[0]).toBe('puzzle');
|
||||
expect(visibleIds).toEqual([
|
||||
'rpg',
|
||||
'puzzle',
|
||||
'match3d',
|
||||
'square-hole',
|
||||
'airp',
|
||||
'visual-novel',
|
||||
|
||||
@@ -83,21 +83,18 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('作品名称'), {
|
||||
target: { value: '暖灯猫街' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('作品描述'), {
|
||||
target: { value: '一套雨夜猫街主题拼图。' },
|
||||
});
|
||||
expect(screen.queryByLabelText('作品名称')).toBeNull();
|
||||
expect(screen.queryByLabelText('作品描述')).toBeNull();
|
||||
expect(screen.getByText('创建拼图')).toBeTruthy();
|
||||
expect(screen.queryByText('try')).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '暖灯猫街',
|
||||
workTitle: '暖灯猫街',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
seedText: '一只猫在雨夜灯牌下回头。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
imageModel: 'gpt-image-2',
|
||||
@@ -107,6 +104,35 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
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', () => {
|
||||
const onExecuteAction = 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(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: '潮雾中的灯塔与断桥',
|
||||
workTitle: '雾港遗迹拼图',
|
||||
workDescription: '雾港遗迹拼图',
|
||||
pictureDescription: '潮雾中的灯塔与断桥',
|
||||
promptText: '潮雾中的灯塔与断桥',
|
||||
referenceImageSrc: null,
|
||||
imageModel: 'gpt-image-2',
|
||||
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('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
@@ -175,8 +193,7 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
const onAutoSaveForm = vi.fn();
|
||||
const formDraftSession: PuzzleAgentSessionSnapshot = {
|
||||
...baseSession,
|
||||
seedText:
|
||||
'作品名称:旧街拼图\n作品描述:旧街雨夜的拼图草稿。\n画面描述:旧街灯牌下的猫。',
|
||||
seedText: '画面描述:旧街灯牌下的猫。',
|
||||
draft: {
|
||||
workTitle: '旧街拼图',
|
||||
workDescription: '旧街雨夜的拼图草稿。',
|
||||
@@ -204,8 +221,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
},
|
||||
],
|
||||
formDraft: {
|
||||
workTitle: '旧街拼图',
|
||||
workDescription: '旧街雨夜的拼图草稿。',
|
||||
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(
|
||||
'旧街灯牌下的猫。',
|
||||
);
|
||||
@@ -240,9 +249,7 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
});
|
||||
|
||||
expect(onAutoSaveForm).toHaveBeenCalledWith({
|
||||
seedText: '旧街拼图',
|
||||
workTitle: '旧街拼图',
|
||||
workDescription: '旧街雨夜的拼图草稿。',
|
||||
seedText: '旧街灯牌下的猫和发光雨伞。',
|
||||
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
|
||||
referenceImageSrc: null,
|
||||
imageModel: 'gpt-image-2',
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { PUZZLE_CREATION_TEMPLATES } from './puzzleCreationTemplates';
|
||||
import {
|
||||
normalizePuzzleImageModel,
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -28,8 +29,6 @@ type PuzzleAgentWorkspaceProps = {
|
||||
};
|
||||
|
||||
type PuzzleFormState = {
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
pictureDescription: string;
|
||||
referenceImageSrc: string;
|
||||
referenceImageLabel: string;
|
||||
@@ -37,8 +36,6 @@ type PuzzleFormState = {
|
||||
};
|
||||
|
||||
const EMPTY_FORM_STATE: PuzzleFormState = {
|
||||
workTitle: '',
|
||||
workDescription: '',
|
||||
pictureDescription: '',
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
@@ -52,8 +49,6 @@ function resolveInitialFormState(
|
||||
const formDraft = session?.draft?.formDraft;
|
||||
if (formDraft) {
|
||||
return {
|
||||
workTitle: formDraft.workTitle ?? '',
|
||||
workDescription: formDraft.workDescription ?? '',
|
||||
pictureDescription: formDraft.pictureDescription ?? '',
|
||||
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
|
||||
referenceImageLabel: initialFormPayload?.referenceImageSrc
|
||||
@@ -65,10 +60,10 @@ function resolveInitialFormState(
|
||||
|
||||
if (initialFormPayload) {
|
||||
return {
|
||||
workTitle:
|
||||
initialFormPayload.workTitle ?? initialFormPayload.seedText ?? '',
|
||||
workDescription: initialFormPayload.workDescription ?? '',
|
||||
pictureDescription: initialFormPayload.pictureDescription ?? '',
|
||||
pictureDescription:
|
||||
initialFormPayload.pictureDescription ??
|
||||
initialFormPayload.seedText ??
|
||||
'',
|
||||
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
|
||||
referenceImageLabel: initialFormPayload.referenceImageSrc
|
||||
? '已选择参考图'
|
||||
@@ -82,19 +77,12 @@ function resolveInitialFormState(
|
||||
}
|
||||
|
||||
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:
|
||||
session.draft?.summary || session.anchorPack.visualSubject.value || '',
|
||||
session.draft?.formDraft?.pictureDescription ||
|
||||
session.draft?.levels?.[0]?.pictureDescription ||
|
||||
session.anchorPack.visualSubject.value ||
|
||||
session.seedText ||
|
||||
'',
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -121,6 +109,9 @@ export function PuzzleAgentWorkspace({
|
||||
const [referenceImageError, setReferenceImageError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState(
|
||||
PUZZLE_CREATION_TEMPLATES[0]?.id ?? '',
|
||||
);
|
||||
const previousSessionIdRef = useRef<string | null>(
|
||||
session?.sessionId ?? null,
|
||||
);
|
||||
@@ -148,18 +139,13 @@ export function PuzzleAgentWorkspace({
|
||||
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setReferenceImageError(null);
|
||||
}, [initialFormPayload, session?.sessionId]);
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const workTitle = formState.workTitle.trim();
|
||||
const workDescription = formState.workDescription.trim();
|
||||
const pictureDescription = formState.pictureDescription.trim();
|
||||
const canSubmit =
|
||||
Boolean(workTitle && workDescription && pictureDescription) && !isBusy;
|
||||
const canSubmit = Boolean(pictureDescription) && !isBusy;
|
||||
const autosavePayload = useMemo(
|
||||
() => ({
|
||||
seedText: workTitle,
|
||||
workTitle,
|
||||
workDescription,
|
||||
seedText: pictureDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
imageModel: formState.imageModel,
|
||||
@@ -168,13 +154,9 @@ export function PuzzleAgentWorkspace({
|
||||
formState.referenceImageSrc,
|
||||
formState.imageModel,
|
||||
pictureDescription,
|
||||
workDescription,
|
||||
workTitle,
|
||||
],
|
||||
);
|
||||
const autosaveSignature = JSON.stringify([
|
||||
autosavePayload.workTitle,
|
||||
autosavePayload.workDescription,
|
||||
autosavePayload.pictureDescription,
|
||||
autosavePayload.imageModel,
|
||||
]);
|
||||
@@ -189,7 +171,7 @@ export function PuzzleAgentWorkspace({
|
||||
|
||||
autosaveSessionIdRef.current = currentSessionId;
|
||||
lastAutosaveSignatureRef.current = autosaveSignature;
|
||||
}, [autosaveSignature, session?.sessionId]);
|
||||
}, [autosaveSignature, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -214,7 +196,7 @@ export function PuzzleAgentWorkspace({
|
||||
onAutoSaveForm,
|
||||
session?.draft?.formDraft,
|
||||
session?.stage,
|
||||
session?.sessionId,
|
||||
session,
|
||||
]);
|
||||
|
||||
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 = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
seedText: workTitle,
|
||||
workTitle,
|
||||
workDescription,
|
||||
seedText: pictureDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
imageModel: formState.imageModel,
|
||||
@@ -265,8 +260,6 @@ export function PuzzleAgentWorkspace({
|
||||
onExecuteAction({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: pictureDescription,
|
||||
workTitle,
|
||||
workDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
imageModel: formState.imageModel,
|
||||
@@ -275,7 +268,7 @@ export function PuzzleAgentWorkspace({
|
||||
};
|
||||
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -291,61 +284,107 @@ export function PuzzleAgentWorkspace({
|
||||
</div>
|
||||
|
||||
<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="space-y-5">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品名称
|
||||
<div className="mb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 text-5xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||
创建拼图
|
||||
</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>
|
||||
<input
|
||||
value={formState.workTitle}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={isBusy}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workTitle: event.target.value,
|
||||
}))
|
||||
}
|
||||
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="作品名称"
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品描述
|
||||
</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">
|
||||
<span className="sr-only">画面描述</span>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={formState.pictureDescription}
|
||||
disabled={isBusy}
|
||||
rows={10}
|
||||
placeholder="一只猫在雨夜灯牌下回头,霓虹反光清晰,街角有花店和小伞,适合切成拼图。"
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
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="画面描述"
|
||||
/>
|
||||
<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>
|
||||
</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',
|
||||
anchorPack,
|
||||
draft: {
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
workTitle: overrides.draft?.workTitle ?? '暖灯猫街作品',
|
||||
workDescription:
|
||||
overrides.draft?.workDescription ?? '一套雨夜猫街主题拼图。',
|
||||
levelName: level.levelName,
|
||||
summary: level.pictureDescription,
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
themeTags: overrides.draft?.themeTags ?? ['猫咪', '雨夜', '暖灯'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
@@ -119,6 +120,7 @@ function createSession(
|
||||
generationStatus: 'ready',
|
||||
levels: [level],
|
||||
metadata: null,
|
||||
...overrides.draft,
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
@@ -199,7 +201,7 @@ describe('PuzzleResultView', () => {
|
||||
workTitle: '暖灯猫街合集',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levels: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -250,7 +252,7 @@ describe('PuzzleResultView', () => {
|
||||
candidateCount: 1,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一只猫在雨夜灯牌下回头。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
@@ -280,7 +282,7 @@ describe('PuzzleResultView', () => {
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levelName: '暖灯猫街',
|
||||
summary: '一只猫在雨夜灯牌下回头。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
@@ -386,7 +388,7 @@ describe('PuzzleResultView', () => {
|
||||
candidateCount: 1,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '新关卡里有一座发光钟楼。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
@@ -427,7 +429,7 @@ describe('PuzzleResultView', () => {
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
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 () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
|
||||
@@ -496,7 +549,7 @@ describe('PuzzleResultView', () => {
|
||||
candidateCount: 1,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ function syncDraftFromEditState(
|
||||
const primaryLevel = levels[0] ?? buildFallbackLevelFromDraft(draft);
|
||||
return {
|
||||
...draft,
|
||||
workTitle: editState.workTitle.trim() || draft.workTitle,
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
levelName: primaryLevel.levelName,
|
||||
summary: editState.workDescription.trim(),
|
||||
@@ -145,8 +145,8 @@ function syncDraftFromEditState(
|
||||
|
||||
function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
|
||||
return {
|
||||
workTitle: draft.workTitle || draft.levelName,
|
||||
workDescription: draft.workDescription || '',
|
||||
workTitle: draft.workTitle ?? '',
|
||||
workDescription: draft.workDescription ?? '',
|
||||
themeTags: normalizeThemeTagInput(draft.themeTags.join(',')),
|
||||
levels: normalizeDraftLevels(draft),
|
||||
};
|
||||
@@ -219,16 +219,7 @@ function buildPublishReady(
|
||||
|
||||
return {
|
||||
blockers: [...new Set(blockers.filter(Boolean))],
|
||||
publishReady:
|
||||
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),
|
||||
),
|
||||
publishReady: blockers.filter(Boolean).length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,11 +299,15 @@ function PuzzleResultTabs({
|
||||
function PuzzleThemeTagEditor({
|
||||
editState,
|
||||
isBusy,
|
||||
error,
|
||||
onChange,
|
||||
onGenerateTags,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerateTags: () => void;
|
||||
}) {
|
||||
const [newTagText, setNewTagText] = useState('');
|
||||
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>
|
||||
{!isAddingTag ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsAddingTag(true)}
|
||||
onClick={onGenerateTags}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="新增作品标签"
|
||||
title="新增作品标签"
|
||||
aria-label="AI生成作品标签"
|
||||
title="AI生成作品标签"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
</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 className="mt-3 flex flex-wrap gap-2">
|
||||
@@ -430,6 +441,11 @@ function PuzzleThemeTagEditor({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1191,12 +1207,16 @@ function PuzzleLevelListTab({
|
||||
|
||||
function PuzzleWorkInfoTab({
|
||||
editState,
|
||||
tagGenerationError,
|
||||
isBusy,
|
||||
onChange,
|
||||
onGenerateTags,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
tagGenerationError: string | null;
|
||||
isBusy: boolean;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerateTags: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -1233,8 +1253,10 @@ function PuzzleWorkInfoTab({
|
||||
|
||||
<PuzzleThemeTagEditor
|
||||
editState={editState}
|
||||
error={tagGenerationError}
|
||||
isBusy={isBusy}
|
||||
onChange={onChange}
|
||||
onGenerateTags={onGenerateTags}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1304,6 +1326,9 @@ export function PuzzleResultView({
|
||||
const [autoSaveState, setAutoSaveState] =
|
||||
useState<PuzzleAutoSaveState>('idle');
|
||||
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const savedEditStateRef = useRef<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
);
|
||||
@@ -1314,6 +1339,7 @@ export function PuzzleResultView({
|
||||
setActiveLevelId(null);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
return;
|
||||
}
|
||||
const nextState = createDraftEditState(draft);
|
||||
@@ -1327,6 +1353,7 @@ export function PuzzleResultView({
|
||||
);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
}, [draft]);
|
||||
|
||||
const syncedDraft = useMemo(() => {
|
||||
@@ -1445,7 +1472,7 @@ export function PuzzleResultView({
|
||||
const buildLevelDraft = (level: PuzzleDraftLevel): PuzzleResultDraft => ({
|
||||
...syncedDraft,
|
||||
levelName: level.levelName,
|
||||
summary: level.pictureDescription,
|
||||
summary: editState.workDescription.trim(),
|
||||
candidates: level.candidates,
|
||||
selectedCandidateId: level.selectedCandidateId,
|
||||
coverImageSrc: resolveLevelFormalImageSrc(level) || level.coverImageSrc,
|
||||
@@ -1498,8 +1525,28 @@ export function PuzzleResultView({
|
||||
) : (
|
||||
<PuzzleWorkInfoTab
|
||||
editState={editState}
|
||||
tagGenerationError={tagGenerationError}
|
||||
isBusy={isBusy}
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { Character, CustomWorldProfile } from '../../types';
|
||||
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
|
||||
import {
|
||||
CustomWorldEntityCatalog,
|
||||
type ResultTab,
|
||||
@@ -91,10 +92,19 @@ export function RpgCreationResultView({
|
||||
qualityFindings = [],
|
||||
}: RpgCreationResultViewProps) {
|
||||
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(
|
||||
() => shouldEnableRpgCreationAssetDebugPanel(),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
latestProfileRef.current = profile;
|
||||
}, [profile]);
|
||||
const {
|
||||
closeEditorTarget,
|
||||
createLabel,
|
||||
@@ -133,6 +143,32 @@ export function RpgCreationResultView({
|
||||
}
|
||||
: 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 (
|
||||
<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
|
||||
@@ -152,6 +188,14 @@ export function RpgCreationResultView({
|
||||
onProfileChange={onProfileChange}
|
||||
onDeleteStoryNpcs={deleteStoryNpcs}
|
||||
onDeleteLandmarks={deleteLandmarks}
|
||||
openingCgGenerating={openingCgGenerating}
|
||||
openingCgPhaseLabel={
|
||||
openingCgGenerating ? '正在生成开局 CG' : null
|
||||
}
|
||||
openingCgGenerateDisabled={isGenerating}
|
||||
onGenerateOpeningCg={
|
||||
readOnly ? undefined : () => void handleGenerateOpeningCg()
|
||||
}
|
||||
createActionLabel={
|
||||
readOnly || (compactAgentResultMode && !onGenerateEntity)
|
||||
? undefined
|
||||
@@ -227,6 +271,11 @@ export function RpgCreationResultView({
|
||||
{localGenerationError}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && openingCgGenerationError ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{openingCgGenerationError}
|
||||
</div>
|
||||
) : null}
|
||||
{assetDebugEnabled ? (
|
||||
<RpgCreationAssetDebugPanel profile={profile} />
|
||||
) : null}
|
||||
|
||||
@@ -138,7 +138,9 @@ async function clickFirstAsyncButtonByName(
|
||||
|
||||
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(await screen.findByText('角色扮演')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openExistingRpgDraft(
|
||||
@@ -1867,7 +1869,7 @@ beforeEach(() => {
|
||||
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();
|
||||
|
||||
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((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
|
||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
||||
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(rpgButton);
|
||||
|
||||
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /抓大鹅/u })).toBeNull();
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /角色扮演/u }),
|
||||
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
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 requireAuth = vi.fn();
|
||||
|
||||
@@ -2658,12 +2655,15 @@ test('selecting RPG creation while logged out routes through requireAuth', async
|
||||
);
|
||||
|
||||
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);
|
||||
await user.click(rpgButton);
|
||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(puzzleButton);
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
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 () => {
|
||||
@@ -2772,10 +2772,10 @@ test('refreshing RPG agent path restores stored agent workspace pointer', async
|
||||
).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();
|
||||
|
||||
vi.mocked(createRpgCreationSession).mockRejectedValueOnce(
|
||||
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '缺少 Authorization Bearer Token',
|
||||
status: 401,
|
||||
@@ -2786,13 +2786,15 @@ test('new creation entry maps raw bearer token errors to user-facing auth copy',
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
||||
const puzzleButton = screen.getByRole('button', {
|
||||
name: /拼图.*创意礼物/u,
|
||||
});
|
||||
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(rpgButton);
|
||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(puzzleButton);
|
||||
|
||||
expect(listPuzzleWorks).toHaveBeenCalled();
|
||||
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
|
||||
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
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();
|
||||
});
|
||||
|
||||
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 match3dSession = buildMockMatch3DAgentSession();
|
||||
|
||||
@@ -2858,20 +2860,13 @@ test('match3d creation card opens workspace even when public galleries fail', as
|
||||
await openCreationHub(user);
|
||||
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
||||
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
||||
|
||||
const button = screen.getByRole('button', {
|
||||
name: /抓大鹅.*经典消除玩法/u,
|
||||
});
|
||||
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();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /抓大鹅.*经典消除玩法/u }),
|
||||
).toBeNull();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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: '返回' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
|
||||
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
).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 () => {
|
||||
@@ -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 waitFor(() => {
|
||||
expect(screen.getByText('角色扮演')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -4677,13 +4677,17 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(await screen.findByText('角色扮演')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
).toBeTruthy();
|
||||
|
||||
resolveGalleryRequest([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getPlatformTabPanel('create')).getByText('角色扮演'),
|
||||
within(getPlatformTabPanel('create')).getByRole('button', {
|
||||
name: /拼图.*创意礼物/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -5045,9 +5049,22 @@ test('creation hub published work card keeps delete action guarded by detail flo
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -151,6 +151,7 @@ export function useRpgRuntimeShellViewModel(
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
onDeferredAutoChoice: (option) => handleChoice(option),
|
||||
});
|
||||
const {
|
||||
visibleGameState,
|
||||
@@ -222,12 +223,24 @@ export function useRpgRuntimeShellViewModel(
|
||||
const handleSceneTransitionChoice = useCallback(
|
||||
(option: StoryOption) => {
|
||||
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);
|
||||
}
|
||||
handleChoice(option);
|
||||
},
|
||||
[beginSceneTransition, handleChoice],
|
||||
[
|
||||
beginSceneTransition,
|
||||
currentStory?.deferredAutoChoice,
|
||||
currentStory?.deferredRuntimeState,
|
||||
handleChoice,
|
||||
],
|
||||
);
|
||||
|
||||
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 type { GameState, StoryMoment } from '../../types';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering';
|
||||
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<
|
||||
Record<string, SceneTransitionTriggerMode>
|
||||
> = {
|
||||
story_continue_adventure: 'content-change',
|
||||
idle_travel_next_scene: 'scene-change',
|
||||
camp_travel_home_scene: 'scene-change',
|
||||
idle_explore_forward: 'content-change',
|
||||
@@ -29,6 +30,9 @@ function buildSceneTransitionContentKey(
|
||||
currentStory: StoryMoment | null,
|
||||
) {
|
||||
const sceneId = gameState.currentScenePreset?.id ?? 'scene:none';
|
||||
const sceneActId =
|
||||
gameState.storyEngineMemory?.currentSceneActState?.currentActId ??
|
||||
'act:none';
|
||||
const encounterKey = gameState.currentEncounter
|
||||
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
|
||||
: 'encounter:none';
|
||||
@@ -39,9 +43,9 @@ function buildSceneTransitionContentKey(
|
||||
)
|
||||
.join('|');
|
||||
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';
|
||||
return [sceneId, encounterKey, monsterKey, storyKey].join('::');
|
||||
return [sceneId, sceneActId, encounterKey, monsterKey, storyKey].join('::');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,8 +56,14 @@ export function useRpgSceneTransitionModel(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | 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 [renderCurrentStory, setRenderCurrentStory] = useState(currentStory);
|
||||
const [sceneTransitionPhase, setSceneTransitionPhase] =
|
||||
@@ -73,6 +83,13 @@ export function useRpgSceneTransitionModel(params: {
|
||||
});
|
||||
const sceneTransitionTimerIdsRef = useRef<number[]>([]);
|
||||
const sceneTransitionRequestRef = useRef<SceneTransitionRequest | null>(null);
|
||||
const pendingDeferredAutoChoiceRef =
|
||||
useRef<StoryOption | null>(null);
|
||||
const onDeferredAutoChoiceRef = useRef(onDeferredAutoChoice);
|
||||
|
||||
useEffect(() => {
|
||||
onDeferredAutoChoiceRef.current = onDeferredAutoChoice;
|
||||
}, [onDeferredAutoChoice]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -81,6 +98,7 @@ export function useRpgSceneTransitionModel(params: {
|
||||
);
|
||||
sceneTransitionTimerIdsRef.current = [];
|
||||
sceneTransitionRequestRef.current = null;
|
||||
pendingDeferredAutoChoiceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -98,6 +116,15 @@ export function useRpgSceneTransitionModel(params: {
|
||||
|
||||
const entryTimerId = window.setTimeout(() => {
|
||||
setSceneTransitionPhase('idle');
|
||||
const autoChoice =
|
||||
payload.currentStory?.deferredAutoChoice ??
|
||||
pendingDeferredAutoChoiceRef.current;
|
||||
if (autoChoice) {
|
||||
pendingDeferredAutoChoiceRef.current = null;
|
||||
// 中文注释:入场计时器可能跨过一次 currentStory/gameState 更新,
|
||||
// 必须读取最新回调,避免用点击“继续冒险”前的旧状态自动开聊。
|
||||
onDeferredAutoChoiceRef.current?.(autoChoice);
|
||||
}
|
||||
}, sceneTransitionDurations.entryMs);
|
||||
sceneTransitionTimerIdsRef.current.push(entryTimerId);
|
||||
},
|
||||
@@ -109,6 +136,7 @@ export function useRpgSceneTransitionModel(params: {
|
||||
if (sceneTransitionPhase !== 'idle') return;
|
||||
|
||||
pendingScenePayloadRef.current = { gameState, currentStory };
|
||||
pendingDeferredAutoChoiceRef.current = null;
|
||||
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
|
||||
window.clearTimeout(timerId),
|
||||
);
|
||||
@@ -170,6 +198,8 @@ export function useRpgSceneTransitionModel(params: {
|
||||
: buildSceneTransitionContentKey(gameState, currentStory) !==
|
||||
request.baselineContentKey;
|
||||
if (isReady) {
|
||||
pendingDeferredAutoChoiceRef.current =
|
||||
currentStory?.deferredAutoChoice ?? null;
|
||||
startSceneEntering({ gameState, currentStory });
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -19,7 +19,7 @@ export const NEW_WORK_ENTRY_CONFIG = {
|
||||
title: '角色扮演',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
visible: true,
|
||||
visible: false,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
@@ -43,7 +43,7 @@ export const NEW_WORK_ENTRY_CONFIG = {
|
||||
title: '抓大鹅',
|
||||
subtitle: '经典消除玩法',
|
||||
badge: '可创建',
|
||||
visible: true,
|
||||
visible: false,
|
||||
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 () => {
|
||||
const state = createBaseState();
|
||||
const option = createBattleOption('npc_chat');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { isServerRuntimeFunctionId } from '../../services/rpg-runtime';
|
||||
import {
|
||||
@@ -185,8 +186,13 @@ export function createStoryChoiceActions({
|
||||
currentStory?.deferredOptions?.length &&
|
||||
isContinueAdventureOption(option)
|
||||
) {
|
||||
const deferredAutoChoice =
|
||||
currentStory.deferredAutoChoice &&
|
||||
currentStory.deferredOptions.includes(currentStory.deferredAutoChoice)
|
||||
? currentStory.deferredAutoChoice
|
||||
: undefined;
|
||||
if (currentStory.deferredRuntimeState) {
|
||||
setGameState({
|
||||
const restoredState = ensureSceneEncounterPreview({
|
||||
...gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
@@ -202,12 +208,15 @@ export function createStoryChoiceActions({
|
||||
currentStory.deferredRuntimeState.storyEngineMemory ??
|
||||
gameState.storyEngineMemory,
|
||||
});
|
||||
|
||||
setGameState(restoredState);
|
||||
}
|
||||
setCurrentStory({
|
||||
...currentStory,
|
||||
options: currentStory.deferredOptions,
|
||||
deferredOptions: undefined,
|
||||
deferredRuntimeState: undefined,
|
||||
deferredAutoChoice,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator';
|
||||
type RpgRuntimeInteractionFlowParams = {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
|
||||
interactionConfig: StoryInteractionCoordinatorConfig;
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
buildResolvedChoiceState: (
|
||||
@@ -76,6 +77,7 @@ export function createClearStoryInteractionUi(params: {
|
||||
export function useRpgRuntimeInteractionFlow({
|
||||
gameState,
|
||||
isLoading,
|
||||
sceneTransitionPhase = 'idle',
|
||||
interactionConfig,
|
||||
runtimeSupport,
|
||||
buildResolvedChoiceState,
|
||||
@@ -117,7 +119,15 @@ export function useRpgRuntimeInteractionFlow({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || gameState.inBattle || gameState.npcInteractionActive) {
|
||||
const pendingAutoChoice =
|
||||
interactionConfig.npcEncounterActions.currentStory?.deferredAutoChoice;
|
||||
if (
|
||||
isLoading ||
|
||||
sceneTransitionPhase !== 'idle' ||
|
||||
pendingAutoChoice ||
|
||||
gameState.inBattle ||
|
||||
gameState.npcInteractionActive
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -134,8 +144,10 @@ export function useRpgRuntimeInteractionFlow({
|
||||
gameState.currentEncounter,
|
||||
gameState.inBattle,
|
||||
gameState.npcInteractionActive,
|
||||
interactionConfig.npcEncounterActions.currentStory?.deferredAutoChoice,
|
||||
isLoading,
|
||||
isNpcEncounter,
|
||||
sceneTransitionPhase,
|
||||
]);
|
||||
|
||||
const choiceRuntimeController: Parameters<
|
||||
|
||||
@@ -25,8 +25,8 @@ import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
advanceSceneActRuntimeState,
|
||||
getSceneConnectionDirectionText,
|
||||
resolveSceneActProgression,
|
||||
resolveLimitedPrimaryNpcChatState,
|
||||
resolveSceneActProgression,
|
||||
} from '../../services/customWorldSceneActRuntime';
|
||||
import { normalizeStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import type {
|
||||
@@ -1730,6 +1730,14 @@ export function createStoryNpcEncounterActions({
|
||||
deferredOptions: progressionResult?.options,
|
||||
deferredRuntimeState:
|
||||
progressionResult?.deferredRuntimeState ?? undefined,
|
||||
deferredAutoChoice:
|
||||
progressionResult?.options.find(
|
||||
(option) => option.functionId === 'npc_preview_talk',
|
||||
) ??
|
||||
progressionResult?.options.find(
|
||||
(option) => option.functionId === 'npc_chat',
|
||||
) ??
|
||||
undefined,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -56,11 +56,13 @@ export type {
|
||||
export function useRpgRuntimeStory({
|
||||
gameState,
|
||||
setGameState,
|
||||
sceneTransitionPhase = 'idle',
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
@@ -108,6 +110,7 @@ export function useRpgRuntimeStory({
|
||||
} = useRpgRuntimeStoryFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
sceneTransitionPhase,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useStoryGoalOptionCoordinator } from './useStoryGoalOptionCoordinator';
|
||||
type RpgRuntimeStoryFlowParams = {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
@@ -61,6 +62,7 @@ type RpgRuntimeStoryFlowParams = {
|
||||
export function useRpgRuntimeStoryFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
sceneTransitionPhase = 'idle',
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs,
|
||||
@@ -148,6 +150,7 @@ export function useRpgRuntimeStoryFlow({
|
||||
} = useRpgRuntimeInteractionFlow({
|
||||
gameState,
|
||||
isLoading,
|
||||
sceneTransitionPhase,
|
||||
interactionConfig,
|
||||
runtimeSupport,
|
||||
buildResolvedChoiceState,
|
||||
|
||||
@@ -7,6 +7,46 @@ import {
|
||||
} from './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', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'big-fish',
|
||||
@@ -111,24 +151,12 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
}, {
|
||||
seedText: '表单作品名',
|
||||
workTitle: '暖灯猫街',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
seedText: '一只猫在雨夜灯牌下回头。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'puzzle-title',
|
||||
label: '作品名称',
|
||||
value: '暖灯猫街',
|
||||
},
|
||||
{
|
||||
id: 'work-description',
|
||||
label: '作品描述',
|
||||
value: '一套雨夜猫街主题拼图。',
|
||||
},
|
||||
{
|
||||
id: 'picture-description',
|
||||
label: '画面描述',
|
||||
|
||||
@@ -47,20 +47,20 @@ type MiniGameAnchorSource = {
|
||||
const PUZZLE_STEPS = [
|
||||
{
|
||||
id: 'compile',
|
||||
label: '编译拼图草稿',
|
||||
detail: '整理主题、主体、构图与标签。',
|
||||
label: '编译首关草稿',
|
||||
detail: '根据画面描述生成首关名称和结果页草稿。',
|
||||
weight: 34,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-images',
|
||||
label: '生成拼图图片',
|
||||
detail: '根据草稿生成候选图。',
|
||||
label: '生成首关画面',
|
||||
detail: '按画面描述和参考图生成第一张拼图图。',
|
||||
weight: 33,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-select-image',
|
||||
label: '确认正式图片',
|
||||
detail: '选择候选图写入结果页。',
|
||||
label: '写入正式草稿',
|
||||
detail: '把首图设为正式图并同步到结果页。',
|
||||
weight: 33,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
@@ -211,7 +211,7 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
(normalizedState.phase === 'ready'
|
||||
? normalizedState.kind === 'big-fish'
|
||||
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
|
||||
: '完整草稿与资产已准备完成。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: clampProgress(overallProgress),
|
||||
@@ -238,28 +238,12 @@ export function buildPuzzleGenerationAnchorEntries(
|
||||
}
|
||||
|
||||
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',
|
||||
label: '画面描述',
|
||||
value:
|
||||
formPayload?.pictureDescription?.trim() ||
|
||||
formPayload?.seedText?.trim() ||
|
||||
session.draft?.levels?.[0]?.pictureDescription ||
|
||||
session.anchorPack.visualSubject.value,
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
generateRpgWorldOpeningCg,
|
||||
generateRpgWorldLandmark,
|
||||
generateRpgWorldSceneImage,
|
||||
generateRpgWorldSceneNpc,
|
||||
@@ -23,6 +24,11 @@ describe('rpgCreationAssetClient', () => {
|
||||
entity: { id: 'landmark-1', name: '雾港' },
|
||||
imageSrc: '/generated-custom-world-scenes/profile/scene/image.webp',
|
||||
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 失败',
|
||||
);
|
||||
});
|
||||
|
||||
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 {
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldOpeningCgProfile,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../../types';
|
||||
@@ -132,6 +133,20 @@ export async function generateRpgWorldLandmark(payload: {
|
||||
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,
|
||||
* 保留封面资产服务的既有边界,不把逻辑重新塞回 `aiService.ts`。
|
||||
@@ -143,6 +158,7 @@ export const rpgCreationAssetClient = {
|
||||
generatePlayableNpc: generateRpgWorldPlayableNpc,
|
||||
generateStoryNpc: generateRpgWorldStoryNpc,
|
||||
generateLandmark: generateRpgWorldLandmark,
|
||||
generateOpeningCg: generateRpgWorldOpeningCg,
|
||||
generateCoverImage: generateCustomWorldCoverImage,
|
||||
uploadCoverImage: uploadCustomWorldCoverImage,
|
||||
};
|
||||
|
||||
@@ -65,7 +65,8 @@ function createRuntimeProjection(
|
||||
overrides: RuntimeProjectionOverrides = {},
|
||||
): StoryRuntimeProjectionResponse {
|
||||
const storySession = createStorySession(overrides.storySession);
|
||||
const serverVersion = overrides.serverVersion ?? storySession.version;
|
||||
const serverVersion =
|
||||
overrides.serverVersion ?? storySession.version ?? 1;
|
||||
|
||||
return {
|
||||
storySession,
|
||||
|
||||
@@ -46,6 +46,37 @@ export interface CustomWorldCoverCropRect {
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -411,6 +442,7 @@ export interface CustomWorldProfile {
|
||||
*/
|
||||
playerPremise?: string | null;
|
||||
cover?: CustomWorldCoverProfile | null;
|
||||
openingCg?: CustomWorldOpeningCgProfile | null;
|
||||
templateWorldType: WorldTemplateType;
|
||||
compatibilityTemplateWorldType?: WorldTemplateType | null;
|
||||
majorFactions: string[];
|
||||
|
||||
@@ -166,6 +166,8 @@ export interface StoryMoment {
|
||||
currentScenePreset?: ScenePresetInfo | null;
|
||||
storyEngineMemory?: StoryEngineMemoryState;
|
||||
};
|
||||
// 中文注释:用于“继续冒险”过场完成后自动执行下一幕入口,避免角色尚未走到位就开聊。
|
||||
deferredAutoChoice?: StoryOption;
|
||||
historyRole?: StoryHistoryRole;
|
||||
npcChatState?: StoryNpcChatState;
|
||||
npcAffinityEffect?: StoryNpcAffinityEffect | null;
|
||||
|
||||