4 Commits

Author SHA1 Message Date
06b8b46530 Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
2026-05-05 14:58:07 +08:00
07e777fef8 1 2026-05-05 14:40:41 +08:00
Hermes Agent
e847fcea6f Merge remote-tracking branch 'origin/master' 2026-05-04 03:02:49 +08:00
Hermes Agent
46d240e37d feat: add custom world opening cg contract 2026-05-04 03:02:24 +08:00
79 changed files with 4289 additions and 449 deletions

View 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.

View 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

View File

@@ -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": "儿童认知学习插画,木质桌面上有积木、彩色形状、动物玩偶和小书本,色彩明快,元素边界清晰,无文字,适合儿童教育拼图。"
}
]

View File

@@ -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,
),
);

View File

@@ -85,6 +85,11 @@
2. 用户返回成本更低
3. 操作像手游副面板,更符合预期
### 4.3.1 弹出确认面板不能透明
- 删除作品、发布后分享、确认离开等关键弹窗必须有实体面板底色,不能只靠透明背景、毛玻璃或遮罩承载内容。
- 通过 portal 挂到 `body` 的平台弹窗必须在遮罩层补齐平台主题类,否则主题变量会脱离页面容器,轻则颜色漂移,重则面板背景看起来透明。
- 移动端关键确认弹窗优先居中显示,并保留 `max-height + 内部滚动`,避免被底部导航、安全区或底部抽屉布局遮住。
### 4.4 图标优于文字按钮
- 在底部工具区,队伍/背包改成 icon 后更紧凑。
- 但必须保留 `aria-label`,保证语义清晰、后续也方便测试。

View File

@@ -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、拼图、抓大鹅等公开广场数据公开广场接口未就绪、空表或临时失败不应污染创作入口错误态也不应表现成登录异常。

View File

@@ -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 包。

View File

@@ -18,10 +18,10 @@
| 玩法 | 展示 | 开放 | 说明 |
| --- | --- | --- | --- |
| 角色扮演 | | 是 | 点击后进入 RPG Agent 共创工作台 |
| 角色扮演 | | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 |
| 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 |
| 拼图 | 是 | 是 | 点击后进入拼图 Agent 共创工作台 |
| 抓大鹅 | | 是 | 点击后进入抓大鹅 Agent 共创工作台 |
| 抓大鹅 | | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 |
| 方洞挑战 | 是 | 是 | 点击后进入方洞挑战 Agent 共创工作台,支持草稿、结果页、发布、试玩、作品架与广场 |
| AIRP | 是 | 否 | 保留入口,显示敬请期待 |
| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待 |

View File

@@ -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`:进入拼图结果页。
### 大鱼吃小鱼

View File

@@ -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`,不生成图片,不进入生成进度页。

View File

@@ -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. 拼图草稿结果页左上角返回直接回到创作页,不再显示上一页表单。

View File

@@ -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` 通过。

View File

@@ -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):记录拼图失败后重新开始/付费续时,以及进入作品与过关后同步存档页投影的落地规则。

View File

@@ -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 文案,只保留游戏内演出。

View 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 故事板图2k16:9
-> Seedance 使用故事板作为参考图生成单段 15 秒视频480p16: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网格格式创建故事板169。像素风角色扮演游戏开场动画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` 分钟,避免低于产品展示的预计等待时长。

View File

@@ -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

View File

@@ -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
```

View File

@@ -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 发布成功后由父层回传分享数据并打开面板。

View File

@@ -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;

View File

@@ -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> = {

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -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)

View File

@@ -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网格格式创建故事板169。像素风角色扮演游戏开场动画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))
}

View File

@@ -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]

View 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("第一关关卡名"));
}
}

View File

@@ -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;

View 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 个作品标签"));
}
}

View File

@@ -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 =

View File

@@ -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(

View File

@@ -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")
);
}
}

View File

@@ -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。

View File

@@ -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(&current_story), &json!({}));
assert_eq!(options[0].function_id, "npc_chat");
assert_eq!(options[0].action_text, "继续交谈");

View File

@@ -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 {

View File

@@ -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={

View File

@@ -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 = {

View 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} />;
}

View File

@@ -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: '分享' }));

View File

@@ -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>

View File

@@ -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

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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>
);
})}
</>

View File

@@ -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

View File

@@ -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',

View File

@@ -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',

View File

@@ -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>

View 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:
'儿童认知学习插画,木质桌面上有积木、彩色形状、动物玩偶和小书本,色彩明快,元素边界清晰,无文字。',
},
];

View File

@@ -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),
});

View File

@@ -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>

View File

@@ -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}

View File

@@ -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();
});

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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,
},
{

View File

@@ -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');

View File

@@ -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;
}

View File

@@ -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<

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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: '画面描述',

View File

@@ -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,
},

View File

@@ -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 失败',
);
});
});

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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[];

View File

@@ -166,6 +166,8 @@ export interface StoryMoment {
currentScenePreset?: ScenePresetInfo | null;
storyEngineMemory?: StoryEngineMemoryState;
};
// 中文注释:用于“继续冒险”过场完成后自动执行下一幕入口,避免角色尚未走到位就开聊。
deferredAutoChoice?: StoryOption;
historyRole?: StoryHistoryRole;
npcChatState?: StoryNpcChatState;
npcAffinityEffect?: StoryNpcAffinityEffect | null;