Merge branch 'master' of https://git.genarrative.world/GenarrativeAI/Genarrative
@@ -0,0 +1,351 @@
|
|||||||
|
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 defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
|
||||||
|
const defaultTimeoutMs = 180000;
|
||||||
|
|
||||||
|
const prompts = [
|
||||||
|
{
|
||||||
|
id: 'cat-barista',
|
||||||
|
title: '咖啡师猫咪',
|
||||||
|
subject:
|
||||||
|
'一只奶油色猫咪像人一样双足站立,穿深绿色围裙,在温暖咖啡馆吧台前专注拉花,爪子扶着咖啡杯,蓬松尾巴自然弯起,童书级精致插画,柔和自然光,主体清晰。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cat-detective',
|
||||||
|
title: '侦探猫咪',
|
||||||
|
subject:
|
||||||
|
'一只黑白猫咪像侦探一样双足站在雨后街角,穿短风衣和小帽子,单爪拿放大镜,另一只爪插兜,路灯和湿润石板路反光,电影感但可爱,插画风格。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cat-dancer',
|
||||||
|
title: '舞者猫咪',
|
||||||
|
subject:
|
||||||
|
'一只橘猫以拟人舞者姿态单脚旋转,穿轻盈舞台披肩,前爪展开,尾巴形成优雅弧线,背景是暖色小剧场灯光,动作灵动,精致插画。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cat-knight',
|
||||||
|
title: '骑士猫咪',
|
||||||
|
subject:
|
||||||
|
'一只银灰猫咪像小骑士一样站在苔藓石台上,披短斗篷,双爪握着细剑指向地面,姿态勇敢但可亲,远处森林微光,奇幻插画风格。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cat-painter',
|
||||||
|
title: '画家猫咪',
|
||||||
|
subject:
|
||||||
|
'一只三花猫咪双足站在画架前,穿宽松蓝色工作衫,一爪拿画笔一爪托调色盘,鼻尖有颜料点,窗边画室阳光明亮,温柔手绘插画。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cat-astronaut',
|
||||||
|
title: '宇航员猫咪',
|
||||||
|
subject:
|
||||||
|
'一只白猫咪以拟人宇航员姿态站在月面,透明头盔内露出猫脸,尾巴在宇航服后轻轻翘起,爪子向远处蓝色星球敬礼,梦幻插画风格。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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.VECTOR_ENGINE_BASE_URL || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\/+$/u, ''),
|
||||||
|
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||||
|
timeoutMs: Number.parseInt(
|
||||||
|
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVectorEngineImagesGenerationUrl(baseUrl) {
|
||||||
|
return baseUrl.endsWith('/v1')
|
||||||
|
? `${baseUrl}/images/generations`
|
||||||
|
: `${baseUrl}/v1/images/generations`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt(entry) {
|
||||||
|
return [
|
||||||
|
'请生成一张高清 1:1 方形插画。',
|
||||||
|
`画面主体:${entry.subject}`,
|
||||||
|
'要求:猫咪保留清晰猫脸、猫耳、猫尾和毛发质感,但身体姿态像人一样自然;构图完整,角色占画面主体,适合作为项目插画素材。',
|
||||||
|
'避免:文字、水印、边框、按钮、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 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(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||||
|
}
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} 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',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
throw new Error(`Generated image download timed out after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateOne(env, entry, outDir) {
|
||||||
|
const requestBody = {
|
||||||
|
model: 'gpt-image-2-all',
|
||||||
|
prompt: buildPrompt(entry),
|
||||||
|
n: 1,
|
||||||
|
size: '1024x1024',
|
||||||
|
};
|
||||||
|
const payload = await fetchJson(
|
||||||
|
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${env.apiKey}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
},
|
||||||
|
env.timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urls = extractImageUrls(payload);
|
||||||
|
const b64Images = extractBase64Images(payload);
|
||||||
|
|
||||||
|
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(`VectorEngine returned no image for ${entry.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
const outputPath = path.join(outDir, `${entry.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 selectedPrompts = limit > 0 ? prompts.slice(0, limit) : prompts;
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
const env = resolveEnv();
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mode: 'dry-run',
|
||||||
|
outDir,
|
||||||
|
count: selectedPrompts.length,
|
||||||
|
hasBaseUrl: Boolean(env.baseUrl),
|
||||||
|
hasApiKey: Boolean(env.apiKey),
|
||||||
|
requests: selectedPrompts.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
title: entry.title,
|
||||||
|
body: {
|
||||||
|
model: 'gpt-image-2-all',
|
||||||
|
prompt: buildPrompt(entry),
|
||||||
|
n: 1,
|
||||||
|
size: '1024x1024',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = resolveEnv();
|
||||||
|
if (!env.baseUrl || !env.apiKey) {
|
||||||
|
console.error(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
|
||||||
|
hasBaseUrl: Boolean(env.baseUrl),
|
||||||
|
hasApiKey: Boolean(env.apiKey),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = [];
|
||||||
|
for (const entry of selectedPrompts) {
|
||||||
|
console.log(`Generating ${entry.id}...`);
|
||||||
|
generated.push(await generateOne(env, entry, outDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
count: generated.length,
|
||||||
|
files: generated,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
- 不提交个人配置、API Key、会话转录、模型密钥、本地路径密钥等敏感内容。
|
- 不提交个人配置、API Key、会话转录、模型密钥、本地路径密钥等敏感内容。
|
||||||
- 个人 Hermes 的 `~/.hermes/config.yaml`、`~/.hermes/.env`、`~/.hermes/sessions/` 不应复制到本仓库。
|
- 个人 Hermes 的 `~/.hermes/config.yaml`、`~/.hermes/.env`、`~/.hermes/sessions/` 不应复制到本仓库。
|
||||||
- 开发前先阅读本目录下与任务相关的记忆文件;开发后如产生稳定知识,更新对应文档。
|
- 开发前先阅读本目录下与任务相关的记忆文件;开发后如产生稳定知识,更新对应文档。
|
||||||
|
- 后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`,便于团队跨目录检索。
|
||||||
- 若本目录内容与 `docs/` 或代码事实冲突,以当前代码和最新 `docs/` 为准,并同步修正过期记忆。
|
- 若本目录内容与 `docs/` 或代码事实冲突,以当前代码和最新 `docs/` 为准,并同步修正过期记忆。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
│ ├─ pitfalls.md # 踩坑与排障记录
|
│ ├─ pitfalls.md # 踩坑与排障记录
|
||||||
│ └─ handoff-template.md # 任务交接模板
|
│ └─ handoff-template.md # 任务交接模板
|
||||||
├─ plans/ # 阶段性计划与实施方案
|
├─ plans/ # 阶段性计划与实施方案
|
||||||
|
├─ todos/ # 已定稿但尚未执行的共享 TODO 计划
|
||||||
├─ skills/ # 仓库级 Hermes skills
|
├─ skills/ # 仓库级 Hermes skills
|
||||||
└─ plugins/ # 仓库级 Hermes plugins(需显式启用项目 plugin)
|
└─ plugins/ # 仓库级 Hermes plugins(需显式启用项目 plugin)
|
||||||
```
|
```
|
||||||
@@ -89,4 +91,3 @@ HERMES_ENABLE_PROJECT_PLUGINS=1 HERMES_PLUGINS_DEBUG=1 hermes chat -q "请读取
|
|||||||
- 大段临时聊天记录
|
- 大段临时聊天记录
|
||||||
- 尚未确认的一次性猜测
|
- 尚未确认的一次性猜测
|
||||||
- 构建产物、日志、缓存、数据库 dump
|
- 构建产物、日志、缓存、数据库 dump
|
||||||
|
|
||||||
|
|||||||
549
.hermes/plans/BARK_BATTLE_PHASE2_PLATFORM_WORK_LOOP_PLAN.md
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
# Bark Battle Phase 2 Platform Work Loop Implementation Plan
|
||||||
|
|
||||||
|
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 将 `bark-battle` 从内部试玩 demo 升级为 Genarrative 正式 play type,打通轻创作配置、发布态作品、正式 runtime、run start / finish、后端裁决、个人历史、作品统计和最小排行榜闭环。
|
||||||
|
|
||||||
|
**Architecture:** 先冻结 shared contracts 与 `module-bark-battle` 纯领域规则,再落 SpacetimeDB 表/reducer、`spacetime-client` facade 和 `api-server` BFF,随后接前端最小纵切,最后补排行榜/个人历史/作品统计投影体验。前端只承接表现、交互和临时 UI 状态,正式业务真相由后端裁决。
|
||||||
|
|
||||||
|
**Tech Stack:** React + TypeScript + Vite, server-rs + Axum, SpacetimeDB Rust module, shared-contracts, Vitest, Cargo tests, npm scripts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 已确认决策
|
||||||
|
|
||||||
|
1. “有效叫声”统一为 **有效声浪触发**:当前采样响度达到有效阈值且满足 `minBarkGapMs` 冷却即触发;不再要求 `minBarkDurationMs` / `maxBarkDurationMs`,也不等待响度回落。
|
||||||
|
2. Phase 2 范围是 **Bark Battle 平台作品闭环**,不是单纯玩法表现深化。
|
||||||
|
3. 作品形态是 **轻创作配置作品**:标题、描述、主题/背景预设、狗狗皮肤预设、难度预设、排行榜开关。
|
||||||
|
4. 难度预设只影响 AI 对手行为;不影响有效阈值、冷却、时长、分数公式或反作弊阈值。
|
||||||
|
5. 排行榜按 `workId + difficultyPreset + rulesetVersion` 分榜。
|
||||||
|
6. 后端裁决正式单局结果;前端只提交派生指标,`clientResult` 只用于 debug/对账。
|
||||||
|
7. 排行榜只收录 `serverResult = player_win` 且未被反作弊拒绝的单局结果,排序以 `finalEnergy` 优先。
|
||||||
|
8. 作品统计使用最小后端投影:start、finish、win/draw/loss、flagged、leaderboard、best/avg energy。
|
||||||
|
9. 个人历史成绩 = 最近记录列表 + 个人最佳摘要;仅本人可见。
|
||||||
|
10. 正式入口闭环覆盖创作入口、作品详情 CTA、广场/作品卡片、我的作品、稳定作品 ID runtime 路由和 `work_play_start`。
|
||||||
|
11. 创作编辑形态是单页轻配置表单 + 预览卡片。
|
||||||
|
12. 实施顺序固定为:契约与领域规则 → SpacetimeDB 表/reducer 与 api-server BFF → 最小前端纵切 → 投影与列表体验 → 收口验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 必读文档与约束
|
||||||
|
|
||||||
|
实施前先读:
|
||||||
|
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `CONTEXT.md`
|
||||||
|
- `docs/prd/BARK_BATTLE_BDD_2026-05-11.md`
|
||||||
|
- `docs/technical/BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md`
|
||||||
|
- `docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md`
|
||||||
|
- `docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`
|
||||||
|
- `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`
|
||||||
|
- `.codex/skills/spacetimedb-cli/SKILL.md`
|
||||||
|
- `.codex/skills/spacetimedb-rust/SKILL.md`
|
||||||
|
- `.codex/skills/spacetimedb-concepts/SKILL.md`
|
||||||
|
- `.codex/skills/spacetimedb-typescript/SKILL.md`
|
||||||
|
|
||||||
|
关键约束:
|
||||||
|
|
||||||
|
- 后端路线固定 `server-rs + Axum + SpacetimeDB`。
|
||||||
|
- 领域规则进 `module-bark-battle`,SpacetimeDB 表和事务编排进 `spacetime-module`。
|
||||||
|
- HTTP/SSE/BFF 留在 `api-server`。
|
||||||
|
- 前后端 DTO 留在 `shared-contracts`。
|
||||||
|
- 数据库表结构更改必须同步 `migration.rs` 和生成绑定。
|
||||||
|
- 人工命令/文档示例禁止继续使用 `spacetime --root-dir`。
|
||||||
|
- 修改中文文件后必须跑 `npm run check:encoding`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 阶段一:契约与领域规则
|
||||||
|
|
||||||
|
### Task 1.1: 新增 Rust shared-contracts 模块
|
||||||
|
|
||||||
|
**Objective:** 定义 Bark Battle Phase 2 的 Rust DTO 边界。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server-rs/crates/shared-contracts/src/bark_battle.rs`
|
||||||
|
- Modify: `server-rs/crates/shared-contracts/src/lib.rs`
|
||||||
|
- Test: `server-rs/crates/shared-contracts/src/bark_battle.rs`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. 新增枚举:`BarkBattleDifficultyPreset { Easy, Normal, Hard }`、`BarkBattleServerResult { PlayerWin, OpponentWin, Draw }`、`BarkBattleFinishStatus { Accepted, AcceptedWithFlags, Rejected }`。
|
||||||
|
2. 新增配置 DTO:`BarkBattleDraftConfig`、`BarkBattlePublishedConfig`、`BarkBattleRuntimeConfig`。
|
||||||
|
3. 新增 run DTO:`BarkBattleRunStartRequest/Response`、`BarkBattleRunFinishRequest/Response`。
|
||||||
|
4. 新增派生指标 DTO:`BarkBattleDerivedMetrics`,字段包含 `trigger_count`、`max_volume`、`average_volume`、`final_energy`、`combo_max`。
|
||||||
|
5. 新增排行榜/历史/统计 DTO:`BarkBattleLeaderboardEntry`、`BarkBattlePersonalHistoryItem`、`BarkBattlePersonalBestSummary`、`BarkBattleWorkStats`。
|
||||||
|
6. 在 `lib.rs` 导出 `pub mod bark_battle;`。
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p shared-contracts bark_battle
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: contracts tests pass.
|
||||||
|
|
||||||
|
### Task 1.2: 新增 TypeScript shared contracts mirror
|
||||||
|
|
||||||
|
**Objective:** 让前端获得与 Rust DTO 对齐的类型。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/shared/src/contracts/barkBattle.ts`
|
||||||
|
- Modify: `packages/shared/src/contracts/index.ts`
|
||||||
|
- Test: `packages/shared/src/contracts/barkBattle.test.ts`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. 定义 `BarkBattleDifficultyPreset = 'easy' | 'normal' | 'hard'`。
|
||||||
|
2. 定义 `BarkBattleServerResult = 'player_win' | 'opponent_win' | 'draw'`。
|
||||||
|
3. 定义 draft / published / runtime config 类型。
|
||||||
|
4. 定义 start / finish request response 类型。
|
||||||
|
5. 定义 leaderboard / personal history / work stats 类型。
|
||||||
|
6. 写最小序列化/fixture 测试,确保字段命名采用前端约定 camelCase,并在 API client 层做必要映射。
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- --run packages/shared/src/contracts/barkBattle.test.ts
|
||||||
|
npx tsc -p tsconfig.typecheck-guardrails.json --noEmit --pretty false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 1.3: 新建 module-bark-battle crate
|
||||||
|
|
||||||
|
**Objective:** 将正式裁决规则放入纯领域 crate。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server-rs/crates/module-bark-battle/Cargo.toml`
|
||||||
|
- Create: `server-rs/crates/module-bark-battle/src/lib.rs`
|
||||||
|
- Create: `server-rs/crates/module-bark-battle/src/domain.rs`
|
||||||
|
- Create: `server-rs/crates/module-bark-battle/src/scoring.rs`
|
||||||
|
- Modify: `server-rs/Cargo.toml`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. 在 workspace 中注册 `module-bark-battle`。
|
||||||
|
2. 定义 `RulesetVersion`,首版固定如 `bark-battle-ruleset-v1`。
|
||||||
|
3. 定义 `BarkBattleRuleset`,包含标准局时长 30s、`min_bark_gap_ms`、合法音量/能量/连击范围、duration tolerance。
|
||||||
|
4. 实现 `validate_finish_metrics()`。
|
||||||
|
5. 实现 `adjudicate_result()`:以后端 `final_energy` 和 draw threshold 生成 `serverResult`。
|
||||||
|
6. 实现 `compute_leaderboard_score()`:只允许胜利局入榜,排序因子为 `finalEnergy`、`triggerCount`、`maxVolume`、duration 接近度、`finishedAt`。
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p module-bark-battle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 1.4: 领域规则单测覆盖作弊边界
|
||||||
|
|
||||||
|
**Objective:** 防止前端伪造 finish 直接刷榜。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server-rs/crates/module-bark-battle/src/scoring.rs`
|
||||||
|
|
||||||
|
**Test cases:**
|
||||||
|
- 28s-35s 合法窗口内可接受。
|
||||||
|
- 1s / 300s 应 rejected 或 flagged。
|
||||||
|
- `triggerCount > durationMs / minBarkGapMs + tolerance` 应 flagged。
|
||||||
|
- `finalEnergy` 越界应 rejected。
|
||||||
|
- 平/负不生成 leaderboard entry。
|
||||||
|
- easy/normal/hard 不改变阈值、冷却、分数公式,只改变 AI preset key。
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p module-bark-battle -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 阶段二:SpacetimeDB 表/reducer 与 api-server BFF
|
||||||
|
|
||||||
|
### Task 2.1: 设计 SpacetimeDB 表目录
|
||||||
|
|
||||||
|
**Objective:** 新增 Bark Battle 表并与 migration 对齐。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server-rs/crates/spacetime-module/src/bark_battle/mod.rs`
|
||||||
|
- Create: `server-rs/crates/spacetime-module/src/bark_battle/types.rs`
|
||||||
|
- Create: `server-rs/crates/spacetime-module/src/bark_battle/tables.rs`
|
||||||
|
- Modify: `server-rs/crates/spacetime-module/src/lib.rs`
|
||||||
|
- Modify: `server-rs/crates/spacetime-module/src/migration.rs`
|
||||||
|
|
||||||
|
**Tables:**
|
||||||
|
- `bark_battle_draft_config`
|
||||||
|
- `bark_battle_published_config`
|
||||||
|
- `bark_battle_runtime_run`
|
||||||
|
- `bark_battle_score_record`
|
||||||
|
- `bark_battle_leaderboard_entry`
|
||||||
|
- `bark_battle_work_stats_projection`
|
||||||
|
- `bark_battle_personal_best_projection`
|
||||||
|
|
||||||
|
**Pitfalls:**
|
||||||
|
- 表结构不要 derive `SpacetimeType`。
|
||||||
|
- reducer 使用 `&ReducerContext`。
|
||||||
|
- 授权身份来自 `ctx.sender()`。
|
||||||
|
- 需要公开订阅的表才加 `public`。
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p spacetime-module
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2.2: 实现草稿/发布 reducer
|
||||||
|
|
||||||
|
**Objective:** 支持轻配置草稿保存和发布态 config 固化。
|
||||||
|
|
||||||
|
**Reducers:**
|
||||||
|
- `create_bark_battle_draft`
|
||||||
|
- `update_bark_battle_draft_config`
|
||||||
|
- `publish_bark_battle_work`
|
||||||
|
- `get_bark_battle_runtime_config` 如仓库约定使用 reducer/procedure 查询则按现有 pattern 实现。
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- 草稿配置只允许标题、描述、主题/背景预设、狗狗皮肤预设、难度预设、排行榜开关。
|
||||||
|
- 发布生成稳定作品 ID / config version。
|
||||||
|
- 发布态 config 包含 `rulesetVersion`。
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p spacetime-module bark_battle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2.3: 实现 run start / finish reducer
|
||||||
|
|
||||||
|
**Objective:** 打通正式运行态后端事务。
|
||||||
|
|
||||||
|
**Reducers:**
|
||||||
|
- `start_bark_battle_run`
|
||||||
|
- `finish_bark_battle_run`
|
||||||
|
- `get_bark_battle_run`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- start 创建 `run_id` 和一次性 `run_token`。
|
||||||
|
- start 记录 work/config/ruleset/difficulty 快照。
|
||||||
|
- finish 必须校验 run token、未 finish、work/config/ruleset/difficulty 一致。
|
||||||
|
- finish 调用 `module-bark-battle` 裁决结果。
|
||||||
|
- accepted 写 score record。
|
||||||
|
- `serverResult = player_win` 且排行榜开启且未 rejected 时写 leaderboard entry。
|
||||||
|
- accepted / accepted_with_flags 更新 work stats 和 personal best projection。
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p spacetime-module bark_battle_run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2.4: 更新 migration 与生成绑定
|
||||||
|
|
||||||
|
**Objective:** 让 SpacetimeDB 表结构变更可发布。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server-rs/crates/spacetime-module/src/migration.rs`
|
||||||
|
- Generated: `server-rs/crates/spacetime-client/src/module_bindings/*bark*`
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
按仓库现有脚本优先;不要手改 generated bindings。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run spacetime:build
|
||||||
|
npm run spacetime:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
若脚本名不同,先查 `package.json` 和 `server-rs` README。
|
||||||
|
|
||||||
|
### Task 2.5: 实现 spacetime-client facade
|
||||||
|
|
||||||
|
**Objective:** api-server 不直接操作 generated bindings。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server-rs/crates/spacetime-client/src/bark_battle.rs`
|
||||||
|
- Modify: `server-rs/crates/spacetime-client/src/lib.rs`
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `create_bark_battle_draft`
|
||||||
|
- `save_bark_battle_draft_config`
|
||||||
|
- `publish_bark_battle_work`
|
||||||
|
- `get_bark_battle_runtime_config`
|
||||||
|
- `start_bark_battle_run`
|
||||||
|
- `finish_bark_battle_run`
|
||||||
|
- `list_bark_battle_leaderboard`
|
||||||
|
- `list_my_bark_battle_history`
|
||||||
|
- `get_my_bark_battle_best_summary`
|
||||||
|
- `get_bark_battle_work_stats`
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p spacetime-client bark_battle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2.6: 实现 api-server BFF 路由
|
||||||
|
|
||||||
|
**Objective:** 暴露前端需要的 HTTP API。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server-rs/crates/api-server/src/bark_battle.rs`
|
||||||
|
- Modify: `server-rs/crates/api-server/src/app.rs`
|
||||||
|
|
||||||
|
**Routes:**
|
||||||
|
- `POST /api/bark-battle/drafts`
|
||||||
|
- `PATCH /api/bark-battle/drafts/:draftId`
|
||||||
|
- `POST /api/bark-battle/drafts/:draftId/publish`
|
||||||
|
- `GET /api/bark-battle/works/:workId/runtime-config`
|
||||||
|
- `POST /api/bark-battle/runs/start`
|
||||||
|
- `POST /api/bark-battle/runs/:runId/finish`
|
||||||
|
- `GET /api/bark-battle/works/:workId/leaderboard`
|
||||||
|
- `GET /api/bark-battle/me/history`
|
||||||
|
- `GET /api/bark-battle/me/best-summary`
|
||||||
|
- `GET /api/bark-battle/works/:workId/stats`
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p api-server bark_battle
|
||||||
|
npm run api-server
|
||||||
|
curl -f http://127.0.0.1:<api-port>/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 阶段三:最小前端纵切
|
||||||
|
|
||||||
|
### Task 3.1: 新增前端 service client
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/services/bark-battle/barkBattleClient.ts`
|
||||||
|
- Test: `src/services/bark-battle/barkBattleClient.test.ts`
|
||||||
|
|
||||||
|
**Methods:** 与 BFF routes 一一对应。
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- --run src/services/bark-battle/barkBattleClient.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3.2: 接入创作入口与 SelectionStage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/config/newWorkEntryConfig.ts`
|
||||||
|
- Modify: `src/components/platform-entry/platformEntryCreationTypes.ts`
|
||||||
|
- Modify: `src/components/platform-entry/platformEntryTypes.ts`
|
||||||
|
- Modify: `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- 新增 `bark-battle` play type。
|
||||||
|
- 入口打开单页轻配置表单,不走复杂 agent workspace。
|
||||||
|
- 移动端入口布局不能溢出。
|
||||||
|
|
||||||
|
### Task 3.3: 实现单页轻配置表单 + 预览卡片
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/bark-battle-creation/BarkBattleConfigEditor.tsx`
|
||||||
|
- Create: `src/components/bark-battle-creation/BarkBattlePreviewCard.tsx`
|
||||||
|
- Test: `src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx`
|
||||||
|
|
||||||
|
**UI fields:**
|
||||||
|
- 标题必填
|
||||||
|
- 简介选填
|
||||||
|
- 主题/背景预设
|
||||||
|
- 狗狗皮肤预设
|
||||||
|
- 难度预设,默认 `normal`
|
||||||
|
- 排行榜开关,默认开启
|
||||||
|
|
||||||
|
**UI constraints:**
|
||||||
|
- 不堆大段玩法说明。
|
||||||
|
- 按现有游戏 UI 风格设计。
|
||||||
|
- 移动端优先。
|
||||||
|
|
||||||
|
### Task 3.4: 发布后进入作品详情
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||||
|
- Modify: `src/components/platform-entry/PlatformWorkDetailView.tsx`
|
||||||
|
- Modify: `src/components/custom-world-home/CustomWorldCreationHub.tsx`
|
||||||
|
- Modify: `src/components/custom-world-home/creationWorkShelf.ts`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- 发布成功刷新 works/gallery/shelf。
|
||||||
|
- 跳作品详情。
|
||||||
|
- 详情 CTA 可以进入正式 runtime。
|
||||||
|
|
||||||
|
### Task 3.5: runtime 拉发布态 config 并 start / finish
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/games/bark-battle/*`
|
||||||
|
- Modify: `src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`
|
||||||
|
- Create/Modify: `src/components/bark-battle-runtime/BarkBattleRuntimeRoute.tsx` 如需要
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- runtime 通过稳定 `workId` 拉 `BarkBattleRuntimeConfig`。
|
||||||
|
- 开始正式局时调用 start run。
|
||||||
|
- 结束时提交 finish 派生指标。
|
||||||
|
- 结算展示 `serverResult`、`scoreSummary`、`antiCheatFlags`、leaderboard entry。
|
||||||
|
- 麦克风原始音频不上传。
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- --run src/games/bark-battle/domain/__tests__/BarkDetector.test.ts src/games/bark-battle/application/__tests__/BarkBattleController.test.ts src/games/bark-battle/ui/__tests__/BarkBattleRuntimeShell.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 阶段四:投影与列表体验
|
||||||
|
|
||||||
|
### Task 4.1: 排行榜 UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/bark-battle-leaderboard/BarkBattleLeaderboardPanel.tsx`
|
||||||
|
- Test: `src/components/bark-battle-leaderboard/BarkBattleLeaderboardPanel.test.tsx`
|
||||||
|
- Modify: `src/components/platform-entry/PlatformWorkDetailView.tsx`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- 查询维度 `workId + difficultyPreset + rulesetVersion`。
|
||||||
|
- 只展示胜利入榜成绩。
|
||||||
|
- 不展示平/负/flagged 历史。
|
||||||
|
|
||||||
|
### Task 4.2: 个人历史最近记录 + 最佳摘要 UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/bark-battle-history/BarkBattlePersonalHistoryPanel.tsx`
|
||||||
|
- Test: `src/components/bark-battle-history/BarkBattlePersonalHistoryPanel.test.tsx`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- 默认最近 20 条。
|
||||||
|
- 仅本人可见。
|
||||||
|
- 可按 workId / difficultyPreset 过滤。
|
||||||
|
- flagged 只做轻提示,不展示详细反作弊原因。
|
||||||
|
|
||||||
|
### Task 4.3: 作品统计展示
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/bark-battle-stats/BarkBattleWorkStatsPanel.tsx`
|
||||||
|
- Test: `src/components/bark-battle-stats/BarkBattleWorkStatsPanel.test.tsx`
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
- `playStartCount`
|
||||||
|
- `finishCount`
|
||||||
|
- `winCount`
|
||||||
|
- `drawCount`
|
||||||
|
- `lossCount`
|
||||||
|
- `flaggedCount`
|
||||||
|
- `leaderboardEntryCount`
|
||||||
|
- `bestLeaderboardScore`
|
||||||
|
- `bestFinalEnergy`
|
||||||
|
- `averageFinalEnergy`
|
||||||
|
- `updatedAt`
|
||||||
|
|
||||||
|
### Task 4.4: 广场卡片/我的作品适配
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/custom-world-home/creationWorkShelf.ts`
|
||||||
|
- Modify: `src/components/custom-world-home/CustomWorldCreationHub.tsx`
|
||||||
|
- Modify: `src/components/rpg-entry/rpgEntryWorldPresentation.ts`
|
||||||
|
- Modify: `src/services/publicWorkCode.ts` 如分享码需要支持
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Bark Battle 作品能展示、打开详情、开始游玩。
|
||||||
|
- 不新增独立 Bark Battle 专区。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 阶段五:收口验证
|
||||||
|
|
||||||
|
### Task 5.1: 自动测试清单
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p shared-contracts bark_battle
|
||||||
|
cargo test -p module-bark-battle
|
||||||
|
cargo test -p spacetime-module bark_battle
|
||||||
|
cargo test -p spacetime-client bark_battle
|
||||||
|
cargo test -p api-server bark_battle
|
||||||
|
npm test -- --run packages/shared/src/contracts/barkBattle.test.ts
|
||||||
|
npm test -- --run src/services/bark-battle/barkBattleClient.test.ts
|
||||||
|
npm test -- --run src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx
|
||||||
|
npm test -- --run src/games/bark-battle/domain/__tests__/BarkDetector.test.ts src/games/bark-battle/application/__tests__/BarkBattleController.test.ts src/games/bark-battle/ui/__tests__/BarkBattleRuntimeShell.test.tsx
|
||||||
|
npx tsc -p tsconfig.typecheck-guardrails.json --noEmit --pretty false
|
||||||
|
npm run check:encoding
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5.2: 后端 smoke
|
||||||
|
|
||||||
|
1. 按项目脚本启动 SpacetimeDB + api-server,优先使用 `npm run api-server`,不要使用旧命令。
|
||||||
|
2. 确认 `/healthz`。
|
||||||
|
3. smoke 流程:创建草稿 → 保存配置 → 发布 → 拉 runtime config → start run → finish run → 查询 leaderboard/history/stats。
|
||||||
|
|
||||||
|
### Task 5.3: 人工验收路径
|
||||||
|
|
||||||
|
1. 进入创作入口/玩法选择,选择 Bark Battle。
|
||||||
|
2. 在单页轻配置表单中填写标题,选择主题、狗狗皮肤、难度,保持排行榜开启。
|
||||||
|
3. 保存草稿。
|
||||||
|
4. 发布作品。
|
||||||
|
5. 发布后自动进入作品详情。
|
||||||
|
6. 点击开始游玩进入正式 runtime。
|
||||||
|
7. 授权麦克风,完成 30 秒单局。
|
||||||
|
8. 结算页显示后端 `serverResult` 和 score summary。
|
||||||
|
9. 若胜利,排行榜出现本局成绩。
|
||||||
|
10. 我的记录显示最近记录和个人最佳摘要。
|
||||||
|
11. 作品详情/作者视角能看到作品统计。
|
||||||
|
12. 广场/作品卡片和我的作品入口都能再次进入详情和 runtime。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 不做范围
|
||||||
|
|
||||||
|
- 不做实时多人。
|
||||||
|
- 不做 ghost replay。
|
||||||
|
- 不做 AI 狗叫识别。
|
||||||
|
- 不保存原始音频、PCM、waveform 或可还原语音内容。
|
||||||
|
- 不做独立 Bark Battle 专区/活动页。
|
||||||
|
- 不做挑战分享、好友邀请、多人数房间。
|
||||||
|
- 不做复杂编辑器、多步骤向导、规则参数编辑、AI 生成配置。
|
||||||
|
- 不做 DAU/留存、按小时统计曲线、好友对比。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 三人并行建议
|
||||||
|
|
||||||
|
### 开发者 A:后端契约与领域规则
|
||||||
|
|
||||||
|
负责 Task 1.1、1.3、1.4。先提交 contracts 与 `module-bark-battle`,为后续后端/前端提供稳定类型和裁决规则。
|
||||||
|
|
||||||
|
### 开发者 B:SpacetimeDB + api-server
|
||||||
|
|
||||||
|
负责 Task 2.1 到 2.6。必须等开发者 A 的 DTO/领域规则基本稳定后开始,或先基于计划字段开分支实现表结构。
|
||||||
|
|
||||||
|
### 开发者 C:前端纵切与 UI
|
||||||
|
|
||||||
|
负责 Task 3.x 与 4.x。开始时可先做组件空态和 service client 类型,真正联调等 B 的 BFF ready。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 推荐提交节奏
|
||||||
|
|
||||||
|
1. `feat: add bark battle contracts and domain rules`
|
||||||
|
2. `feat: add bark battle spacetime tables and reducers`
|
||||||
|
3. `feat: add bark battle api server routes`
|
||||||
|
4. `feat: add bark battle creation editor`
|
||||||
|
5. `feat: connect bark battle runtime to server results`
|
||||||
|
6. `feat: add bark battle leaderboard history stats`
|
||||||
|
7. `docs: finalize bark battle phase2 verification guide`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 完成定义
|
||||||
|
|
||||||
|
Phase 2 完成必须同时满足:
|
||||||
|
|
||||||
|
- Bark Battle 可以从正式创作入口创建轻配置作品。
|
||||||
|
- 作品可以发布为稳定 workId。
|
||||||
|
- 作品详情/广场/我的作品可以发现并进入正式 runtime。
|
||||||
|
- runtime 从后端发布态 config 拉配置。
|
||||||
|
- start run 写 `work_play_start`。
|
||||||
|
- finish 只上传派生指标。
|
||||||
|
- 后端裁决 `serverResult` / `scoreSummary` / `leaderboardScore` / `antiCheatFlags`。
|
||||||
|
- 胜利局进入按 `workId + difficultyPreset + rulesetVersion` 分榜的排行榜。
|
||||||
|
- 个人历史和作品统计可查询。
|
||||||
|
- 自动测试、encoding、typecheck、diff check 和人工验收路径通过。
|
||||||
@@ -127,6 +127,22 @@
|
|||||||
- 验证方式:执行 `npm run check:encoding`、`node scripts/check-wechat-miniprogram-auth-smoke.mjs`、`cargo test -p shared-contracts wechat_bind_phone_request_accepts_mini_program_phone_code --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_miniprogram_bind_phone_code_activates_pending_user --manifest-path server-rs/Cargo.toml -- --nocapture`。
|
- 验证方式:执行 `npm run check:encoding`、`node scripts/check-wechat-miniprogram-auth-smoke.mjs`、`cargo test -p shared-contracts wechat_bind_phone_request_accepts_mini_program_phone_code --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_miniprogram_bind_phone_code_activates_pending_user --manifest-path server-rs/Cargo.toml -- --nocapture`。
|
||||||
- 关联文档:`docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md`。
|
- 关联文档:`docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md`。
|
||||||
|
|
||||||
|
## 2026-05-13 宝贝爱画先作为寓教于乐独立本地 Demo 落地
|
||||||
|
|
||||||
|
- 背景:第三关 `宝贝爱画` 需要默认出现在“发现 / 寓教于乐”板块下方,但本阶段只验证画板、手部绘制、绘画魔法和本地保存闭环,不进入创作模板、公开作品或正式持久化。
|
||||||
|
- 决策:`baby-love-drawing / 宝贝爱画` 先作为独立运行态接入,入口由发现页寓教于乐默认卡片打开,并支持 `/runtime/baby-love-drawing` 直达;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时前端不展示频道/卡片且直达路由回落主应用。绘画魔法统一走 `POST /api/creation/edutainment/baby-love-drawing/magic` 后端安全代理,使用 VectorEngine `gpt-image-2-all` 与原始画布 Data URL 参考图生成绘本风图片;保存只写 localStorage,正式持久化后续再设计。
|
||||||
|
- 影响范围:`packages/shared/src/contracts/edutainmentBabyDrawing.ts`、`src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.tsx`、`src/services/edutainment-baby-drawing/`、`src/routing/appRoutes.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`server-rs/crates/api-server/src/edutainment_baby_drawing.rs`、`src/index.css`、宝贝爱画 PRD 与技术方案。
|
||||||
|
- 验证方式:执行宝贝爱画 model/runtime/service/route 定向测试、`npm run typecheck`、定向 ESLint、`cargo test -p api-server edutainment_baby_drawing --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server resolves_runtime_paths_to_creation_type_ids --manifest-path server-rs/Cargo.toml` 和编码检查;真实魔法生成需配置 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`。
|
||||||
|
- 关联文档:`docs/prd/BABY_LOVE_DRAWING_EDUTAINMENT_LEVEL_PRD_2026-05-13.md`、`docs/technical/BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md`。
|
||||||
|
|
||||||
|
## 2026-05-12 宝贝识物创作同时生成玩法视觉主题包
|
||||||
|
|
||||||
|
- 背景:`宝贝识物` 创作原本只根据两个关键词生成物品透明图,运行态背景、UI、礼物盒和篮子仍使用固定 CSS 绘本风,无法根据“小猪佩琪 / 奥特曼”或“苹果 / 橘子”等创作者提示词做主题化包装。
|
||||||
|
- 决策:`POST /api/creation/edutainment/baby-object-match/assets` 同一次 image-2 / VectorEngine 调用链返回两个物品图和 `visualPackage`。视觉包包含 `background`、`ui-frame`、`gift-box`、`basket`、`smoke-puff` 五类资源;总风格保持寓教于乐明亮卡通绘本插画风,主题按两个物品关键词匹配,水果偏果园自然,动漫角色 / 玩具偏动漫玩具。物品图和礼物盒 / 篮子 / UI / 烟雾特效资源走透明 PNG 后处理,背景为清爽不遮挡玩法区的环境图;运行态中礼物盒按约 2 倍视觉尺寸展示、篮子按约 1.5 倍展示,礼物盒打开时使用 `smoke-puff` 弹出中央物品并移除礼盒。前端草稿保存该包,运行态消费该包;旧草稿以 `visualPackage = null` 继续使用 CSS 兜底。
|
||||||
|
- 影响范围:`packages/shared/src/contracts/edutainmentBabyObject.ts`、`server-rs/crates/api-server/src/edutainment_baby_object.rs`、`src/services/edutainment-baby-object/babyObjectMatchClient.ts`、`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`src/index.css`、宝贝识物 PRD 与技术方案。
|
||||||
|
- 验证方式:执行宝贝识物 service / runtime 定向测试、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml`、相关 ESLint 与编码检查;真实生图需配置 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`。
|
||||||
|
- 关联文档:`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
||||||
|
|
||||||
## 2026-05-11 拼图与抓大鹅结果页音频资产复用通用创作音频链路
|
## 2026-05-11 拼图与抓大鹅结果页音频资产复用通用创作音频链路
|
||||||
|
|
||||||
- 背景:拼图和抓大鹅结果页需要接入 Suno 背景音乐,抓大鹅还需要物体点击音效,但当前两类作品没有独立的作品级音频表或 metadata 字段。
|
- 背景:拼图和抓大鹅结果页需要接入 Suno 背景音乐,抓大鹅还需要物体点击音效,但当前两类作品没有独立的作品级音频表或 metadata 字段。
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
- `operations/`:后台运营核查、对账和排障查询。
|
- `operations/`:后台运营核查、对账和排障查询。
|
||||||
- `prd/`:产品需求与阶段计划。
|
- `prd/`:产品需求与阶段计划。
|
||||||
|
|
||||||
|
新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。标签用于跨目录检索,不替代 `docs/` 的目录分类;历史文档不要求批量重命名。
|
||||||
|
|
||||||
## 推荐阅读顺序
|
## 推荐阅读顺序
|
||||||
|
|
||||||
通用复杂任务:
|
通用复杂任务:
|
||||||
@@ -96,5 +98,6 @@ RPG 创作与运行时链路:
|
|||||||
|
|
||||||
- 新增工程实现时,如果已有对应文档,必须同步更新。
|
- 新增工程实现时,如果已有对应文档,必须同步更新。
|
||||||
- 如果没有对应文档,新文档放入 `docs/` 下合适分类。
|
- 如果没有对应文档,新文档放入 `docs/` 下合适分类。
|
||||||
|
- 新文档文件名必须使用 `【标签名】` 前缀,标题尽量保留中文语义,日期使用 `YYYY-MM-DD`。
|
||||||
- `.hermes/shared-memory/` 只保留跨任务、跨成员、高频使用的摘要和索引。
|
- `.hermes/shared-memory/` 只保留跨任务、跨成员、高频使用的摘要和索引。
|
||||||
- 如果文档与代码冲突,先确认代码事实,再更新过期文档和共享记忆。
|
- 如果文档与代码冲突,先确认代码事实,再更新过期文档和共享记忆。
|
||||||
|
|||||||
@@ -93,15 +93,55 @@
|
|||||||
- 验证:运行 `npx vitest run src\services\useMocapInput.test.ts src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx`,并在本地硬件服务启动后进入 `/child-motion-demo` 实测站位、招手、左右手挥动和跳跃阶段。
|
- 验证:运行 `npx vitest run src\services\useMocapInput.test.ts src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx`,并在本地硬件服务启动后进入 `/child-motion-demo` 实测站位、招手、左右手挥动和跳跃阶段。
|
||||||
- 关联:`src/services/useMocapInput.ts`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
- 关联:`src/services/useMocapInput.ts`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
||||||
|
|
||||||
|
## 儿童动作 Demo 左右手阶段误通过先查身体侧映射和手臂展开阈值
|
||||||
|
|
||||||
|
- 现象:热身关“挥动左手 / 挥动右手”阶段,用户只是手自然下垂、横向小幅抖动,或挥了相反侧手,也可能被判定通过。
|
||||||
|
- 原因:本地 mocap 的 handedness 当前按摄像头视角输出,不能直接当作用户身体左/右;同时左右手阶段的目标是确认现实空间安全,需要验证手臂向外打开和上下摆动角度,不能只看手部 `x` 轨迹范围。
|
||||||
|
- 处理:热身关中用户左手应消费 camera-right,用户右手应消费 camera-left;左右手阶段只在同侧肩肘腕外展、手腕非自然下垂、连续有效帧、横向范围、上下摆动范围、肩腕角度范围和上下方向变化全部达标时完成,并记录轨迹空间包络、角度范围和最大外展距离。
|
||||||
|
- 验证:运行 `npx vitest run src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx src\components\child-motion-demo\childMotionWarmupModel.test.ts`,确认相反侧手、自然下垂、单纯横向轨迹不会完成,真实展开上下摆动可以完成。
|
||||||
|
- 关联:`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx`、`src/components/child-motion-demo/childMotionWarmupModel.ts`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
||||||
|
|
||||||
|
## 儿童动作 Demo 角色轮廓抽搐先查 mocap 坐标防抖和渲染分层
|
||||||
|
|
||||||
|
- 现象:`/child-motion-demo` 中间半透明小人在真实硬件驱动下左右轻微来回摆,移动过程中看起来忽大忽小,用户很难稳定停在目标圆环内。
|
||||||
|
- 原因:`general.body.center_norm.x` 原始值逐包直接写入 `avatarX` 时,硬件坐标小噪声会直接驱动位置保持判定和 CSS 动画;如果角色外层同时承担横向定位和跳跃 `transform`,半透明 PNG 在移动时也更容易出现重采样抖动观感。
|
||||||
|
- 处理:mocap 身体中心进入角色位置前必须先 clamp,再经过小幅死区、低通阻尼和单包最大步长限制;键盘 A/D 调试输入仍保持即时。角色 DOM 外层只负责横向定位,内层 sprite 负责轮廓图和跳跃位移,避免同一层 `transform` 同时表达多种运动。
|
||||||
|
- 验证:运行 `npx vitest run src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx src\components\child-motion-demo\childMotionWarmupModel.test.ts src\services\useMocapInput.test.ts src\services\child-motion-demo\childMotionDebugInput.test.ts`,并用真实硬件进入站位阶段观察小幅身体晃动不会导致角色频繁左右跳动。
|
||||||
|
- 关联:`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx`、`src/index.css`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
||||||
|
|
||||||
## 宝贝识物选篮误触发先查多套判定和残余轨迹
|
## 宝贝识物选篮误触发先查多套判定和残余轨迹
|
||||||
|
|
||||||
- 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮。
|
- 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮。
|
||||||
- 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名和手部轨迹,或在 `correct` / `wrong` 反馈阶段继续累计手部路径,会把抓握、反馈期间残留移动或未知侧别手部误算成下一次选篮。
|
- 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名和手部轨迹,或在 `correct` / `wrong` 反馈阶段继续累计手部路径,会把抓握、反馈期间残留移动或未知侧别手部误算成下一次选篮。
|
||||||
- 处理:宝贝识物选篮只使用明确 `leftHand` / `rightHand` 的连续横向轨迹阈值;侧别为 `unknown` 的手部轨迹不参与选篮;礼物盒打开和反馈阶段清空轨迹,不在非 `active` 阶段累计路径。礼物盒激活仍使用 `open_palm -> grab` 抓握序列。
|
- 处理:宝贝识物选篮只使用明确 `leftHand` / `rightHand` 的连续横向轨迹阈值;侧别为 `unknown` 的手部轨迹不参与选篮;反馈阶段清空轨迹,不在非 `active` 阶段累计路径。进入关卡和每次正确反馈结束后自动弹出物品,不再用 `open_palm -> grab` 抓握序列激活礼物盒。
|
||||||
- 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物选篮前需要换算为用户身体视角;`rightHand` 轨迹代表玩家左手并进入左篮,`leftHand` 轨迹代表玩家右手并进入右篮。键鼠调试不走该换算,仍保持鼠标左键=左篮、右键=右篮。
|
- 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物选篮前需要换算为用户身体视角;`rightHand` 轨迹代表玩家左手并进入左篮,`leftHand` 轨迹代表玩家右手并进入右篮。键鼠调试不走该换算,仍保持鼠标左键=左篮、右键=右篮。
|
||||||
- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/services/useMocapInput.test.ts`,确认动作名负向测试、未知侧别负向测试和左右手横向轨迹测试通过。
|
- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/services/useMocapInput.test.ts`,确认动作名负向测试、未知侧别负向测试和左右手横向轨迹测试通过。
|
||||||
- 关联:`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
- 关联:`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
||||||
|
|
||||||
|
## 宝贝爱画左右手反了先查 mocap 摄像头视角换算
|
||||||
|
|
||||||
|
- 现象:`宝贝爱画` 中真实硬件下左手指示器和右手画笔表现反向,用户抬右手却出现左手选色指示器,或抬左手却驱动画笔 / 橡皮。
|
||||||
|
- 原因:本地 mocap 的 handedness 当前按摄像头视角输出,不能直接当成用户身体左 / 右;宝贝爱画初版直接消费 `latestCommand.leftHand/rightHand`,漏做摄像头视角到用户身体视角的换算。
|
||||||
|
- 处理:宝贝爱画运行态消费 mocap 前先换算:`rightHand` 作为用户左手,用于颜色悬停和左手指示器;`leftHand` 作为用户右手,用于画笔 / 橡皮光标、绘制、擦除和工具切换。键鼠调试输入不做该换算,继续保持鼠标左键为左手、右键为右手。
|
||||||
|
- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.test.tsx src/components/edutainment-runtime/babyLoveDrawingModel.test.ts`,确认 camera-left 驱动用户右手画笔、camera-right 渲染用户左手选色指示器。
|
||||||
|
- 关联:`src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.tsx`、`docs/technical/BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md`。
|
||||||
|
|
||||||
|
## 宝贝识物创作卡在准备结果页先查长耗时 image-2 请求
|
||||||
|
|
||||||
|
- 现象:`/creation/baby-object-match` 创作生成停在“准备结果页”,约 3 分钟后显示“生成失败 / 请求超时”;后端日志可能出现同一路由 `status=502 latency_ms=231291`,或前端已失败但后端稍后返回 200。
|
||||||
|
- 原因:宝贝识物一次创作会生成 2 张物品图和 `background`、`ui-frame`、`gift-box`、`basket`、`smoke-puff` 5 张视觉包装图。旧前端只等待 180 秒并对长耗时 POST 自动重试,容易在 VectorEngine 仍在生成时先 abort,再重复发起第二次生成;上游某张图超过后端 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 或返回 5xx 时会表现为 502。
|
||||||
|
- 处理:`babyObjectMatchClient` 对 `/api/creation/edutainment/baby-object-match/assets` 使用 10 分钟超时并取消自动重试;后端并发启动物品图和视觉主题包生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,按资源类别输出开始、完成和耗时日志。
|
||||||
|
- 验证:运行 `npm run test -- src/services/edutainment-baby-object/babyObjectMatchClient.test.ts src/services/miniGameDraftGenerationProgress.test.ts`、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml` 和编码检查;真实联调时查看 `宝贝识物 image-2 资源生成完成` 耗时是否小于前端超时,若仍 502 再看 `VectorEngine 图片生成上游错误` 的 `upstreamStatus/raw_excerpt`。
|
||||||
|
- 关联:`src/services/edutainment-baby-object/babyObjectMatchClient.ts`、`src/services/miniGameDraftGenerationProgress.ts`、`server-rs/crates/api-server/src/edutainment_baby_object.rs`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
||||||
|
|
||||||
|
## 寓教于乐作品和宝贝识物模板同时消失先查入口种子
|
||||||
|
|
||||||
|
- 现象:发现页“寓教于乐”分类下已发布的宝贝识物作品突然消失,同时创作界面模板选项中也看不到或无法正常展示 `宝贝识物`。
|
||||||
|
- 原因:创作入口配置事实源已迁到 SpacetimeDB `creation_entry_type_config`;前端用 `baby-object-match` 入口可见性同时控制创作模板展示和发现页宝贝识物公开作品合入。若默认种子或后台配置缺少 `baby-object-match` 行,两条链路会一起被判定为不可见。
|
||||||
|
- 处理:确认 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 默认种子包含 `id=baby-object-match`、`title=宝贝识物`、`visible=true`、`open=true`、`sort_order=90`;api-server 测试降级配置也要同步包含该类型。入口图片路径需指向真实存在资源,避免卡片图片 404。
|
||||||
|
- 验证:运行 `cargo test -p module-runtime default_creation_entry_types_include_baby_object_match --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server test_creation_entry_config_response_keeps_baby_object_match_visible --manifest-path server-rs/Cargo.toml`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 和 `npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts`。
|
||||||
|
- 关联:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`、`server-rs/crates/api-server/src/creation_entry_config.rs`、`docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md`。
|
||||||
|
|
||||||
## 儿童动作 Demo 绘本风资源未生成先查 VectorEngine 配置
|
## 儿童动作 Demo 绘本风资源未生成先查 VectorEngine 配置
|
||||||
|
|
||||||
- 现象:`/child-motion-demo` 已经呈现绘本草地风格,但 `public/child-motion-demo/picture-book-grass-stage.png`、`picture-book-grass-floor.png`、`picture-book-ground-ring.png`、`picture-book-character-outline.png`、`picture-book-ui-panel.png` 或 `picture-book-ui-button.png` 不存在,Network 里对应图片返回 404,或运行 `npm run assets:child-motion-demo -- --live` 返回缺少 VectorEngine 配置。
|
- 现象:`/child-motion-demo` 已经呈现绘本草地风格,但 `public/child-motion-demo/picture-book-grass-stage.png`、`picture-book-grass-floor.png`、`picture-book-ground-ring.png`、`picture-book-character-outline.png`、`picture-book-ui-panel.png` 或 `picture-book-ui-button.png` 不存在,Network 里对应图片返回 404,或运行 `npm run assets:child-motion-demo -- --live` 返回缺少 VectorEngine 配置。
|
||||||
@@ -250,8 +290,8 @@
|
|||||||
|
|
||||||
- 现象:本地 `npm run dev` 因 `3101` 已占用、重复发布 SpacetimeDB wasm 编译太慢,或只想检查 `spacetime-module` 语法而被完整联调链路拖慢。
|
- 现象:本地 `npm run dev` 因 `3101` 已占用、重复发布 SpacetimeDB wasm 编译太慢,或只想检查 `spacetime-module` 语法而被完整联调链路拖慢。
|
||||||
- 原因:`npm run dev` 默认同时启动 SpacetimeDB standalone、发布 `server-rs/crates/spacetime-module`、启动 Rust `api-server`、主站 Vite 与后台 Vite;并非每个阶段都需要完整重启和重新发布。
|
- 原因:`npm run dev` 默认同时启动 SpacetimeDB standalone、发布 `server-rs/crates/spacetime-module`、启动 Rust `api-server`、主站 Vite 与后台 Vite;并非每个阶段都需要完整重启和重新发布。
|
||||||
- 处理:`npm run dev` 启动后会把实际 SpacetimeDB URL 记录到 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`。下次启动即使没有传 `--skip-spacetime`,脚本也会先检查 `spacetime.pid` 对应进程和该 URL 是否在线;在线则直接复用现有宿主。确认需要新启动 SpacetimeDB 时,脚本先检测 `3101`,被占用则输出占用进程并选择最近可用端口,保证 publish 与 `api-server` 都连接同一个实际 SpacetimeDB URL。`api-server` 启动前也会检测 `8082` 并选择最近可用端口。Windows / Git Bash 下不要用 `tr/head/xargs` 管道读取 `spacetime.pid` 或 URL 记录,脚本应使用 Node 读取并短重试,避免 `tr: read error: Device or resource busy`;未修改 `spacetime-module` 时使用 `npm run dev -- --skip-publish`;只查模块语法时执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。`npm run dev` 会在启动前检查 SpacetimeDB、api-server、主站 Vite、后台 Vite 端口,不可用时自动寻找后续可用端口,并把实际端口传给 publish、后端环境变量和前端代理目标。
|
- 处理:`npm run dev` 启动后会把实际 SpacetimeDB URL 记录到 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`。下次启动即使没有传 `--skip-spacetime`,脚本也会先检查 `spacetime.pid` 对应进程和该 URL 是否在线;在线则直接复用现有宿主。确认需要新启动 SpacetimeDB 时,脚本先检测 `3101`,被占用则输出占用进程并选择最近可用端口,保证 publish 与 `api-server` 都连接同一个实际 SpacetimeDB URL。显式传 `--skip-spacetime` 时表示复用既有宿主,脚本不再对 SpacetimeDB 端口做可用性漂移;`--spacetime-port 3101` 就是后端要连接的实际端口,避免被误改到空闲但未启动的 `3102`。`api-server` 启动前也会检测 `8082` 并选择最近可用端口。Windows / Git Bash 下不要用 `tr/head/xargs` 管道读取 `spacetime.pid` 或 URL 记录,脚本应使用 Node 读取并短重试,避免 `tr: read error: Device or resource busy`;未修改 `spacetime-module` 时使用 `npm run dev -- --skip-publish`;只查模块语法时执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。`npm run dev` 会在启动前检查 SpacetimeDB、api-server、主站 Vite、后台 Vite 端口,不可用时自动寻找后续可用端口,并把实际端口传给 publish、后端环境变量和前端代理目标。
|
||||||
- 验证:`--skip-spacetime` 后脚本复用现有 `http://127.0.0.1:3101`;`3101` 或 `8082` 被其他进程占用时,脚本输出占用进程并使用最近可用端口;`--skip-publish` 后不再进入 publish 阶段;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能完成 Rust 语法和类型检查。端口漂移时控制台会打印 `[dev:ports] ... 不可用,改用 ...`,后续 `[dev:rust] web/admin web/rust api/spacetime` 地址应与实际端口一致。
|
- 验证:`--skip-spacetime` 后脚本复用现有 `http://127.0.0.1:3101`;日志中的 `[dev:rust] spacetime:` 不应漂移到没有服务的 `3102`;`GET /api/creation-entry/config` 不应返回连接空端口导致的 `502`。`3101` 或 `8082` 被其他进程占用时,脚本输出占用进程并使用最近可用端口;`--skip-publish` 后不再进入 publish 阶段;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能完成 Rust 语法和类型检查。端口漂移时控制台会打印 `[dev:ports] ... 不可用,改用 ...`,后续 `[dev:rust] web/admin web/rust api/spacetime` 地址应与实际端口一致。
|
||||||
- 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`、`scripts/dev-rust-stack.sh`。
|
- 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`、`scripts/dev-rust-stack.sh`。
|
||||||
|
|
||||||
## 本地 SpacetimeDB publish 401 可清本地库重发
|
## 本地 SpacetimeDB publish 401 可清本地库重发
|
||||||
@@ -654,6 +694,14 @@
|
|||||||
- 验证:mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。
|
- 验证:mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。
|
||||||
- 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。
|
- 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。
|
||||||
|
|
||||||
|
## 后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码
|
||||||
|
|
||||||
|
- 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind` 和 `status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。
|
||||||
|
- 原因:SpacetimeDB HTTP SQL 对无载荷枚举会返回 SATS 形态 `[variant_index, []]`;后台通用 normalizer 曾把任何 `[0, value]` 都当作 `Option::Some(value)` 展开,导致 `[0, []]` 最终只剩 `[]`。
|
||||||
|
- 处理:通用表查询解析应先按表名和列名识别已知业务枚举,再落回 Option / Timestamp 通用展开;例如 `profile_recharge_order.kind` 映射为 `points` / `membership`,`profile_recharge_order.status` 映射为 `pending` / `paid` / `failed` / `closed` / `refunded`。
|
||||||
|
- 验证:执行 `cargo test -p api-server admin_database -- --nocapture`,并确认后台详情弹层的 `raw` 与表格 `cells` 都显示业务字符串。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/admin.rs`、`docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md`。
|
||||||
|
|
||||||
## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布
|
## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布
|
||||||
|
|
||||||
- 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。
|
- 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
- `.hermes/shared-memory/` 团队级长期记忆
|
- `.hermes/shared-memory/` 团队级长期记忆
|
||||||
- `.hermes/plans/` 阶段性实施计划
|
- `.hermes/plans/` 阶段性实施计划
|
||||||
|
- `.hermes/todos/` 已确定需要执行、但尚未进入实施的共享 TODO 计划
|
||||||
- `.hermes/skills/` 未来可复用仓库级 skills
|
- `.hermes/skills/` 未来可复用仓库级 skills
|
||||||
- `docs/` 中 PRD、设计、技术、经验、审计、查询手册
|
- `docs/` 中 PRD、设计、技术、经验、审计、查询手册
|
||||||
- `AGENTS.md` 项目级 Agent 约束
|
- `AGENTS.md` 项目级 Agent 约束
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
|
|
||||||
- 保持修改范围聚焦,不做无关重构。
|
- 保持修改范围聚焦,不做无关重构。
|
||||||
- 复用、修改、扩展现有系统优先,避免新建重复系统或页面。
|
- 复用、修改、扩展现有系统优先,避免新建重复系统或页面。
|
||||||
|
- 新增 Markdown 文档时,文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;只在任务需要时重命名历史文档,避免无关大 diff。
|
||||||
- 涉及中文文本时注意 UTF-8 编码和乱码排查。
|
- 涉及中文文本时注意 UTF-8 编码和乱码排查。
|
||||||
- 涉及后端时遵循 DDD 分层,不把业务真相下沉到前端或临时兼容层。
|
- 涉及后端时遵循 DDD 分层,不把业务真相下沉到前端或临时兼容层。
|
||||||
- `maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求均视为历史残留,禁止新增、运行或引用;API smoke 统一使用 `npm run api-server` 与 `/healthz`。
|
- `maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求均视为历史残留,禁止新增、运行或引用;API smoke 统一使用 `npm run api-server` 与 `/healthz`。
|
||||||
@@ -51,9 +53,10 @@
|
|||||||
|
|
||||||
1. 运行与修改范围匹配的测试或验证命令。
|
1. 运行与修改范围匹配的测试或验证命令。
|
||||||
2. 更新相关 `docs/` 文档。
|
2. 更新相关 `docs/` 文档。
|
||||||
3. 若产生长期有效知识,更新 `.hermes/shared-memory/`。
|
3. 新增或沉淀 Markdown 文档时,确认文件名已使用 `【标签名】` 前缀。
|
||||||
4. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`。
|
4. 若产生长期有效知识,更新 `.hermes/shared-memory/`。
|
||||||
5. 在提交信息中区分代码变更与文档/记忆变更。
|
5. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`。
|
||||||
|
6. 在提交信息中区分代码变更与文档/记忆变更。
|
||||||
|
|
||||||
## 文档阅读顺序
|
## 文档阅读顺序
|
||||||
|
|
||||||
|
|||||||
15
.hermes/todos/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 项目共享 TODO 计划
|
||||||
|
|
||||||
|
本目录用于存放已经讨论定稿、需要后续执行,但当前尚未实施的项目级计划文档。
|
||||||
|
|
||||||
|
## 使用规则
|
||||||
|
|
||||||
|
- 每个 TODO 计划单独一个 Markdown 文件,文件名优先使用可检索的中文标题与日期。
|
||||||
|
- 每个 TODO 计划文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。
|
||||||
|
- 计划内容必须足够明确,后续开发者可以直接据此开始实施。
|
||||||
|
- 已执行完成的计划应迁移到对应 `docs/` 技术/规划文档,或在本文档中标记完成并移出 TODO 队列。
|
||||||
|
- 不在这里保存个人私密路径、密钥、临时聊天记录或未确认猜测。
|
||||||
|
|
||||||
|
## 当前待执行
|
||||||
|
|
||||||
|
- [【后端架构】api-server 能力模块化与图片资产 Adapter 收口计划](./【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# api-server 能力模块化与生成资产 Adapter 完整收口计划
|
||||||
|
|
||||||
|
状态:待执行
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
目标是把 `api-server` 从“超大 `app.rs` + 多个超大 handler 文件 + 多处生成资产重复链路”收成可长期维护的能力 Module 结构。
|
||||||
|
|
||||||
|
本计划完整覆盖:路由能力模块化、生成图片资产 Adapter、复杂媒体链路扩展、大 handler 瘦身、文档与验收。全程不改变 HTTP contract、DTO、SpacetimeDB schema、前端行为和计费语义,除非某阶段文档先明确提出并单独批准。
|
||||||
|
|
||||||
|
## 文档交付
|
||||||
|
|
||||||
|
先新增一个总纲,再按阶段新增单独执行文档:
|
||||||
|
|
||||||
|
- 总纲:`docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md`
|
||||||
|
- 阶段 1:`docs/technical/【后端架构】api-server路由能力模块化执行计划-2026-05-14.md`
|
||||||
|
- 阶段 2:`docs/technical/【后端架构】生成图片资产Adapter收口执行计划-2026-05-14.md`
|
||||||
|
- 阶段 3:`docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md`
|
||||||
|
- 阶段 4:`docs/technical/【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md`
|
||||||
|
- 同步更新 `server-rs/crates/api-server/README.md`、`docs/technical/README.md`、本 TODO 文档。
|
||||||
|
|
||||||
|
## 阶段 0:基线盘点与总纲冻结
|
||||||
|
|
||||||
|
- 盘点 `app.rs` 当前全部 route,按 `admin/auth/assets/profile/creation/runtime/story/platform/internal` 分类形成 route inventory。
|
||||||
|
- 盘点 Big Fish、Square Hole、Custom World、Puzzle、Match3D、Visual Novel、音频、视频、GLB 的生成资产链路,标出 provider、下载方式、OSS prefix、asset kind、entity binding、计费位置、降级行为。
|
||||||
|
- 在总纲文档中冻结边界:`api-server` 只做 HTTP/SSE/BFF、鉴权、DTO 映射、平台服务编排;领域规则仍归 `module-*`,SpacetimeDB 真相仍归 `spacetime-module`。
|
||||||
|
|
||||||
|
退出条件:总纲和 inventory 能指导后续编码,不再只是一段方向描述。
|
||||||
|
|
||||||
|
## 阶段 1:路由能力模块化完整收口
|
||||||
|
|
||||||
|
- 新增 `server-rs/crates/api-server/src/modules/`,每个能力 Module 暴露 `router(state) -> Router<AppState>`。
|
||||||
|
- 第一批迁移低风险路由:`admin`、`auth`、`assets`、`profile`、`internal`、`health`。
|
||||||
|
- 第二批迁移平台编排路由:`ai_tasks`、`llm`、`speech`、`wechat`、`creative_agent`、`visual_novel`、通用 audio。
|
||||||
|
- 第三批迁移玩法路由:`big_fish`、`square_hole`、`puzzle`、`match3d`、`custom_world`、`story`、runtime save/settings/chat/inventory。
|
||||||
|
- `app.rs` 最终只保留全局 middleware、TraceLayer、request context、tracking middleware、`.merge(modules::*::router(...))` 和少量顶层 glue。
|
||||||
|
- handler 实现第一阶段可以继续留在原文件;本阶段只改变路由装配位置,不混入业务重构。
|
||||||
|
|
||||||
|
验收:route inventory 中所有原 route 仍存在;旧明确下线 route 继续 404;`cargo test -p api-server app --manifest-path server-rs/Cargo.toml` 或等价 route 回归通过。
|
||||||
|
|
||||||
|
## 阶段 2:稳定单图生成资产 Adapter 收口
|
||||||
|
|
||||||
|
- 新增 `modules/assets/generated_image_assets` 内部 Module,Interface 覆盖“provider 生成 -> 下载/base64 解码 -> MIME/extension 归一 -> OSS private upload -> HEAD -> asset_object confirm -> entity binding”。
|
||||||
|
- Adapter 输入包含 provider、prompt、negative prompt、size、reference images、OSS prefix/path/file name、asset kind、entity kind/id、slot、owner/profile/source_job_id、metadata、可选透明背景后处理。
|
||||||
|
- Adapter 输出包含 `legacy_public_path`、`object_key`、`asset_object_id`、`mime_type`、`extension`、`task_id`、`actual_prompt`。
|
||||||
|
- 首批迁移:
|
||||||
|
- Big Fish 正式图片:主图、动作图、舞台背景。
|
||||||
|
- Square Hole 图片:保留生成成功但入库失败时回退 Data URL。
|
||||||
|
- Custom World 场景图、自动草稿场景图、生成封面图。
|
||||||
|
- `asset_billing.rs` 仍由调用方显式包裹;Adapter 不扣费、不退款、不读钱包。
|
||||||
|
|
||||||
|
验收:三类调用方都经过同一 Adapter;删除旧重复 persist 函数后行为不变;Big Fish、Square Hole、Custom World 定向测试通过。
|
||||||
|
|
||||||
|
## 阶段 3:复杂媒体资产链路扩展
|
||||||
|
|
||||||
|
- 扩展 Adapter,但不把玩法图像处理规则塞进公共 Interface。
|
||||||
|
- Puzzle:
|
||||||
|
- 收口普通 generations、edits/multipart 生成结果的下载、OSS、asset object、binding。
|
||||||
|
- 拼图关卡 JSON 更新、参考图策略、UI 背景落位仍留在 Puzzle 编排层。
|
||||||
|
- Match3D:
|
||||||
|
- APIMart material sheet、VectorEngine 背景/容器/封面生成接入统一入库能力。
|
||||||
|
- 5x5 切图、绿幕透明化、格内校准、批量新增补齐规则仍留在 Match3D 专属处理器。
|
||||||
|
- 音频:
|
||||||
|
- 评估是否抽 `generated_media_assets`,但背景音乐、点击音效的计费和落位语义不与图片 Adapter 混用。
|
||||||
|
- GLB/视频:
|
||||||
|
- 仅历史转存链路复用“OSS + asset_object + binding”底层持久化能力,不恢复新草稿 GLB 生产。
|
||||||
|
|
||||||
|
验收:Puzzle 与 Match3D 的 generated 私有资产仍通过 `/api/assets/read-url` 换签读取;Match3D 不回退 Rodin/GLB;音频试听和运行态仍可播放。
|
||||||
|
|
||||||
|
## 阶段 4:超大 handler 能力内瘦身
|
||||||
|
|
||||||
|
- 在路由已模块化、资产 Adapter 已稳定后,再拆大文件,避免同时改 route 和业务实现。
|
||||||
|
- 对 `match3d.rs`、`puzzle.rs`、`custom_world.rs`、`custom_world_ai.rs`、`big_fish.rs`、`square_hole.rs` 分别按能力拆:
|
||||||
|
- `router.rs` 只挂路由。
|
||||||
|
- `handlers.rs` 只做 Axum extract、鉴权、request/response。
|
||||||
|
- `application.rs` 做 api-server 层编排。
|
||||||
|
- `assets.rs` 只放玩法专属生成资产策略。
|
||||||
|
- `mapper.rs` 只做 DTO/record 映射。
|
||||||
|
- `errors.rs` 只做该能力错误映射。
|
||||||
|
- 不把领域规则留在 handler;发现领域规则时只登记迁出候选,不在本阶段直接扩大到 `module-*` 重构。
|
||||||
|
|
||||||
|
验收:每个原超大文件显著缩小;新文件按能力可读;定向玩法测试和全量 `api-server` 测试通过。
|
||||||
|
|
||||||
|
## 阶段 5:清理、文档和最终验收
|
||||||
|
|
||||||
|
- 删除旧重复 helper、过时注释和已迁移的私有函数。
|
||||||
|
- 更新 `api-server` README:目录规则、Router 暴露规则、Adapter 边界、禁止事项。
|
||||||
|
- 更新 `.hermes/shared-memory` 中长期有效的架构约定和排障经验。
|
||||||
|
- 最终验收命令:
|
||||||
|
- `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
|
||||||
|
- `cargo test -p api-server --manifest-path server-rs/Cargo.toml`
|
||||||
|
- `npm run check:server-rs-ddd`
|
||||||
|
- `npm run check:encoding`
|
||||||
|
- `git diff --check`
|
||||||
|
- `npm run api-server` 后检查 `/healthz`
|
||||||
|
- 禁止使用 `api-server:maincloud` 作为本轮 smoke。
|
||||||
|
|
||||||
|
## Public Interfaces
|
||||||
|
|
||||||
|
- HTTP route、DTO、error envelope、SpacetimeDB schema、前端调用方式默认不变。
|
||||||
|
- 新增的都是 `api-server` 内部 Rust Interface,不进入 `shared-contracts`。
|
||||||
|
- 若后续任何阶段发现必须改 contract,先更新对应阶段文档和 G1 route/contract 矩阵,再单独实施。
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- 本计划目标是完整收口,不是只完成第一阶段。
|
||||||
|
- 可以分阶段提交,但每个阶段都必须有文档、测试和明确退出条件。
|
||||||
|
- `generated_image_assets` 首版必须至少被三个真实调用方使用,否则不算形成有效 Module。
|
||||||
@@ -30,6 +30,7 @@ Single-context layout: read root `CONTEXT.md` when present and architecture deci
|
|||||||
- 代码需要有完善的中文注释
|
- 代码需要有完善的中文注释
|
||||||
- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。
|
- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。
|
||||||
- 对工程的修改不仅要落地到代码更面,还要更改对应文档,若没有生成新的文档,文档统一存在doc目录中
|
- 对工程的修改不仅要落地到代码更面,还要更改对应文档,若没有生成新的文档,文档统一存在doc目录中
|
||||||
|
- 后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;例如 `【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md`。不要求批量重命名历史文档,除非本次任务明确涉及该文档。
|
||||||
- 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。
|
- 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。
|
||||||
- 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。
|
- 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。
|
||||||
- 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8;如果终端输出疑似乱码,要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。
|
- 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8;如果终端输出疑似乱码,要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。
|
||||||
|
|||||||
110
CONTEXT.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Genarrative Domain Context
|
||||||
|
|
||||||
|
Genarrative 是一个 AI 原生互动内容与小游戏平台,当前上下文记录团队在玩法、作品、运行态和平台闭环中使用的领域语言。
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
### Bark Battle
|
||||||
|
|
||||||
|
**汪汪声浪大作战**:
|
||||||
|
浏览器 2D 声控对战玩法,玩家通过麦克风响度触发声浪并推动能量条。
|
||||||
|
_Avoid_: 狗叫识别游戏、声纹识别玩法
|
||||||
|
|
||||||
|
**有效声浪触发**:
|
||||||
|
玩家麦克风采样点的归一化响度在冷却结束后达到或超过有效阈值时产生的一次计分输入。
|
||||||
|
_Avoid_: 有效叫声持续时长、狗叫识别结果、等待回落后的叫声
|
||||||
|
|
||||||
|
**有效阈值**:
|
||||||
|
用于判定麦克风采样是否产生有效声浪触发的响度门槛。
|
||||||
|
_Avoid_: 狗叫识别阈值、语义识别阈值
|
||||||
|
|
||||||
|
**声浪冷却**:
|
||||||
|
两次有效声浪触发之间必须满足的最小时间间隔。
|
||||||
|
_Avoid_: 叫声持续时长、回落等待时间
|
||||||
|
|
||||||
|
**能量条**:
|
||||||
|
表示玩家与对手当前声浪优势的连续对抗刻度。
|
||||||
|
_Avoid_: 血条、分数条
|
||||||
|
|
||||||
|
**单局结果**:
|
||||||
|
一局 Bark Battle 结束后形成的胜负、平局和派生统计摘要。
|
||||||
|
_Avoid_: 原始音频记录、语音内容
|
||||||
|
|
||||||
|
**Bark Battle 平台作品闭环**:
|
||||||
|
Bark Battle 从创作配置、发布作品、正式运行态到单局结果记录和作品统计的完整平台流程。
|
||||||
|
_Avoid_: 孤立 demo、只做表现深化
|
||||||
|
|
||||||
|
**轻创作配置作品**:
|
||||||
|
创作者只配置展示与难度预设字段、但不能直接配置公平性敏感规则的 Bark Battle 作品。
|
||||||
|
_Avoid_: 完整规则编辑器、固定官方关卡
|
||||||
|
|
||||||
|
**难度预设**:
|
||||||
|
Bark Battle 作品中用于选择 AI 对手行为强度的发布态配置值。
|
||||||
|
_Avoid_: 阈值配置、分数公式配置
|
||||||
|
|
||||||
|
**排行榜分榜**:
|
||||||
|
排行榜按作品、难度预设和规则集版本拆分后的独立排名空间,只收录后端裁决为玩家胜利的单局结果。
|
||||||
|
_Avoid_: 全难度混排、跨规则版本混排、失败刷分榜
|
||||||
|
|
||||||
|
**后端裁决结果**:
|
||||||
|
后端根据 start run 记录和 finish 派生指标校验后生成的正式单局结果。
|
||||||
|
_Avoid_: 前端最终分数、客户端胜负裁决
|
||||||
|
|
||||||
|
**派生指标**:
|
||||||
|
前端从本地 runtime 汇总出的不可还原原始音频的单局统计值。
|
||||||
|
_Avoid_: 原始音频、可还原语音内容
|
||||||
|
|
||||||
|
**作品统计投影**:
|
||||||
|
按作品聚合的 Bark Battle 游玩开始、完成结果、反作弊标记和最佳/平均表现摘要。
|
||||||
|
_Avoid_: 只从排行榜反推、原始音频分析、留存分析
|
||||||
|
|
||||||
|
**个人历史成绩**:
|
||||||
|
玩家本人可查看的 Bark Battle 最近完成记录和个人最佳摘要。
|
||||||
|
_Avoid_: 公开失败记录、完整无限历史、好友对比
|
||||||
|
|
||||||
|
**正式作品入口闭环**:
|
||||||
|
Bark Battle 作品从创作入口、作品详情、广场/作品卡片、我的作品到正式 runtime 路由的可发现、可进入流程。
|
||||||
|
_Avoid_: 内部试玩入口、独立活动专区
|
||||||
|
|
||||||
|
**轻配置编辑流程**:
|
||||||
|
Bark Battle 创作者用单页轻配置表单和预览卡片完成草稿保存与发布的创作流程。
|
||||||
|
_Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
|
||||||
|
|
||||||
|
**Phase 2 实施顺序**:
|
||||||
|
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
|
||||||
|
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
|
||||||
|
- 每个 **有效声浪触发** 必须达到 **有效阈值** 并满足 **声浪冷却**。
|
||||||
|
- **有效声浪触发** 推动 **能量条**。
|
||||||
|
- **能量条** 在倒计时结束时产生一个 **单局结果**。
|
||||||
|
- **单局结果** 可以被后端记录为派生摘要,但不包含原始麦克风音频。
|
||||||
|
- **Bark Battle 平台作品闭环** 包含发布态作品配置、**正式作品入口闭环**、run start / finish、个人历史成绩、**作品统计投影** 和最小排行榜。
|
||||||
|
- Phase 2 的 Bark Battle 作品是 **轻创作配置作品**,通过 **轻配置编辑流程** 创建;配置范围限制为标题、描述、主题/背景预设、狗狗皮肤预设和排行榜开关,其中 **难度预设** 只影响 AI 对手行为。
|
||||||
|
- **排行榜分榜** 由 `workId + difficultyPreset + rulesetVersion` 唯一确定,只收录 `serverResult = player_win` 的单局结果。
|
||||||
|
- **单局结果** 的正式胜负、分数与排行榜成绩来自 **后端裁决结果**;前端只提交 **派生指标**,客户端结果仅用于 debug/对账。
|
||||||
|
- **作品统计投影** 计入成功 start run 的 playStartCount、后端接受 finish 的 finishCount、胜/平/负、flagged、leaderboard 以及最佳/平均能量表现。
|
||||||
|
- **个人历史成绩** 由最近记录列表和个人最佳摘要组成,只允许本人查看;排行榜只公开入榜胜利成绩。
|
||||||
|
- **正式作品入口闭环** 必须覆盖创作入口、作品详情 CTA、广场/作品卡片、我的作品/个人作品架、稳定作品 ID runtime 路由和 `work_play_start` 埋点。
|
||||||
|
- **Phase 2 实施顺序** 固定为:契约与领域规则 → SpacetimeDB 表/reducer 与 api-server BFF → 最小前端纵切 → 投影与列表体验 → 收口验证。
|
||||||
|
|
||||||
|
## Example dialogue
|
||||||
|
|
||||||
|
> **Dev:** “第二阶段排行榜要按玩家狗叫持续时间排序吗?”
|
||||||
|
> **Domain expert:** “不按持续时间;Bark Battle 的计分输入是有效声浪触发,排行榜只能基于触发次数、峰值、能量条结果等派生摘要。”
|
||||||
|
|
||||||
|
## Flagged ambiguities
|
||||||
|
|
||||||
|
- “有效叫声”曾同时指代持续时长合规的声音片段和瞬时响度触发;已解析为 **有效声浪触发**,不再要求 `minBarkDurationMs` / `maxBarkDurationMs`,也不等待响度回落。
|
||||||
|
- “第二阶段”曾可能指玩法表现深化或平台接入;已解析为 **Bark Battle 平台作品闭环**,优先补正式 play type、作品配置、发布、正式 runtime、结果持久化、历史成绩、作品统计和最小排行榜。
|
||||||
|
- “创作者可配置作品”曾可能指完整规则编辑器;已解析为 **轻创作配置作品**,Phase 2 不允许创作者直接配置单局时长、有效阈值、声浪冷却、AI 细粒度参数、分数公式或反作弊阈值。
|
||||||
|
- “难度预设”曾可能影响阈值、冷却或计分;已解析为只影响 AI 对手行为,排行榜按 `workId + difficultyPreset + rulesetVersion` 分榜。
|
||||||
|
- “单局结果”曾可能由前端直接决定;已解析为必须由 **后端裁决结果** 决定,前端只提交触发次数、音量、能量、连击、时长等 **派生指标**。
|
||||||
|
- “排行榜成绩”曾可能收录胜/平/负或按触发次数排序;已解析为只收录玩家胜利局,并以 `finalEnergy` 优先、`triggerCount` / `maxVolume` / 标准局时长接近度 / `finishedAt` 作为后续排序因子。
|
||||||
|
- “作品统计”曾可能只从排行榜反推;已解析为独立 **作品统计投影**,失败、平局和 flagged finish 都可进入统计,但 rejected finish 不进入完成统计。
|
||||||
|
- “个人历史成绩”曾可能指完整无限历史或公开记录;已解析为仅本人可见的最近记录列表 + 个人最佳摘要,不公开失败、平局或 flagged 历史。
|
||||||
|
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
|
||||||
|
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
|
||||||
|
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
|
||||||
@@ -49,3 +49,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
|
|||||||
- `reference/`:偏目录、速查、检索辅助。
|
- `reference/`:偏目录、速查、检索辅助。
|
||||||
- `tracking/`:偏埋点原始事实和聚合投影查询,不放任务进度或钱包对账。
|
- `tracking/`:偏埋点原始事实和聚合投影查询,不放任务进度或钱包对账。
|
||||||
- `operations/`:偏后台运营核查、对账和排障查询。
|
- `operations/`:偏后台运营核查、对账和排障查询。
|
||||||
|
|
||||||
|
## 文档命名规则
|
||||||
|
|
||||||
|
后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。标签用于跨目录检索,不替代上面的目录分类;历史文档不要求批量重命名,除非本次任务明确涉及该文档。
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
## 文档列表
|
## 文档列表
|
||||||
|
|
||||||
- [CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md):4-8 岁儿童动作识别互动玩法 Demo 固定热身关的横屏体验流程、识别目标、表现需求与待确认事项。
|
- [CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md):4-8 岁儿童动作识别互动玩法 Demo 固定热身关的横屏体验流程、识别目标、表现需求与待确认事项。
|
||||||
|
- [TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md](./TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md):候选产品名“陶泥儿”的品牌定位归纳、gpt-image-2 Logo 概念图和主标选择建议。
|
||||||
- [CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md](./CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md):自定义世界里陶泥儿主输入与 AI 分工边界设计。
|
- [CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md](./CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md):自定义世界里陶泥儿主输入与 AI 分工边界设计。
|
||||||
- [CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md):自定义世界创作里“手填锚点 / AI 可改初稿 / 系统托管层”的平衡设计。
|
- [CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md):自定义世界创作里“手填锚点 / AI 可改初稿 / 系统托管层”的平衡设计。
|
||||||
- [CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md):纯 Agent 式创作工具与结构化工作台方案的优缺点对比,以及转型设计。
|
- [CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md):纯 Agent 式创作工具与结构化工作台方案的优缺点对比,以及转型设计。
|
||||||
|
|||||||
423
docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
# 陶泥儿品牌 Logo 概念稿
|
||||||
|
|
||||||
|
> 本稿是围绕候选产品名“陶泥儿”的品牌视觉探索,不替代当前已冻结的“百梦”正式命名口径。若后续确认更名,需要另起产品命名、前后端文案和商标检索落地方案。
|
||||||
|
|
||||||
|
## 1. 品牌定位归纳
|
||||||
|
|
||||||
|
“陶泥儿”适合承接的不是传统陶艺或儿童黏土,而是“把灵感塑形成可玩内容”的 AI 创作平台隐喻。
|
||||||
|
|
||||||
|
核心关键词:
|
||||||
|
|
||||||
|
- 精品:作品不是随手糊出来,而是经过 AI 辅助打磨、可被消费和传播的轻精品内容。
|
||||||
|
- UGC:用户是主要造物者,平台降低创作门槛。
|
||||||
|
- 创作:从一句脑洞、一个梗、一张图,生成小游戏、互动作品或可分享内容。
|
||||||
|
- 裂变与梗:名字要支持“开捏”“捏个梗”“捏个小游戏”这类用户语言。
|
||||||
|
- 轻度休闲:体验应松弛、即时、好玩,不走硬核生产工具气质。
|
||||||
|
- AI:AI 是塑形能力,不是冷冰冰的技术标签。
|
||||||
|
|
||||||
|
推荐品牌主张:
|
||||||
|
|
||||||
|
```text
|
||||||
|
把脑洞捏成小游戏
|
||||||
|
```
|
||||||
|
|
||||||
|
备选表达:
|
||||||
|
|
||||||
|
```text
|
||||||
|
捏个脑洞,马上开玩
|
||||||
|
AI 开捏,人人会创作
|
||||||
|
随手造梗,随心开玩
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 生成原则
|
||||||
|
|
||||||
|
本轮使用仓库 GPT-image-2 / VectorEngine 工作流生成 Logo 概念图,生成时刻意要求“无文字 Logo 图标”。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- AI 生图直接生成中文品牌字容易出现笔画错误,不适合作为正式字标。
|
||||||
|
- 当前阶段更适合先确定图形符号方向,再由设计师或前端继续做矢量化、字标搭配和多尺寸适配。
|
||||||
|
- 图标需要优先服务 App icon、平台左上角品牌、分享卡片和加载页,而不是一次性海报图。
|
||||||
|
|
||||||
|
生成文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
public/branding/taonier-logo-v3-concepts/
|
||||||
|
├─ taonier-logo-v3-contact-sheet.png
|
||||||
|
├─ taonier-v3-finger-spark.png
|
||||||
|
├─ taonier-v3-seed-pop.png
|
||||||
|
├─ taonier-v3-magic-dot.png
|
||||||
|
├─ taonier-v3-work-gem.png
|
||||||
|
└─ taonier-v3-soft-t.png
|
||||||
|
|
||||||
|
public/branding/taonier-logo-magic-dot-concepts/
|
||||||
|
├─ taonier-logo-magic-dot-contact-sheet.png
|
||||||
|
├─ taonier-magic-dot-orbit.png
|
||||||
|
├─ taonier-magic-dot-seal.png
|
||||||
|
├─ taonier-magic-dot-squish.png
|
||||||
|
├─ taonier-magic-dot-mold.png
|
||||||
|
└─ taonier-magic-dot-bloom.png
|
||||||
|
|
||||||
|
public/branding/taonier-logo-flat-concepts/
|
||||||
|
├─ taonier-logo-flat-contact-sheet.png
|
||||||
|
├─ taonier-flat-play-clay.png
|
||||||
|
├─ taonier-flat-spark-clay.png
|
||||||
|
├─ taonier-flat-meme-smile.png
|
||||||
|
├─ taonier-flat-loop-mold.png
|
||||||
|
└─ taonier-flat-seal-blocks.png
|
||||||
|
|
||||||
|
public/branding/taonier-logo-concepts/
|
||||||
|
├─ taonier-logo-contact-sheet.png
|
||||||
|
├─ taonier-clay-spark.png
|
||||||
|
├─ taonier-play-mold.png
|
||||||
|
├─ taonier-meme-bubble.png
|
||||||
|
├─ taonier-creation-loop.png
|
||||||
|
└─ taonier-premium-seal.png
|
||||||
|
```
|
||||||
|
|
||||||
|
生成脚本:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scripts/generate-taonier-logo-concepts.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. V3-03 一捏成型延展
|
||||||
|
|
||||||
|
这一组专门沿 V3 “一捏成型”继续打磨。目标是保留“两个软形触点 + 中央作品核”的成型瞬间,同时降低括号感、碰撞特效感和功能按钮感。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 3.1 捏合星核
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:一捏成型方向的主标首选。
|
||||||
|
|
||||||
|
这个方向最稳地保留了“左右合拢、中央成型”的核心动作,中心青绿色星核形成了明确焦点,整体比原 V3-03 更完整,也没有明显播放器、聊天或表情联想。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 结构清楚,第一眼能看出“合拢生成”。
|
||||||
|
- 元素少,小尺寸适配潜力好。
|
||||||
|
- 中央星核可以做加载、生成成功、发布完成等动效延展。
|
||||||
|
|
||||||
|
风险:
|
||||||
|
|
||||||
|
- 左右软形仍有一点括号感,后续矢量化可把外轮廓做得更不对称、更像被捏塑的软泥。
|
||||||
|
|
||||||
|
建议用途:主 Logo 备选首选、AI 生成按钮、启动动效核心符号。
|
||||||
|
|
||||||
|
### 3.2 成型印记
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:完整主标感最强的延展方向。
|
||||||
|
|
||||||
|
这个方向把左右触点收成一个更完整的软形图腾,减少了“两个括号”的割裂感。视觉上更像独立品牌符号,但也因此少了一点“捏合动作”的即时感。
|
||||||
|
|
||||||
|
建议用途:主 Logo 强备选;若选择它,后续应去掉背景底色并强化中心负形星点。
|
||||||
|
|
||||||
|
### 3.3 软泥合拍
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:轻松、年轻、动效友好。
|
||||||
|
|
||||||
|
这个方向的上下软形更活泼,适合表达“啪嗒一下成型”。但静态 Logo 中的黄色星点和短线略像特效贴纸,主标使用前需要继续简化。
|
||||||
|
|
||||||
|
建议用途:生成中动效、运营图、互动反馈,不建议直接定为主 Logo。
|
||||||
|
|
||||||
|
### 3.4 灵感模口
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:最有“模口 / 造物容器”意味。
|
||||||
|
|
||||||
|
这个方向图形独特,和“从软泥模口里生成作品”的隐喻贴合。但外形复杂度比 01、02 更高,边缘细节在小尺寸下可能损失。
|
||||||
|
|
||||||
|
建议用途:主 Logo 备选探索,适合继续做专业矢量简化。
|
||||||
|
|
||||||
|
### 3.5 捏开灵感
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:温和、包裹、生成容器。
|
||||||
|
|
||||||
|
这个方向亲和、平衡,但整体像眼睛 / 容器 / 开合结构,陶泥儿的“捏”动作弱一些。
|
||||||
|
|
||||||
|
建议用途:AI 生成入口、等待态、创作容器辅助图形。
|
||||||
|
|
||||||
|
## 4. V3 抽象主标候选
|
||||||
|
|
||||||
|
V3 根据评审反馈重新避开了五个问题:播放三角、褐色陶土主色、聊天气泡 / 表情包、循环符号,以及过多碎元素。方向转为更抽象、更亮眼、更像长期主 Logo 的符号。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 4.1 灵感捏痕
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:主 Logo 首选。
|
||||||
|
|
||||||
|
这个方向用醒目的珊瑚红软形、指纹捏痕和星点负形建立记忆点。它不再依赖“陶泥的褐色”,而是用“被捏过的痕迹”表达陶泥儿的核心动作:用户把脑洞捏成作品。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 第一眼足够醒目,远离旧版褐色和播放器感。
|
||||||
|
- 指纹捏痕有独特性,能承接“人人创作”和“亲手塑形”。
|
||||||
|
- 元素少,适合继续矢量化和小尺寸适配。
|
||||||
|
|
||||||
|
风险:
|
||||||
|
|
||||||
|
- 指纹弧线后续需要进一步简化,避免在 24px 以下变糊。
|
||||||
|
- 星点比例要克制,避免变成普通灵感图标。
|
||||||
|
|
||||||
|
建议用途:主 Logo、App icon、平台顶栏、启动页、生成按钮。
|
||||||
|
|
||||||
|
### 4.2 脑洞种子
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:创意生长与新手友好。
|
||||||
|
|
||||||
|
这个方向从“灵感发芽”切入,比陶泥更偏创造生命力。它亲和、可爱,但容易让用户联想到教育、植物、儿童启蒙或种植类产品。
|
||||||
|
|
||||||
|
建议用途:新手引导、创作孵化、儿童 / 寓教于乐支线,不建议作为主 Logo。
|
||||||
|
|
||||||
|
### 4.3 一捏成型
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:AI 把灵感合成为作品的瞬间。
|
||||||
|
|
||||||
|
这个方向很简洁,用左右两个软形触点和中心星点表达“捏合”。它避开了播放器和聊天气泡,也能做动效,但静态图形目前稍像碰撞特效或括号,需要继续重绘增强独特轮廓。
|
||||||
|
|
||||||
|
建议用途:生成按钮、AI 施法动效、主 Logo 备选微调方向。
|
||||||
|
|
||||||
|
### 4.4 作品胶囊
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:精品内容和作品沉淀。
|
||||||
|
|
||||||
|
这个方向更稳、更精品,青绿色也比褐色更吸睛。但整体像水滴、宝石或通用内容图标,和“捏”这个动作的关系弱。
|
||||||
|
|
||||||
|
建议用途:精选作品、作品库、创作者中心,不建议优先做主 Logo。
|
||||||
|
|
||||||
|
### 4.5 软体 T 形
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:英文辅助名 / Taonier 的抽象首字母。
|
||||||
|
|
||||||
|
这个方向试图做更品牌化的抽象符号,但当前形体还不够自然,也未形成足够强的“陶泥儿”心智。若未来英文名确定为 `Taonier` 或类似形式,可以继续沿这个方向做专业字母标重绘。
|
||||||
|
|
||||||
|
建议用途:英文标识探索,不作为当前主 Logo 首选。
|
||||||
|
|
||||||
|
## 5. V2 扁平矢量候选
|
||||||
|
|
||||||
|
第一批图形偏 3D 和拟物,更适合作为吉祥物、运营图或启动页气氛图,不适合作为长期主 Logo。V2 已把约束收紧为扁平、矢量、少元素、强轮廓和小尺寸可识别。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 5.1 扁平开捏
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:最直接的主 Logo 候选。
|
||||||
|
|
||||||
|
这个方向用一团柔软陶泥承载播放符号,用户一眼能理解“点开玩 / 马上玩”,同时外形保留“捏出来”的不规则软泥感。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 识别速度最快,移动端小尺寸也成立。
|
||||||
|
- 符合主流 App Logo 语言,亲和、不重、不技术冷。
|
||||||
|
- 和“把脑洞捏成小游戏”的主张绑定最强。
|
||||||
|
|
||||||
|
风险:
|
||||||
|
|
||||||
|
- 播放符号是常见母题,后续矢量化时要通过不规则软泥外轮廓、颜色和字标形成独特资产。
|
||||||
|
|
||||||
|
建议用途:主 Logo 首选、App icon、平台顶栏、分享卡片角标。
|
||||||
|
|
||||||
|
### 5.2 灵感泥星
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:AI 创作与灵感生成。
|
||||||
|
|
||||||
|
这个方向比“扁平开捏”更品牌化,中心负形星点表达灵感、AI 生成和创意爆发。它没有播放符号那么直白,但更容易和“陶泥儿”的创作平台气质绑定。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 图形更简洁,品牌记忆点强。
|
||||||
|
- 陶泥心智、AI 灵感和精品感比较平衡。
|
||||||
|
- 适合未来扩成字标、启动页和生成态动效。
|
||||||
|
|
||||||
|
风险:
|
||||||
|
|
||||||
|
- 对“小游戏/马上玩”的表达弱于播放符号。
|
||||||
|
|
||||||
|
建议用途:主 Logo 强备选、创作首页、AI 生成按钮和品牌主视觉。
|
||||||
|
|
||||||
|
### 5.3 造梗笑泥
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:社交传播和玩梗亲和力。
|
||||||
|
|
||||||
|
这个方向的气泡与笑脸非常亲和,适合表达“分享快乐”和“造梗”。但它和聊天、社区类产品的通用图形过近,作为主 Logo 可能会让用户误判产品品类。
|
||||||
|
|
||||||
|
建议用途:社区、评论、分享、活动贴纸,不建议做主 Logo。
|
||||||
|
|
||||||
|
### 5.4 共创泥环
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:AI 与用户共创闭环。
|
||||||
|
|
||||||
|
这个方向表达共创与循环,但生成结果带有偏柔和彩虹渐变的视觉倾向,与“陶泥儿”的软泥名称关联不够直观,也不如 01/02 容易记住。
|
||||||
|
|
||||||
|
建议用途:创作流程、共创能力、生成进度辅助图形。
|
||||||
|
|
||||||
|
### 5.5 精品泥印
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:精品作品和内容集合。
|
||||||
|
|
||||||
|
这个方向像内容平台或作品库入口,能表达图片、用户、游戏等多形态内容。但图形元素较多,主标识别不如 01/02 凝练。
|
||||||
|
|
||||||
|
建议用途:精选作品、作品集、创作者中心、内容品质标识。
|
||||||
|
|
||||||
|
## 6. V1 立体探索
|
||||||
|
|
||||||
|
### 6.1 灵感陶团
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:AI 共创与灵感造物。
|
||||||
|
|
||||||
|
这个方向把“陶泥”作为主视觉,内部用发光火花和节点表达 AI 赋能。它最贴“陶泥儿”名字本身,也能说明平台不是普通小游戏集合,而是从灵感生成作品的创作容器。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 与“陶泥儿”的名称绑定最强。
|
||||||
|
- 有 AI、创作、造物的综合含义。
|
||||||
|
- 适合启动页、品牌介绍、创作首页空状态。
|
||||||
|
|
||||||
|
风险:
|
||||||
|
|
||||||
|
- 小尺寸下细节偏多,需要后续矢量化时压缩节点和纹理。
|
||||||
|
- 如果色彩处理不当,会回到手工陶艺联想。
|
||||||
|
|
||||||
|
建议用途:品牌主视觉备选、官网/启动页、创作入口图形。
|
||||||
|
|
||||||
|
### 6.2 开玩模具
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:把脑洞捏成小游戏。
|
||||||
|
|
||||||
|
这个方向用软陶捏出播放符号,最直接地连接“创作”和“马上玩”。它比单纯陶泥团更有产品动作,也更适合轻休闲、小游戏、短内容传播。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 识别强,小尺寸也清楚。
|
||||||
|
- 与轻度休闲小游戏的关系最直接。
|
||||||
|
- 适合作为 App icon 和主 Logo 图形。
|
||||||
|
|
||||||
|
风险:
|
||||||
|
|
||||||
|
- 播放符号相对常见,需要后续在外轮廓、捏痕和色彩上做独特性。
|
||||||
|
- 如果三角形过硬,会削弱“陶泥儿”的柔软感。
|
||||||
|
|
||||||
|
建议用途:主 Logo 首选、App icon、分享卡片角标、加载态图形。
|
||||||
|
|
||||||
|
### 6.3 造梗气泡
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:社交传播、玩梗、裂变。
|
||||||
|
|
||||||
|
这个方向把陶泥变形成聊天气泡和表情,强调“梗”和“传播”。它最有社交平台感,也适合表情包、活动贴纸和运营视觉。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 传播感强,年轻、轻松、容易做 IP 化。
|
||||||
|
- 能承接社区、评论、分享和玩梗场景。
|
||||||
|
- 比较容易延展成贴纸和表情包。
|
||||||
|
|
||||||
|
风险:
|
||||||
|
|
||||||
|
- 偏软萌,可能削弱“精品 AI 创作平台”的质感。
|
||||||
|
- 作为主 Logo 容易显得像聊天或表情产品。
|
||||||
|
|
||||||
|
建议用途:社区模块、活动运营、IP 辅助形象,不建议作为唯一主 Logo。
|
||||||
|
|
||||||
|
### 6.4 共创回路
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:AI 与用户共同迭代生成。
|
||||||
|
|
||||||
|
这个方向用软陶带形成循环和造物轨迹,表达“灵感 -> AI 塑形 -> 用户修改 -> 作品传播”的闭环。它比其他方向更抽象,也更有平台级和工具级气质。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 高级、简洁,避免儿童化。
|
||||||
|
- 适合表达 AI 共创、迭代和作品循环。
|
||||||
|
- 可用于创作者工作台或生成进度标识。
|
||||||
|
|
||||||
|
风险:
|
||||||
|
|
||||||
|
- 与“陶泥儿”名称的直观关联较弱。
|
||||||
|
- 缺少小游戏和玩梗的即时识别。
|
||||||
|
|
||||||
|
建议用途:创作流程标识、AI 共创能力图标、品牌辅助图形。
|
||||||
|
|
||||||
|
### 6.5 精品泥印
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
定位:精品内容、作品认证、创作者成果。
|
||||||
|
|
||||||
|
这个方向像一个被压印的软陶徽章,中间有方块和火花,比较适合表达“作品被打磨成型”。它的内容平台感强于游戏入口感。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 精品感和作品库气质较强。
|
||||||
|
- 适合作品认证、精选、创作者徽章。
|
||||||
|
- 与“陶泥压印”隐喻相对自然。
|
||||||
|
|
||||||
|
风险:
|
||||||
|
|
||||||
|
- 细节较多,主 Logo 小尺寸可读性不如“开玩模具”。
|
||||||
|
- 徽章感偏静态,轻休闲的即时性稍弱。
|
||||||
|
|
||||||
|
建议用途:精选作品标识、创作者荣誉、内容品质标签。
|
||||||
|
|
||||||
|
## 7. 推荐结论
|
||||||
|
|
||||||
|
优先级建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
主 Logo 首选:V3 01 灵感捏痕
|
||||||
|
一捏成型首选:V3-03 延展 01 捏合星核
|
||||||
|
完整主标备选:V3-03 延展 02 成型印记
|
||||||
|
英文标识探索:V3 05 软体 T 形
|
||||||
|
精品内容辅助:V3 04 作品胶囊
|
||||||
|
新手 / 寓教于乐辅助:V3 02 脑洞种子
|
||||||
|
```
|
||||||
|
|
||||||
|
若要兼顾主流、亲和、醒目和“陶泥儿”的动作隐喻,优先继续打磨 V3 “灵感捏痕”。
|
||||||
|
若想把 Logo 做得更抽象、更像 AI 生成瞬间,可以继续打磨 V3-03 延展中的“捏合星核”或“成型印记”。
|
||||||
|
V1 的 3D 图标不建议直接作为主 Logo,只适合做运营图、吉祥物探索或风格参考;V2 的播放、气泡、碎元素方向本轮已降级为历史探索。
|
||||||
|
|
||||||
|
## 8. 后续落地建议
|
||||||
|
|
||||||
|
1. 先围绕 V3 “灵感捏痕”做 3 到 5 个专业矢量微调版:减少指纹线条、强化软形轮廓、测试深色 / 浅色底。
|
||||||
|
2. 同步对 V3-03 “捏合星核”做一版更独特的轮廓重绘,弱化括号感,保留中央成型星核。
|
||||||
|
3. 字标不要直接使用生图结果,应单独设计“陶泥儿”中文字标,并准备英文辅助名。
|
||||||
|
4. 正式应用前做商标近似检索,重点覆盖第 9、35、38、41、42 类。
|
||||||
|
5. 若确认替换“百梦”,再更新现有命名规范文档、前端品牌组件、HTML metadata、后台和后端默认文案。
|
||||||
101
docs/prd/BABY_LOVE_DRAWING_EDUTAINMENT_LEVEL_PRD_2026-05-13.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# 宝贝爱画寓教于乐独立关卡 PRD 2026-05-13
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
新增寓教于乐内容线独立关卡:
|
||||||
|
|
||||||
|
```text
|
||||||
|
宝贝爱画
|
||||||
|
```
|
||||||
|
|
||||||
|
工程 ID 固定为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
baby-love-drawing
|
||||||
|
```
|
||||||
|
|
||||||
|
该关卡默认出现在“发现 / 寓教于乐”板块下方。当前阶段只做本地 Demo 保存,验证完成后再补正式持久化。
|
||||||
|
|
||||||
|
## 2. 关卡结构
|
||||||
|
|
||||||
|
启动宝贝爱画后进入绘画运行态:
|
||||||
|
|
||||||
|
1. 屏幕中央是带边框的空白画板;
|
||||||
|
2. 画板边框内是可绘画区域;
|
||||||
|
3. 画板外左侧展示彩虹 7 色;
|
||||||
|
4. 画板外右侧中下方展示画笔和橡皮;
|
||||||
|
5. 用户进入内容后默认手持画笔;
|
||||||
|
6. 手持画笔或橡皮时,屏幕上实时显示跟随右手位置的工具图案标识。
|
||||||
|
|
||||||
|
## 3. 输入规则
|
||||||
|
|
||||||
|
颜色选择:
|
||||||
|
|
||||||
|
1. 仅检测左手;
|
||||||
|
2. 左手悬停在某个颜色区域 1.5 秒后,选中该颜色;
|
||||||
|
3. 颜色固定为彩虹 7 色。
|
||||||
|
|
||||||
|
工具切换:
|
||||||
|
|
||||||
|
1. 右手移动到画笔或橡皮工具区域;
|
||||||
|
2. 右手握拳后,将手里的工具切换为对应工具。
|
||||||
|
|
||||||
|
绘画与擦除:
|
||||||
|
|
||||||
|
1. 右手在画布区域握拳时,当前工具生效;
|
||||||
|
2. 当前工具为画笔时留下轨迹;
|
||||||
|
3. 当前工具为橡皮时擦除轨迹;
|
||||||
|
4. 右手张开时,画笔或橡皮抬起,不在画布上生效。
|
||||||
|
|
||||||
|
按钮选择:
|
||||||
|
|
||||||
|
1. 完成;
|
||||||
|
2. 使用绘画魔法;
|
||||||
|
3. 保存;
|
||||||
|
4. 再画一张;
|
||||||
|
5. 返回。
|
||||||
|
|
||||||
|
以上按钮都使用任一手悬停 2 秒完成选中。
|
||||||
|
|
||||||
|
## 4. 绘画魔法
|
||||||
|
|
||||||
|
用户完成绘画后,可使用“绘画魔法”。
|
||||||
|
|
||||||
|
绘画魔法使用 image-2,以用户绘画内容和笔触轨迹生成对应绘本风格图片内容。
|
||||||
|
|
||||||
|
前端不得直接读取、拼接或暴露图片生成密钥。image-2 调用必须通过后端代理。
|
||||||
|
|
||||||
|
## 5. 保存规则
|
||||||
|
|
||||||
|
当前只做本地 Demo 保存。
|
||||||
|
|
||||||
|
保存规则:
|
||||||
|
|
||||||
|
1. 魔法生成前保存,只保存一张原图;
|
||||||
|
2. 若用户未保存原图,直接点击魔法生成,则魔法生成后保存时同时保存原图和魔法图;
|
||||||
|
3. 保存完毕后,可继续“再画一张”或“返回”。
|
||||||
|
|
||||||
|
## 6. 展示与开关
|
||||||
|
|
||||||
|
宝贝爱画只属于寓教于乐内容线。
|
||||||
|
|
||||||
|
`VITE_ENABLE_EDUTAINMENT_ENTRY` 关闭时:
|
||||||
|
|
||||||
|
1. 不展示“发现 / 寓教于乐”频道;
|
||||||
|
2. 不展示宝贝爱画默认卡片;
|
||||||
|
3. 不允许通过 `/runtime/baby-love-drawing` 直达运行态。
|
||||||
|
|
||||||
|
## 7. 验收
|
||||||
|
|
||||||
|
1. 寓教于乐开启时,“发现 / 寓教于乐”下方展示“宝贝爱画”默认关卡卡片;
|
||||||
|
2. 寓教于乐关闭时,不展示宝贝爱画,也不能直达运行态;
|
||||||
|
3. 进入后展示空白画板、彩虹 7 色、画笔和橡皮;
|
||||||
|
4. 默认工具为画笔;
|
||||||
|
5. 左手悬停颜色 1.5 秒后选中颜色;
|
||||||
|
6. 右手移动到工具区并握拳后切换画笔或橡皮;
|
||||||
|
7. 右手握拳在画布内绘制或擦除,张开时不生效;
|
||||||
|
8. 任一手悬停按钮 2 秒后触发按钮;
|
||||||
|
9. 完成后可保存原图;
|
||||||
|
10. 完成后可使用绘画魔法生成绘本风格图片;
|
||||||
|
11. 未保存原图直接使用绘画魔法后,保存会同时保存原图和魔法图;
|
||||||
|
12. 保存后可再画一张或返回。
|
||||||
@@ -29,11 +29,24 @@
|
|||||||
2. 模板名称:`宝贝识物`;
|
2. 模板名称:`宝贝识物`;
|
||||||
3. 两个物品;
|
3. 两个物品;
|
||||||
4. 两个物品图;
|
4. 两个物品图;
|
||||||
5. 作品标签。
|
5. 游戏视觉主题包;
|
||||||
|
6. 作品标签。
|
||||||
|
|
||||||
物品图使用 VectorEngine `gpt-image-2-all` / image-2 生成。图片生成只能走后端或后续后端预留接口,前端不得泄露 `VECTOR_ENGINE_API_KEY`。
|
物品图使用 VectorEngine `gpt-image-2-all` / image-2 生成。图片生成只能走后端接口,前端不得读取、拼接或暴露 `VECTOR_ENGINE_API_KEY`。
|
||||||
|
|
||||||
本地 Demo 阶段若真实生图接口未接入完成,允许前端 service 返回明确标记为 `placeholder` 的占位图形,用于打通创作到结果页的交互链路;该占位结果不得伪装成正式 image-2 资产。
|
每个关键词只生成一张围绕该关键词的单一物品形象。生成 prompt 必须锁定寓教于乐板块统一的卡通绘本草地舞台插画风,但最终画面不生成背景、场景、氛围渲染、人物、手、篮子、礼物盒、文字、水印或 UI。服务端必须把生成结果转成透明 PNG,并执行透明抠图后处理;只有透明抠图后的素材才允许写入草稿 `itemAssets` 并进入游戏运行态。
|
||||||
|
|
||||||
|
同一次创作还必须使用 image-2 生成游戏视觉主题包,包含背景环境、UI 装饰框、礼物盒、篮子和烟雾弹出特效资源。主题包必须继续保持寓教于乐插画风,并根据用户填写的两个物品关键词匹配主题:例如关键词偏动漫角色或玩具时,背景环境和元素可使用动漫、玩具主题;关键词偏水果时,背景环境和元素可匹配果园、自然主题;其它关键词按其语义匹配合适主题。主题包不得改变关卡玩法规则,不新增文字说明、额外按钮或额外判定规则。
|
||||||
|
|
||||||
|
视觉主题包的资源边界:
|
||||||
|
|
||||||
|
1. 背景环境图不做透明抠图,但必须保证屏幕中间、中下方和底部左右篮子区域清爽,不遮挡放大后的物品、礼物盒和篮子;
|
||||||
|
2. UI 装饰框用于字幕条和计数器风格化包装,只生成装饰边框和主题点缀,不生成文字、数字或按钮;
|
||||||
|
3. 礼物盒资源输出为透明 PNG,运行态按当前礼盒视觉的 2 倍尺寸展示,素材主体必须饱满清晰;
|
||||||
|
4. 篮子资源输出为透明 PNG,运行态按当前篮子视觉的 1.5 倍尺寸展示,左右篮子仍固定为两个物品对应选项,篮子造型资源可以复用同一张主题篮子图;
|
||||||
|
5. 烟雾弹出特效资源输出为透明 PNG,用于礼物盒打开瞬间覆盖开盒区域并承接中央物品弹出,不生成物品、篮子、礼物盒或文字。
|
||||||
|
|
||||||
|
当前本地 Demo 阶段已接入真实 image-2 资源链路。创作提交必须成功获得 `generationProvider = "vector-engine-gpt-image-2"` 的两个物品透明 PNG 和完整视觉主题包后,才能进入结果页、试玩或发布;若后端接口、登录态、VectorEngine 配置或上游生成失败,前端必须停留在生成失败状态并展示错误,不得静默回退为占位图。历史草稿中若仍存在 `generationProvider = "placeholder"` 的占位资源,结果页必须提示重新生成,试玩和发布前必须先补齐 image-2 资源。
|
||||||
|
|
||||||
## 4. 标签规则
|
## 4. 标签规则
|
||||||
|
|
||||||
@@ -63,6 +76,8 @@
|
|||||||
|
|
||||||
试玩按钮进入宝贝识物首关运行态,运行态消费当前草稿中的两个物品名称和两张物品图,不重新生成或改写物品内容。
|
试玩按钮进入宝贝识物首关运行态,运行态消费当前草稿中的两个物品名称和两张物品图,不重新生成或改写物品内容。
|
||||||
|
|
||||||
|
若草稿包含视觉主题包,运行态还必须消费该主题包中的背景环境、UI 装饰、礼物盒、篮子和烟雾弹出特效资源;旧草稿或接口失败时允许回退到当前 CSS 绘本风兜底。
|
||||||
|
|
||||||
## 6. 发布后体验
|
## 6. 发布后体验
|
||||||
|
|
||||||
发布完成后作品应进入寓教于乐内容线,并在寓教于乐入口开启时可被板块消费。
|
发布完成后作品应进入寓教于乐内容线,并在寓教于乐入口开启时可被板块消费。
|
||||||
@@ -73,27 +88,30 @@
|
|||||||
|
|
||||||
本 PRD 同步约束首关运行态,已确认规则包括:
|
本 PRD 同步约束首关运行态,已确认规则包括:
|
||||||
|
|
||||||
1. 礼物盒打开在本地调试绑定 `F` 键;
|
1. 进入关卡后礼物盒自动打开并弹出首个随机物品;
|
||||||
2. 每轮仅中间礼物盒跳出的物品随机;左右两侧篮子固定为当前草稿两个物品的顺序;
|
2. 每轮仅中间礼物盒跳出的物品随机;左右两侧篮子固定为当前草稿两个物品的顺序;
|
||||||
3. 下一关按钮当前占位;
|
3. 下一关按钮当前占位;
|
||||||
4. 不新增用户未确认的计时、失败次数、分数、体力或难度递增。
|
4. 不新增用户未确认的计时、失败次数、分数、体力或难度递增。
|
||||||
5. 屏幕中上方字幕固定为“将物品放入对应的篮子里”。
|
5. 屏幕中上方字幕固定为“将物品放入对应的篮子里”。
|
||||||
6. 礼物盒位于屏幕中下方,任意手抬起后打开并跳出下一个随机物品。
|
6. 礼物盒位于屏幕中下方并按当前视觉放大一倍,首次进入关卡和每次正确反馈结束后的新轮次都从上方落下后自动打开。
|
||||||
7. 屏幕下方左侧和右侧分别展示两个固定篮子,左侧固定使用草稿第一个物品图,右侧固定使用草稿第二个物品图。
|
7. 屏幕下方左侧和右侧分别展示两个固定篮子,左侧固定使用草稿第一个物品图,右侧固定使用草稿第二个物品图。
|
||||||
8. 明确左手连续横向移动达到阈值时将当前物品送入左侧篮子,明确右手连续横向移动达到阈值时将当前物品送入右侧篮子;选篮不使用动作名判定,侧别未知的手部轨迹不参与选篮。
|
8. 左右篮子按当前视觉放大 50%。
|
||||||
9. 正确时展示“真棒”字幕和正确特效;错误时展示“再想一想吧”字幕和错误特效,物品回到中央。
|
9. 礼物盒打开时播放烟雾特效,中央物品从烟雾特效中弹出;物品弹出后礼物盒从舞台移除。
|
||||||
10. 成功 20 次后展示“恭喜你!小朋友!”字幕和特效,并展示“再来一次”和“下一关”按钮。
|
10. 明确左手连续横向移动达到阈值时将当前物品送入左侧篮子,明确右手连续横向移动达到阈值时将当前物品送入右侧篮子;选篮不使用动作名判定,侧别未知的手部轨迹不参与选篮。
|
||||||
11. 当前本地 Demo 阶段音效与语音播报接口只预留调用点,不在前端写死外部硬件或服务接口。
|
11. 正确时展示“真棒”字幕和正确特效;错误时展示“再想一想吧”字幕和错误特效,物品回到中央。
|
||||||
|
12. 成功 20 次后展示“恭喜你!小朋友!”字幕和特效,并展示“再来一次”和“下一关”按钮。
|
||||||
|
13. 当前本地 Demo 阶段音效与语音播报接口只预留调用点,不在前端写死外部硬件或服务接口。
|
||||||
|
|
||||||
## 8. 验收
|
## 8. 验收
|
||||||
|
|
||||||
1. 创作入口显示 `宝贝识物` 并可进入模板表单。
|
1. 创作入口显示 `宝贝识物` 并可进入模板表单。
|
||||||
2. 未填写任一物品名称时不能生成草稿。
|
2. 未填写任一物品名称时不能生成草稿。
|
||||||
3. 生成草稿后进入结果页,展示两个物品名称和物品图。
|
3. 生成草稿后进入结果页,展示两个物品名称和物品图。
|
||||||
4. 草稿标签中始终包含精确 `寓教于乐`。
|
4. 生成草稿后包含视觉主题包,主题包含背景环境、UI 装饰框、礼物盒、篮子和烟雾弹出特效资源。
|
||||||
5. 发布 payload 始终包含精确 `寓教于乐`。
|
5. 草稿标签中始终包含精确 `寓教于乐`。
|
||||||
6. 发布完成后出现分享弹窗或发布完成状态。
|
6. 发布 payload 始终包含精确 `寓教于乐`。
|
||||||
7. 前端不读取或暴露 VectorEngine 密钥。
|
7. 发布完成后出现分享弹窗或发布完成状态。
|
||||||
8. 结果页试玩进入宝贝识物运行态,不再显示“试玩关卡正在接入中”。
|
8. 前端不读取或暴露 VectorEngine 密钥。
|
||||||
9. 运行态可通过 `F` 打开礼物盒,通过鼠标左键拖动映射左手横向移动,通过鼠标右键拖动映射右手横向移动。
|
9. 结果页试玩进入宝贝识物运行态,不再显示“试玩关卡正在接入中”。
|
||||||
10. 成功 20 次后出现“再来一次”和“下一关”按钮。
|
10. 运行态通过鼠标左键拖动映射左手横向移动,通过鼠标右键拖动映射右手横向移动。
|
||||||
|
11. 成功 20 次后出现“再来一次”和“下一关”按钮。
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- 需求来源:用户提供的视频 `C:\Users\DSK\Videos\一款双方比狗叫的游戏 - 1.一款双方比狗叫的游戏(Av116504192360177,P1).mp4`,并已在 `.hermes/plans/2026-05-11_144229-bark-battle-2d-game-bdd-ddd-tdd-plan.md` 中完成抽帧分析和玩法方案整理。
|
- 需求来源:用户提供的视频 `C:\Users\DSK\Videos\一款双方比狗叫的游戏 - 1.一款双方比狗叫的游戏(Av116504192360177,P1).mp4`,并已在 `.hermes/plans/2026-05-11_144229-bark-battle-2d-game-bdd-ddd-tdd-plan.md` 中完成抽帧分析和玩法方案整理。
|
||||||
- 玩法定位:浏览器 2D 声控狗叫对战小游戏,暂定中文名 `汪汪声浪大作战`,英文代号与 play type ID 建议为 `bark-battle`。
|
- 玩法定位:浏览器 2D 声控狗叫对战小游戏,暂定中文名 `汪汪声浪大作战`,英文代号与 play type ID 建议为 `bark-battle`。
|
||||||
- 核心玩法:双方狗狗在 30 秒限时内通过麦克风输入“狗叫声”进行声浪拔河;系统依据声音强度、有效叫声次数和叫声节奏计算推动力,实时推动顶部红蓝能量条;倒计时结束后按能量条位置判定胜负或平局。
|
- 核心玩法:双方狗狗在 30 秒限时内通过麦克风输入“狗叫声”进行声浪拔河;系统依据声音强度、有效声浪触发次数和声浪节奏计算推动力,实时推动顶部红蓝能量条;倒计时结束后按能量条位置判定胜负或平局。
|
||||||
- 文档目的:为产品、测试、前端、后端在编码前统一可验证验收口径;本文只定义 PRD/BDD 级行为与测试映射,不实现工程代码。
|
- 文档目的:为产品、测试、前端、后端在编码前统一可验证验收口径;本文只定义 PRD/BDD 级行为与测试映射,不实现工程代码。
|
||||||
|
|
||||||
## 角色与目标
|
## 角色与目标
|
||||||
@@ -20,14 +20,14 @@
|
|||||||
### 用户目标
|
### 用户目标
|
||||||
|
|
||||||
- 玩家可以在开局前完成麦克风授权和环境噪音校准。
|
- 玩家可以在开局前完成麦克风授权和环境噪音校准。
|
||||||
- 玩家发出有效狗叫时,能看到叫声计数、狗狗动画、拟声词/冲击波以及能量条变化。
|
- 玩家产生有效声浪触发时,能看到声浪计数、狗狗动画、拟声词/冲击波以及能量条变化。
|
||||||
- 低于阈值的背景噪音不会被误计为有效叫声。
|
- 低于阈值的背景噪音不会被误计为有效声浪触发。
|
||||||
- 单局在 30 秒后给出明确胜负、平局和关键数据。
|
- 单局在 30 秒后给出明确胜负、平局和关键数据。
|
||||||
- 移动端和不支持麦克风的环境不会进入不可操作状态。
|
- 移动端和不支持麦克风的环境不会进入不可操作状态。
|
||||||
|
|
||||||
### 非目标
|
### 非目标
|
||||||
|
|
||||||
- MVP 不要求识别“是否真实狗叫”,不引入机器学习声纹/物种分类;有效输入以音量阈值、峰值间隔、持续时间和校准结果为准。
|
- MVP 不要求识别“是否真实狗叫”,不引入机器学习声纹/物种分类;有效输入以音量阈值、峰值冷却间隔和校准结果为准。
|
||||||
- MVP 不要求实时联机对战;可先按“玩家 vs AI 对手”完成单机浏览器 runtime。
|
- MVP 不要求实时联机对战;可先按“玩家 vs AI 对手”完成单机浏览器 runtime。
|
||||||
- MVP 不要求成绩持久化、作品发布、作品架、广场和排行榜;若后续接入 Genarrative 作品闭环,需要另补玩法类型集成 PRD/技术文档。
|
- MVP 不要求成绩持久化、作品发布、作品架、广场和排行榜;若后续接入 Genarrative 作品闭环,需要另补玩法类型集成 PRD/技术文档。
|
||||||
- MVP 不要求在 UI 中长期展示大段规则说明;游戏界面应保持倒计时、能量条、狗狗、麦克风状态和结算信息为主。
|
- MVP 不要求在 UI 中长期展示大段规则说明;游戏界面应保持倒计时、能量条、狗狗、麦克风状态和结算信息为主。
|
||||||
@@ -38,10 +38,10 @@
|
|||||||
- 单局时长:默认 30 秒,从正式进入 `playing` 阶段开始计时。
|
- 单局时长:默认 30 秒,从正式进入 `playing` 阶段开始计时。
|
||||||
- 能量条:使用 `-100` 到 `100` 的连续值表示,负数偏对手侧,正数偏玩家侧,`0` 为中线。
|
- 能量条:使用 `-100` 到 `100` 的连续值表示,负数偏对手侧,正数偏玩家侧,`0` 为中线。
|
||||||
- 平局阈值:倒计时结束时,若能量条绝对值小于或等于 `drawThreshold`,判定平局;具体数值由实现配置,但测试需可注入固定阈值。
|
- 平局阈值:倒计时结束时,若能量条绝对值小于或等于 `drawThreshold`,判定平局;具体数值由实现配置,但测试需可注入固定阈值。
|
||||||
- 有效叫声:一次有效叫声至少满足:音量超过校准后的有效阈值、与上一次有效峰值间隔不小于 `minBarkGapMs`、持续时长在 `minBarkDurationMs` 到 `maxBarkDurationMs` 之间。
|
- 有效声浪触发:一次有效声浪触发满足:当前麦克风采样点的归一化响度达到或超过校准后的有效阈值,且与上一次有效声浪触发间隔不小于 `minBarkGapMs`;不再要求持续高响度时长达标,也不等待响度回落。
|
||||||
- 背景噪音:校准阶段采集到的环境声用于计算动态阈值;低于阈值的输入不得增加叫声次数,也不得让能量条出现可见推进。
|
- 背景噪音:校准阶段采集到的环境声用于计算动态阈值;低于阈值的输入不得增加声浪触发次数,也不得让能量条出现可见推进。
|
||||||
- 推动力:玩家推动力由音量分数、有效叫声频率和连击加成组成;能量条按玩家推动力与对手推动力差值移动,并被限制在 `-100` 到 `100`。
|
- 推动力:玩家推动力由音量分数、有效声浪触发频率和连击加成组成;能量条按玩家推动力与对手推动力差值移动,并被限制在 `-100` 到 `100`。
|
||||||
- UI 反馈:有效叫声应触发可观察反馈,包括玩家侧狗狗张嘴/吠叫动画、拟声词或冲击波;反馈不应遮挡倒计时和顶部能量条。
|
- UI 反馈:有效声浪触发应触发可观察反馈,包括玩家侧狗狗张嘴/吠叫动画、拟声词或冲击波;反馈不应遮挡倒计时和顶部能量条。
|
||||||
|
|
||||||
## 中文 Gherkin 场景
|
## 中文 Gherkin 场景
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
场景: 校准完成后进入开局倒计时
|
场景: 校准完成后进入开局倒计时
|
||||||
假如玩家已允许麦克风权限
|
假如玩家已允许麦克风权限
|
||||||
而且系统已采集足够的环境噪音样本
|
而且系统已采集足够的环境噪音样本
|
||||||
当校准计算出有效叫声阈值
|
当校准计算出有效声浪阈值
|
||||||
那么系统应进入开局倒计时阶段
|
那么系统应进入开局倒计时阶段
|
||||||
而且倒计时结束后应进入 30 秒对战阶段
|
而且倒计时结束后应进入 30 秒对战阶段
|
||||||
而且初始能量条应位于中线
|
而且初始能量条应位于中线
|
||||||
@@ -84,19 +84,19 @@
|
|||||||
功能: 环境噪音校准
|
功能: 环境噪音校准
|
||||||
为了减少背景噪音误触发
|
为了减少背景噪音误触发
|
||||||
作为浏览器玩家
|
作为浏览器玩家
|
||||||
我希望游戏在开局前根据当前环境设置有效叫声阈值
|
我希望游戏在开局前根据当前环境设置有效声浪阈值
|
||||||
|
|
||||||
场景: 安静环境生成低但非零的有效阈值
|
场景: 安静环境生成低但非零的有效阈值
|
||||||
假如校准阶段采集到的环境噪音 RMS 稳定低于默认噪音基线
|
假如校准阶段采集到的环境噪音 RMS 稳定低于默认噪音基线
|
||||||
当系统完成校准
|
当系统完成校准
|
||||||
那么有效叫声阈值应高于环境噪音平均值
|
那么有效声浪阈值应高于环境噪音平均值
|
||||||
而且阈值不应低于系统配置的最小阈值
|
而且阈值不应低于系统配置的最小阈值
|
||||||
|
|
||||||
场景: 嘈杂环境生成更高的有效阈值
|
场景: 嘈杂环境生成更高的有效阈值
|
||||||
假如校准阶段采集到的环境噪音 RMS 高于默认噪音基线
|
假如校准阶段采集到的环境噪音 RMS 高于默认噪音基线
|
||||||
当系统完成校准
|
当系统完成校准
|
||||||
那么有效叫声阈值应随环境噪音上调
|
那么有效声浪阈值应随环境噪音上调
|
||||||
而且低于该阈值的后续输入不应计为有效叫声
|
而且低于该阈值的后续输入不应计为有效声浪触发
|
||||||
|
|
||||||
场景: 校准期间无法获得有效音频样本
|
场景: 校准期间无法获得有效音频样本
|
||||||
假如麦克风授权成功但音频样本持续为空或不可读
|
假如麦克风授权成功但音频样本持续为空或不可读
|
||||||
@@ -106,36 +106,36 @@
|
|||||||
而且不应直接开始对战
|
而且不应直接开始对战
|
||||||
```
|
```
|
||||||
|
|
||||||
### 功能: 有效叫声计数
|
### 功能: 有效声浪触发计数
|
||||||
|
|
||||||
```gherkin
|
```gherkin
|
||||||
功能: 有效叫声计数
|
功能: 有效声浪触发计数
|
||||||
为了把玩家的狗叫行为转换为可计分输入
|
为了把玩家的声控行为转换为可计分输入
|
||||||
作为玩家
|
作为玩家
|
||||||
我希望每次符合规则的短促叫声只被计数一次
|
我希望每次超过阈值且满足冷却的声浪触发只被计数一次
|
||||||
|
|
||||||
背景:
|
背景:
|
||||||
假如游戏处于 30 秒 playing 阶段
|
假如游戏处于 30 秒 playing 阶段
|
||||||
而且系统已完成环境噪音校准
|
而且系统已完成环境噪音校准
|
||||||
|
|
||||||
场景: 单次超过阈值且间隔足够的叫声计数加一
|
场景: 单次超过阈值且间隔足够的声浪触发计数加一
|
||||||
假如玩家当前叫声次数为 0
|
假如玩家当前声浪触发次数为 0
|
||||||
而且上一次有效叫声时间早于 minBarkGapMs
|
而且上一次有效声浪触发时间早于 minBarkGapMs
|
||||||
当麦克风输入出现一次超过有效阈值且持续时长合规的峰值
|
当某个麦克风采样点达到或超过有效阈值且满足声浪冷却
|
||||||
那么玩家叫声次数应变为 1
|
那么玩家声浪触发次数应变为 1
|
||||||
而且玩家侧应出现一次吠叫动画反馈
|
而且玩家侧应出现一次吠叫动画反馈
|
||||||
而且画面应出现一次拟声词或冲击波反馈
|
而且画面应出现一次拟声词或冲击波反馈
|
||||||
|
|
||||||
场景: 持续噪音不会被无限计数
|
场景: 持续高响度输入只按冷却节奏计数
|
||||||
假如玩家当前叫声次数为 1
|
假如玩家当前声浪触发次数为 1
|
||||||
当麦克风输入持续超过阈值但没有新的峰值间隔
|
当麦克风输入持续超过阈值但仍处于声浪冷却内
|
||||||
那么玩家叫声次数不应在每个 tick 中持续增加
|
那么玩家声浪触发次数不应在每个 tick 中持续增加
|
||||||
而且系统最多只应记录当前连续声音段内的一次有效叫声
|
而且系统只应在冷却结束后的采样点再次达阈值时记录下一次有效声浪触发
|
||||||
|
|
||||||
场景: 间隔过短的连续峰值不重复计数
|
场景: 间隔过短的连续峰值不重复计数
|
||||||
假如玩家刚刚产生一次有效叫声
|
假如玩家刚刚产生一次有效声浪触发
|
||||||
当麦克风输入在 minBarkGapMs 内再次出现峰值
|
当麦克风输入在 minBarkGapMs 内再次达到有效阈值
|
||||||
那么玩家叫声次数不应增加
|
那么玩家声浪触发次数不应增加
|
||||||
而且连击或推动力不应因该峰值重复加成
|
而且连击或推动力不应因该峰值重复加成
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -145,23 +145,23 @@
|
|||||||
功能: 声浪推动能量条
|
功能: 声浪推动能量条
|
||||||
为了复刻双方比狗叫的核心体验
|
为了复刻双方比狗叫的核心体验
|
||||||
作为玩家
|
作为玩家
|
||||||
我希望更响、更连续的有效叫声能把顶部能量条推向自己一侧
|
我希望更响、更高频的有效声浪触发能把顶部能量条推向自己一侧
|
||||||
|
|
||||||
背景:
|
背景:
|
||||||
假如游戏处于 30 秒 playing 阶段
|
假如游戏处于 30 秒 playing 阶段
|
||||||
而且能量条当前位于中线
|
而且能量条当前位于中线
|
||||||
|
|
||||||
场景: 玩家推动力高于对手时能量条向玩家侧移动
|
场景: 玩家推动力高于对手时能量条向玩家侧移动
|
||||||
假如玩家在短时间窗口内产生多次有效叫声
|
假如玩家在短时间窗口内产生多次有效声浪触发
|
||||||
而且玩家推动力高于对手推动力
|
而且玩家推动力高于对手推动力
|
||||||
当系统推进一个 simulation tick
|
当系统推进一个 simulation tick
|
||||||
那么能量条数值应向玩家侧增加
|
那么能量条数值应向玩家侧增加
|
||||||
而且顶部红蓝能量条的玩家侧占比应变大
|
而且顶部红蓝能量条的玩家侧占比应变大
|
||||||
|
|
||||||
场景: 连续大声叫声触发更强反馈
|
场景: 连续强声浪触发触发更强反馈
|
||||||
假如玩家连续产生多次高于强叫声阈值的有效叫声
|
假如玩家连续产生多次高于强声浪阈值的有效声浪触发
|
||||||
当系统计算玩家连击加成
|
当系统计算玩家连击加成
|
||||||
那么玩家侧推动力应高于单次普通叫声推动力
|
那么玩家侧推动力应高于单次普通声浪触发推动力
|
||||||
而且玩家侧声浪或冲击波反馈应比普通叫声更明显
|
而且玩家侧声浪或冲击波反馈应比普通叫声更明显
|
||||||
但是反馈不应遮挡倒计时和能量条
|
但是反馈不应遮挡倒计时和能量条
|
||||||
|
|
||||||
@@ -185,31 +185,30 @@
|
|||||||
功能: 背景噪音过滤
|
功能: 背景噪音过滤
|
||||||
为了避免环境声替玩家自动得分
|
为了避免环境声替玩家自动得分
|
||||||
作为玩家
|
作为玩家
|
||||||
我希望低于阈值或不合规的声音不会被当作有效狗叫
|
我希望低于阈值或处于冷却内的声音不会被当作有效声浪触发
|
||||||
|
|
||||||
背景:
|
背景:
|
||||||
假如游戏处于 30 秒 playing 阶段
|
假如游戏处于 30 秒 playing 阶段
|
||||||
而且系统已完成环境噪音校准
|
而且系统已完成环境噪音校准
|
||||||
|
|
||||||
场景: 低于阈值的背景噪音不计数
|
场景: 低于阈值的背景噪音不计数
|
||||||
当麦克风只接收到低于有效叫声阈值的背景噪音
|
当麦克风只接收到低于有效声浪阈值的背景噪音
|
||||||
那么玩家叫声次数不应增加
|
那么玩家声浪触发次数不应增加
|
||||||
而且玩家侧不应播放吠叫动画
|
而且玩家侧不应播放吠叫动画
|
||||||
而且能量条不应因为该背景噪音出现可见推进
|
而且能量条不应因为该背景噪音出现可见推进
|
||||||
|
|
||||||
场景: 过短脉冲不计为有效叫声
|
场景: 冷却内重复达阈值不计数
|
||||||
假如麦克风输入峰值超过有效阈值
|
假如玩家刚刚产生一次有效声浪触发
|
||||||
但是持续时长短于 minBarkDurationMs
|
当麦克风输入在 minBarkGapMs 内再次达到有效声浪阈值
|
||||||
当系统完成该声音段判定
|
那么玩家声浪触发次数不应增加
|
||||||
那么玩家叫声次数不应增加
|
|
||||||
而且不应触发连击加成
|
而且不应触发连击加成
|
||||||
|
|
||||||
场景: 过长持续声被削弱为单段输入
|
场景: 持续高响度输入只按冷却节奏产生触发
|
||||||
假如麦克风输入持续超过有效阈值
|
假如麦克风输入持续达到或超过有效声浪阈值
|
||||||
但是持续时长长于 maxBarkDurationMs
|
当声浪冷却尚未结束
|
||||||
当系统完成该声音段判定
|
那么系统不应在每个 tick 中重复计数
|
||||||
那么系统不应按多个叫声重复计数
|
当声浪冷却结束且当前采样仍达到有效声浪阈值
|
||||||
而且该声音段的推动力应按持续噪音削弱规则处理
|
那么系统可以记录下一次有效声浪触发
|
||||||
```
|
```
|
||||||
|
|
||||||
### 功能: 倒计时与胜负结算
|
### 功能: 倒计时与胜负结算
|
||||||
@@ -227,20 +226,20 @@
|
|||||||
当系统时间从 30 秒推进到 0 秒
|
当系统时间从 30 秒推进到 0 秒
|
||||||
那么界面应显示倒计时归零
|
那么界面应显示倒计时归零
|
||||||
而且系统应进入 finished 结算阶段
|
而且系统应进入 finished 结算阶段
|
||||||
而且归零后的麦克风输入不应再改变本局能量条和叫声次数
|
而且归零后的麦克风输入不应再改变本局能量条和声浪触发次数
|
||||||
|
|
||||||
场景: 玩家侧占优时判定玩家胜利
|
场景: 玩家侧占优时判定玩家胜利
|
||||||
假如倒计时归零时能量条数值大于 drawThreshold
|
假如倒计时归零时能量条数值大于 drawThreshold
|
||||||
当系统进入结算阶段
|
当系统进入结算阶段
|
||||||
那么系统应判定玩家胜利
|
那么系统应判定玩家胜利
|
||||||
而且结算面板应展示玩家叫声次数、最大音量和声浪评分
|
而且结算面板应展示玩家声浪触发次数、最大音量和声浪评分
|
||||||
而且应提供再来一局入口
|
而且应提供再来一局入口
|
||||||
|
|
||||||
场景: 对手侧占优时判定玩家失败
|
场景: 对手侧占优时判定玩家失败
|
||||||
假如倒计时归零时能量条数值小于 -drawThreshold
|
假如倒计时归零时能量条数值小于 -drawThreshold
|
||||||
当系统进入结算阶段
|
当系统进入结算阶段
|
||||||
那么系统应判定对手胜利
|
那么系统应判定对手胜利
|
||||||
而且结算面板应展示玩家叫声次数、最大音量和声浪评分
|
而且结算面板应展示玩家声浪触发次数、最大音量和声浪评分
|
||||||
而且应提供再来一局入口
|
而且应提供再来一局入口
|
||||||
|
|
||||||
场景: 能量条接近平衡时判定平局
|
场景: 能量条接近平衡时判定平局
|
||||||
@@ -265,7 +264,7 @@
|
|||||||
当玩家选择再来一局
|
当玩家选择再来一局
|
||||||
那么系统应重置剩余时间为 30 秒
|
那么系统应重置剩余时间为 30 秒
|
||||||
而且能量条应回到中线
|
而且能量条应回到中线
|
||||||
而且玩家叫声次数、最大音量、连击和胜负结果应清零
|
而且玩家声浪触发次数、最大音量、连击和胜负结果应清零
|
||||||
而且系统应重新进入校准或开局倒计时流程
|
而且系统应重新进入校准或开局倒计时流程
|
||||||
|
|
||||||
场景: 结算后返回玩法入口
|
场景: 结算后返回玩法入口
|
||||||
@@ -353,7 +352,7 @@
|
|||||||
假如玩家在 playing 阶段刷新页面
|
假如玩家在 playing 阶段刷新页面
|
||||||
当页面重新加载 bark-battle
|
当页面重新加载 bark-battle
|
||||||
那么系统应重新进入权限检查或授权准备状态
|
那么系统应重新进入权限检查或授权准备状态
|
||||||
而且不应沿用刷新前的剩余时间、能量条和叫声次数作为新局结果
|
而且不应沿用刷新前的剩余时间、能量条和声浪触发次数作为新局结果
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试映射
|
## 测试映射
|
||||||
@@ -367,15 +366,15 @@
|
|||||||
| 嘈杂环境生成更高的有效阈值 | unit | `src/games/bark-battle/domain/BarkNoiseCalibration.test.ts` | planned |
|
| 嘈杂环境生成更高的有效阈值 | unit | `src/games/bark-battle/domain/BarkNoiseCalibration.test.ts` | planned |
|
||||||
| 校准期间无法获得有效音频样本 | application/component | `src/games/bark-battle/application/BarkBattleController.test.ts`, `src/games/bark-battle/ui/BarkBattlePermissionPanel.test.tsx` | planned |
|
| 校准期间无法获得有效音频样本 | application/component | `src/games/bark-battle/application/BarkBattleController.test.ts`, `src/games/bark-battle/ui/BarkBattlePermissionPanel.test.tsx` | planned |
|
||||||
| 单次超过阈值且间隔足够的叫声计数加一 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
| 单次超过阈值且间隔足够的叫声计数加一 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||||
| 持续噪音不会被无限计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
| 持续高响度输入只按冷却节奏计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||||
| 间隔过短的连续峰值不重复计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
| 间隔过短的连续峰值不重复计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||||
| 玩家推动力高于对手时能量条向玩家侧移动 | unit | `src/games/bark-battle/domain/EnergyTugOfWar.test.ts` | planned |
|
| 玩家推动力高于对手时能量条向玩家侧移动 | unit | `src/games/bark-battle/domain/EnergyTugOfWar.test.ts` | planned |
|
||||||
| 连续大声叫声触发更强反馈 | unit/integration/component | `src/games/bark-battle/domain/BarkBattleScoring.test.ts`, `src/games/bark-battle/ui/BarkBattleHud.test.tsx` | planned |
|
| 连续强声浪触发触发更强反馈 | unit/integration/component | `src/games/bark-battle/domain/BarkBattleScoring.test.ts`, `src/games/bark-battle/ui/BarkBattleHud.test.tsx` | planned |
|
||||||
| 能量条到达边界后不会越界 | unit | `src/games/bark-battle/domain/EnergyTugOfWar.test.ts` | planned |
|
| 能量条到达边界后不会越界 | unit | `src/games/bark-battle/domain/EnergyTugOfWar.test.ts` | planned |
|
||||||
| 对手推动力高于玩家时能量条向对手侧移动 | unit | `src/games/bark-battle/domain/EnergyTugOfWar.test.ts` | planned |
|
| 对手推动力高于玩家时能量条向对手侧移动 | unit | `src/games/bark-battle/domain/EnergyTugOfWar.test.ts` | planned |
|
||||||
| 低于阈值的背景噪音不计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
| 低于阈值的背景噪音不计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||||
| 过短脉冲不计为有效叫声 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
| 冷却内重复达阈值不计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||||
| 过长持续声被削弱为单段输入 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
| 持续高响度输入只按冷却节奏产生触发 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||||
| 倒计时每秒递减并在归零时停止对战输入 | unit/application | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/application/BarkBattleController.test.ts` | planned |
|
| 倒计时每秒递减并在归零时停止对战输入 | unit/application | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/application/BarkBattleController.test.ts` | planned |
|
||||||
| 玩家侧占优时判定玩家胜利 | unit/component | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/ui/BarkBattleResultPanel.test.tsx` | planned |
|
| 玩家侧占优时判定玩家胜利 | unit/component | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/ui/BarkBattleResultPanel.test.tsx` | planned |
|
||||||
| 对手侧占优时判定玩家失败 | unit/component | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/ui/BarkBattleResultPanel.test.tsx` | planned |
|
| 对手侧占优时判定玩家失败 | unit/component | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/ui/BarkBattleResultPanel.test.tsx` | planned |
|
||||||
@@ -394,8 +393,8 @@
|
|||||||
## 验收清单
|
## 验收清单
|
||||||
|
|
||||||
- [ ] 权限允许、拒绝、非安全上下文、API 不支持、麦克风未找到/不可读、AudioContext 被拦截、校准超时或样本不可读均有明确状态,且不会误进入 playing。
|
- [ ] 权限允许、拒绝、非安全上下文、API 不支持、麦克风未找到/不可读、AudioContext 被拦截、校准超时或样本不可读均有明确状态,且不会误进入 playing。
|
||||||
- [ ] 校准阶段会影响有效叫声阈值,低噪音不会增加叫声计数。
|
- [ ] 校准阶段会影响有效声浪阈值,低噪音不会增加叫声计数。
|
||||||
- [ ] 有效叫声计数具备阈值、峰值间隔、持续时长约束。
|
- [ ] 有效声浪触发计数具备阈值与声浪冷却约束。
|
||||||
- [ ] 能量条根据双方推动力差值双向移动,并限制在 `-100` 到 `100`。
|
- [ ] 能量条根据双方推动力差值双向移动,并限制在 `-100` 到 `100`。
|
||||||
- [ ] 30 秒归零后停止本局输入影响,并按玩家胜利、对手胜利、平局三类结果结算。
|
- [ ] 30 秒归零后停止本局输入影响,并按玩家胜利、对手胜利、平局三类结果结算。
|
||||||
- [ ] 移动端核心元素可见,非关键设置收起,不在主画面堆叠长规则说明。
|
- [ ] 移动端核心元素可见,非关键设置收起,不在主画面堆叠长规则说明。
|
||||||
@@ -405,8 +404,8 @@
|
|||||||
## 开放问题
|
## 开放问题
|
||||||
|
|
||||||
1. MVP 是否确认只做“玩家 vs AI”,还是第一版需要双人同屏或联机对战?
|
1. MVP 是否确认只做“玩家 vs AI”,还是第一版需要双人同屏或联机对战?
|
||||||
2. `drawThreshold`、`minBarkGapMs`、`minBarkDurationMs`、`maxBarkDurationMs` 的首版默认值由产品/调参阶段确认,还是先采用开发可配置默认值?
|
2. `drawThreshold`、`minBarkGapMs`、有效声浪阈值 的首版默认值由产品/调参阶段确认,还是先采用开发可配置默认值?
|
||||||
3. 是否允许无麦克风设备提供键盘/点击备用输入?若允许,需要另补非声控模式场景;若不允许,当前降级只提供返回入口。
|
3. 是否允许无麦克风设备提供键盘/点击备用输入?若允许,需要另补非声控模式场景;若不允许,当前降级只提供返回入口。
|
||||||
4. 是否需要在结算中记录或上报成绩、最高音量、叫声次数和声浪评分?若需要,需补埋点/后端持久化场景。
|
4. 是否需要在结算中记录或上报成绩、最高音量、声浪触发次数和声浪评分?若需要,需补埋点/后端持久化场景。
|
||||||
5. bark-battle 是否作为 Genarrative 正式 play type 接入创作入口、作品发布和广场,还是先作为独立 runtime 原型验证?
|
5. bark-battle 是否作为 Genarrative 正式 play type 接入创作入口、作品发布和广场,还是先作为独立 runtime 原型验证?
|
||||||
6. 狗狗、背景、拟声词和冲击波素材来源是临时占位、AI 生成,还是复用项目现有素材管线?
|
6. 狗狗、背景、拟声词和冲击波素材来源是临时占位、AI 生成,还是复用项目现有素材管线?
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
## 重点入口
|
## 重点入口
|
||||||
|
|
||||||
|
- [宝贝爱画寓教于乐独立关卡 PRD](./BABY_LOVE_DRAWING_EDUTAINMENT_LEVEL_PRD_2026-05-13.md):定义寓教于乐内容线的 `宝贝爱画` 独立本地 Demo 关卡,覆盖画板、七色选择、画笔/橡皮、手部绘画、完成、image-2 绘画魔法、本地保存和关闭入口隐藏边界。
|
||||||
- [宝贝识物寓教于乐模板 PRD](./BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md):定义寓教于乐内容线的 `宝贝识物` 创作模板,覆盖两个物品名称输入、image-2 物品图生成、精确 `寓教于乐` 标签、结果页和发布边界。
|
- [宝贝识物寓教于乐模板 PRD](./BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md):定义寓教于乐内容线的 `宝贝识物` 创作模板,覆盖两个物品名称输入、image-2 物品图生成、精确 `寓教于乐` 标签、结果页和发布边界。
|
||||||
- [AI 原生幕间文字游戏模板 PRD:参考 MOKU 的剧本模拟器闭环](./AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md):参考 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验,但只落为陶泥儿 `text-game` 模板,复用平台接口,不迁入外部社区、支付、私有存档或回放。
|
- [AI 原生幕间文字游戏模板 PRD:参考 MOKU 的剧本模拟器闭环](./AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md):参考 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验,但只落为陶泥儿 `text-game` 模板,复用平台接口,不迁入外部社区、支付、私有存档或回放。
|
||||||
- [AI 原生视觉小说模板 PRD:TXT 玩法平台化接入](./AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md):参考 `Interactive-fiction-backend` / `Interactive-fiction-frontend` 的 TXT 玩法经验,但只保留视觉小说模板创作与运行闭环,完全使用 Genarrative 平台接口,并明确删除回放和外部平台功能。
|
- [AI 原生视觉小说模板 PRD:TXT 玩法平台化接入](./AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md):参考 `Interactive-fiction-backend` / `Interactive-fiction-frontend` 的 TXT 玩法经验,但只保留视觉小说模板创作与运行闭环,完全使用 Genarrative 平台接口,并明确删除回放和外部平台功能。
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ Query:
|
|||||||
- SQL 固定为 `SELECT * FROM {tableName} LIMIT {limit}`;SpacetimeDB 2.2 HTTP SQL 不拼 `ORDER BY`。
|
- SQL 固定为 `SELECT * FROM {tableName} LIMIT {limit}`;SpacetimeDB 2.2 HTTP SQL 不拼 `ORDER BY`。
|
||||||
- 用户输入不直接拼入 SQL;关键词和条件在 API Server 内存中过滤。
|
- 用户输入不直接拼入 SQL;关键词和条件在 API Server 内存中过滤。
|
||||||
- private 表或 token 不可见时返回后台可读错误信息。
|
- private 表或 token 不可见时返回后台可读错误信息。
|
||||||
- SpacetimeDB SQL 行和 SATS 值统一转成人可读 JSON:Option None 为 null,Some 展开为内部值,Timestamp 单元素数组展开为内部值,enum 可保留 tag/name 或原始数组文本。
|
- SpacetimeDB SQL 行和 SATS 值统一转成人可读 JSON:Option None 为 null,Some 展开为内部值,Timestamp 单元素数组展开为内部值;已知业务枚举列应在 API Server 按表名和列名转换为业务字符串,例如 `profile_recharge_order.kind` 转为 `points` / `membership`,`profile_recharge_order.status` 转为 `pending` / `paid` / `failed` / `closed` / `refunded`。
|
||||||
|
|
||||||
## 前端页面
|
## 前端页面
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# 宝贝爱画本地 Demo 运行态实现方案 2026-05-13
|
||||||
|
|
||||||
|
## 1. 范围
|
||||||
|
|
||||||
|
本方案落地寓教于乐独立关卡:
|
||||||
|
|
||||||
|
```text
|
||||||
|
baby-love-drawing / 宝贝爱画
|
||||||
|
```
|
||||||
|
|
||||||
|
当前范围只做本地 Demo 闭环:
|
||||||
|
|
||||||
|
1. 寓教于乐频道默认关卡卡片;
|
||||||
|
2. 独立运行态;
|
||||||
|
3. mocap 与开发者调试输入;
|
||||||
|
4. Canvas 绘制和擦除;
|
||||||
|
5. image-2 绘画魔法后端代理;
|
||||||
|
6. localStorage 本地保存;
|
||||||
|
7. 直达路由开关保护。
|
||||||
|
|
||||||
|
本阶段不接正式持久化表,不新增作品发布、作品号、公开详情或搜索入口。
|
||||||
|
|
||||||
|
## 2. 前端接入点
|
||||||
|
|
||||||
|
已新增页面阶段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
baby-love-drawing-runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
已新增路由:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/runtime/baby-love-drawing
|
||||||
|
```
|
||||||
|
|
||||||
|
已新增文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/shared/src/contracts/edutainmentBabyDrawing.ts
|
||||||
|
src/services/edutainment-baby-drawing/babyDrawingClient.ts
|
||||||
|
src/components/edutainment-runtime/babyLoveDrawingModel.ts
|
||||||
|
src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.tsx
|
||||||
|
server-rs/crates/api-server/src/edutainment_baby_drawing.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
已接入:
|
||||||
|
|
||||||
|
1. `src/components/rpg-entry/RpgEntryHomeView.tsx`:寓教于乐频道默认展示宝贝爱画卡片;
|
||||||
|
2. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`:启动宝贝爱画运行态;
|
||||||
|
3. `src/components/platform-entry/platformEntryTypes.ts`:扩展 `SelectionStage`;
|
||||||
|
4. `src/routing/appPageRoutes.ts`:扩展路由;
|
||||||
|
5. `src/routing/appRoutes.tsx`:直达路由开关保护;
|
||||||
|
6. `src/index.css`:补齐寓教于乐默认关卡卡片和宝贝爱画运行态样式;
|
||||||
|
7. `server-rs/crates/api-server/src/app.rs`:挂载绘画魔法后端路由。
|
||||||
|
|
||||||
|
## 3. 契约
|
||||||
|
|
||||||
|
契约放在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/shared/src/contracts/edutainmentBabyDrawing.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
核心字段:
|
||||||
|
|
||||||
|
1. `templateId = "baby-love-drawing"`;
|
||||||
|
2. `templateName = "宝贝爱画"`;
|
||||||
|
3. `originalImageSrc` 保存原始画布图;
|
||||||
|
4. `magicImageSrc` 保存 image-2 魔法图,可为 `null`;
|
||||||
|
5. `strokeTrace` 保存画笔和橡皮轨迹;
|
||||||
|
6. `saveMode = "original-only" | "original-and-magic"` 记录保存结果。
|
||||||
|
|
||||||
|
## 4. 运行态模型
|
||||||
|
|
||||||
|
运行态状态:
|
||||||
|
|
||||||
|
```text
|
||||||
|
drawing
|
||||||
|
finished
|
||||||
|
magicPending
|
||||||
|
magicReady
|
||||||
|
saved
|
||||||
|
```
|
||||||
|
|
||||||
|
工具:
|
||||||
|
|
||||||
|
```text
|
||||||
|
brush
|
||||||
|
eraser
|
||||||
|
```
|
||||||
|
|
||||||
|
颜色:
|
||||||
|
|
||||||
|
```text
|
||||||
|
红、橙、黄、绿、青、蓝、紫
|
||||||
|
```
|
||||||
|
|
||||||
|
按钮悬停:
|
||||||
|
|
||||||
|
1. 颜色选择只接受左手悬停,阈值 1500ms;
|
||||||
|
2. 按钮选择接受任一手悬停,阈值 2000ms;
|
||||||
|
3. 工具切换只接受右手在工具区域握拳。
|
||||||
|
4. 画笔 / 橡皮光标位置只接受右手坐标;左手缺帧或左手移动不得重置、替换或驱动画笔位置。
|
||||||
|
5. 左手需要显示独立位置指示器,帮助用户确认当前是否悬停在目标颜色上;该指示器只表达左手位置,不参与画笔 / 橡皮操作。
|
||||||
|
6. 本地 mocap handedness 当前按摄像头视角输出,宝贝爱画运行态消费前需要换算为用户身体视角:`rightHand` 作为用户左手,`leftHand` 作为用户右手。键鼠调试输入不做该换算。
|
||||||
|
7. 真实硬件短暂缺失某只手时,显示层保留上一帧位置约 320ms 并做轻微坐标平滑;绘制层仍只在当前帧确认用户右手存在时生效。
|
||||||
|
8. 为避免左手抢画笔,本关不做动态 handedness 换手纠正;`rightHand` 永远只进入用户左手选色通道,`leftHand` 永远只进入用户右手画笔通道。若硬件侧 handedness 继续抖动,宁可右手画笔短暂停住,也不允许左手驱动画笔。
|
||||||
|
9. 右手画笔通道增加单帧最大位移门禁;若 camera-left 候选点相对上一帧右手位置出现不合理大跳,判定为不可信帧,只保留上一帧光标并停止绘制。
|
||||||
|
|
||||||
|
## 5. Canvas 绘制
|
||||||
|
|
||||||
|
画板使用 DOM Canvas。
|
||||||
|
|
||||||
|
绘制规则:
|
||||||
|
|
||||||
|
1. 右手在画板内且状态为 `grab` 时生效;
|
||||||
|
2. 工具为 `brush` 时,以当前颜色绘制连续线段;
|
||||||
|
3. 工具为 `eraser` 时,以 `destination-out` 擦除;
|
||||||
|
4. 右手状态为 `open_palm` 或离开画板时结束当前笔画;
|
||||||
|
5. 当前帧没有右手坐标时只结束当前笔画,不把左手坐标用于绘制、擦除或光标定位;
|
||||||
|
6. 每条笔画记录工具、颜色、点位和时间。
|
||||||
|
|
||||||
|
## 6. 绘画魔法
|
||||||
|
|
||||||
|
前端 service:
|
||||||
|
|
||||||
|
```text
|
||||||
|
createBabyDrawingMagicImage(payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
后端接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/creation/edutainment/baby-love-drawing/magic
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"originalImageSrc": "data:image/png;base64,...",
|
||||||
|
"strokeTrace": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"magicImageSrc": "data:image/png;base64,...",
|
||||||
|
"generationProvider": "vector-engine-gpt-image-2",
|
||||||
|
"prompt": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
后端使用 VectorEngine `gpt-image-2-all`,把原始画布图作为参考图,生成绘本风格图片。
|
||||||
|
|
||||||
|
本地未配置 VectorEngine 或接口失败时,前端允许提示错误并保留原图保存能力;不得把失败伪装成正式魔法图。
|
||||||
|
|
||||||
|
后端接入约束:
|
||||||
|
|
||||||
|
1. 接口需要 Bearer 鉴权;
|
||||||
|
2. 请求体限制为 8MB;
|
||||||
|
3. `originalImageSrc` 只接受图片 Data URL;
|
||||||
|
4. 笔触数量上限为 600 条;
|
||||||
|
5. 上游参考图字段使用 VectorEngine 统一契约 `image`;
|
||||||
|
6. 关闭入口时,`creation_entry_config` 路由熔断可识别 `baby-love-drawing`。
|
||||||
|
|
||||||
|
## 7. 本地保存
|
||||||
|
|
||||||
|
本地保存使用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
localStorage key = genarrative.edutainmentBabyDrawing.localDrawings.v1
|
||||||
|
```
|
||||||
|
|
||||||
|
保存策略:
|
||||||
|
|
||||||
|
1. 魔法生成前保存:`saveMode = "original-only"`,只保存 `originalImageSrc`;
|
||||||
|
2. 未保存原图直接生成魔法后保存:`saveMode = "original-and-magic"`,保存 `originalImageSrc` 和 `magicImageSrc`;
|
||||||
|
3. 保存后展示“再画一张”和“返回”。
|
||||||
|
|
||||||
|
## 8. 验收命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- src/components/edutainment-runtime/babyLoveDrawingModel.test.ts src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.test.tsx src/services/edutainment-baby-drawing/babyDrawingClient.test.ts src/routing/appRoutes.test.ts
|
||||||
|
cargo test -p api-server edutainment_baby_drawing --manifest-path server-rs/Cargo.toml
|
||||||
|
cargo test -p api-server resolves_runtime_paths_to_creation_type_ids --manifest-path server-rs/Cargo.toml
|
||||||
|
npx eslint src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.tsx src/components/edutainment-runtime/babyLoveDrawingModel.ts src/services/edutainment-baby-drawing/babyDrawingClient.ts src/routing/appRoutes.tsx --ext .ts,.tsx --max-warnings 0
|
||||||
|
npm run typecheck
|
||||||
|
npm run check:encoding
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 已覆盖测试
|
||||||
|
|
||||||
|
1. `src/components/edutainment-runtime/babyLoveDrawingModel.test.ts`:颜色 / 按钮悬停阈值、坐标归一化、笔触追加;
|
||||||
|
2. `src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.test.tsx`:画板、七色、画笔 / 橡皮、完成保存、返回按钮、左手位置指示器、mocap 摄像头视角到用户身体视角换算、左手输入不替换画笔光标位置、左手短暂缺帧不闪烁、用户左手不能抢占右手画笔、camera-left 大跳不接入画笔;
|
||||||
|
3. `src/services/edutainment-baby-drawing/babyDrawingClient.test.ts`:原图保存、原图加魔法图保存、后端魔法接口请求;
|
||||||
|
4. `src/routing/appRoutes.test.ts`:`/runtime/baby-love-drawing` 开启可达、关闭回落主应用;
|
||||||
|
5. `server-rs/crates/api-server/src/edutainment_baby_drawing.rs` 内部单测:prompt、Data URL 校验、PNG 输出和轨迹范围摘要;
|
||||||
|
6. `server-rs/crates/api-server/src/creation_entry_config.rs` 路由映射单测:确认后端熔断可识别 `baby-love-drawing`。
|
||||||
@@ -30,13 +30,15 @@ baby-object-match
|
|||||||
宝贝识物
|
宝贝识物
|
||||||
```
|
```
|
||||||
|
|
||||||
入口文件:
|
工程接入文件:
|
||||||
|
|
||||||
1. `src/config/newWorkEntryConfig.ts`
|
1. `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
|
||||||
2. `src/components/platform-entry/platformEntryCreationTypes.ts`
|
2. `src/components/platform-entry/platformEntryCreationTypes.ts`
|
||||||
3. `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
|
3. `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
|
||||||
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||||
|
|
||||||
|
`src/config/newWorkEntryConfig.ts` 已迁移删除,不再作为入口事实源。`baby-object-match` 必须存在于 SpacetimeDB `creation_entry_type_config` 默认种子中,默认展示名为 `宝贝识物`、`visible=true`、`open=true`、`sortOrder=90`;前端只通过 `GET /api/creation-entry/config` 读取后端配置并在 `platformEntryCreationTypes.ts` 做展示派生。
|
||||||
|
|
||||||
`baby-object-match` 必须复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时,创作类型弹层不展示 `宝贝识物`,创作页作品架不展示本地宝贝识物草稿或已发布作品卡,公开发现、搜索、详情、作品号和浏览历史也继续完全不可见。
|
`baby-object-match` 必须复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时,创作类型弹层不展示 `宝贝识物`,创作页作品架不展示本地宝贝识物草稿或已发布作品卡,公开发现、搜索、详情、作品号和浏览历史也继续完全不可见。
|
||||||
|
|
||||||
新增阶段:
|
新增阶段:
|
||||||
@@ -62,7 +64,8 @@ packages/shared/src/contracts/edutainmentBabyObject.ts
|
|||||||
2. `BabyObjectMatchDraft.templateName = "宝贝识物"`;
|
2. `BabyObjectMatchDraft.templateName = "宝贝识物"`;
|
||||||
3. `BabyObjectMatchDraft.themeTags` 必须包含精确 `寓教于乐`;
|
3. `BabyObjectMatchDraft.themeTags` 必须包含精确 `寓教于乐`;
|
||||||
4. `BabyObjectMatchItemAsset.generationProvider` 首版允许为 `vector-engine-gpt-image-2` 或 `placeholder`;
|
4. `BabyObjectMatchItemAsset.generationProvider` 首版允许为 `vector-engine-gpt-image-2` 或 `placeholder`;
|
||||||
5. `BabyObjectMatchPublishRequest.draft.themeTags` 发布前必须归一化补齐 `寓教于乐`。
|
5. `BabyObjectMatchDraft.visualPackage` 可选承载背景环境、UI 装饰框、礼物盒、篮子和烟雾弹出特效五类视觉资源;
|
||||||
|
6. `BabyObjectMatchPublishRequest.draft.themeTags` 发布前必须归一化补齐 `寓教于乐`。
|
||||||
|
|
||||||
## 4. Service 边界
|
## 4. Service 边界
|
||||||
|
|
||||||
@@ -76,9 +79,64 @@ src/services/edutainment-baby-object/babyObjectMatchClient.ts
|
|||||||
|
|
||||||
1. `createBabyObjectMatchDraft(payload)`;
|
1. `createBabyObjectMatchDraft(payload)`;
|
||||||
2. `saveBabyObjectMatchDraft(draft)`;
|
2. `saveBabyObjectMatchDraft(draft)`;
|
||||||
3. `publishBabyObjectMatchWork(payload)`。
|
3. `publishBabyObjectMatchWork(payload)`;
|
||||||
|
4. `deleteLocalBabyObjectMatchDraft(profileId)`;
|
||||||
|
5. `regenerateBabyObjectMatchDraftAssets(draft)`;
|
||||||
|
6. `hasBabyObjectMatchPlaceholderAssets(draft)`。
|
||||||
|
|
||||||
当前后端正式接口未在本线程扩表落地,因此 service 先走本地 Demo 存储,并把 asset 结果标记为 `placeholder`。后续后端接入时,应替换为:
|
当前后端正式作品持久化接口未在本线程扩表落地,因此 service 仍使用本地 Demo 存储草稿和发布状态。由于 image-2 会返回多张 base64 PNG 大图,本地 Demo 草稿必须优先写入 IndexedDB `genarrative-edutainment-baby-object-drafts/drafts`,不得把完整草稿 JSON 写入 `localStorage`;`localStorage` 仅作为旧版小草稿迁移读取来源,读取后迁移到 IndexedDB 并清理旧 key,避免触发浏览器 `Storage` 配额错误。
|
||||||
|
|
||||||
|
物品图片生成已接入后端 image-2 接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/creation/edutainment/baby-object-match/assets
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"itemNames": ["苹果", "香蕉"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"itemId": "baby-object-item-1",
|
||||||
|
"itemName": "苹果",
|
||||||
|
"imageSrc": "data:image/png;base64,...",
|
||||||
|
"assetObjectId": null,
|
||||||
|
"generationProvider": "vector-engine-gpt-image-2",
|
||||||
|
"prompt": "..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"visualPackage": {
|
||||||
|
"themePrompt": "...",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"assetId": "baby-object-visual-background",
|
||||||
|
"assetKind": "background",
|
||||||
|
"imageSrc": "data:image/png;base64,...",
|
||||||
|
"assetObjectId": null,
|
||||||
|
"generationProvider": "vector-engine-gpt-image-2",
|
||||||
|
"prompt": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
该接口返回物品透明 PNG data URL,以及同一次创作生成的视觉主题包。本地 Demo 阶段暂不写入 OSS 或 SpacetimeDB `asset_object`。当前创作链路必须真实拿到 `generationProvider = "vector-engine-gpt-image-2"` 的物品图和视觉主题包后才允许进入结果页;若本地未配置 VectorEngine、登录态失效、接口返回 401/5xx、上游生成失败或响应缺少任一资源,前端 service 必须抛出错误并停留在生成失败状态,不得静默回退到占位图。
|
||||||
|
|
||||||
|
由于一次创作会生成 2 张物品图和 `background`、`ui-frame`、`gift-box`、`basket`、`smoke-puff` 5 张视觉包装图,该请求属于长耗时 image-2 链路。前端 `babyObjectMatchClient` 对该 POST 使用 10 分钟请求超时,且不做自动重试,避免第一次生成仍在后端执行时又发起第二次重复生成。后端同时启动物品图与视觉主题包生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,避免某张图 3 分钟附近仍在生成时被后端提前断开。后端日志记录每类资源的开始、完成和耗时,排查时优先按同一次 HTTP 请求查看 `宝贝识物 image-2 物品资源生成完成`、`宝贝识物 image-2 视觉资源生成完成` 与 `VectorEngine 图片生成上游错误`。
|
||||||
|
|
||||||
|
历史本地草稿中若已保存 `generationProvider = "placeholder"` 的旧占位资源,结果页必须提示“重新生成 image-2 资源”,并禁用试玩和发布。用户点击重新生成、发布或试玩前,前端统一调用 `regenerateBabyObjectMatchDraftAssets(draft)` 补齐资源;补齐失败时保留在结果页并展示错误。
|
||||||
|
|
||||||
|
后续正式作品持久化接入时,应补齐:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST /api/creation/edutainment/baby-object-match/drafts
|
POST /api/creation/edutainment/baby-object-match/drafts
|
||||||
@@ -88,6 +146,25 @@ POST /api/creation/edutainment/baby-object-match/drafts/{draftId}/publish
|
|||||||
|
|
||||||
图片生成必须在后端调用 VectorEngine `gpt-image-2-all`,不得从前端直接调用外部图片接口。
|
图片生成必须在后端调用 VectorEngine `gpt-image-2-all`,不得从前端直接调用外部图片接口。
|
||||||
|
|
||||||
|
后端 image-2 prompt 约束:
|
||||||
|
|
||||||
|
1. 锁定寓教于乐板块统一的卡通绘本草地舞台插画风;
|
||||||
|
2. 每张图只能围绕对应关键词生成一个单一物品;
|
||||||
|
3. 不生成背景、场景、氛围渲染、人物、手、篮子、礼物盒、文字、水印或 UI;
|
||||||
|
4. 优先要求纯白或透明抠图友好的干净背景,服务端再统一转透明 PNG 并执行背景 alpha 清理;
|
||||||
|
5. 返回 `generationProvider = "vector-engine-gpt-image-2"` 的素材必须已经完成透明抠图。
|
||||||
|
|
||||||
|
后端视觉主题包 prompt 约束:
|
||||||
|
|
||||||
|
1. 同一次请求根据两个物品关键词生成 `background`、`ui-frame`、`gift-box`、`basket`、`smoke-puff` 五类资源;
|
||||||
|
2. 总风格继续锁定寓教于乐明亮卡通绘本插画风;
|
||||||
|
3. 若关键词偏动漫角色、玩具或公仔,背景环境和 UI 元素匹配动漫、玩具主题;若关键词偏水果,匹配果园、自然主题;其它关键词按语义匹配合适主题;
|
||||||
|
4. 背景环境图使用非透明 16:9 图,但必须保证中间、中下方和底部左右篮子区域清爽,给放大后的礼物盒、中央物品和左右篮子预留空间,不画入礼物盒、篮子、物品、人物、文字或操作 UI;
|
||||||
|
5. UI 装饰框、礼物盒、篮子和烟雾弹出特效使用透明 PNG 后处理,不生成文字、数字、按钮、人物或待分类物品;
|
||||||
|
6. `gift-box` 提示词必须面向运行态约 2 倍视觉尺寸生成主体饱满的大号礼物盒,`basket` 提示词必须面向运行态约 1.5 倍视觉尺寸生成可读性高的大号篮子;
|
||||||
|
7. `smoke-puff` 只生成礼物盒打开瞬间使用的柔和烟雾云朵特效,不生成礼物盒、篮子、物品或文字;
|
||||||
|
8. 左右篮子的固定选项规则不受主题包影响,运行态只把 `basket` 作为篮子造型包装复用。
|
||||||
|
|
||||||
## 5. UI 边界
|
## 5. UI 边界
|
||||||
|
|
||||||
工作台只展示两个必填输入和生成按钮。
|
工作台只展示两个必填输入和生成按钮。
|
||||||
@@ -107,27 +184,37 @@ src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx
|
|||||||
运行态直接消费 `BabyObjectMatchDraft`,必须使用草稿中的两个物品名称和物品图。
|
运行态直接消费 `BabyObjectMatchDraft`,必须使用草稿中的两个物品名称和物品图。
|
||||||
每轮只随机当前从礼物盒跳出的物品;左右篮子不随机交换,左侧固定为草稿 `itemAssets[0]`,右侧固定为草稿 `itemAssets[1]`。
|
每轮只随机当前从礼物盒跳出的物品;左右篮子不随机交换,左侧固定为草稿 `itemAssets[0]`,右侧固定为草稿 `itemAssets[1]`。
|
||||||
|
|
||||||
|
若草稿包含 `visualPackage`,运行态通过背景图片层、CSS 变量和图片节点消费:
|
||||||
|
|
||||||
|
1. `background`:作为舞台最底层 `ResolvedAssetImage` 背景图;存在该资源时必须关闭默认草地兜底层,避免生成场景被 CSS 草地遮住或弱化;
|
||||||
|
2. `ui-frame`:作为字幕条和计数器装饰背景;
|
||||||
|
3. `gift-box`:替换 CSS 礼物盒主体,按旧视觉约 2 倍尺寸展示,只在礼盒入场和打开阶段存在;
|
||||||
|
4. `basket`:替换篮子主体造型,按旧视觉约 1.5 倍尺寸展示,左右两侧复用同一张主题篮子图;
|
||||||
|
5. `smoke-puff`:作为礼物盒打开和中央物品弹出期间的透明烟雾特效资源。
|
||||||
|
|
||||||
|
旧草稿或接口失败时 `visualPackage = null`,运行态继续使用现有 CSS 绘本风兜底。
|
||||||
|
|
||||||
首关状态机:
|
首关状态机:
|
||||||
|
|
||||||
1. `waiting`:礼物盒关闭,等待任意手抬起;
|
1. `gift-entering`:礼物盒从上方落下入场动画阶段,不接受动作判定;
|
||||||
2. `active`:当前物品停留在屏幕中央;
|
2. `gift-opening`:礼物盒打开并播放烟雾特效阶段,不接受动作判定;
|
||||||
3. `correct`:展示“真棒”反馈,成功次数加 1;
|
3. `item-appearing`:礼物盒从舞台移除,当前物品从烟雾中出现并停稳,不接受动作判定;
|
||||||
4. `wrong`:展示“再想一想吧”反馈,当前物品回到中央;
|
4. `active`:物品彻底出现后才开放选篮判定;
|
||||||
5. `complete`:成功次数达到 20,展示“恭喜你!小朋友!”和按钮。
|
5. `correct`:展示“真棒”反馈,对应篮筐播放正确特效并停顿,成功次数加 1;特效完全结束后重新进入 `gift-entering`,下一轮礼物盒从上方落下;
|
||||||
|
6. `wrong`:展示“再想一想吧”反馈,物品弹回中央;反馈结束后回到 `active`,不重新随机物品;
|
||||||
|
7. `complete`:成功次数达到 20,展示“恭喜你!小朋友!”和按钮。
|
||||||
|
|
||||||
动作输入:
|
动作输入:
|
||||||
|
|
||||||
1. 任意手完成一次 `open_palm -> grab` 抓握序列:打开礼物盒并生成当前物品;
|
1. 左手连续横向移动达到阈值:将当前物品送入左侧篮子;
|
||||||
2. 左手连续横向移动达到阈值:将当前物品送入左侧篮子;
|
2. 右手连续横向移动达到阈值:将当前物品送入右侧篮子。
|
||||||
3. 右手连续横向移动达到阈值:将当前物品送入右侧篮子。
|
|
||||||
|
|
||||||
运行态直接通过 `useMocapInput` 消费本地 mocap WebSocket `/stream`。选篮只使用明确 `leftHand` 或 `rightHand` 的连续横向轨迹阈值,不再通过 `wave_left_hand`、`wave_right_hand`、`wave` 等动作名触发;侧别为 `unknown` 的手部轨迹也不参与选篮,以避免多套判定误命中和连续误触发。当前本地 mocap 输出的 handedness 按摄像头视角标记,宝贝识物运行态必须先换算为用户身体视角:`rightHand` 轨迹映射玩家左手并进入左侧篮子,`leftHand` 轨迹映射玩家右手并进入右侧篮子。草稿试玩、发布后正式体验和热身关后的本地 Demo 都复用同一个运行态,因此三条入口都必须具备同一套动作控制能力。
|
运行态直接通过 `useMocapInput` 消费本地 mocap WebSocket `/stream`。选篮只使用明确 `leftHand` 或 `rightHand` 的连续横向轨迹阈值,不再通过 `wave_left_hand`、`wave_right_hand`、`wave` 等动作名触发;侧别为 `unknown` 的手部轨迹也不参与选篮,以避免多套判定误命中和连续误触发。动作判定只在 `active` 阶段开放,礼盒入场、礼盒打开、物品出现、正确反馈和错误反馈阶段收到的动作包必须清空轨迹并忽略,不允许跨阶段补判定。当前本地 mocap 输出的 handedness 按摄像头视角标记,宝贝识物运行态必须先换算为用户身体视角:`rightHand` 轨迹映射玩家左手并进入左侧篮子,`leftHand` 轨迹映射玩家右手并进入右侧篮子。草稿试玩、发布后正式体验和热身关后的本地 Demo 都复用同一个运行态,因此三条入口都必须具备同一套动作控制能力。
|
||||||
|
|
||||||
开发者调试输入:
|
开发者调试输入:
|
||||||
|
|
||||||
1. `F`:映射任意手抬起,打开礼物盒并生成当前物品;
|
1. 鼠标左键按下并拖动:映射左手轨迹,抬起后将当前物品送入左侧篮子;
|
||||||
2. 鼠标左键按下并拖动:映射左手轨迹,抬起后将当前物品送入左侧篮子;
|
2. 鼠标右键按下并拖动:映射右手轨迹,抬起后将当前物品送入右侧篮子。
|
||||||
3. 鼠标右键按下并拖动:映射右手轨迹,抬起后将当前物品送入右侧篮子。
|
|
||||||
|
|
||||||
运行态不得新增计时、失败次数、分数、体力或难度递增规则。
|
运行态不得新增计时、失败次数、分数、体力或难度递增规则。
|
||||||
|
|
||||||
@@ -154,6 +241,7 @@ src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/edutainment-baby-object/babyObjectMatchClient.test.ts
|
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/edutainment-baby-object/babyObjectMatchClient.test.ts
|
||||||
|
cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml
|
||||||
npx vitest run src/components/platform-entry/platformEdutainmentVisibility.test.ts src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/custom-world-home/creationWorkShelf.test.ts src/services/useMocapInput.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts
|
npx vitest run src/components/platform-entry/platformEdutainmentVisibility.test.ts src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/custom-world-home/creationWorkShelf.test.ts src/services/useMocapInput.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts
|
||||||
npx eslint src/components/platform-entry/platformEntryCreationTypes.ts src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --ext .ts,.tsx --max-warnings 0
|
npx eslint src/components/platform-entry/platformEntryCreationTypes.ts src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --ext .ts,.tsx --max-warnings 0
|
||||||
npm run check:encoding
|
npm run check:encoding
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
### 1.1 背景
|
### 1.1 背景
|
||||||
|
|
||||||
`bark-battle` / “汪汪声浪大作战”是一个浏览器 2D 声控狗叫对战玩法。玩家通过麦克风发出狗叫声,浏览器 runtime 根据音量峰值、有效叫声次数与节奏推动顶部红蓝能量条;每局默认 30 秒;结束后按能量条偏向判定胜负或平局。
|
`bark-battle` / “汪汪声浪大作战”是一个浏览器 2D 声控狗叫对战玩法。玩家通过麦克风发出狗叫声,浏览器 runtime 根据音量峰值、有效声浪触发次数与节奏推动顶部红蓝能量条;每局默认 30 秒;结束后按能量条偏向判定胜负或平局。
|
||||||
|
|
||||||
现有前端方案 `docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md` 已覆盖 Phaser / TypeScript / Vite / Web Audio / DOM HUD 的 runtime 落地方式,并明确不覆盖后端表结构、成绩持久化、作品发布、广场接入与实时多人协议。因此需要单独补充后端 DDD 技术方案,避免前端 runtime 在接入平台作品、正式游玩埋点、成绩、排行榜和发布闭环时承接不属于表现层的业务真相。
|
现有前端方案 `docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md` 已覆盖 Phaser / TypeScript / Vite / Web Audio / DOM HUD 的 runtime 落地方式,并明确不覆盖后端表结构、成绩持久化、作品发布、广场接入与实时多人协议。因此需要单独补充后端 DDD 技术方案,避免前端 runtime 在接入平台作品、正式游玩埋点、成绩、排行榜和发布闭环时承接不属于表现层的业务真相。
|
||||||
|
|
||||||
@@ -44,26 +44,80 @@ MVP 明确不做:
|
|||||||
|
|
||||||
## 2. 玩法接入级别建议
|
## 2. 玩法接入级别建议
|
||||||
|
|
||||||
### 2.1 推荐首版闭环
|
### 2.1 第二阶段范围:平台作品闭环
|
||||||
|
|
||||||
建议先支持“本地 runtime + 可发布配置化作品 + 单局结果记录 / 可选排行榜”的闭环:
|
第二阶段已明确为“Bark Battle 平台作品闭环”,不是单纯玩法表现深化。目标是让 bark-battle 成为 Genarrative 的正式 play type,并完成从轻创作配置、发布、正式 runtime、run start / finish、单局结果持久化、个人历史成绩、作品统计到最小排行榜的闭环。
|
||||||
|
|
||||||
1. 创作者创建 bark-battle 草稿,配置标题、描述、狗狗主题、背景、难度、单局时长、音量阈值、AI 对手参数和排行榜开关。
|
Phase 2 的作品配置边界是“轻创作配置作品”:创作者可以配置标题、描述、主题/背景预设、狗狗皮肤预设、难度预设和排行榜开关;不得直接配置单局时长、有效声浪阈值、`minBarkGapMs`、AI 对手细粒度参数、分数公式或反作弊阈值。难度预设只影响 AI 对手行为强度,不影响有效阈值、声浪冷却、单局时长或分数公式;排行榜必须按 `workId + difficultyPreset + rulesetVersion` 分榜,避免不同难度和不同规则版本混排。
|
||||||
|
|
||||||
|
建议先支持“本地 runtime + 可发布配置化作品 + 单局结果记录 + 个人历史成绩 / 作品统计 / 最小排行榜”的闭环:
|
||||||
|
|
||||||
|
1. 创作者从玩法选择进入 bark-battle 后创建草稿,通过单页轻配置表单 + 预览卡片配置标题、描述、主题/背景预设、狗狗皮肤预设、难度预设和排行榜开关。
|
||||||
2. 发布为稳定作品 ID,`playTypeId = "bark-battle"`。
|
2. 发布为稳定作品 ID,`playTypeId = "bark-battle"`。
|
||||||
3. 玩家从作品页或广场进入 runtime,前端获取发布态 runtime config。
|
3. 玩家可从作品详情页 CTA、广场/作品卡片、我的作品/个人作品架进入正式 runtime,前端使用稳定作品 ID 获取发布态 runtime config。
|
||||||
4. 玩家授权麦克风后在本地完成 30 秒声控对战。
|
4. 玩家授权麦克风后在本地完成 30 秒声控对战。
|
||||||
5. 前端提交单局 finish 请求,只上传派生指标,例如峰值、有效叫声次数、节奏命中、最终能量、客户端结果摘要等。
|
5. 前端提交单局 finish 请求,只上传派生指标,例如峰值、有效声浪触发次数、节奏命中、最终能量、客户端结果摘要等。
|
||||||
6. 后端校验 work、config version、run token、时长、分数范围和权限后,生成服务端认可的 run result / score summary。
|
6. 后端校验 work、config version、ruleset version、difficulty preset、run token、时长、派生指标范围和权限后,生成服务端裁决的 run result / score summary。
|
||||||
7. 若作品开启排行榜,则写入可投影的 leaderboard 记录。
|
7. 写入个人历史成绩与最小作品统计投影。
|
||||||
8. 正式作品级游玩埋点统一写 `work_play_start`,其中 `scope_kind=work`,`scope_id=稳定作品 ID`,metadata 包含 `playType`、`workId`、`sourceRoute`、`userId`。
|
8. 若作品开启排行榜且后端裁决 `serverResult = player_win`,则写入可投影的 leaderboard 记录;排行榜首版只做最小排序与展示,不引入赛季、段位或复杂反作弊,并按 `workId + difficultyPreset + rulesetVersion` 分榜。
|
||||||
|
9. 正式作品级游玩埋点统一写 `work_play_start`,其中 `scope_kind=work`,`scope_id=稳定作品 ID`,metadata 包含 `playType`、`workId`、`sourceRoute`、`userId`。
|
||||||
|
|
||||||
|
|
||||||
|
### 2.2.1 难度预设与排行榜分榜
|
||||||
|
|
||||||
|
Phase 2 只允许三个难度预设:`easy`、`normal`、`hard`。难度预设只能影响 AI 对手推动力曲线和 AI 声浪节奏;不得影响单局时长、有效声浪阈值、`minBarkGapMs`、分数公式或反作弊阈值。排行榜记录和查询必须带上 `difficultyPreset` 与 `rulesetVersion`,以 `workId + difficultyPreset + rulesetVersion` 作为分榜维度。
|
||||||
|
|
||||||
|
### 2.2.2 单局结果后端裁决
|
||||||
|
|
||||||
|
Phase 2 不信任前端提交的最终胜负和正式分数。前端 `finish` 只提交不可还原原始音频的派生指标:`runId`、`workId`、`configVersion`、`rulesetVersion`、`difficultyPreset`、`clientStartedAt`、`clientFinishedAt`、`durationMs`、`triggerCount`、`maxVolume`、`averageVolume`、`finalEnergy`、`comboMax`、`clientResult`,以及可选的 `sampleDigest`。其中 `clientResult` 只用于 debug/对账,不进入正式结果或排行榜。
|
||||||
|
|
||||||
|
后端必须校验 run 由 start 创建且未 finish、run token 匹配、work/config/ruleset/difficulty 与 start 时一致、duration 处于合理窗口、triggerCount 不超过 `durationMs / minBarkGapMs + tolerance`、音量/能量/连击字段在合法范围内。后端生成 `serverResult`、`scoreSummary`、`leaderboardScore` 和 `antiCheatFlags`,排行榜只使用后端裁决后的胜利局成绩。
|
||||||
|
|
||||||
|
### 2.2.3 排行榜排序口径
|
||||||
|
|
||||||
|
Phase 2 排行榜只收录 `serverResult = player_win` 且未被反作弊规则拒绝的单局结果;平局和失败仍进入个人历史成绩与作品统计,但不进入排行榜。`leaderboardScore` 由后端规则集生成,排序优先级为:`finalEnergy` 降序、`triggerCount` 降序、`maxVolume` 降序、`durationMs` 越接近标准局时长越优、`finishedAt` 越早越优。
|
||||||
|
|
||||||
|
### 2.2.4 作品统计投影口径
|
||||||
|
|
||||||
|
Phase 2 的作品统计是最小后端投影,不从排行榜反推。`playStartCount` 在 start run 成功时计入一次,并对齐 `work_play_start` 埋点;`finishCount` 在 finish 被后端接受时计入一次,包含胜利、平局和失败。`accepted_with_flags` 可以计入 `finishCount`,但必须同时计入 `flaggedCount`;未 start 成功、run token 不合法、重复 finish、被后端 rejected 的结果不计入 `finishCount`。
|
||||||
|
|
||||||
|
作品统计字段首版包含:`playStartCount`、`finishCount`、`winCount`、`drawCount`、`lossCount`、`flaggedCount`、`leaderboardEntryCount`、`bestLeaderboardScore`、`bestFinalEnergy`、`averageFinalEnergy`、`updatedAt`。Phase 2 不做 DAU/留存、按小时曲线、原始音频分析或每玩家每天聚合统计。
|
||||||
|
|
||||||
|
### 2.2.5 个人历史成绩口径
|
||||||
|
|
||||||
|
Phase 2 的个人历史成绩由“最近记录列表 + 个人最佳摘要”组成,并且只允许本人查询。后端可以保存每次被接受的 finish 记录,但首版查询接口只暴露默认最近 20 条记录,可按 `workId` 和 `difficultyPreset` 过滤;最近记录包含胜利、平局、失败和是否 flagged,但不展示详细反作弊原因。
|
||||||
|
|
||||||
|
个人最佳摘要按 `userId + workId + difficultyPreset + rulesetVersion` 聚合,字段包含 `bestLeaderboardScore`、`bestFinalEnergy`、`bestTriggerCount`、`bestMaxVolume`、`winCount`、`finishCount`、`lastPlayedAt`。失败、平局和 flagged 历史不对其他玩家公开;排行榜只展示公开入榜的胜利成绩。Phase 2 不做无限滚动完整历史、每日/每周曲线、好友对比或普通玩家可见的详细反作弊说明。
|
||||||
|
|
||||||
|
### 2.2.6 正式作品入口闭环
|
||||||
|
|
||||||
|
Phase 2 必须接入 Bark Battle 正式作品入口闭环,但不新增独立专区、活动页、挑战分享页、好友邀请或多人房间入口。入口范围包括:创作入口/玩法选择中出现 `bark-battle`,进入单页轻配置表单 + 预览卡片;作品详情页 CTA 点击“开始游玩”进入正式 runtime;广场/作品卡片可以展示、打开详情并开始游玩;我的作品/个人作品架能看到作者发布的 Bark Battle 作品;runtime 路由使用稳定作品 ID 并从后端发布态 config 拉取配置。
|
||||||
|
|
||||||
|
正式 run start 成功后必须写 `work_play_start`,其中 `scope_kind=work`、`scope_id=稳定作品 ID`,metadata 至少包含 `playType=bark-battle`、`workId`、`sourceRoute`、`userId`。内部试玩入口可以作为开发调试保留,但不得作为 Phase 2 正式入口。
|
||||||
|
|
||||||
|
### 2.2.7 轻配置编辑流程
|
||||||
|
|
||||||
|
Phase 2 的创作编辑形态是“单页轻配置表单 + 预览卡片”,不是多步骤向导、拖拽编辑器或完整规则编辑器。表单字段包含:标题(必填)、简介(选填)、主题/背景预设(必填枚举)、狗狗皮肤预设(必填枚举)、难度预设(必填,默认 `normal`)、排行榜开关(默认开启)。
|
||||||
|
|
||||||
|
交互流程:创作者从玩法选择进入后生成草稿;在同一页编辑轻配置并查看预览卡片;支持保存草稿和发布;发布成功后跳转作品详情;可从我的作品再次编辑草稿或基于已发布作品创建新版本。Phase 2 不做 AI 生成配置、多步骤 wizard、规则参数编辑、复杂封面编辑、runtime 内嵌预览或大段玩法说明文案。
|
||||||
|
|
||||||
### 2.2 后续增强路径
|
### 2.2 后续增强路径
|
||||||
|
|
||||||
后续再考虑多人实时:
|
第二阶段之后再考虑:
|
||||||
|
|
||||||
- Phase 2:排行榜、挑战分享、个人历史成绩、作品统计面板。
|
- Phase 2.1:挑战分享、作品统计面板细化、排行榜体验优化。
|
||||||
- Phase 3:异步影子对手 / ghost replay,但仍不保存原始音频,只保存低维派生曲线或聚合指标。
|
- Phase 3:异步影子对手 / ghost replay,但仍不保存原始音频,只保存低维派生曲线或聚合指标。
|
||||||
- Phase 4:实时多人对战协议,需要独立同步模型、房间服务、延迟补偿、断线恢复与更严格反作弊;不应混入 MVP。
|
- Phase 4:实时多人对战协议,需要独立同步模型、房间服务、延迟补偿、断线恢复与更严格反作弊;不应混入第二阶段平台作品闭环。
|
||||||
|
|
||||||
|
## 2.3 Phase 2 技术实施顺序
|
||||||
|
|
||||||
|
Phase 2 按“契约和领域规则先行,然后最小纵切,再扩展投影”的顺序实施,避免前端 mock 堆积、后端孤岛或排行榜 UI 先行。
|
||||||
|
|
||||||
|
1. 契约与领域规则:补 `shared-contracts` DTO、`module-bark-battle` 纯领域规则、`rulesetVersion` / `difficultyPreset` / score adjudication,并先写单测。
|
||||||
|
2. SpacetimeDB 表与 reducer + api-server BFF:落草稿/config/发布态 config、runtime run start / finish、score record、leaderboard entry、work stats projection、personal summary projection、`migration.rs` 与绑定生成。
|
||||||
|
3. 最小前端纵切:接创作入口、单页轻配置表单、发布到稳定 workId、作品详情 CTA、runtime 拉 config、start / finish 串通、结算展示 `serverResult`。
|
||||||
|
4. 投影与列表体验:接排行榜、个人历史最近记录 + 最佳摘要、作品统计、我的作品/广场卡片适配。
|
||||||
|
5. 收口验证:把 BDD 场景落到测试,执行编码检查、后端 `/healthz` + API smoke、前端人工验收路径,并更新 README/文档。
|
||||||
|
|
||||||
## 3. DDD 分层设计
|
## 3. DDD 分层设计
|
||||||
|
|
||||||
@@ -90,7 +144,7 @@ frontend/runtime
|
|||||||
- 定义配置版本兼容规则。
|
- 定义配置版本兼容规则。
|
||||||
- 计算提交结果的派生分数区间与胜负判定是否自洽。
|
- 计算提交结果的派生分数区间与胜负判定是否自洽。
|
||||||
- 计算 `ScoreSummary`、排行榜排序分数、统计指标。
|
- 计算 `ScoreSummary`、排行榜排序分数、统计指标。
|
||||||
- 定义反作弊基础规则:时长范围、有效叫声次数上限、峰值范围、能量范围、提交窗口、run 状态机。
|
- 定义反作弊基础规则:时长范围、有效声浪触发次数上限、峰值范围、能量范围、提交窗口、run 状态机。
|
||||||
|
|
||||||
不职责:
|
不职责:
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,9 @@
|
|||||||
3. 位置类状态必须满足“到达绿色圆环并保持 2 秒”。
|
3. 位置类状态必须满足“到达绿色圆环并保持 2 秒”。
|
||||||
4. 动作类状态没有最长等待时间。
|
4. 动作类状态没有最长等待时间。
|
||||||
5. 动作类状态等待 3 秒后可以播放对应引导动画。
|
5. 动作类状态等待 3 秒后可以播放对应引导动画。
|
||||||
|
6. 每个步骤进入时需要先展示本步骤文字字幕和语音播报入口,约 1 秒后再进入可交互阶段并展示绿色圆环、手势引导等检测提示。
|
||||||
|
7. 步骤完成后需要先进入完成停顿阶段,当前停顿约 0.8 秒;停顿期间保留完成反馈位置,后续可在该阶段补充完成特效或音效,再切换到下一步骤。
|
||||||
|
8. 入场等待和完成停顿阶段不消费动作完成判定,避免用户上一步残留动作直接触发下一步。
|
||||||
|
|
||||||
### 6.3 开发者调试输入
|
### 6.3 开发者调试输入
|
||||||
|
|
||||||
@@ -368,6 +371,17 @@
|
|||||||
|
|
||||||
用户完成挥动左手。
|
用户完成挥动左手。
|
||||||
|
|
||||||
|
当前本地 mocap 的 handedness 按摄像头视角输出,热身关内需要先换算成用户身体视角再判断:摄像头右侧手对应用户左手。挥动左手不是普通横向轨迹检测,而是用于确认现实环境中用户左侧手臂打开空间足够和安全。
|
||||||
|
|
||||||
|
完成条件必须同时满足:
|
||||||
|
|
||||||
|
1. 使用用户身体左手轨迹。
|
||||||
|
2. 手腕在左肩外侧达到最小外展距离。
|
||||||
|
3. 手腕不能处于自然下垂低位。
|
||||||
|
4. 最近连续有效帧中,手臂存在足够上下摆动幅度。
|
||||||
|
5. 最近连续有效帧中,肩膀到手腕向量的角度变化达到阈值。
|
||||||
|
6. 至少出现一次上下摆动方向变化。
|
||||||
|
|
||||||
#### 完成反馈
|
#### 完成反馈
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -376,7 +390,7 @@
|
|||||||
|
|
||||||
#### 数据记录
|
#### 数据记录
|
||||||
|
|
||||||
记录用户挥动左手的空间,保存为该用户对应的行为坐标。
|
记录用户挥动左手的轨迹、空间包络、角度范围和最大外展距离,保存为该用户对应的行为坐标。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -398,6 +412,17 @@
|
|||||||
|
|
||||||
用户完成挥动右手。
|
用户完成挥动右手。
|
||||||
|
|
||||||
|
当前本地 mocap 的 handedness 按摄像头视角输出,热身关内需要先换算成用户身体视角再判断:摄像头左侧手对应用户右手。挥动右手不是普通横向轨迹检测,而是用于确认现实环境中用户右侧手臂打开空间足够和安全。
|
||||||
|
|
||||||
|
完成条件必须同时满足:
|
||||||
|
|
||||||
|
1. 使用用户身体右手轨迹。
|
||||||
|
2. 手腕在右肩外侧达到最小外展距离。
|
||||||
|
3. 手腕不能处于自然下垂低位。
|
||||||
|
4. 最近连续有效帧中,手臂存在足够上下摆动幅度。
|
||||||
|
5. 最近连续有效帧中,肩膀到手腕向量的角度变化达到阈值。
|
||||||
|
6. 至少出现一次上下摆动方向变化。
|
||||||
|
|
||||||
#### 完成反馈
|
#### 完成反馈
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -406,7 +431,7 @@
|
|||||||
|
|
||||||
#### 数据记录
|
#### 数据记录
|
||||||
|
|
||||||
记录用户挥动右手的空间,保存为该用户对应的行为坐标。
|
记录用户挥动右手的轨迹、空间包络、角度范围和最大外展距离,保存为该用户对应的行为坐标。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -653,18 +678,21 @@
|
|||||||
3. 鼠标左键按下并拖动映射左手轨迹。
|
3. 鼠标左键按下并拖动映射左手轨迹。
|
||||||
4. 鼠标右键按下并拖动映射右手轨迹。
|
4. 鼠标右键按下并拖动映射右手轨迹。
|
||||||
5. 空格键映射原地跳跃。
|
5. 空格键映射原地跳跃。
|
||||||
|
6. 调试输入只在步骤可交互阶段触发步骤完成;步骤入场字幕阶段和完成停顿阶段会忽略完成判定,便于观察节奏和后续补充特效。
|
||||||
|
|
||||||
当前硬件和动作检测接口接入:
|
当前硬件和动作检测接口接入:
|
||||||
|
|
||||||
1. 浏览器摄像头视频流已接入舞台背景。
|
1. 浏览器摄像头视频流已接入舞台背景。
|
||||||
2. 热身关全流程已通过 `src/services/useMocapInput.ts` 接入本地 mocap WebSocket `/stream`;动作数据源状态优先于浏览器背景摄像头状态展示。
|
2. 热身关全流程已通过 `src/services/useMocapInput.ts` 接入本地 mocap WebSocket `/stream`;动作数据源状态优先于浏览器背景摄像头状态展示。
|
||||||
3. mocap 包支持从 `general.body.center_norm` 读取身体中心,位置类步骤使用该身体中心更新角色剪影横向位置并完成圆环保持检测。
|
3. mocap 包支持从 `general.body.center_norm` 读取身体中心,位置类步骤使用该身体中心更新角色剪影横向位置并完成圆环保持检测。
|
||||||
4. mocap 包支持从 `actions/action/gesture/gestures/event/name/type` 读取动作名,并支持 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 读取左右手坐标。
|
4. 身体中心横向坐标进入角色剪影前必须做输入稳定化处理:先 clamp 到 `0..1`,再使用小幅死区、低通阻尼和单包最大步长限制,避免硬件噪声造成角色左右误判、画面抽搐或视觉上的忽大忽小。当前实现参数为死区 `0.012`、阻尼系数 `0.28`、单包最大步长 `0.035`;位置保持检测使用稳定化后的角色坐标。
|
||||||
5. `hands[].landmarks` 存在时优先用手腕和 MCP 点计算掌心中心;掌心点不足时退回 wrist landmark,再退回 hand 直出坐标。
|
5. 角色剪影渲染需要把水平位移和跳跃表现拆开:外层只负责横向定位,内层资源只负责轮廓图和跳跃位移,避免 `left` 与 `transform` 同时抢占导致半透明资源重采样抖动。
|
||||||
6. `wave_greeting` 可由 `wave/wave_greeting/hand_wave/open_palm` 等动作或 open palm 手势完成。
|
6. mocap 包支持从 `actions/action/gesture/gestures/event/name/type` 读取动作名,并支持 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 读取左右手坐标。
|
||||||
7. `wave_left_hand` 和 `wave_right_hand` 优先消费对应左右手动作名;当硬件只持续输出手部坐标时,也可以根据连续手部横向轨迹完成挥手检测。
|
7. `hands[].landmarks` 存在时优先用手腕和 MCP 点计算掌心中心;掌心点不足时退回 wrist landmark,再退回 hand 直出坐标。
|
||||||
8. `jump_once` 消费 `jump/jump_once/hop` 等跳跃动作事件完成。
|
8. `wave_greeting` 只消费左手、右手或未知单手的连续横向挥手轨迹,不再使用 `wave`、`hand_wave`、`open_palm`、张手状态或动作名直接完成判定;进入轨迹判定前必须先满足抬手有效区:优先使用 `hands[].landmarks.wrist` 与 `general.limb_nodes` 的同侧 `*_elbow` / `*_shoulder` 判断,当前阈值为 `wrist.y <= elbow.y + 0.04`,缺少肘部时使用 `wrist.y <= shoulder.y + 0.08`;缺少同侧肘部和肩膀参考时不允许招呼通过,不再使用身体中心兜底判断抬手。轨迹阈值为至少 5 个连续抬手点,横向 `x` 范围差值不小于 `0.075`,且至少出现 1 次横向方向变化,避免“手刚露出画面”或“手自然下垂抖动”被误判为招手。
|
||||||
9. 键盘 `A/D/Space` 与鼠标左右键拖拽仍保留为本地 Demo 调试兜底,不代表正式硬件口径。
|
9. `wave_left_hand` 和 `wave_right_hand` 只消费用户身体侧对应手的连续坐标轨迹,不再使用动作名、张手状态或 primary hand 兜底完成判定;本地 mocap handedness 当前按摄像头视角输出,因此用户左手使用 camera-right,用户右手使用 camera-left。完成判定必须同时满足对应肩肘腕外展、手腕非自然下垂、连续有效帧、横向范围、上下摆动范围、肩腕角度范围和上下方向变化,当前阈值为连续外展点不少于 5 个、横向 `x` 范围不小于 `0.055`、垂直 `y` 范围不小于 `0.08`、肩腕角度范围不小于 `28°`、外展距离不小于 `0.12`、手腕相对肩膀外侧距离不小于 `0.1`;后续以真实体验结果继续调参。
|
||||||
|
10. `jump_once` 消费 `jump/jump_once/hop` 等跳跃动作事件完成。
|
||||||
|
11. 键盘 `A/D/Space` 与鼠标左右键拖拽仍保留为本地 Demo 调试兜底,不代表正式硬件口径。
|
||||||
|
|
||||||
当前未接入但已保留边界:
|
当前未接入但已保留边界:
|
||||||
|
|
||||||
@@ -682,14 +710,17 @@
|
|||||||
5. 当前已生成并接入以下正式 Demo 资源:
|
5. 当前已生成并接入以下正式 Demo 资源:
|
||||||
- `public/child-motion-demo/picture-book-grass-stage.png`:默认草地舞台背景。
|
- `public/child-motion-demo/picture-book-grass-stage.png`:默认草地舞台背景。
|
||||||
- `public/child-motion-demo/picture-book-foreground-grass-v2.png`:底部前景草坪条,只覆盖舞台下沿,不作为整块地板拉伸。
|
- `public/child-motion-demo/picture-book-foreground-grass-v2.png`:底部前景草坪条,只覆盖舞台下沿,不作为整块地板拉伸。
|
||||||
- `public/child-motion-demo/picture-book-ground-ring-v2.png`:已按透视绘制的地面椭圆指示环,CSS 只等比缩放。
|
- `public/child-motion-demo/picture-book-ground-ring-v3.png`:已按透视绘制的浅蓝与暖黄色地面椭圆指示环,和草地材质做明显区分,CSS 只等比缩放。
|
||||||
- `public/child-motion-demo/picture-book-character-outline-v2.png`:半透明用户角色轮廓,使用独立去背后处理避免内部填充被误删。
|
- `public/child-motion-demo/picture-book-character-outline-v2.png`:半透明用户角色轮廓,使用独立去背后处理避免内部填充被误删。
|
||||||
- `public/child-motion-demo/picture-book-hud-strip-v2.png`:顶部 HUD 细长软纸条。
|
- `public/child-motion-demo/picture-book-hud-strip-v2.png`:顶部 HUD 细长软纸条。
|
||||||
- `public/child-motion-demo/picture-book-calibration-strip-v2.png`:右下角五格热身状态条。
|
- `public/child-motion-demo/picture-book-calibration-strip-v2.png`:右下角五格热身状态条。
|
||||||
- `public/child-motion-demo/picture-book-start-panel-v2.png`:开始按钮背后的轻盈托盘。
|
- `public/child-motion-demo/picture-book-start-panel-v2.png`:开始按钮背后的轻盈托盘。
|
||||||
- `public/child-motion-demo/picture-book-ui-button-v2.png`:开始按钮绘本风按钮底图。
|
- `public/child-motion-demo/picture-book-ui-button-v2.png`:开始按钮绘本风按钮底图。
|
||||||
|
- `public/child-motion-demo/picture-book-wave-cat-body-guide-v6.png`:招手阶段中央猫咪身体底座资源,按可动纸偶结构只包含猫头、短身体和肩部连接点,不再和旧猫头、胸口或猫爪资源叠加。
|
||||||
|
- `public/child-motion-demo/picture-book-wave-cat-arm-guide-v6.png`:招手阶段左右独立手臂资源,也用于左右手阶段单手提示;网页用同一拆件镜像复用,并围绕肩部挂点做挥手摆动动画。
|
||||||
6. v2 资源按最终用途拆分,CSS 必须按资源原始比例、`aspect-ratio` 或 `background-size: contain / auto` 等方式等比使用;禁止把方形面板强行拉伸为 HUD、状态条或地板,也禁止把底部草坪扩展成覆盖角色脚下的大色块。
|
6. v2 资源按最终用途拆分,CSS 必须按资源原始比例、`aspect-ratio` 或 `background-size: contain / auto` 等方式等比使用;禁止把方形面板强行拉伸为 HUD、状态条或地板,也禁止把底部草坪扩展成覆盖角色脚下的大色块。
|
||||||
7. 若后续补充或重绘资源,应先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt 和输出路径,再使用 `--live --only <asset-id>` 小批量生成;仅调整透明去背、裁切、画布归一或品红边缘时,可用 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用 `tmp/child-motion-demo-assets/` 中的源图,不额外请求 image-2;不得把 `VECTOR_ENGINE_API_KEY`、源图或中间预览图提交到仓库。
|
7. 猫咪招手引导资源使用 `cat-guide` 透明后处理:先由 image-2 生成品红底源图,再通过边缘背景连通区域去背,避免把浅粉、淡橘和暖棕主体误删。源图只保存在 `tmp/child-motion-demo-assets/`,正式页面只引用 `public/child-motion-demo/` 下的最终 PNG。
|
||||||
|
8. 若后续补充或重绘资源,应先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt 和输出路径,再使用 `--live --only <asset-id>` 小批量生成;仅调整透明去背、裁切、画布归一或品红边缘时,可用 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用 `tmp/child-motion-demo-assets/` 中的源图,不额外请求 image-2;不得把 `VECTOR_ENGINE_API_KEY`、源图或中间预览图提交到仓库。
|
||||||
|
|
||||||
已执行的定向验证命令:
|
已执行的定向验证命令:
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ process didn't exit successfully: `server-rs\target\debug\api-server.exe`
|
|||||||
|
|
||||||
主站和后台 Vite 也追加 `--strictPort`,避免默认漂移到 `3001`、`3103` 等端口后让浏览器继续访问旧页面。
|
主站和后台 Vite 也追加 `--strictPort`,避免默认漂移到 `3001`、`3103` 等端口后让浏览器继续访问旧页面。
|
||||||
|
|
||||||
|
`--skip-spacetime` 是复用既有 SpacetimeDB 宿主的模式。该模式下脚本不会再把 SpacetimeDB 端口纳入可用性漂移;如果传入 `--spacetime-port 3101`,后端就会连接 `http://127.0.0.1:3101`。这可以避免 `3101` 已有 SpacetimeDB 在线时,端口工具误把它当作冲突并改到空闲的 `3102`,导致 api-server 连接空端口后 `/api/creation-entry/config`、作品架和公开图库接口同时返回 `502`。
|
||||||
|
|
||||||
## 排障步骤
|
## 排障步骤
|
||||||
|
|
||||||
PowerShell 查看默认端口占用:
|
PowerShell 查看默认端口占用:
|
||||||
@@ -72,3 +74,4 @@ node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh \
|
|||||||
1. `bash -n scripts/dev-rust-stack.sh` 通过。
|
1. `bash -n scripts/dev-rust-stack.sh` 通过。
|
||||||
2. 默认端口被占用时重新运行完整栈,脚本应在 publish 前失败并打印占用进程。
|
2. 默认端口被占用时重新运行完整栈,脚本应在 publish 前失败并打印占用进程。
|
||||||
3. 清理占用进程或换端口后,重新启动时不再出现 Vite 端口漂移或 `api-server` `AddrInUse`。
|
3. 清理占用进程或换端口后,重新启动时不再出现 Vite 端口漂移或 `api-server` `AddrInUse`。
|
||||||
|
4. 复用 SpacetimeDB 时执行 `npm run dev -- --skip-spacetime --skip-publish --spacetime-port 3101`,启动日志里的 `[dev:rust] spacetime:` 应保持为 `http://127.0.0.1:3101`,并且 `GET /api/creation-entry/config` 不应因连接 `3102` 这类空端口而失败。
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
1. 新建作品入口配置统一存放在 SpacetimeDB 的 `creation_entry_config` / `creation_entry_type_config` 表;默认种子位于 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`。
|
1. 新建作品入口配置统一存放在 SpacetimeDB 的 `creation_entry_config` / `creation_entry_type_config` 表;默认种子位于 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`。
|
||||||
2. `visible` 控制玩法是否展示在创作 Tab 模板入口、新建作品入口和创作类型弹层中。
|
2. `visible` 控制玩法是否展示在创作 Tab 模板入口、新建作品入口和创作类型弹层中。
|
||||||
3. `open` 控制玩法是否允许点击创建以及对应创作 / runtime API 路由是否放行;`open: false` 时入口保持展示但禁用,并由 `api-server` 熔断对应玩法 API。
|
3. `open` 控制玩法是否允许点击创建以及对应创作 / runtime API 路由是否放行;`open: false` 时入口保持展示但禁用,并由 `api-server` 熔断对应玩法 API。
|
||||||
|
- 前端作品架、发现页聚合和预加载只应请求 `open: true` 的玩法接口;`open: false` 的未开放玩法可以展示为敬请期待入口,但不得把对应 API 熔断错误透传到草稿页或发现页。
|
||||||
4. `title`、`subtitle`、`badge` 控制玩法卡片文案。
|
4. `title`、`subtitle`、`badge` 控制玩法卡片文案。
|
||||||
5. `startCard` 控制旧创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案;当前创作 Tab 首屏标题固定在 `PlatformEntryFlowShellImpl.tsx`,不再由 `startCard` 控制。
|
5. `startCard` 控制旧创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案;当前创作 Tab 首屏标题固定在 `PlatformEntryFlowShellImpl.tsx`,不再由 `startCard` 控制。
|
||||||
6. `typeModal` 控制平台创作类型弹层标题和描述。
|
6. `typeModal` 控制平台创作类型弹层标题和描述。
|
||||||
@@ -28,6 +29,22 @@
|
|||||||
| AIRP | 是 | 否 | 保留入口,显示敬请期待 |
|
| AIRP | 是 | 否 | 保留入口,显示敬请期待 |
|
||||||
| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待,暂不允许创建视觉小说草稿 |
|
| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待,暂不允许创建视觉小说草稿 |
|
||||||
| 智能创作 | 否 | 是 | 入口隐藏,既有 `creative-agent` 链路保留 |
|
| 智能创作 | 否 | 是 | 入口隐藏,既有 `creative-agent` 链路保留 |
|
||||||
|
| 汪汪声浪 | 是 | 是 | `bark-battle` 正式轻创作入口,进入单页配置表单并发布后启动声控对战 runtime |
|
||||||
|
| 宝贝识物 | 是 | 是 | 寓教于乐首关模板,必须由 `creation_entry_type_config` 默认种子或后台入口开关保持存在 |
|
||||||
|
|
||||||
|
## 排障约束
|
||||||
|
|
||||||
|
`baby-object-match` 是寓教于乐创作模板和发现页宝贝识物作品合流的共同开关。若 `creation_entry_type_config` 缺少该行,前端会同时出现“创作界面没有宝贝识物模板”和“寓教于乐分类下已发布作品消失”的现象。
|
||||||
|
|
||||||
|
默认种子必须包含:
|
||||||
|
|
||||||
|
- `id = baby-object-match`
|
||||||
|
- `title = 宝贝识物`
|
||||||
|
- `visible = true`
|
||||||
|
- `open = true`
|
||||||
|
- `sort_order = 90`
|
||||||
|
|
||||||
|
入口图首版复用 `/child-motion-demo/picture-book-grass-stage.png`,后续如生成专属 image-2 入口图,可通过后台入口开关更新 `imageSrc`。
|
||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
## 文档列表
|
## 文档列表
|
||||||
|
|
||||||
- [WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md](./WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md):记录微信小程序 `web-view` 壳的最小接入范围、需要填写的 H5 业务域名、微信后台配置、`npm run check:wechat-miniprogram-auth` 可重复登录链路 smoke 和后续原生化边界。
|
- [WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md](./WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md):记录微信小程序 `web-view` 壳的最小接入范围、需要填写的 H5 业务域名、微信后台配置、`npm run check:wechat-miniprogram-auth` 可重复登录链路 smoke 和后续原生化边界。
|
||||||
|
- [BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md](./BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md):冻结寓教于乐 `宝贝爱画` 独立本地 Demo 运行态实现方案,明确发现页默认卡片、`/runtime/baby-love-drawing` 路由、画板交互、mocap/键鼠调试映射、本地保存和 VectorEngine image-2 绘画魔法后端代理。
|
||||||
- [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。
|
- [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。
|
||||||
- [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。
|
- [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。
|
||||||
- [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。
|
- [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ npm run dev:rust
|
|||||||
|
|
||||||
1. `npm run dev` / `npm run dev:rust` 会先检查 SpacetimeDB、Rust `api-server`、主站 Vite、后台 Vite 需要使用的端口。
|
1. `npm run dev` / `npm run dev:rust` 会先检查 SpacetimeDB、Rust `api-server`、主站 Vite、后台 Vite 需要使用的端口。
|
||||||
2. 如果优先端口不可用,脚本会从该端口开始向后寻找可用端口,并将解析后的端口覆盖到后续 `spacetime start`、`spacetime publish --server`、`GENARRATIVE_API_PORT`、`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`、`ADMIN_API_TARGET` 与 Vite 启动参数。
|
2. 如果优先端口不可用,脚本会从该端口开始向后寻找可用端口,并将解析后的端口覆盖到后续 `spacetime start`、`spacetime publish --server`、`GENARRATIVE_API_PORT`、`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`、`ADMIN_API_TARGET` 与 Vite 启动参数。
|
||||||
3. 控制台会打印 `[dev:ports] ... 可用` 或 `[dev:ports] ... 不可用,改用 ...`,排查代理错配时以该日志和后续 `[dev:rust] web/admin web/rust api/spacetime` 实际地址为准。
|
3. 显式传入 `--skip-spacetime` 时,脚本不会对 SpacetimeDB 端口做可用性漂移;此时 `--spacetime-port` 表示要复用的既有 SpacetimeDB 地址。后端会直接使用该地址,避免 `3101` 已有可用宿主时被误改到空闲但未启动的 `3102`。
|
||||||
4. 单独 `npm run dev:web` 也会检查主站 Vite 端口;`WEB_PORT` 或默认 `3000` 不可用时,会自动切到后续可用端口并继续严格端口启动。
|
4. 控制台会打印 `[dev:ports] ... 可用` 或 `[dev:ports] ... 不可用,改用 ...`,排查代理错配时以该日志和后续 `[dev:rust] web/admin web/rust api/spacetime` 实际地址为准。
|
||||||
|
5. 单独 `npm run dev:web` 也会检查主站 Vite 端口;`WEB_PORT` 或默认 `3000` 不可用时,会自动切到后续可用端口并继续严格端口启动。
|
||||||
|
|
||||||
默认流程:
|
默认流程:
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ Vite 代理覆盖范围:
|
|||||||
|
|
||||||
本地联调跳过策略:
|
本地联调跳过策略:
|
||||||
|
|
||||||
1. 如果 `3101` 已被当前可复用的 SpacetimeDB standalone 占用,脚本会优先按 `spacetime.pid` 与 `dev-rust-spacetime-url` 复用该宿主;如果确认不是可复用宿主,则会先输出占用进程并选择最近可用端口。也可显式使用 `npm run dev -- --skip-spacetime` 跳过 SpacetimeDB 宿主启动,或用 `--spacetime-port` 指定起始探测端口。
|
1. 如果 `3101` 已被当前可复用的 SpacetimeDB standalone 占用,脚本会优先按 `spacetime.pid` 与 `dev-rust-spacetime-url` 复用该宿主;如果确认不是可复用宿主,则会先输出占用进程并选择最近可用端口。也可显式使用 `npm run dev -- --skip-spacetime --spacetime-port 3101` 跳过 SpacetimeDB 宿主启动并复用 `http://127.0.0.1:3101`。在 `--skip-spacetime` 模式下,`--spacetime-port` 不是起始探测端口,而是必须已经在线的目标端口。
|
||||||
2. 如果当前没有修改 `server-rs/crates/spacetime-module`,可使用 `npm run dev -- --skip-publish` 跳过数据库发布,降低本地启动时的 SpacetimeDB wasm 编译耗时。
|
2. 如果当前没有修改 `server-rs/crates/spacetime-module`,可使用 `npm run dev -- --skip-publish` 跳过数据库发布,降低本地启动时的 SpacetimeDB wasm 编译耗时。
|
||||||
3. 如果当前阶段只需要检查 `spacetime-module` 语法,不需要重新发布本地数据库,可执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。该命令只做 Rust 编译检查,不生成新数据库,也不刷新 bindings。
|
3. 如果当前阶段只需要检查 `spacetime-module` 语法,不需要重新发布本地数据库,可执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。该命令只做 Rust 编译检查,不生成新数据库,也不刷新 bindings。
|
||||||
|
|
||||||
|
|||||||
130
packages/shared/src/contracts/barkBattle.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BARK_BATTLE_DIFFICULTY_PRESETS,
|
||||||
|
type BarkBattleDraftConfig,
|
||||||
|
type BarkBattleFinishResponse,
|
||||||
|
type BarkBattlePersonalBestSummary,
|
||||||
|
type BarkBattleWorkStats,
|
||||||
|
} from './barkBattle';
|
||||||
|
|
||||||
|
describe('Bark Battle shared contracts', () => {
|
||||||
|
test('default draft config fixture uses normal difficulty and camelCase fields', () => {
|
||||||
|
const draft: BarkBattleDraftConfig = {
|
||||||
|
draftId: 'draft-bark-1',
|
||||||
|
title: '汪汪声浪挑战',
|
||||||
|
description: '轻配置草稿',
|
||||||
|
themePreset: 'city-park',
|
||||||
|
playerDogSkinPreset: 'corgi',
|
||||||
|
opponentDogSkinPreset: 'husky',
|
||||||
|
difficultyPreset: 'normal',
|
||||||
|
leaderboardEnabled: true,
|
||||||
|
updatedAt: '2026-05-13T03:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(BARK_BATTLE_DIFFICULTY_PRESETS).toEqual(['easy', 'normal', 'hard']);
|
||||||
|
expect(draft.difficultyPreset).toBe('normal');
|
||||||
|
expect(Object.keys(draft)).toEqual([
|
||||||
|
'draftId',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'themePreset',
|
||||||
|
'playerDogSkinPreset',
|
||||||
|
'opponentDogSkinPreset',
|
||||||
|
'difficultyPreset',
|
||||||
|
'leaderboardEnabled',
|
||||||
|
'updatedAt',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finish accepted player_win fixture exposes backend adjudication result', () => {
|
||||||
|
const response: BarkBattleFinishResponse = {
|
||||||
|
status: 'accepted',
|
||||||
|
runId: 'run-bark-1',
|
||||||
|
workId: 'work-bark-1',
|
||||||
|
configVersion: 3,
|
||||||
|
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||||
|
difficultyPreset: 'hard',
|
||||||
|
serverResult: 'player_win',
|
||||||
|
scoreSummary: {
|
||||||
|
finalEnergy: 87,
|
||||||
|
triggerCount: 42,
|
||||||
|
maxVolume: 0.96,
|
||||||
|
averageVolume: 0.61,
|
||||||
|
comboMax: 9,
|
||||||
|
durationMs: 30000,
|
||||||
|
},
|
||||||
|
leaderboardScore: 870429630,
|
||||||
|
antiCheatFlags: [],
|
||||||
|
updatedAt: '2026-05-13T03:00:30.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(response.status).toBe('accepted');
|
||||||
|
expect(response.serverResult).toBe('player_win');
|
||||||
|
expect(response.scoreSummary.finalEnergy).toBe(87);
|
||||||
|
expect(response.antiCheatFlags).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('work stats fixture tracks starts, finishes, result counts, flags and energy summary', () => {
|
||||||
|
const stats: BarkBattleWorkStats = {
|
||||||
|
workId: 'work-bark-1',
|
||||||
|
configVersion: 3,
|
||||||
|
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||||
|
difficultyPreset: 'normal',
|
||||||
|
playStartCount: 18,
|
||||||
|
finishCount: 15,
|
||||||
|
winCount: 8,
|
||||||
|
drawCount: 2,
|
||||||
|
lossCount: 5,
|
||||||
|
flaggedCount: 1,
|
||||||
|
leaderboardEntryCount: 7,
|
||||||
|
bestLeaderboardScore: 930389410,
|
||||||
|
bestFinalEnergy: 93,
|
||||||
|
averageFinalEnergy: 41.25,
|
||||||
|
updatedAt: '2026-05-13T04:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(stats.playStartCount).toBe(18);
|
||||||
|
expect(stats.finishCount).toBe(15);
|
||||||
|
expect(stats.winCount + stats.drawCount + stats.lossCount).toBe(15);
|
||||||
|
expect(stats.flaggedCount).toBe(1);
|
||||||
|
expect(stats.bestFinalEnergy).toBeGreaterThan(stats.averageFinalEnergy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('optional score fields may be omitted instead of serialized as null', () => {
|
||||||
|
const finishWithoutLeaderboard: BarkBattleFinishResponse = {
|
||||||
|
status: 'accepted',
|
||||||
|
runId: 'run-bark-no-rank',
|
||||||
|
workId: 'work-bark-1',
|
||||||
|
configVersion: 3,
|
||||||
|
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||||
|
difficultyPreset: 'normal',
|
||||||
|
serverResult: 'draw',
|
||||||
|
scoreSummary: {
|
||||||
|
finalEnergy: 50,
|
||||||
|
triggerCount: 12,
|
||||||
|
maxVolume: 0.7,
|
||||||
|
averageVolume: 0.5,
|
||||||
|
comboMax: 3,
|
||||||
|
durationMs: 30000,
|
||||||
|
},
|
||||||
|
antiCheatFlags: [],
|
||||||
|
updatedAt: '2026-05-13T03:00:30.000Z',
|
||||||
|
};
|
||||||
|
const personalBestWithoutWin: BarkBattlePersonalBestSummary = {
|
||||||
|
workId: 'work-bark-1',
|
||||||
|
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||||
|
difficultyPreset: 'normal',
|
||||||
|
winCount: 0,
|
||||||
|
drawCount: 1,
|
||||||
|
lossCount: 2,
|
||||||
|
finishCount: 3,
|
||||||
|
updatedAt: '2026-05-13T04:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect('leaderboardScore' in finishWithoutLeaderboard).toBe(false);
|
||||||
|
expect('bestLeaderboardScore' in personalBestWithoutWin).toBe(false);
|
||||||
|
expect('bestFinalEnergy' in personalBestWithoutWin).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
218
packages/shared/src/contracts/barkBattle.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
export const BARK_BATTLE_DIFFICULTY_PRESETS = [
|
||||||
|
'easy',
|
||||||
|
'normal',
|
||||||
|
'hard',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BarkBattleDifficultyPreset =
|
||||||
|
(typeof BARK_BATTLE_DIFFICULTY_PRESETS)[number];
|
||||||
|
|
||||||
|
export type BarkBattleServerResult = 'player_win' | 'opponent_win' | 'draw';
|
||||||
|
|
||||||
|
export type BarkBattleFinishStatus =
|
||||||
|
| 'accepted'
|
||||||
|
| 'accepted_with_flags'
|
||||||
|
| 'rejected';
|
||||||
|
|
||||||
|
export type BarkBattlePlayTypeId = 'bark-battle';
|
||||||
|
|
||||||
|
export interface BarkBattleConfigEditorPayload {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
themePreset: string;
|
||||||
|
playerDogSkinPreset: string;
|
||||||
|
opponentDogSkinPreset: string;
|
||||||
|
difficultyPreset: BarkBattleDifficultyPreset;
|
||||||
|
leaderboardEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleDraftCreateRequest extends BarkBattleConfigEditorPayload {}
|
||||||
|
|
||||||
|
export interface BarkBattleWorkPublishRequest {
|
||||||
|
draftId: string;
|
||||||
|
workId?: string;
|
||||||
|
publishedSnapshot?: BarkBattleConfigEditorPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleDraftConfig extends BarkBattleConfigEditorPayload {
|
||||||
|
draftId: string;
|
||||||
|
workId?: string;
|
||||||
|
configVersion?: number;
|
||||||
|
rulesetVersion?: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattlePublishedConfig {
|
||||||
|
workId: string;
|
||||||
|
draftId?: string | null;
|
||||||
|
configVersion: number;
|
||||||
|
rulesetVersion: string;
|
||||||
|
playTypeId: BarkBattlePlayTypeId;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
themePreset: string;
|
||||||
|
playerDogSkinPreset: string;
|
||||||
|
opponentDogSkinPreset: string;
|
||||||
|
difficultyPreset: BarkBattleDifficultyPreset;
|
||||||
|
leaderboardEnabled: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
publishedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleRuntimeConfig {
|
||||||
|
workId: string;
|
||||||
|
configVersion: number;
|
||||||
|
rulesetVersion: string;
|
||||||
|
playTypeId: BarkBattlePlayTypeId;
|
||||||
|
durationMs: number;
|
||||||
|
energyMin: number;
|
||||||
|
energyMax: number;
|
||||||
|
drawThreshold: number;
|
||||||
|
minBarkGapMs: number;
|
||||||
|
difficultyPreset: BarkBattleDifficultyPreset;
|
||||||
|
themePreset: string;
|
||||||
|
playerDogSkinPreset: string;
|
||||||
|
opponentDogSkinPreset: string;
|
||||||
|
leaderboardEnabled: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleRunStartRequest {
|
||||||
|
workId: string;
|
||||||
|
configVersion?: number;
|
||||||
|
sourceRoute?: string;
|
||||||
|
clientRuntimeVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleRunStartResponse {
|
||||||
|
runId: string;
|
||||||
|
runToken: string;
|
||||||
|
workId: string;
|
||||||
|
configVersion: number;
|
||||||
|
rulesetVersion: string;
|
||||||
|
difficultyPreset: BarkBattleDifficultyPreset;
|
||||||
|
runtimeConfig: BarkBattleRuntimeConfig;
|
||||||
|
serverStartedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleDerivedMetrics {
|
||||||
|
triggerCount: number;
|
||||||
|
maxVolume: number;
|
||||||
|
averageVolume: number;
|
||||||
|
finalEnergy: number;
|
||||||
|
comboMax: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleRunFinishRequest {
|
||||||
|
runId: string;
|
||||||
|
runToken: string;
|
||||||
|
workId: string;
|
||||||
|
configVersion: number;
|
||||||
|
rulesetVersion: string;
|
||||||
|
difficultyPreset: BarkBattleDifficultyPreset;
|
||||||
|
clientStartedAt: string;
|
||||||
|
clientFinishedAt: string;
|
||||||
|
durationMs: number;
|
||||||
|
derivedMetrics: BarkBattleDerivedMetrics;
|
||||||
|
clientResult?: BarkBattleServerResult;
|
||||||
|
sampleDigest?: string;
|
||||||
|
clientRuntimeVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleScoreSummary extends BarkBattleDerivedMetrics {
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleFinishResponse {
|
||||||
|
status: BarkBattleFinishStatus;
|
||||||
|
runId: string;
|
||||||
|
workId: string;
|
||||||
|
configVersion: number;
|
||||||
|
rulesetVersion: string;
|
||||||
|
difficultyPreset: BarkBattleDifficultyPreset;
|
||||||
|
serverResult: BarkBattleServerResult;
|
||||||
|
scoreSummary: BarkBattleScoreSummary;
|
||||||
|
leaderboardScore?: number;
|
||||||
|
antiCheatFlags: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleLeaderboardEntry {
|
||||||
|
rank: number;
|
||||||
|
runId: string;
|
||||||
|
workId: string;
|
||||||
|
configVersion: number;
|
||||||
|
rulesetVersion: string;
|
||||||
|
difficultyPreset: BarkBattleDifficultyPreset;
|
||||||
|
displayName: string;
|
||||||
|
serverResult: BarkBattleServerResult;
|
||||||
|
scoreSummary: BarkBattleScoreSummary;
|
||||||
|
leaderboardScore: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleLeaderboardResponse {
|
||||||
|
workId: string;
|
||||||
|
configVersion?: number;
|
||||||
|
rulesetVersion: string;
|
||||||
|
difficultyPreset: BarkBattleDifficultyPreset;
|
||||||
|
entries: BarkBattleLeaderboardEntry[];
|
||||||
|
viewerBest?: BarkBattleLeaderboardEntry | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattlePersonalHistoryItem {
|
||||||
|
runId: string;
|
||||||
|
workId: string;
|
||||||
|
configVersion: number;
|
||||||
|
rulesetVersion: string;
|
||||||
|
difficultyPreset: BarkBattleDifficultyPreset;
|
||||||
|
serverResult: BarkBattleServerResult;
|
||||||
|
scoreSummary: BarkBattleScoreSummary;
|
||||||
|
leaderboardScore?: number;
|
||||||
|
antiCheatFlags: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattlePersonalBestSummary {
|
||||||
|
workId: string;
|
||||||
|
configVersion?: number;
|
||||||
|
rulesetVersion: string;
|
||||||
|
difficultyPreset: BarkBattleDifficultyPreset;
|
||||||
|
bestLeaderboardScore?: number;
|
||||||
|
bestFinalEnergy?: number;
|
||||||
|
bestTriggerCount?: number;
|
||||||
|
bestMaxVolume?: number;
|
||||||
|
winCount: number;
|
||||||
|
drawCount: number;
|
||||||
|
lossCount: number;
|
||||||
|
finishCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattlePersonalHistoryResponse {
|
||||||
|
workId?: string;
|
||||||
|
difficultyPreset?: BarkBattleDifficultyPreset;
|
||||||
|
items: BarkBattlePersonalHistoryItem[];
|
||||||
|
bestSummary?: BarkBattlePersonalBestSummary | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarkBattleWorkStats {
|
||||||
|
workId: string;
|
||||||
|
configVersion?: number;
|
||||||
|
rulesetVersion: string;
|
||||||
|
difficultyPreset: BarkBattleDifficultyPreset;
|
||||||
|
playStartCount: number;
|
||||||
|
finishCount: number;
|
||||||
|
winCount: number;
|
||||||
|
drawCount: number;
|
||||||
|
lossCount: number;
|
||||||
|
flaggedCount: number;
|
||||||
|
leaderboardEntryCount: number;
|
||||||
|
bestLeaderboardScore?: number;
|
||||||
|
bestFinalEnergy?: number;
|
||||||
|
averageFinalEnergy?: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
85
packages/shared/src/contracts/edutainmentBabyDrawing.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
export const BABY_LOVE_DRAWING_TEMPLATE_ID = 'baby-love-drawing';
|
||||||
|
export const BABY_LOVE_DRAWING_TEMPLATE_NAME = '宝贝爱画';
|
||||||
|
export const BABY_LOVE_DRAWING_EDUTAINMENT_TAG = '寓教于乐';
|
||||||
|
|
||||||
|
export type BabyLoveDrawingTemplateId =
|
||||||
|
typeof BABY_LOVE_DRAWING_TEMPLATE_ID;
|
||||||
|
|
||||||
|
export type BabyLoveDrawingTool = 'brush' | 'eraser';
|
||||||
|
|
||||||
|
export type BabyLoveDrawingSaveMode =
|
||||||
|
| 'original-only'
|
||||||
|
| 'original-and-magic';
|
||||||
|
|
||||||
|
export type BabyLoveDrawingGenerationProvider =
|
||||||
|
| 'vector-engine-gpt-image-2'
|
||||||
|
| 'local-demo';
|
||||||
|
|
||||||
|
export type BabyLoveDrawingPoint = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
t: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BabyLoveDrawingStroke = {
|
||||||
|
strokeId: string;
|
||||||
|
tool: BabyLoveDrawingTool;
|
||||||
|
color: string;
|
||||||
|
points: BabyLoveDrawingPoint[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BabyLoveDrawingRecord = {
|
||||||
|
drawingId: string;
|
||||||
|
templateId: BabyLoveDrawingTemplateId;
|
||||||
|
templateName: typeof BABY_LOVE_DRAWING_TEMPLATE_NAME;
|
||||||
|
originalImageSrc: string;
|
||||||
|
magicImageSrc: string | null;
|
||||||
|
strokeTrace: BabyLoveDrawingStroke[];
|
||||||
|
saveMode: BabyLoveDrawingSaveMode;
|
||||||
|
themeTags: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateBabyLoveDrawingMagicRequest = {
|
||||||
|
originalImageSrc: string;
|
||||||
|
strokeTrace: BabyLoveDrawingStroke[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateBabyLoveDrawingMagicResponse = {
|
||||||
|
magicImageSrc: string;
|
||||||
|
generationProvider: BabyLoveDrawingGenerationProvider;
|
||||||
|
prompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SaveBabyLoveDrawingRequest = {
|
||||||
|
originalImageSrc: string;
|
||||||
|
magicImageSrc?: string | null;
|
||||||
|
strokeTrace: BabyLoveDrawingStroke[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SaveBabyLoveDrawingResponse = {
|
||||||
|
record: BabyLoveDrawingRecord;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BABY_LOVE_DRAWING_RAINBOW_COLORS = [
|
||||||
|
{ id: 'red', label: '红', value: '#ef4444' },
|
||||||
|
{ id: 'orange', label: '橙', value: '#f97316' },
|
||||||
|
{ id: 'yellow', label: '黄', value: '#facc15' },
|
||||||
|
{ id: 'green', label: '绿', value: '#22c55e' },
|
||||||
|
{ id: 'cyan', label: '青', value: '#06b6d4' },
|
||||||
|
{ id: 'blue', label: '蓝', value: '#3b82f6' },
|
||||||
|
{ id: 'purple', label: '紫', value: '#a855f7' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BabyLoveDrawingRainbowColorId =
|
||||||
|
(typeof BABY_LOVE_DRAWING_RAINBOW_COLORS)[number]['id'];
|
||||||
|
|
||||||
|
export function normalizeBabyLoveDrawingTags(tags: string[]) {
|
||||||
|
return [
|
||||||
|
...new Set([
|
||||||
|
BABY_LOVE_DRAWING_EDUTAINMENT_TAG,
|
||||||
|
...tags.map((tag) => tag.trim()).filter(Boolean),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -2,8 +2,7 @@ export const BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match';
|
|||||||
export const BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
|
export const BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
|
||||||
export const BABY_OBJECT_MATCH_EDUTAINMENT_TAG = '寓教于乐';
|
export const BABY_OBJECT_MATCH_EDUTAINMENT_TAG = '寓教于乐';
|
||||||
|
|
||||||
export type BabyObjectMatchTemplateId =
|
export type BabyObjectMatchTemplateId = typeof BABY_OBJECT_MATCH_TEMPLATE_ID;
|
||||||
typeof BABY_OBJECT_MATCH_TEMPLATE_ID;
|
|
||||||
|
|
||||||
export type BabyObjectMatchAssetProvider =
|
export type BabyObjectMatchAssetProvider =
|
||||||
| 'vector-engine-gpt-image-2'
|
| 'vector-engine-gpt-image-2'
|
||||||
@@ -20,6 +19,27 @@ export type BabyObjectMatchItemAsset = {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BabyObjectMatchVisualAssetKind =
|
||||||
|
| 'background'
|
||||||
|
| 'ui-frame'
|
||||||
|
| 'gift-box'
|
||||||
|
| 'basket'
|
||||||
|
| 'smoke-puff';
|
||||||
|
|
||||||
|
export type BabyObjectMatchVisualAsset = {
|
||||||
|
assetId: string;
|
||||||
|
assetKind: BabyObjectMatchVisualAssetKind;
|
||||||
|
imageSrc: string;
|
||||||
|
assetObjectId: string | null;
|
||||||
|
generationProvider: BabyObjectMatchAssetProvider;
|
||||||
|
prompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BabyObjectMatchVisualPackage = {
|
||||||
|
themePrompt: string;
|
||||||
|
assets: BabyObjectMatchVisualAsset[];
|
||||||
|
};
|
||||||
|
|
||||||
export type BabyObjectMatchDraft = {
|
export type BabyObjectMatchDraft = {
|
||||||
draftId: string;
|
draftId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
@@ -29,6 +49,7 @@ export type BabyObjectMatchDraft = {
|
|||||||
workDescription: string;
|
workDescription: string;
|
||||||
itemNames: [string, string];
|
itemNames: [string, string];
|
||||||
itemAssets: [BabyObjectMatchItemAsset, BabyObjectMatchItemAsset];
|
itemAssets: [BabyObjectMatchItemAsset, BabyObjectMatchItemAsset];
|
||||||
|
visualPackage?: BabyObjectMatchVisualPackage | null;
|
||||||
themeTags: string[];
|
themeTags: string[];
|
||||||
publicationStatus: BabyObjectMatchPublicationStatus;
|
publicationStatus: BabyObjectMatchPublicationStatus;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -41,6 +62,15 @@ export type CreateBabyObjectMatchDraftRequest = {
|
|||||||
itemBName: string;
|
itemBName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GenerateBabyObjectMatchAssetsRequest = {
|
||||||
|
itemNames: [string, string];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateBabyObjectMatchAssetsResponse = {
|
||||||
|
assets: [BabyObjectMatchItemAsset, BabyObjectMatchItemAsset];
|
||||||
|
visualPackage?: BabyObjectMatchVisualPackage | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type BabyObjectMatchDraftResponse = {
|
export type BabyObjectMatchDraftResponse = {
|
||||||
draft: BabyObjectMatchDraft;
|
draft: BabyObjectMatchDraft;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export type * from './creationAudio';
|
|||||||
export type * from './hyper3d';
|
export type * from './hyper3d';
|
||||||
export type * from './puzzleCreativeTemplate';
|
export type * from './puzzleCreativeTemplate';
|
||||||
export type * from './visualNovel';
|
export type * from './visualNovel';
|
||||||
|
export type * from './barkBattle';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type * from './contracts/creationAgentDocumentInput';
|
|||||||
export type * from './contracts/creationAudio';
|
export type * from './contracts/creationAudio';
|
||||||
export type * from './contracts/creativeAgent';
|
export type * from './contracts/creativeAgent';
|
||||||
export type * from './contracts/customWorldAgent';
|
export type * from './contracts/customWorldAgent';
|
||||||
|
export * from './contracts/edutainmentBabyDrawing';
|
||||||
export * from './contracts/edutainmentBabyObject';
|
export * from './contracts/edutainmentBabyObject';
|
||||||
export type * from './contracts/hyper3d';
|
export type * from './contracts/hyper3d';
|
||||||
export * from './contracts/match3dAgent';
|
export * from './contracts/match3dAgent';
|
||||||
|
|||||||
BIN
public/anthro-cat-illustrations/cat-astronaut.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/anthro-cat-illustrations/cat-barista.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/anthro-cat-illustrations/cat-dancer.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/anthro-cat-illustrations/cat-detective.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
public/anthro-cat-illustrations/cat-knight.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/anthro-cat-illustrations/cat-painter.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/branding/taonier-logo-concepts/taonier-clay-spark.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/branding/taonier-logo-concepts/taonier-creation-loop.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 699 KiB |
BIN
public/branding/taonier-logo-concepts/taonier-meme-bubble.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/branding/taonier-logo-concepts/taonier-play-mold.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/branding/taonier-logo-concepts/taonier-premium-seal.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 846 KiB |
|
After Width: | Height: | Size: 927 KiB |
|
After Width: | Height: | Size: 840 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 584 KiB |
|
After Width: | Height: | Size: 782 KiB |
|
After Width: | Height: | Size: 912 KiB |
|
After Width: | Height: | Size: 812 KiB |
|
After Width: | Height: | Size: 886 KiB |
|
After Width: | Height: | Size: 699 KiB |
|
After Width: | Height: | Size: 637 KiB |
|
After Width: | Height: | Size: 901 KiB |
|
After Width: | Height: | Size: 715 KiB |
BIN
public/branding/taonier-logo-v3-concepts/taonier-v3-seed-pop.png
Normal file
|
After Width: | Height: | Size: 1016 KiB |
BIN
public/branding/taonier-logo-v3-concepts/taonier-v3-soft-t.png
Normal file
|
After Width: | Height: | Size: 848 KiB |
BIN
public/branding/taonier-logo-v3-concepts/taonier-v3-work-gem.png
Normal file
|
After Width: | Height: | Size: 900 KiB |
BIN
public/child-motion-demo/picture-book-ground-ring-v3.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
public/child-motion-demo/picture-book-wave-cat-arm-guide-v4.png
Normal file
|
After Width: | Height: | Size: 323 KiB |
BIN
public/child-motion-demo/picture-book-wave-cat-arm-guide-v5.png
Normal file
|
After Width: | Height: | Size: 467 KiB |
BIN
public/child-motion-demo/picture-book-wave-cat-arm-guide-v6.png
Normal file
|
After Width: | Height: | Size: 443 KiB |
BIN
public/child-motion-demo/picture-book-wave-cat-body-guide-v4.png
Normal file
|
After Width: | Height: | Size: 606 KiB |
BIN
public/child-motion-demo/picture-book-wave-cat-body-guide-v5.png
Normal file
|
After Width: | Height: | Size: 504 KiB |
BIN
public/child-motion-demo/picture-book-wave-cat-body-guide-v6.png
Normal file
|
After Width: | Height: | Size: 784 KiB |
BIN
public/child-motion-demo/picture-book-wave-cat-head-guide-v1.png
Normal file
|
After Width: | Height: | Size: 706 KiB |
BIN
public/child-motion-demo/picture-book-wave-cat-head-guide-v2.png
Normal file
|
After Width: | Height: | Size: 706 KiB |
BIN
public/child-motion-demo/picture-book-wave-cat-paw-guide-v1.png
Normal file
|
After Width: | Height: | Size: 518 KiB |
BIN
public/child-motion-demo/picture-book-wave-cat-paw-guide-v2.png
Normal file
|
After Width: | Height: | Size: 482 KiB |
|
After Width: | Height: | Size: 147 KiB |
BIN
public/child-motion-demo/picture-book-wave-hand-guide-v1.png
Normal file
|
After Width: | Height: | Size: 364 KiB |
@@ -106,6 +106,11 @@ NODE
|
|||||||
resolve_dev_stack_ports() {
|
resolve_dev_stack_ports() {
|
||||||
local key
|
local key
|
||||||
local value
|
local value
|
||||||
|
local spacetime_port_args=()
|
||||||
|
|
||||||
|
if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
|
||||||
|
spacetime_port_args+=("spacetime:${SPACETIME_HOST}:${SPACETIME_PORT}")
|
||||||
|
fi
|
||||||
|
|
||||||
while IFS='=' read -r key value; do
|
while IFS='=' read -r key value; do
|
||||||
case "${key}" in
|
case "${key}" in
|
||||||
@@ -115,7 +120,7 @@ resolve_dev_stack_ports() {
|
|||||||
esac
|
esac
|
||||||
done < <(
|
done < <(
|
||||||
node "${REPO_ROOT}/scripts/dev-stack-port-utils.mjs" resolve-dev-stack \
|
node "${REPO_ROOT}/scripts/dev-stack-port-utils.mjs" resolve-dev-stack \
|
||||||
"spacetime:${SPACETIME_HOST}:${SPACETIME_PORT}" \
|
"${spacetime_port_args[@]}" \
|
||||||
"api:${API_TARGET_HOST}:${API_PORT}" \
|
"api:${API_TARGET_HOST}:${API_PORT}" \
|
||||||
"web:${WEB_HOST}:${WEB_PORT}" \
|
"web:${WEB_HOST}:${WEB_PORT}" \
|
||||||
"adminWeb:${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}"
|
"adminWeb:${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}"
|
||||||
|
|||||||
@@ -270,6 +270,368 @@ const assetDefinitions = [
|
|||||||
chromaKeyNote,
|
chromaKeyNote,
|
||||||
].join(''),
|
].join(''),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-head-guide',
|
||||||
|
output: 'picture-book-wave-cat-head-guide-v1.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-head-guide-v1-source.png',
|
||||||
|
size: '1024x1024',
|
||||||
|
transparent: true,
|
||||||
|
useBackgroundReference: true,
|
||||||
|
useLayoutReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1024,
|
||||||
|
canvasHeight: 1024,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.76,
|
||||||
|
fillHeight: 0.76,
|
||||||
|
anchorY: 'center',
|
||||||
|
padding: 22,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请生成儿童动作互动游戏招手提示中央使用的卡通猫猫头资产,只画猫猫头,不要身体和爪子。',
|
||||||
|
'主体是一只原创绘本卡通猫猫头,圆润、亲切、表情开心,适合夹在左右两只猫爪中间作为挥手引导。',
|
||||||
|
'猫头可以是浅米白、淡橘、柔和浅棕和浅蓝绿色高光,轮廓清晰,五官简洁可爱,不能像真实照片或具体 IP 角色。',
|
||||||
|
'资产需要轻盈半透明、水彩纸张质感,缩小后仍能清楚看出猫脸和耳朵,边缘不要有复杂毛发。',
|
||||||
|
'整体风格必须和参考背景一致:明亮、温暖、卡通绘本、草地游戏舞台气质。',
|
||||||
|
'不要文字、数字、按钮、面板、人物、全身动物、品牌符号、水印、真实照片质感、厚重阴影或科技感。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-paw-guide',
|
||||||
|
output: 'picture-book-wave-cat-paw-guide-v1.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-paw-guide-v1-source.png',
|
||||||
|
size: '1024x1024',
|
||||||
|
transparent: true,
|
||||||
|
useBackgroundReference: true,
|
||||||
|
useLayoutReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1024,
|
||||||
|
canvasHeight: 1024,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.82,
|
||||||
|
fillHeight: 0.82,
|
||||||
|
anchorY: 'center',
|
||||||
|
padding: 22,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请生成儿童动作互动游戏的挥手引导猫爪资产,只画一只猫爪和一小段前臂,用于网页左右镜像复用。',
|
||||||
|
'主体是一段从画面下方斜向上伸出的柔软卡通猫前臂,末端是圆润猫爪,不展示手指细节,爪垫可以用几个浅色圆形简化表达。',
|
||||||
|
'猫爪需要像儿童绘本里的玩偶圆爪,简洁可爱,适合放在猫猫头左右两侧做左右摆动动画。',
|
||||||
|
'资产需要半透明、轻盈,轮廓清晰,缩小后仍能看出前臂和猫爪;边缘不要复杂毛发,不要尖爪。',
|
||||||
|
'颜色使用浅米白、淡橘、柔和草绿色和浅蓝绿色水彩高光,风格和参考背景一致,明亮、温暖、卡通绘本、轻微纸张纹理。',
|
||||||
|
'不要文字、数字、按钮、面板、人物全身、完整动物、真实照片质感、厚重阴影或科技感。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-head-guide-v2',
|
||||||
|
output: 'picture-book-wave-cat-head-guide-v2.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-head-guide-v2-source.png',
|
||||||
|
size: '1024x1024',
|
||||||
|
transparent: true,
|
||||||
|
useBackgroundReference: true,
|
||||||
|
useLayoutReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1024,
|
||||||
|
canvasHeight: 1024,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.72,
|
||||||
|
fillHeight: 0.72,
|
||||||
|
anchorY: 'center',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请重新设计一版儿童动作互动游戏招手提示中央使用的原创绘本卡通猫猫头资产,只画猫猫头,不要身体和爪子。',
|
||||||
|
'主体是一只圆润的小猫头,像贴在游戏舞台中央的柔软绘本贴纸,轮廓大而简洁,表情开心、友好、轻轻张嘴微笑。',
|
||||||
|
'五官必须更简化:大眼睛、短鼻子、小嘴巴、短胡须即可;不要长胡须伸出太远,不要复杂毛发,不要真实猫毛细节。',
|
||||||
|
'色彩使用浅奶油白、淡橘和少量浅草绿或天空蓝高光,整体更轻、更通透,适合叠在明亮草地舞台上。',
|
||||||
|
'边缘是柔和水彩描边和轻微纸张纹理,缩小到舞台中央后仍能一眼看出是可爱的猫猫头。',
|
||||||
|
'不要文字、数字、按钮、面板、人物、全身动物、品牌符号、水印、真实照片质感、厚重阴影或科技感。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-paw-guide-v2',
|
||||||
|
output: 'picture-book-wave-cat-paw-guide-v2.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-paw-guide-v2-source.png',
|
||||||
|
size: '1024x1024',
|
||||||
|
transparent: true,
|
||||||
|
useBackgroundReference: true,
|
||||||
|
useLayoutReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1024,
|
||||||
|
canvasHeight: 1024,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.74,
|
||||||
|
fillHeight: 0.78,
|
||||||
|
anchorY: 'center',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请重新设计一版儿童动作互动游戏挥手引导猫爪资产,只画一只圆润猫爪和很短一段前臂,用于网页左右镜像复用。',
|
||||||
|
'主体是大号圆猫爪,爪面朝向观众,爪垫用一个浅粉色大圆垫和几个浅粉色小圆垫简化表达,前臂只保留短短一截,不要画成长手臂。',
|
||||||
|
'猫爪要像儿童绘本贴纸或软玩具爪子,轮廓饱满、简洁、可爱,适合放在猫猫头左右两侧做挥动动画。',
|
||||||
|
'色彩与猫猫头统一:浅奶油白、淡橘、柔和浅粉或淡桃色爪垫和少量浅草绿或天空蓝高光;整体半透明、轻盈、无厚重阴影。',
|
||||||
|
'爪垫必须保持明亮柔和,禁止黑色、灰色、深棕色、深色阴影或高反差硬边。',
|
||||||
|
'缩小后必须清楚看出猫爪轮廓和爪垫;不要尖爪、不要手指细节、不要真实皮肤或真实毛发质感。',
|
||||||
|
'不要文字、数字、按钮、面板、人物全身、完整动物、真实照片质感、厚重阴影或科技感。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ground-ring-v3',
|
||||||
|
output: 'picture-book-ground-ring-v3.png',
|
||||||
|
sourceOutput: 'picture-book-ground-ring-v3-source.png',
|
||||||
|
size: '1536x512',
|
||||||
|
transparent: true,
|
||||||
|
useBackgroundReference: true,
|
||||||
|
useLayoutReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1200,
|
||||||
|
canvasHeight: 520,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.92,
|
||||||
|
fillHeight: 0.78,
|
||||||
|
anchorY: 'center',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请重新设计儿童动作互动游戏地面位置指示环资产,用于放在绿色草地上,必须和草皮明显区分。',
|
||||||
|
'主体是单个贴在地面上的透视椭圆指示环,不是完整背景,不要依赖网页后期压扁。',
|
||||||
|
'样式像浅蓝天空色和暖黄色软垫组成的绘本地贴:外圈为浅蓝白水彩描边,内圈有柔和暖黄色或奶油色高光,中心留空透明。',
|
||||||
|
'圆环边缘可以有少量星星光点、短虚线或纸贴边,但不要用大面积绿色草叶作为主体,避免和草地混在一起。',
|
||||||
|
'禁止使用粉紫色、品红色、紫色外圈、玫红光晕或任何接近 #ff00ff 的颜色;这些颜色会被当成透明背景删除。',
|
||||||
|
'除纯色品红背景外,主体只能使用浅蓝、白色、奶油黄、暖黄色、浅橙和极少量浅草绿。',
|
||||||
|
'整体要明亮、温暖、儿童绘本风,和草地舞台统一但有清楚视觉对比;不要科技感,不要霓虹,不要金属材质。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-torso-guide-v3',
|
||||||
|
output: 'picture-book-wave-cat-torso-guide-v3.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-torso-guide-v3-source.png',
|
||||||
|
size: '1024x1024',
|
||||||
|
transparent: true,
|
||||||
|
useBackgroundReference: true,
|
||||||
|
useLayoutReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1024,
|
||||||
|
canvasHeight: 1024,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.62,
|
||||||
|
fillHeight: 0.58,
|
||||||
|
anchorY: 'bottom',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请为输入图中的橘白绘本猫猫头补充一个可单独叠放在头部下方的猫猫上半身胸口资产。',
|
||||||
|
'只画短短的上半身胸口、脖子下沿、圆润肩膀和一点点短前肢根部;不要画头、耳朵、眼睛、嘴巴、胡须、完整爪子、腿或脚。',
|
||||||
|
'主体必须是橘白小猫身体,色彩和输入图一致:浅奶油白为主,淡橘色斑纹点缀,柔和浅棕描边,少量浅草绿或天空蓝高光。',
|
||||||
|
'形状像儿童绘本贴纸里的圆润上半身,底部自然截断,适合网页叠在猫头下面形成半身猫猫。',
|
||||||
|
'两侧不要伸出长手臂,左右猫爪会由网页单独叠加。',
|
||||||
|
'禁止黑色、黑白猫、大面积深色毛、真实毛发、尖锐漫画黑线、高反差阴影。',
|
||||||
|
'不要文字、数字、按钮、面板、人物、完整动物、品牌符号、水印、真实照片质感、厚重阴影或科技感。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-body-guide-v4',
|
||||||
|
output: 'picture-book-wave-cat-body-guide-v4.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-body-guide-v4-source.png',
|
||||||
|
size: '1024x1024',
|
||||||
|
transparent: true,
|
||||||
|
useBackgroundReference: true,
|
||||||
|
useLayoutReference: true,
|
||||||
|
useWaveCatHeadReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1024,
|
||||||
|
canvasHeight: 1024,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.78,
|
||||||
|
fillHeight: 0.88,
|
||||||
|
anchorY: 'bottom',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请按参考结构重新绘制儿童动作互动游戏中央招手提示的猫咪身体主体资源。',
|
||||||
|
'主体结构参考用户草图:正面半身猫咪,圆猫头在上方,两个三角耳朵,头下方接一个简单圆润躯干,躯干到胸口和腰部一半为止。',
|
||||||
|
'本资源只包含猫头、耳朵、脖子、躯干和肩部连接点,不要画任何手臂、前臂、手掌、猫爪、腿或脚;左右肩膀两侧要留出手臂接入空间。',
|
||||||
|
'角色必须是橘白猫:主体毛色 80% 为浅奶油白和温暖淡橘色,少量浅棕描边;只能有小面积深棕眼睛和细线五官。',
|
||||||
|
'五官简洁可爱:大眼睛、短鼻子、小嘴巴、短胡须;躯干为浅奶油白和淡橘色斑纹,边缘柔和水彩描边。',
|
||||||
|
'整体像儿童绘本贴纸,半透明、轻盈,缩小到舞台中央后仍能看清猫头和半身结构。',
|
||||||
|
'禁止画手臂或猫爪,禁止黑色、灰色、黑白猫、奶牛猫、虎斑深色块、大面积深棕毛、真实毛发、尖锐漫画黑线、高反差阴影、文字、数字、按钮、面板、水印和真实照片质感。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-arm-guide-v4',
|
||||||
|
output: 'picture-book-wave-cat-arm-guide-v4.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-arm-guide-v4-source.png',
|
||||||
|
size: '1024x1024',
|
||||||
|
transparent: true,
|
||||||
|
useBackgroundReference: true,
|
||||||
|
useLayoutReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1024,
|
||||||
|
canvasHeight: 1024,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.7,
|
||||||
|
fillHeight: 0.82,
|
||||||
|
anchorY: 'bottom',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请按参考结构重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂资源。',
|
||||||
|
'只画一条橘白猫咪手臂:从肩膀连接处开始,弯曲向上,包含上臂、前臂和末端圆猫爪,整体像用户草图中单侧向上挥动的弯曲手臂。',
|
||||||
|
'资源需要适合网页左右镜像复用:默认绘制一条从画面下方肩部连接点向上弯到画面左上方的手臂,肩部连接点在资源下方内侧,方便 CSS 设置旋转轴。',
|
||||||
|
'猫爪末端是圆润猫爪,爪垫浅粉或淡桃色,不要尖爪;手臂粗细均匀、短而可爱,不要画成长人类手臂。',
|
||||||
|
'角色必须是橘白猫手臂:主体毛色 80% 为浅奶油白和温暖淡橘色,淡橘斑纹点缀,柔和浅棕描边,爪垫浅粉或淡桃色。',
|
||||||
|
'整体像儿童绘本贴纸,半透明、轻盈,边缘清晰,缩小后仍能看出弯曲手臂和圆猫爪。',
|
||||||
|
'不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-body-guide-v5',
|
||||||
|
output: 'picture-book-wave-cat-body-guide-v5.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-body-guide-v5-source.png',
|
||||||
|
size: '1024x1024',
|
||||||
|
transparent: true,
|
||||||
|
transparencyCleanup: 'cat-guide',
|
||||||
|
useBackgroundReference: true,
|
||||||
|
useLayoutReference: true,
|
||||||
|
useWaveCatHeadReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1024,
|
||||||
|
canvasHeight: 1024,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.72,
|
||||||
|
fillHeight: 0.88,
|
||||||
|
anchorY: 'bottom',
|
||||||
|
padding: 22,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请按用户参考结构重新绘制儿童动作互动游戏中央招手提示的猫咪身体主体资源,用作动画底座。主体必须是正面纸偶结构:一个大圆猫头、两个三角耳朵、头下方连接短脖子和圆润半身躯干,画到上半身和腰部一半即可。',
|
||||||
|
'本资源只包含猫头、耳朵、五官、脖子、躯干、圆润肩膀和两侧肩部连接点;绝对不要画任何手臂、前臂、手掌、猫爪、小手、小脚、腿、脚或尾巴。左右肩膀外侧需要留出干净的手臂接入空间,方便网页单独叠加手臂动画。',
|
||||||
|
'请保持输入猫猫头的暖橘白绘本风格:头顶和耳朵外侧有淡橘色块,脸和肚子为浅奶油白,少量浅橘斑纹,五官只用小面积深棕眼睛和暖棕细线。',
|
||||||
|
'所有描边必须是柔和暖棕或浅橘棕,不要使用纯黑描边;资源自身保持清晰不透明,网页会统一设置半透明效果,不要在图片里主动降低主体透明度。',
|
||||||
|
'整体像儿童绘本贴纸或可动纸偶底座,结构简单、比例可爱,缩小到舞台中央后仍能看清大猫头、小身体和肩部挂点。',
|
||||||
|
'禁止画手臂或猫爪,禁止黑色、灰色、黑白猫、奶牛猫、虎斑深色块、大面积深棕毛、真实毛发、尖锐漫画黑线、高反差阴影、文字、数字、按钮、面板、水印和真实照片质感。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-arm-guide-v5',
|
||||||
|
output: 'picture-book-wave-cat-arm-guide-v5.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-arm-guide-v5-source.png',
|
||||||
|
size: '1024x1024',
|
||||||
|
transparent: true,
|
||||||
|
transparencyCleanup: 'cat-guide',
|
||||||
|
useBackgroundReference: true,
|
||||||
|
useLayoutReference: true,
|
||||||
|
useWaveCatHeadReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1024,
|
||||||
|
canvasHeight: 1024,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.58,
|
||||||
|
fillHeight: 0.86,
|
||||||
|
anchorY: 'bottom',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请按用户参考结构重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂手部资源。只画一条猫咪手臂:从底部肩膀连接点开始,包含短上臂、弯曲前臂和末端圆猫爪,像可动纸偶的一条独立手臂。',
|
||||||
|
'默认绘制一条向左上方举起的手臂,肩膀连接点在画面底部偏内侧,圆猫爪在画面上方;资源需要适合网页左右镜像复用和围绕肩膀连接点旋转摆动。',
|
||||||
|
'猫爪用类似多啦A梦圆手的圆润简化形状,不展示手指细节,不要尖爪;爪面可以有浅粉或淡桃色圆形爪垫。手臂短而可爱,比例像小猫上肢,不要画成人类长手臂。',
|
||||||
|
'请保持输入猫猫头的暖橘白绘本风格:手臂主体为浅奶油白和淡橘色,少量浅橘斑纹,爪垫浅粉或淡桃色,柔和暖棕描边。',
|
||||||
|
'所有描边必须是柔和暖棕或浅橘棕,不要使用纯黑描边;资源自身保持清晰不透明,网页会统一设置半透明效果,不要在图片里主动降低主体透明度。',
|
||||||
|
'不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-body-guide-v6',
|
||||||
|
output: 'picture-book-wave-cat-body-guide-v6.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-body-guide-v6-source.png',
|
||||||
|
size: '1024x1024',
|
||||||
|
transparent: true,
|
||||||
|
transparencyCleanup: 'cat-guide',
|
||||||
|
useBackgroundReference: true,
|
||||||
|
useLayoutReference: true,
|
||||||
|
useWaveCatHeadReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1024,
|
||||||
|
canvasHeight: 1024,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.68,
|
||||||
|
fillHeight: 0.86,
|
||||||
|
anchorY: 'bottom',
|
||||||
|
padding: 22,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请重新绘制儿童动作互动游戏中央招手提示的猫咪身体主体资源,严格按可动纸偶拆件结构生成。主体只有一只正面橘白猫:大圆猫头、两个三角耳朵、短脖子、梨形半身躯干,底部自然截断。',
|
||||||
|
'身体两侧只允许出现圆润肩膀轮廓和一个很小的肩部连接圆点或肩窝标记;绝对不要画伸出的手臂、前臂、手掌、猫爪、小手、小脚、腿、脚或尾巴。肩膀外侧必须留空,后续网页会单独叠加两条手臂。',
|
||||||
|
'猫咪造型参考输入猫猫头的暖橘白配色:头顶、耳朵外侧和身体侧边为淡橘色,脸和肚子为浅奶油白,少量浅橘斑纹;五官使用暖棕细线和小面积深棕眼睛。',
|
||||||
|
'请避免粉色大背景、避免主体外侧彩色光晕,主体贴纸外轮廓之外必须直接是纯色背景;线条为柔和暖棕或浅橘棕,不要纯黑粗描边。',
|
||||||
|
'资源自身保持清晰不透明,半透明效果由网页 CSS 控制;整体像儿童绘本可动纸偶底座,缩小后仍能看清大猫头、短身体、肩部连接点。',
|
||||||
|
'禁止手臂、爪子、小手、脚、尾巴;禁止黑色、灰色、黑白猫、奶牛猫、虎斑深色块、大面积深棕毛、真实毛发、尖锐漫画黑线、高反差阴影、文字、数字、按钮、面板、水印和真实照片质感。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-arm-guide-v6',
|
||||||
|
output: 'picture-book-wave-cat-arm-guide-v6.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-arm-guide-v6-source.png',
|
||||||
|
size: '1024x1024',
|
||||||
|
transparent: true,
|
||||||
|
transparencyCleanup: 'cat-guide',
|
||||||
|
useBackgroundReference: true,
|
||||||
|
useLayoutReference: true,
|
||||||
|
useWaveCatHeadReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1024,
|
||||||
|
canvasHeight: 1024,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.74,
|
||||||
|
fillHeight: 0.88,
|
||||||
|
anchorY: 'bottom',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂资源,严格作为可动纸偶拆件。只画一条橘白猫手臂:底部是肩膀连接端,向左上方弯曲,末端是一只简化圆猫手。',
|
||||||
|
'猫手必须像多啦A梦式圆手或软玩具圆爪:一个完整圆润手掌,不画手指,不画黑色或深色爪垫,不画粉色爪垫点,不画尖爪。手臂短而厚实,像小猫上肢,不要成人类长手臂。',
|
||||||
|
'资源必须适合网页左右镜像复用和围绕肩部连接点旋转:肩膀连接端在画面底部偏内侧,圆手在画面上方,四周留透明空白。',
|
||||||
|
'颜色参考输入猫猫头:浅奶油白和淡橘色为主体,少量浅橘斑纹,柔和暖棕或浅橘棕描边;不要纯黑粗描边。',
|
||||||
|
'请避免粉色大背景、避免主体外侧彩色光晕,主体贴纸外轮廓之外必须直接是纯色背景。资源自身保持清晰不透明,半透明效果由网页 CSS 控制。',
|
||||||
|
'不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const args = new Map();
|
const args = new Map();
|
||||||
@@ -443,6 +805,12 @@ function buildRequestBody(asset, size) {
|
|||||||
path.join(intermediateDir, layoutReferenceOutput),
|
path.join(intermediateDir, layoutReferenceOutput),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (asset.useWaveCatHeadReference) {
|
||||||
|
pushReferenceImage(
|
||||||
|
body,
|
||||||
|
path.join(assetDir, 'picture-book-wave-cat-head-guide-v2.png'),
|
||||||
|
);
|
||||||
|
}
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,6 +1038,93 @@ function removeCharacterOutlineChromaKey(sourcePath, finalPath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeCatGuideChromaKey(sourcePath, finalPath) {
|
||||||
|
const script = [
|
||||||
|
'from collections import deque',
|
||||||
|
'from PIL import Image',
|
||||||
|
'import sys',
|
||||||
|
'source, out = sys.argv[1], sys.argv[2]',
|
||||||
|
'im = Image.open(source).convert("RGBA")',
|
||||||
|
'px = im.load()',
|
||||||
|
'w, h = im.size',
|
||||||
|
'corner_samples = [im.getpixel((0, 0)), im.getpixel((w - 1, 0)), im.getpixel((0, h - 1)), im.getpixel((w - 1, h - 1))]',
|
||||||
|
'key = tuple(sorted([p[i] for p in corner_samples])[len(corner_samples) // 2] for i in range(3))',
|
||||||
|
'def is_magenta_bg(r, g, b):',
|
||||||
|
' if r > 170 and b > 145 and g < 185 and min(r, b) - g > 36:',
|
||||||
|
' return True',
|
||||||
|
' return r > 140 and b > 90 and r > g + 35 and b > g + 10',
|
||||||
|
'def is_bg_candidate(x, y):',
|
||||||
|
' r, g, b, a = px[x, y]',
|
||||||
|
' if a <= 10:',
|
||||||
|
' return True',
|
||||||
|
' dist = ((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) ** 0.5',
|
||||||
|
' if is_magenta_bg(r, g, b):',
|
||||||
|
' return True',
|
||||||
|
' if key[0] < 32 and key[1] < 32 and key[2] < 32:',
|
||||||
|
' return dist < 34 and max(r, g, b) < 55',
|
||||||
|
' if key[0] > 225 and key[1] > 225 and key[2] > 225:',
|
||||||
|
' return dist < 34 and min(r, g, b) > 210',
|
||||||
|
' return dist < 72',
|
||||||
|
'visited = bytearray(w * h)',
|
||||||
|
'queue = deque()',
|
||||||
|
'def push(x, y):',
|
||||||
|
' if x < 0 or y < 0 or x >= w or y >= h:',
|
||||||
|
' return',
|
||||||
|
' index = y * w + x',
|
||||||
|
' if visited[index] or not is_bg_candidate(x, y):',
|
||||||
|
' return',
|
||||||
|
' visited[index] = 1',
|
||||||
|
' queue.append((x, y))',
|
||||||
|
'for x in range(w):',
|
||||||
|
' push(x, 0)',
|
||||||
|
' push(x, h - 1)',
|
||||||
|
'for y in range(h):',
|
||||||
|
' push(0, y)',
|
||||||
|
' push(w - 1, y)',
|
||||||
|
'while queue:',
|
||||||
|
' x, y = queue.popleft()',
|
||||||
|
' push(x + 1, y)',
|
||||||
|
' push(x - 1, y)',
|
||||||
|
' push(x, y + 1)',
|
||||||
|
' push(x, y - 1)',
|
||||||
|
'for _ in range(3):',
|
||||||
|
' extra = []',
|
||||||
|
' for y in range(1, h - 1):',
|
||||||
|
' for x in range(1, w - 1):',
|
||||||
|
' index = y * w + x',
|
||||||
|
' if visited[index] or not is_bg_candidate(x, y):',
|
||||||
|
' continue',
|
||||||
|
' touches_bg = any(visited[(y + dy) * w + x + dx] for dy in (-1, 0, 1) for dx in (-1, 0, 1) if dx or dy)',
|
||||||
|
' if touches_bg:',
|
||||||
|
' extra.append(index)',
|
||||||
|
' if not extra:',
|
||||||
|
' break',
|
||||||
|
' for index in extra:',
|
||||||
|
' visited[index] = 1',
|
||||||
|
'for y in range(h):',
|
||||||
|
' for x in range(w):',
|
||||||
|
' r, g, b, a = px[x, y]',
|
||||||
|
' if visited[y * w + x]:',
|
||||||
|
' px[x, y] = (r, g, b, 0)',
|
||||||
|
' else:',
|
||||||
|
' if a <= 10:',
|
||||||
|
' a = 255',
|
||||||
|
' px[x, y] = (r, g, b, a)',
|
||||||
|
'im.save(out)',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to clean cat guide transparency: ${(result.stderr || result.stdout).trim()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTransparentAsset(finalPath, layoutNormalization) {
|
function normalizeTransparentAsset(finalPath, layoutNormalization) {
|
||||||
if (!layoutNormalization) {
|
if (!layoutNormalization) {
|
||||||
return;
|
return;
|
||||||
@@ -857,6 +1312,8 @@ async function generateAsset(asset, env, size, force) {
|
|||||||
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
|
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
|
||||||
} else if (asset.transparencyCleanup === 'character-outline') {
|
} else if (asset.transparencyCleanup === 'character-outline') {
|
||||||
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
|
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
|
||||||
|
} else if (asset.transparencyCleanup === 'cat-guide') {
|
||||||
|
removeCatGuideChromaKey(opaqueSourcePath, finalPath);
|
||||||
} else {
|
} else {
|
||||||
removeChromaKey(opaqueSourcePath, finalPath);
|
removeChromaKey(opaqueSourcePath, finalPath);
|
||||||
}
|
}
|
||||||
@@ -917,6 +1374,8 @@ async function generateAsset(asset, env, size, force) {
|
|||||||
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
|
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
|
||||||
} else if (asset.transparencyCleanup === 'character-outline') {
|
} else if (asset.transparencyCleanup === 'character-outline') {
|
||||||
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
|
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
|
||||||
|
} else if (asset.transparencyCleanup === 'cat-guide') {
|
||||||
|
removeCatGuideChromaKey(opaqueSourcePath, finalPath);
|
||||||
} else {
|
} else {
|
||||||
removeChromaKey(opaqueSourcePath, finalPath);
|
removeChromaKey(opaqueSourcePath, finalPath);
|
||||||
}
|
}
|
||||||
|
|||||||
480
scripts/generate-taonier-logo-concepts.mjs
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const outputDir = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-concepts',
|
||||||
|
);
|
||||||
|
const defaultTimeoutMs = 420000;
|
||||||
|
|
||||||
|
const dimensionalConcepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-clay-spark',
|
||||||
|
title: '灵感陶团',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品是精品 AI UGC 创作与轻休闲小游戏平台,核心理念是把脑洞、梗和小游戏像陶泥一样捏出来。图标主体是一团被轻轻捏塑的温润陶泥,内部自然形成一枚发光灵感火花和少量 AI 节点点线,整体高级、亲切、年轻、有传播感。使用暖陶土色、奶白、薄荷绿、深墨色少量点缀,居中构图,适合作为 App icon 和品牌主标。禁止文字、字母、汉字、水印、按钮、界面元素、复杂背景、儿童黏土课风格。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-play-mold',
|
||||||
|
title: '开玩模具',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品强调 AI 创作、UGC、自制小游戏、玩梗传播和轻度休闲。图标主体是一枚柔软陶泥捏成的圆角播放符号,播放三角像被手指压出的模具凹槽,周围有两三颗精品感小星点和像素级小方块,表达“捏个脑洞,马上开玩”。风格是现代品牌标志,柔软但不幼稚,干净、可缩小识别。禁止文字、字母、汉字、水印、真实陶艺工具、UI 按钮、教程感。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-meme-bubble',
|
||||||
|
title: '造梗气泡',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品是 AI UGC 创作社区,主打精品内容、梗传播、裂变分享、休闲小游戏。图标用一团软陶泥变形成聊天气泡和小表情的组合,气泡边缘像被揉捏过,中心有抽象笑脸和创意火花,但不要做儿童玩具感。品牌气质年轻、松弛、聪明、有社交传播力。配色使用陶土橙、奶白、清爽蓝绿和少量深色轮廓。禁止文字、字母、汉字、水印、复杂场景、表情包文字。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-creation-loop',
|
||||||
|
title: '共创回路',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品理念是 AI 与用户共同把灵感塑形成可玩的 UGC 作品。图标主体由两条柔软陶泥带构成循环造物轨迹,形成一个抽象无限符号和手工捏塑旋涡,中间嵌入一颗小型游戏棋子或星点,表达共创、迭代、传播和精品打磨。风格简洁高级、几何清楚、移动端小尺寸仍可识别。禁止文字、字母、汉字、水印、复杂阴影、科技冷硬金属感。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-premium-seal',
|
||||||
|
title: '精品泥印',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品主打精品 AI 创作、UGC 作品和轻小游戏发布。图标是一个被压印过的软陶徽章,外形像圆润印章但更现代,中间有抽象火花、小游戏方块和一处捏痕,表达“精品内容由脑洞塑形”。整体要有品牌信任感和高级手作质感,不要像儿童陶艺班。使用暖陶土、奶油白、莓红或湖蓝少量点缀,清晰居中。禁止文字、字母、汉字、水印、传统篆刻字、真实照片。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const flatConcepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-flat-play-clay',
|
||||||
|
title: '扁平开捏',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品是 AI UGC 创作与轻休闲小游戏平台,主张“把脑洞捏成小游戏”。图标只使用一个柔软圆润的陶泥形主轮廓,内部用极简负形播放三角表达“马上开玩”,整体像现代 App icon 的核心符号。风格要求:flat vector logo, clean geometric, friendly, mainstream, memorable, high contrast, scalable, minimal shapes, solid colors, subtle 2D shadow only。配色使用暖陶土橙、奶油白、清爽薄荷绿或深墨色,最多 3 个主色。禁止:3D、立体、拟物、厚重阴影、渐变高光、照片质感、复杂纹理、中文字、英文字母、水印、UI 按钮、复杂背景、吉祥物。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-flat-spark-clay',
|
||||||
|
title: '灵感泥星',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品强调 AI 创作、UGC、造梗、精品轻小游戏。图形主体是一枚圆润陶泥团,中心用简洁四角星或火花负形表达灵感和 AI 生成,外轮廓要一眼像“可塑形的软泥”,但必须保持现代、主流、亲和、有记忆点。风格要求:flat vector brand mark, simple silhouette, app icon ready, no realism, no texture, no 3D, crisp edges, 2D friendly illustration。最多 3 色,暖陶土 + 奶油白 + 少量蓝绿。禁止文字、字母、水印、复杂小节点、儿童手工课风格。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-flat-meme-smile',
|
||||||
|
title: '造梗笑泥',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品主打 UGC、玩梗传播、裂变分享和轻休闲小游戏。图形是一团被捏成圆润聊天气泡的陶泥,内部只保留极简笑脸或一颗小星点,表达“造梗”和“分享快乐”。整体要像主流社交娱乐 App 的 Logo,亲和、轻松、容易记住,小尺寸清楚。风格要求:flat vector logo, simple, bold, friendly, clean, no gradients, no 3D, no mascot complexity。配色不超过 3 色。禁止中文字、英文字母、水印、表情包文字、复杂装饰、立体高光。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-flat-loop-mold',
|
||||||
|
title: '共创泥环',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品理念是用户与 AI 共同把灵感塑形成可玩的 UGC 作品。图形用一条柔软陶泥带形成简洁闭环或抽象无限符号,中间留出小星点负形,表达共创、迭代、传播和精品打磨。视觉要主流、简洁、亲和,不要科技冷硬。风格要求:flat vector symbol, clean loop mark, minimal, memorable, scalable, solid colors, crisp silhouette, suitable for app icon。禁止 3D、拟物、厚阴影、复杂渐变、文字、字母、水印。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-flat-seal-blocks',
|
||||||
|
title: '精品泥印',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品强调精品 AI 作品、UGC 创作和小游戏发布。图形是一枚现代软陶印记,外形为圆角徽章或圆润印章,内部用 2 到 3 个简洁小方块和一颗星点表达“作品”“小游戏”“精品内容”。整体应像可长期使用的品牌主标,主流、干净、亲和、有辨识度。风格要求:flat vector logo, bold simple shapes, app icon ready, minimal color palette, no realism, no texture。禁止文字、字母、水印、传统篆刻、3D、复杂阴影、拟物陶艺。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const v3Concepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-v3-finger-spark',
|
||||||
|
title: '灵感捏痕',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。不要使用播放三角、聊天气泡、笑脸、循环无限符号、褐色陶土主色、碎片小元素。产品是 AI UGC 创作与轻休闲小游戏平台,核心是“把脑洞捏成可玩的作品”。图形主体是一个醒目的圆润软形,内部只有一枚极简指纹捏痕与小火花负形,表达“被手指一捏,灵感成型”。风格:主流 App icon、flat vector、bold simple silhouette、friendly、memorable、high contrast、可缩小识别。配色:珊瑚橙或莓红作为主色,奶油白负形,少量青绿色投影或边缘点缀,最多 3 色。画面居中,留白干净。禁止文字、字母、水印、3D、拟物、厚阴影、渐变高光、照片质感、复杂纹理、表情包感、UI 按钮。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-v3-seed-pop',
|
||||||
|
title: '脑洞种子',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。不要使用播放三角、聊天气泡、笑脸、无限循环、传统印章、褐色主色或多碎元素。产品主打 AI 创作、UGC、梗传播、精品轻小游戏。图标主体是一颗圆润明亮的“脑洞种子”:像软泥被捏成的一颗种子/小芽,顶部有一个简洁星点缺口,表达灵感生长、内容生成、人人创作。风格:flat vector logo, simple, mainstream, warm, lively, app icon ready, strong outline, minimal shapes。配色使用高饱和青绿、珊瑚粉、奶油白、深墨色中的 2-3 色,不要大面积褐色。禁止文字、字母、水印、3D、拟物、照片、复杂渐变、表情、儿童黏土课风格。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-v3-magic-dot',
|
||||||
|
title: '一捏成型',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。避开播放按钮、聊天气泡、笑脸、循环符号、褐色陶土和堆叠小图标。产品理念是用户轻轻一捏,AI 把脑洞生成小游戏和 UGC 作品。图形由两个圆润手捏触点和中间一个闪光成型点组成,像“捏合灵感”的瞬间,但不要画真实手指。整体应非常简洁,有强记忆点,像主流创作娱乐 App 的标志。风格:flat vector, iconic, minimal, friendly, bold shape, clear at 32px。配色:亮紫红或珊瑚红主色,奶油白负形,青绿色小面积辅助。禁止文字、字母、水印、3D、厚阴影、渐变高光、复杂纹理。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-v3-work-gem',
|
||||||
|
title: '作品胶囊',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。不要使用播放三角、聊天气泡、笑脸、循环无限符号、褐色主色、多枚小卡片或碎图标。产品强调精品 AI UGC 作品和轻小游戏创作。图形主体是一枚被捏成圆角宝石/胶囊的抽象作品符号,内部只有一条柔软弧线切面和一个小星点,表达“脑洞被打磨成精品”。风格:flat vector logo, premium but friendly, simple, memorable, app icon, solid colors, no texture。配色:湖蓝或青绿主色,珊瑚橙点缀,奶白负形,深墨小轮廓可选。禁止文字、字母、水印、3D、复杂渐变、照片质感、游戏手柄、图片卡片、用户头像。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-v3-soft-t',
|
||||||
|
title: '软体 T 形',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。尝试做一个抽象但亲和的品牌首字母符号,灵感来自 Taonier / 陶泥儿 的 T 和“被捏塑的软泥”。不要出现真实字母 T 的硬直排版,而是用一笔圆润软形构成可记忆的图腾。必须避开播放三角、聊天气泡、笑脸、循环符号、褐色陶土主色和碎元素。风格:flat vector brand mark, modern, friendly, bold, iconic, simple silhouette, app icon ready。配色:明亮珊瑚红、奶油白、薄荷青或深墨,最多 3 色。禁止文字、英文字母直出、汉字、水印、3D、拟物、厚阴影、复杂纹理。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const magicDotConcepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-magic-dot-orbit',
|
||||||
|
title: '捏合星核',
|
||||||
|
prompt:
|
||||||
|
'围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量主标:两个圆润软泥触点从左右轻轻合拢,中心不是碰撞爆炸,而是一颗稳定的星核/作品核,外形要形成完整、可记忆的品牌符号。必须避免播放三角、聊天气泡、笑脸、循环无限符号、褐色陶土、真实手指、括号感、爆炸特效和碎元素。风格:flat vector logo, iconic, minimal, friendly, mainstream app icon, strong silhouette, clear at 32px。配色:珊瑚红或莓红主形,奶油白负形,青绿色只做中心小面积,最多 3 色。无文字、无字母、无水印、无 3D、无厚阴影、无拟物。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-magic-dot-seal',
|
||||||
|
title: '成型印记',
|
||||||
|
prompt:
|
||||||
|
'围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量主标:图形像一枚被两侧轻轻按压成型的软形印记,中心留出一个简洁星点或小圆孔,表达 AI 把脑洞塑形成作品。整体要比原本左右括号更完整,外轮廓形成一个独特图腾。禁止播放按钮、聊天气泡、笑脸、循环符号、褐色陶土主色、多小图标、真实手、爆炸火花。风格:flat vector, bold simple shape, friendly premium, memorable, app icon ready, solid colors。配色:亮珊瑚、奶油白、薄荷青或深墨,最多 3 色。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-magic-dot-squish',
|
||||||
|
title: '软泥合拍',
|
||||||
|
prompt:
|
||||||
|
'围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量 Logo:两个软泥形不是分散的括号,而是上下错位地挤压出中心灵感点,像“啪嗒一捏,作品成型”的瞬间。图形需要亲和、轻松、年轻,但不做表情包。必须保持元素极少,只有两块主形和一个中心成型点。禁止播放三角、聊天气泡、笑脸、无限循环、褐色主色、复杂渐变、拟物质感、真实手指、文字、字母。风格:flat vector brand mark, simple, memorable, high contrast, scalable。配色:莓红、奶白、青绿或明黄点缀。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-magic-dot-mold',
|
||||||
|
title: '灵感模口',
|
||||||
|
prompt:
|
||||||
|
'围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量主标:外形像一个被捏开的柔软模口,中心浮出一颗极简星点,表达从软泥模口里生成作品。它应该是一眼可记住的抽象符号,不像聊天框、不像播放键、不像括号。风格:flat vector logo, modern, friendly, clean, bold, minimal, app icon。配色使用高识别珊瑚红或玫粉主色,奶油白负形,少量青绿点缀。禁止褐色陶土、真实陶艺、3D、高光、厚阴影、复杂小碎片、文字、水印。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-magic-dot-bloom',
|
||||||
|
title: '捏开灵感',
|
||||||
|
prompt:
|
||||||
|
'围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量 Logo:用两片圆润软形夹出中央一颗灵感点,整体像一个正在打开的创意容器,但不要像花朵、聊天气泡或笑脸。图形要完整、主流、亲和、醒目,适合 App icon 和品牌主标。禁止播放三角、聊天气泡、笑脸、循环符号、褐色陶土、碎元素、真实手、复杂花瓣。风格:flat vector, minimal brand mark, strong silhouette, warm, youthful, memorable。配色:珊瑚红、奶油白、青绿,最多 3 色。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handsConcepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-hands-cradle-spark',
|
||||||
|
title: '托住灵感',
|
||||||
|
prompt:
|
||||||
|
'围绕“陶泥儿”Logo 方向 03 的“上下两只手托住灵感”的感觉继续打磨。设计一个无文字扁平矢量主标:上下两片圆润软掌状形体像手但不要画真实手指,轻轻托住中央一颗简洁灵感星核,表达用户与 AI 一起把脑洞捏成作品。整体要完整、主流、亲和、醒目,适合 App icon。避免播放三角、聊天气泡、笑脸、眼睛、花朵、循环符号、褐色陶土、多碎元素和真实手掌插画。风格:flat vector logo, bold simple silhouette, friendly, memorable, premium but warm, clear at 32px。配色:上方珊瑚红、下方青绿色、中央奶油白或金色小星,最多 3 色。无文字、无字母、无水印、无 3D、无厚阴影。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-hands-pinched-gem',
|
||||||
|
title: '合捏成珠',
|
||||||
|
prompt:
|
||||||
|
'围绕“陶泥儿”Logo 方向 03 的“上下两只手”感觉做延展。设计一个无文字扁平矢量主标:上下一对抽象软手 / 软泥掌从两侧微微合捏,中间形成一颗小圆珠或作品核。图形要像品牌符号,不像手势教学图;保留托举与成型的温柔感。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、褐色主色、真实手指、复杂掌纹、碎小图标。风格:flat vector, minimal, mainstream app logo, high contrast, iconic, friendly。配色:莓红、奶白、薄荷青、少量深墨,最多 3 色。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-hands-soft-bowl',
|
||||||
|
title: '创意托碗',
|
||||||
|
prompt:
|
||||||
|
'围绕“陶泥儿”Logo 方向 03 的上下手感做主标延展。设计一个无文字扁平矢量 Logo:下方是一片像手掌也像软泥托碗的圆润形体,上方是一片较小软形轻轻压合,中间浮出星点,表达“轻托脑洞、AI 捏成作品”。整体要简洁、有包容感、年轻亲和。避免像眼睛、嘴巴、聊天气泡、播放器、花朵、真实手掌、儿童黏土课。风格:flat vector logo, bold simple shape, app icon ready, clean, memorable。配色:青绿主托、珊瑚红上形、奶白中心,最多 3 色。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-hands-formed-seal',
|
||||||
|
title: '双掌泥印',
|
||||||
|
prompt:
|
||||||
|
'围绕“陶泥儿”Logo 方向 03 的上下两只手感觉做更完整的图腾。设计一个无文字扁平矢量主标:两片抽象软掌上下扣合,外轮廓形成一个圆润印记,中心保留一个星形负空间,像“被双手捏出的创意印记”。要有主流品牌感,不要像宗教手势、医疗关怀、儿童手工。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、循环符号、褐色陶土、真实手指、复杂纹理。风格:flat vector, iconic, simple, friendly premium, solid colors, scalable。配色:珊瑚红、奶油白、青绿或深墨,最多 3 色。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-hands-pop-capsule',
|
||||||
|
title: '掌心开捏',
|
||||||
|
prompt:
|
||||||
|
'围绕“陶泥儿”Logo 方向 03 的“上下两只手托住灵感”感觉做更活泼版本。设计一个无文字扁平矢量 Logo:上下两片软掌像打开的胶囊,中央小星点从掌心弹出,表达“脑洞被捏出来”。图形需要有传播感、亲和力、记忆点,但不要像表情包或聊天软件。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、褐色陶土、真实手指、碎元素。风格:flat vector brand mark, simple, bold, youthful, app icon, high contrast。配色:亮珊瑚红、薄荷青、奶白,最多 3 色。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const args = new Map();
|
||||||
|
for (let index = 2; index < process.argv.length; index += 1) {
|
||||||
|
const raw = process.argv[index];
|
||||||
|
if (!raw.startsWith('--')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = process.argv[index + 1];
|
||||||
|
if (next && !next.startsWith('--')) {
|
||||||
|
args.set(raw, next);
|
||||||
|
index += 1;
|
||||||
|
} else {
|
||||||
|
args.set(raw, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = String(args.get('--style') || 'dimensional').trim();
|
||||||
|
const concepts =
|
||||||
|
style === 'flat'
|
||||||
|
? flatConcepts
|
||||||
|
: style === 'v3'
|
||||||
|
? v3Concepts
|
||||||
|
: style === 'magic'
|
||||||
|
? magicDotConcepts
|
||||||
|
: style === 'hands'
|
||||||
|
? handsConcepts
|
||||||
|
: dimensionalConcepts;
|
||||||
|
const selectedOutputDir =
|
||||||
|
style === 'flat'
|
||||||
|
? path.join(repoRoot, 'public', 'branding', 'taonier-logo-flat-concepts')
|
||||||
|
: style === 'v3'
|
||||||
|
? path.join(repoRoot, 'public', 'branding', 'taonier-logo-v3-concepts')
|
||||||
|
: style === 'magic'
|
||||||
|
? path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-magic-dot-concepts',
|
||||||
|
)
|
||||||
|
: style === 'hands'
|
||||||
|
? path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-hands-concepts',
|
||||||
|
)
|
||||||
|
: outputDir;
|
||||||
|
|
||||||
|
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.VECTOR_ENGINE_BASE_URL || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\/+$/u, ''),
|
||||||
|
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||||
|
timeoutMs: Number.parseInt(
|
||||||
|
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(baseUrl) {
|
||||||
|
return baseUrl.endsWith('/v1')
|
||||||
|
? `${baseUrl}/images/generations`
|
||||||
|
: `${baseUrl}/v1/images/generations`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||||
|
}
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} 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}`);
|
||||||
|
}
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
throw new Error(`Generated image download timed out after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateConcept(env, concept) {
|
||||||
|
const requestBody = {
|
||||||
|
model: 'gpt-image-2-all',
|
||||||
|
prompt: concept.prompt,
|
||||||
|
n: 1,
|
||||||
|
size: '1024x1024',
|
||||||
|
};
|
||||||
|
const payload = await fetchJson(
|
||||||
|
buildUrl(env.baseUrl),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${env.apiKey}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
},
|
||||||
|
env.timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urls = extractImageUrls(payload);
|
||||||
|
const b64Images = extractBase64Images(payload);
|
||||||
|
let bytes;
|
||||||
|
if (urls[0]) {
|
||||||
|
bytes = await downloadUrl(urls[0], env.timeoutMs);
|
||||||
|
} else if (b64Images[0]) {
|
||||||
|
bytes = Buffer.from(b64Images[0], 'base64');
|
||||||
|
} else {
|
||||||
|
throw new Error(`VectorEngine returned no image for ${concept.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(selectedOutputDir, { recursive: true });
|
||||||
|
const extension = inferExtensionFromBytes(bytes);
|
||||||
|
const outputPath = path.join(selectedOutputDir, `${concept.id}.${extension}`);
|
||||||
|
writeFileSync(outputPath, bytes);
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dryRun = args.has('--dry-run') || !args.has('--live');
|
||||||
|
const onlyIds = String(args.get('--only') || '')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const limit = Number.parseInt(String(args.get('--limit') || '0'), 10);
|
||||||
|
const selected = concepts
|
||||||
|
.filter((concept) => !onlyIds.length || onlyIds.includes(concept.id))
|
||||||
|
.slice(0, limit > 0 ? limit : concepts.length);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mode: 'dry-run',
|
||||||
|
style,
|
||||||
|
outputDir: selectedOutputDir,
|
||||||
|
count: selected.length,
|
||||||
|
requests: selected.map((concept) => ({
|
||||||
|
id: concept.id,
|
||||||
|
title: concept.title,
|
||||||
|
body: {
|
||||||
|
model: 'gpt-image-2-all',
|
||||||
|
prompt: concept.prompt,
|
||||||
|
n: 1,
|
||||||
|
size: '1024x1024',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = resolveEnv();
|
||||||
|
if (!env.baseUrl || !env.apiKey) {
|
||||||
|
console.error(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
|
||||||
|
hasBaseUrl: Boolean(env.baseUrl),
|
||||||
|
hasApiKey: Boolean(env.apiKey),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = [];
|
||||||
|
for (const concept of selected) {
|
||||||
|
console.log(`Generating ${concept.id}...`);
|
||||||
|
generated.push(await generateConcept(env, concept));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
count: generated.length,
|
||||||
|
files: generated,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
11
server-rs/Cargo.lock
generated
@@ -90,6 +90,7 @@ dependencies = [
|
|||||||
"module-ai",
|
"module-ai",
|
||||||
"module-assets",
|
"module-assets",
|
||||||
"module-auth",
|
"module-auth",
|
||||||
|
"module-bark-battle",
|
||||||
"module-big-fish",
|
"module-big-fish",
|
||||||
"module-combat",
|
"module-combat",
|
||||||
"module-creative-agent",
|
"module-creative-agent",
|
||||||
@@ -1766,6 +1767,14 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "module-bark-battle"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "module-big-fish"
|
name = "module-big-fish"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3137,6 +3146,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"module-ai",
|
"module-ai",
|
||||||
"module-assets",
|
"module-assets",
|
||||||
|
"module-bark-battle",
|
||||||
"module-big-fish",
|
"module-big-fish",
|
||||||
"module-combat",
|
"module-combat",
|
||||||
"module-custom-world",
|
"module-custom-world",
|
||||||
@@ -3152,6 +3162,7 @@ dependencies = [
|
|||||||
"module-story",
|
"module-story",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"shared-kernel",
|
"shared-kernel",
|
||||||
"spacetimedb",
|
"spacetimedb",
|
||||||
"spacetimedb-lib",
|
"spacetimedb-lib",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ members = [
|
|||||||
"crates/module-ai",
|
"crates/module-ai",
|
||||||
"crates/module-assets",
|
"crates/module-assets",
|
||||||
"crates/module-auth",
|
"crates/module-auth",
|
||||||
|
"crates/module-bark-battle",
|
||||||
"crates/module-big-fish",
|
"crates/module-big-fish",
|
||||||
"crates/module-combat",
|
"crates/module-combat",
|
||||||
"crates/module-creative-agent",
|
"crates/module-creative-agent",
|
||||||
@@ -50,6 +51,7 @@ license = "UNLICENSED"
|
|||||||
module-ai = { path = "crates/module-ai", default-features = false }
|
module-ai = { path = "crates/module-ai", default-features = false }
|
||||||
module-assets = { path = "crates/module-assets", default-features = false }
|
module-assets = { path = "crates/module-assets", default-features = false }
|
||||||
module-auth = { path = "crates/module-auth", default-features = false }
|
module-auth = { path = "crates/module-auth", default-features = false }
|
||||||
|
module-bark-battle = { path = "crates/module-bark-battle", default-features = false }
|
||||||
module-big-fish = { path = "crates/module-big-fish", default-features = false }
|
module-big-fish = { path = "crates/module-big-fish", default-features = false }
|
||||||
module-combat = { path = "crates/module-combat", default-features = false }
|
module-combat = { path = "crates/module-combat", default-features = false }
|
||||||
module-creative-agent = { path = "crates/module-creative-agent", default-features = false }
|
module-creative-agent = { path = "crates/module-creative-agent", default-features = false }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ module-ai = { workspace = true }
|
|||||||
module-assets = { workspace = true, features = ["server-service"] }
|
module-assets = { workspace = true, features = ["server-service"] }
|
||||||
module-auth = { workspace = true }
|
module-auth = { workspace = true }
|
||||||
module-big-fish = { workspace = true }
|
module-big-fish = { workspace = true }
|
||||||
|
module-bark-battle = { workspace = true }
|
||||||
module-combat = { workspace = true }
|
module-combat = { workspace = true }
|
||||||
module-creative-agent = { workspace = true }
|
module-creative-agent = { workspace = true }
|
||||||
module-custom-world = { workspace = true }
|
module-custom-world = { workspace = true }
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"provider": "ark",
|
|
||||||
"protocol": "responses",
|
|
||||||
"model": "deepseek-v3-2-251201",
|
|
||||||
"stream": false,
|
|
||||||
"attempt": 1,
|
|
||||||
"maxTokens": null,
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n这一步只保留世界顶层信息与一个开局归处占位,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物、地图细节或多幕场景内容。\n玩家设定:\n世界承诺:{\"hook\":\"在失真的海图上追查一场被篡改的沉船事故。\"}\n玩家切入口:{\"entryMotivation\":\"查清父亲沉船真相\",\"openingIdentity\":\"被停职返乡的守灯人\",\"openingProblem\":\"灯塔记录被人改写\"}\n\n输出 JSON 模板:\n{\n \"name\": \"世界名称\",\n \"subtitle\": \"世界副标题\",\n \"summary\": \"世界概述\",\n \"tone\": \"世界基调\",\n \"playerGoal\": \"玩家核心目标\",\n \"templateWorldType\": \"WUXIA|XIANXIA\",\n \"majorFactions\": [\"势力甲\", \"势力乙\"],\n \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],\n \"attributeSchema\": {\n \"slots\": [\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" }\n ]\n },\n \"camp\": {\n \"name\": \"开局归处名称\",\n \"description\": \"这是玩家进入世界后的第一处落脚点描述\"\n }\n}\n\n要求:\n- 所有生成文本都必须使用中文。\n- 这一步只输出顶层 10 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。\n- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。\n- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。\n- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。\n- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。\n- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。\n- attributeSchema 必须是本世界专属的角色六维名称体系,slots 必须恰好 6 个,每个 slot 只输出 name,维度名必须是 2 到 4 个汉字且互不重复。\n- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。\n- 不要在 attributeSchema.slots 内输出 definition、positiveSignals、negativeSignals、combatUseText、socialUseText、explorationUseText 或其他说明字段。\n- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。\n- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"choices":[{"message":{"content":"{\"name\":\"雾港归航\",\"subtitle\":\"失灯旧案\",\"summary\":\"守灯人与群岛议会围绕沉船旧案对峙。\",\"tone\":\"海雾悬疑\",\"playerGoal\":\"查清父亲沉船真相\",\"templateWorldType\":\"WUXIA\",\"majorFactions\":[\"群岛议会\",\"灯塔署\"],\"coreConflicts\":[\"守灯塔的旧档案被人改写。\"],\"attributeSchema\":{\"slots\":[{\"name\":\"灯骨\"},{\"name\":\"潮步\"},{\"name\":\"灯识\"},{\"name\":\"雾魄\"},{\"name\":\"旧约\"},{\"name\":\"回澜\"}]},\"camp\":{\"name\":\"旧灯塔归舍\",\"description\":\"海雾边缘的守灯人旧居。\"}}"}}],"id":"resp_01"}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"provider": "ark",
|
|
||||||
"protocol": "responses",
|
|
||||||
"model": "deepseek-v3-2-251201",
|
|
||||||
"stream": false,
|
|
||||||
"attempt": 1,
|
|
||||||
"maxTokens": null,
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "请为下面这一批场景角色补全养成档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n世界核心信息:\n世界:雾港归航\n副标题:失灯旧案\n世界概述:守灯人与群岛议会围绕沉船旧案对峙。\n世界基调:海雾悬疑\n玩家核心目标:查清父亲沉船真相\n主要势力:群岛议会、灯塔署\n核心冲突:守灯塔的旧档案被人改写。\n开局归处:旧灯塔归舍(海雾边缘的守灯人旧居。)\n关键场景:旧灯塔(雾中仍亮着错位灯火)、沉船湾(退潮后露出旧船骨)\n本批角色:\n- 议长甲 / 群岛议长\n身份:遮掩者\n框架描述:压住旧档的人\n预设好感:-10\n关系切入口:旧档案\n标签:议会\n- 潮医乙 / 潮汐医师\n身份:证人\n框架描述:知道沉船伤痕\n预设好感:20\n关系切入口:救治记录\n标签:证人\n出现场景:沉船湾\n输出 JSON 模板:\n{\n \"storyNpcs\": [\n {\n \"name\": \"角色名称\",\n \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },\n \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],\n \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]\n }\n ]\n}\n要求:\n- 必须只补全本批角色,name 必须与本批角色完全一致,不得增删改名。\n- 每个角色必须包含:name、backstoryReveal、skills、initialItems。\n- backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 15、30、60、90。\n- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"error":{"message":"story dossier timeout"}}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"provider": "ark",
|
|
||||||
"protocol": "responses",
|
|
||||||
"model": "deepseek-v3-2-251201",
|
|
||||||
"stream": false,
|
|
||||||
"attempt": 1,
|
|
||||||
"maxTokens": null,
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "请为下面这一批场景角色补全养成档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n世界核心信息:\n世界:雾港归航\n副标题:失灯旧案\n世界概述:守灯人与群岛议会围绕沉船旧案对峙。\n世界基调:海雾悬疑\n玩家核心目标:查清父亲沉船真相\n主要势力:群岛议会、灯塔署\n核心冲突:守灯塔的旧档案被人改写。\n开局归处:旧灯塔归舍(海雾边缘的守灯人旧居。)\n关键场景:旧灯塔(雾中仍亮着错位灯火)、沉船湾(退潮后露出旧船骨)\n本批角色:\n- 雾商丙 / 雾港商人\n身份:中间人\n框架描述:贩卖航线的人\n预设好感:5\n关系切入口:伪造海图\n标签:商人\n- 灯童丁 / 灯塔学徒\n身份:目击者\n框架描述:听见夜钟的人\n预设好感:30\n关系切入口:夜钟\n标签:学徒\n出现场景:旧灯塔\n输出 JSON 模板:\n{\n \"storyNpcs\": [\n {\n \"name\": \"角色名称\",\n \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },\n \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],\n \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]\n }\n ]\n}\n要求:\n- 必须只补全本批角色,name 必须与本批角色完全一致,不得增删改名。\n- 每个角色必须包含:name、backstoryReveal、skills、initialItems。\n- backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 15、30、60、90。\n- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"error":{"message":"story dossier timeout"}}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"next_user_id": 2,
|
|
||||||
"users_by_username": {
|
|
||||||
"phone_00000002": {
|
|
||||||
"user": {
|
|
||||||
"id": "user_00000001",
|
|
||||||
"public_user_code": "SY-00000001",
|
|
||||||
"username": "phone_00000002",
|
|
||||||
"display_name": "138****8000",
|
|
||||||
"phone_number_masked": "138****8000",
|
|
||||||
"login_method": "Phone",
|
|
||||||
"binding_status": "Active",
|
|
||||||
"wechat_bound": false,
|
|
||||||
"token_version": 1
|
|
||||||
},
|
|
||||||
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$hoXmK/LzABj2QfWZSO3SNA$Qg71V2iZCPyLOsoQLffiCv3KPkWVNSAsP6IooTIXi/w",
|
|
||||||
"password_login_enabled": false,
|
|
||||||
"phone_number": "+8613800138000"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"phone_to_user_id": {
|
|
||||||
"+8613800138000": "user_00000001"
|
|
||||||
},
|
|
||||||
"sessions_by_id": {
|
|
||||||
"usess_52522126b58d40e3b9e503808dd11e2c": {
|
|
||||||
"session": {
|
|
||||||
"session_id": "usess_52522126b58d40e3b9e503808dd11e2c",
|
|
||||||
"user_id": "user_00000001",
|
|
||||||
"refresh_token_hash": "f42140526caea3e4a9f533bcc2d8799feae4f96769ea975ef771b1ae11e4dbe9",
|
|
||||||
"issued_by_provider": "Phone",
|
|
||||||
"client_info": {
|
|
||||||
"client_type": "web_browser",
|
|
||||||
"client_runtime": "unknown",
|
|
||||||
"client_platform": "unknown",
|
|
||||||
"client_instance_id": null,
|
|
||||||
"device_fingerprint": null,
|
|
||||||
"device_display_name": "未知设备 / 未知客户端",
|
|
||||||
"mini_program_app_id": null,
|
|
||||||
"mini_program_env": null,
|
|
||||||
"user_agent": null,
|
|
||||||
"ip": null
|
|
||||||
},
|
|
||||||
"expires_at": "2026-05-25T15:41:01.0856147Z",
|
|
||||||
"revoked_at": null,
|
|
||||||
"created_at": "2026-04-25T15:41:01.0856147Z",
|
|
||||||
"updated_at": "2026-04-25T15:41:01.0856147Z",
|
|
||||||
"last_seen_at": "2026-04-25T15:41:01.0856147Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"session_id_by_refresh_token_hash": {
|
|
||||||
"f42140526caea3e4a9f533bcc2d8799feae4f96769ea975ef771b1ae11e4dbe9": "usess_52522126b58d40e3b9e503808dd11e2c"
|
|
||||||
},
|
|
||||||
"wechat_identity_by_provider_uid": {},
|
|
||||||
"user_id_by_provider_union_id": {}
|
|
||||||
}
|
|
||||||
@@ -725,7 +725,7 @@ fn parse_admin_database_table_rows_sql_response(
|
|||||||
.ok_or_else(|| "SQL rows 字段格式非法".to_string())?;
|
.ok_or_else(|| "SQL rows 字段格式非法".to_string())?;
|
||||||
let rows = row_values
|
let rows = row_values
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| build_admin_database_table_row(row, &columns))
|
.map(|row| build_admin_database_table_row_for_table(table_name, row, &columns))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
Ok(AdminDatabaseTableRowsResponse {
|
Ok(AdminDatabaseTableRowsResponse {
|
||||||
table_name: table_name.to_string(),
|
table_name: table_name.to_string(),
|
||||||
@@ -769,7 +769,15 @@ fn extract_sql_statement_columns(statement: &Value) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload {
|
fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload {
|
||||||
let raw = normalize_admin_database_value(row);
|
build_admin_database_table_row_for_table("", row, columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_admin_database_table_row_for_table(
|
||||||
|
table_name: &str,
|
||||||
|
row: &Value,
|
||||||
|
columns: &[String],
|
||||||
|
) -> AdminDatabaseTableRowPayload {
|
||||||
|
let raw = normalize_admin_database_table_row_raw(table_name, row, columns);
|
||||||
let mut cells = Map::new();
|
let mut cells = Map::new();
|
||||||
if let Some(values) = row.as_array() {
|
if let Some(values) = row.as_array() {
|
||||||
for (index, value) in values.iter().enumerate() {
|
for (index, value) in values.iter().enumerate() {
|
||||||
@@ -777,11 +785,17 @@ fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatab
|
|||||||
.get(index)
|
.get(index)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| format!("col_{}", index + 1));
|
.unwrap_or_else(|| format!("col_{}", index + 1));
|
||||||
cells.insert(key, normalize_admin_database_value(value));
|
cells.insert(
|
||||||
|
key.clone(),
|
||||||
|
normalize_admin_database_table_cell(table_name, &key, value),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if let Some(object) = row.as_object() {
|
} else if let Some(object) = row.as_object() {
|
||||||
for (key, value) in object {
|
for (key, value) in object {
|
||||||
cells.insert(key.clone(), normalize_admin_database_value(value));
|
cells.insert(
|
||||||
|
key.clone(),
|
||||||
|
normalize_admin_database_table_cell(table_name, key, value),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AdminDatabaseTableRowPayload {
|
AdminDatabaseTableRowPayload {
|
||||||
@@ -790,6 +804,85 @@ fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_admin_database_table_row_raw(
|
||||||
|
table_name: &str,
|
||||||
|
row: &Value,
|
||||||
|
columns: &[String],
|
||||||
|
) -> Value {
|
||||||
|
if let Some(values) = row.as_array() {
|
||||||
|
return Value::Array(
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, value)| {
|
||||||
|
let key = columns.get(index).map(String::as_str).unwrap_or_default();
|
||||||
|
normalize_admin_database_table_cell(table_name, key, value)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(object) = row.as_object() {
|
||||||
|
return Value::Object(
|
||||||
|
object
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| {
|
||||||
|
(
|
||||||
|
key.clone(),
|
||||||
|
normalize_admin_database_table_cell(table_name, key, value),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_admin_database_value(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_admin_database_table_cell(
|
||||||
|
table_name: &str,
|
||||||
|
column_name: &str,
|
||||||
|
value: &Value,
|
||||||
|
) -> Value {
|
||||||
|
if let Some(enum_value) = normalize_admin_database_known_enum(table_name, column_name, value) {
|
||||||
|
return enum_value;
|
||||||
|
}
|
||||||
|
normalize_admin_database_value(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_admin_database_known_enum(
|
||||||
|
table_name: &str,
|
||||||
|
column_name: &str,
|
||||||
|
value: &Value,
|
||||||
|
) -> Option<Value> {
|
||||||
|
let variant_index = extract_sats_enum_variant_index(value)?;
|
||||||
|
let label = match (table_name, column_name) {
|
||||||
|
("profile_recharge_order", "kind") => match variant_index {
|
||||||
|
0 => "points",
|
||||||
|
1 => "membership",
|
||||||
|
_ => return None,
|
||||||
|
},
|
||||||
|
("profile_recharge_order", "status") => match variant_index {
|
||||||
|
0 => "pending",
|
||||||
|
1 => "paid",
|
||||||
|
2 => "failed",
|
||||||
|
3 => "closed",
|
||||||
|
4 => "refunded",
|
||||||
|
_ => return None,
|
||||||
|
},
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(Value::String(label.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_sats_enum_variant_index(value: &Value) -> Option<u64> {
|
||||||
|
let items = value.as_array()?;
|
||||||
|
if items.len() != 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
items.first()?.as_u64()
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_admin_database_value(value: &Value) -> Value {
|
fn normalize_admin_database_value(value: &Value) -> Value {
|
||||||
match value {
|
match value {
|
||||||
Value::Array(items) if items.len() == 1 => normalize_admin_database_value(&items[0]),
|
Value::Array(items) if items.len() == 1 => normalize_admin_database_value(&items[0]),
|
||||||
@@ -1526,6 +1619,46 @@ mod tests {
|
|||||||
assert_eq!(response.rows[0].cells["points"], json!(12));
|
assert_eq!(response.rows[0].cells["points"], json!(12));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_admin_database_table_rows_sql_response_maps_recharge_order_enum_cells() {
|
||||||
|
let payload = json!([
|
||||||
|
{
|
||||||
|
"schema": {
|
||||||
|
"elements": [
|
||||||
|
{"name": {"some": "order_id"}},
|
||||||
|
{"name": {"some": "kind"}},
|
||||||
|
{"name": {"some": "status"}},
|
||||||
|
{"name": {"some": "paid_at"}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rows": [[
|
||||||
|
"recharge:user_00000001:1778757456811099:points_60",
|
||||||
|
[0, []],
|
||||||
|
[0, []],
|
||||||
|
[1, []]
|
||||||
|
]]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
let response =
|
||||||
|
parse_admin_database_table_rows_sql_response("profile_recharge_order", 100, payload)
|
||||||
|
.expect("recharge order rows should parse");
|
||||||
|
|
||||||
|
let cells = &response.rows[0].cells;
|
||||||
|
assert_eq!(cells["kind"], json!("points"));
|
||||||
|
assert_eq!(cells["status"], json!("pending"));
|
||||||
|
assert_eq!(cells["paid_at"], json!(null));
|
||||||
|
assert_eq!(
|
||||||
|
response.rows[0].raw,
|
||||||
|
json!([
|
||||||
|
"recharge:user_00000001:1778757456811099:points_60",
|
||||||
|
"points",
|
||||||
|
"pending",
|
||||||
|
null
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_admin_database_table_row_normalizes_optional_sats_values() {
|
fn build_admin_database_table_row_normalizes_optional_sats_values() {
|
||||||
let row = build_admin_database_table_row(
|
let row = build_admin_database_table_row(
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ use crate::{
|
|||||||
auth_me::auth_me,
|
auth_me::auth_me,
|
||||||
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
|
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
|
||||||
auth_sessions::{auth_sessions, revoke_auth_session},
|
auth_sessions::{auth_sessions, revoke_auth_session},
|
||||||
|
bark_battle::{
|
||||||
|
create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run,
|
||||||
|
get_bark_battle_runtime_config, publish_bark_battle_work, start_bark_battle_run,
|
||||||
|
},
|
||||||
big_fish::{
|
big_fish::{
|
||||||
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
|
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
|
||||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
|
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
|
||||||
@@ -77,6 +81,8 @@ use crate::{
|
|||||||
generate_custom_world_opening_cg, generate_custom_world_scene_image,
|
generate_custom_world_opening_cg, generate_custom_world_scene_image,
|
||||||
generate_custom_world_scene_npc, upload_custom_world_cover_image,
|
generate_custom_world_scene_npc, upload_custom_world_cover_image,
|
||||||
},
|
},
|
||||||
|
edutainment_baby_drawing::create_baby_love_drawing_magic,
|
||||||
|
edutainment_baby_object::generate_baby_object_match_assets,
|
||||||
error_middleware::normalize_error_response,
|
error_middleware::normalize_error_response,
|
||||||
health::health_check,
|
health::health_check,
|
||||||
hyper3d_generation::{
|
hyper3d_generation::{
|
||||||
@@ -185,6 +191,7 @@ use crate::{
|
|||||||
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||||
const PROFILE_FEEDBACK_BODY_LIMIT_BYTES: usize = 6 * 1024 * 1024;
|
const PROFILE_FEEDBACK_BODY_LIMIT_BYTES: usize = 6 * 1024 * 1024;
|
||||||
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
|
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
|
||||||
|
const BABY_LOVE_DRAWING_MAGIC_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
|
||||||
|
|
||||||
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
||||||
pub fn build_router(state: AppState) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
@@ -648,6 +655,24 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/api/creation-entry/config",
|
"/api/creation-entry/config",
|
||||||
get(get_creation_entry_config_handler),
|
get(get_creation_entry_config_handler),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/edutainment/baby-object-match/assets",
|
||||||
|
post(generate_baby_object_match_assets).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/edutainment/baby-love-drawing/magic",
|
||||||
|
post(create_baby_love_drawing_magic)
|
||||||
|
.layer(DefaultBodyLimit::max(
|
||||||
|
BABY_LOVE_DRAWING_MAGIC_BODY_LIMIT_BYTES,
|
||||||
|
))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/settings",
|
"/api/runtime/settings",
|
||||||
get(get_runtime_settings)
|
get(get_runtime_settings)
|
||||||
@@ -1055,6 +1080,48 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
require_bearer_auth,
|
require_bearer_auth,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/bark-battle/drafts",
|
||||||
|
post(create_bark_battle_draft).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/bark-battle/works/publish",
|
||||||
|
post(publish_bark_battle_work).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/bark-battle/works/{work_id}/config",
|
||||||
|
get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/bark-battle/works/{work_id}/runs",
|
||||||
|
post(start_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/bark-battle/runs/{run_id}",
|
||||||
|
get(get_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/bark-battle/runs/{run_id}/finish",
|
||||||
|
post(finish_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/creation/square-hole/sessions",
|
"/api/creation/square-hole/sessions",
|
||||||
post(create_square_hole_agent_session).route_layer(middleware::from_fn_with_state(
|
post(create_square_hole_agent_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
776
server-rs/crates/api-server/src/bark_battle.rs
Normal file
@@ -0,0 +1,776 @@
|
|||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Extension, Path, State, rejection::JsonRejection},
|
||||||
|
http::{HeaderName, StatusCode, header},
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use module_bark_battle::{BARK_BATTLE_RULESET_VERSION_V1, BarkBattleRuleset};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use shared_contracts::bark_battle::{
|
||||||
|
BarkBattleConfigEditorPayload, BarkBattleDerivedMetrics, BarkBattleDifficultyPreset,
|
||||||
|
BarkBattleDraftConfig, BarkBattleDraftCreateRequest, BarkBattleFinishStatus,
|
||||||
|
BarkBattlePublishedConfig, BarkBattleRunFinishRequest, BarkBattleRunFinishResponse,
|
||||||
|
BarkBattleRunStartRequest, BarkBattleRunStartResponse, BarkBattleScoreSummary,
|
||||||
|
BarkBattleServerResult, BarkBattleWorkPublishRequest,
|
||||||
|
};
|
||||||
|
use shared_kernel::{
|
||||||
|
build_prefixed_uuid_id, format_rfc3339, format_timestamp_micros,
|
||||||
|
offset_datetime_to_unix_micros, parse_rfc3339,
|
||||||
|
};
|
||||||
|
use spacetime_client::{
|
||||||
|
BarkBattleDraftCreateRecordInput, BarkBattleRunFinishRecordInput, BarkBattleRunRecord,
|
||||||
|
BarkBattleRunStartRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError,
|
||||||
|
};
|
||||||
|
use time::{Duration as TimeDuration, OffsetDateTime};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api_response::json_success_body,
|
||||||
|
auth::AuthenticatedAccessToken,
|
||||||
|
http_error::AppError,
|
||||||
|
request_context::RequestContext,
|
||||||
|
state::AppState,
|
||||||
|
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||||
|
};
|
||||||
|
|
||||||
|
const BARK_BATTLE_RUNTIME_PROVIDER: &str = "bark-battle-runtime";
|
||||||
|
const BARK_BATTLE_DRAFT_ID_PREFIX: &str = "bark-battle-draft-";
|
||||||
|
const BARK_BATTLE_WORK_ID_PREFIX: &str = "bark-battle-work-";
|
||||||
|
const BARK_BATTLE_RUN_ID_PREFIX: &str = "bark-battle-run-";
|
||||||
|
const BARK_BATTLE_RUN_TOKEN_PREFIX: &str = "bark-battle-token-";
|
||||||
|
const BARK_BATTLE_PLAY_TYPE_ID: &str = "bark-battle";
|
||||||
|
const BARK_BATTLE_RUN_TTL_SECONDS: i64 = 10 * 60;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BarkBattleRunSnapshotRecord {
|
||||||
|
run_id: String,
|
||||||
|
work_id: String,
|
||||||
|
config_version: u64,
|
||||||
|
ruleset_version: String,
|
||||||
|
difficulty_preset: String,
|
||||||
|
#[serde(default)]
|
||||||
|
client_started_at_micros: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
server_started_at_micros: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
server_finished_at_micros: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
metrics_json: String,
|
||||||
|
#[serde(default)]
|
||||||
|
server_result: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
validation_status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
anti_cheat_flags_json: String,
|
||||||
|
#[serde(default)]
|
||||||
|
leaderboard_score: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BarkBattleDraftConfigSnapshotRecord {
|
||||||
|
draft_id: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
work_id: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
config_version: u64,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
ruleset_version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
config_json: String,
|
||||||
|
updated_at_micros: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BarkBattleRuntimeConfigSnapshotRecord {
|
||||||
|
work_id: String,
|
||||||
|
source_draft_id: Option<String>,
|
||||||
|
config_version: u64,
|
||||||
|
ruleset_version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
config_json: String,
|
||||||
|
published_at_micros: i64,
|
||||||
|
updated_at_micros: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_bark_battle_draft(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
payload: Result<Json<BarkBattleDraftCreateRequest>, JsonRejection>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
||||||
|
let now = current_utc_micros();
|
||||||
|
let draft = state
|
||||||
|
.spacetime_client()
|
||||||
|
.create_bark_battle_draft(BarkBattleDraftCreateRecordInput {
|
||||||
|
draft_id: build_prefixed_uuid_id(BARK_BATTLE_DRAFT_ID_PREFIX),
|
||||||
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||||
|
work_id: build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX),
|
||||||
|
title: Some(payload.title),
|
||||||
|
description: payload.description,
|
||||||
|
theme_preset: payload.theme_preset,
|
||||||
|
player_dog_skin_preset: payload.player_dog_skin_preset,
|
||||||
|
opponent_dog_skin_preset: payload.opponent_dog_skin_preset,
|
||||||
|
difficulty_preset: Some(
|
||||||
|
difficulty_to_spacetime_string(&payload.difficulty_preset).to_string(),
|
||||||
|
),
|
||||||
|
leaderboard_enabled: Some(payload.leaderboard_enabled),
|
||||||
|
editor_state_json: Some("{}".to_string()),
|
||||||
|
created_at_micros: now,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||||
|
})?;
|
||||||
|
let draft = map_draft_config_record(draft, &request_context)?;
|
||||||
|
Ok(json_success_body(Some(&request_context), draft))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn publish_bark_battle_work(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
payload: Result<Json<BarkBattleWorkPublishRequest>, JsonRejection>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
||||||
|
ensure_non_empty(&request_context, &payload.draft_id, "draftId")?;
|
||||||
|
let work_id = payload
|
||||||
|
.work_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.unwrap_or_else(|| build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX));
|
||||||
|
let published_snapshot_json = payload
|
||||||
|
.published_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.map(serde_json::to_string)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|error| {
|
||||||
|
bark_battle_error_response(
|
||||||
|
&request_context,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||||
|
"message": format!("publishedSnapshot JSON 序列化失败: {error}"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let published = state
|
||||||
|
.spacetime_client()
|
||||||
|
.publish_bark_battle_work(BarkBattleWorkPublishRecordInput {
|
||||||
|
draft_id: payload.draft_id,
|
||||||
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||||
|
work_id,
|
||||||
|
published_snapshot_json,
|
||||||
|
published_at_micros: current_utc_micros(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||||
|
})?;
|
||||||
|
let published = map_published_config_record(published, &request_context)?;
|
||||||
|
Ok(json_success_body(Some(&request_context), published))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_bark_battle_runtime_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(work_id): Path<String>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
ensure_non_empty(&request_context, &work_id, "workId")?;
|
||||||
|
|
||||||
|
let config = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_bark_battle_runtime_config(work_id, Some(authenticated.claims().user_id().to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||||
|
})?;
|
||||||
|
let config = map_runtime_config_record(config, &request_context)?;
|
||||||
|
|
||||||
|
Ok(json_success_body(Some(&request_context), config))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_bark_battle_run(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(work_id): Path<String>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
payload: Result<Json<BarkBattleRunStartRequest>, JsonRejection>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
let maybe_payload = payload.ok().map(|Json(payload)| payload);
|
||||||
|
let request = maybe_payload.unwrap_or_else(|| BarkBattleRunStartRequest {
|
||||||
|
work_id: work_id.clone(),
|
||||||
|
config_version: None,
|
||||||
|
source_route: None,
|
||||||
|
client_runtime_version: None,
|
||||||
|
});
|
||||||
|
let work_id = if request.work_id.trim().is_empty() {
|
||||||
|
work_id
|
||||||
|
} else {
|
||||||
|
request.work_id.trim().to_string()
|
||||||
|
};
|
||||||
|
ensure_non_empty(&request_context, &work_id, "workId")?;
|
||||||
|
|
||||||
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||||
|
let runtime_config = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_bark_battle_runtime_config(work_id.clone(), Some(owner_user_id.clone()))
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||||
|
})?;
|
||||||
|
let runtime_config = map_runtime_config_record(runtime_config, &request_context)?;
|
||||||
|
if !request.work_id.trim().is_empty() && request.work_id.trim() != work_id {
|
||||||
|
return Err(bark_battle_bad_request(
|
||||||
|
&request_context,
|
||||||
|
"workId 与路径参数不一致",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expected_version) = request.config_version {
|
||||||
|
if expected_version != runtime_config.config_version {
|
||||||
|
return Err(bark_battle_bad_request(
|
||||||
|
&request_context,
|
||||||
|
"configVersion 与已发布配置不一致",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let client_started_at_micros = current_utc_micros();
|
||||||
|
let run_token = build_prefixed_uuid_id(BARK_BATTLE_RUN_TOKEN_PREFIX);
|
||||||
|
let run = state
|
||||||
|
.spacetime_client()
|
||||||
|
.start_bark_battle_run(BarkBattleRunStartRecordInput {
|
||||||
|
run_id: build_prefixed_uuid_id(BARK_BATTLE_RUN_ID_PREFIX),
|
||||||
|
run_token: run_token.clone(),
|
||||||
|
owner_user_id: owner_user_id.clone(),
|
||||||
|
work_id: work_id.clone(),
|
||||||
|
config_version: u64::from(runtime_config.config_version),
|
||||||
|
ruleset_version: runtime_config.ruleset_version.clone(),
|
||||||
|
difficulty_preset: difficulty_to_spacetime_string(&runtime_config.difficulty_preset)
|
||||||
|
.to_string(),
|
||||||
|
client_started_at_micros,
|
||||||
|
server_started_at_micros: client_started_at_micros,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||||
|
})?;
|
||||||
|
let run_snapshot = parse_run_record(run, &request_context)?;
|
||||||
|
|
||||||
|
record_work_play_start_after_success(
|
||||||
|
&state,
|
||||||
|
&request_context,
|
||||||
|
WorkPlayTrackingDraft::new(
|
||||||
|
BARK_BATTLE_PLAY_TYPE_ID,
|
||||||
|
work_id.clone(),
|
||||||
|
&authenticated,
|
||||||
|
"/api/runtime/bark-battle/...",
|
||||||
|
)
|
||||||
|
.extra(json!({
|
||||||
|
"runId": run_snapshot.run_id,
|
||||||
|
"workId": work_id,
|
||||||
|
"configVersion": runtime_config.config_version,
|
||||||
|
"rulesetVersion": runtime_config.ruleset_version,
|
||||||
|
"difficultyPreset": runtime_config.difficulty_preset,
|
||||||
|
"sourceRoute": request.source_route,
|
||||||
|
"clientRuntimeVersion": request.client_runtime_version,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let server_started_at = format_timestamp_micros(run_snapshot.server_started_at_micros);
|
||||||
|
let expires_at = format_timestamp_micros(
|
||||||
|
run_snapshot
|
||||||
|
.server_started_at_micros
|
||||||
|
.saturating_add(BARK_BATTLE_RUN_TTL_SECONDS * 1_000_000),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(json_success_body(
|
||||||
|
Some(&request_context),
|
||||||
|
BarkBattleRunStartResponse {
|
||||||
|
run_id: run_snapshot.run_id,
|
||||||
|
run_token,
|
||||||
|
work_id: run_snapshot.work_id,
|
||||||
|
config_version: runtime_config.config_version,
|
||||||
|
ruleset_version: runtime_config.ruleset_version.clone(),
|
||||||
|
difficulty_preset: runtime_config.difficulty_preset.clone(),
|
||||||
|
runtime_config,
|
||||||
|
server_started_at,
|
||||||
|
expires_at,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_bark_battle_run(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(run_id): Path<String>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||||
|
let run = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_bark_battle_run(run_id, authenticated.claims().user_id().to_string())
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||||
|
})?;
|
||||||
|
let run = parse_run_record(run, &request_context)?;
|
||||||
|
|
||||||
|
Ok(json_success_body(Some(&request_context), run))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn finish_bark_battle_run(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(run_id): Path<String>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
payload: Result<Json<BarkBattleRunFinishRequest>, JsonRejection>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
||||||
|
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||||
|
ensure_non_empty(&request_context, &payload.work_id, "workId")?;
|
||||||
|
ensure_non_empty(&request_context, &payload.run_token, "runToken")?;
|
||||||
|
if payload.run_id != run_id {
|
||||||
|
return Err(bark_battle_bad_request(
|
||||||
|
&request_context,
|
||||||
|
"runId 与路径参数不一致",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if payload.ruleset_version != BARK_BATTLE_RULESET_VERSION_V1 {
|
||||||
|
return Err(bark_battle_bad_request(
|
||||||
|
&request_context,
|
||||||
|
"rulesetVersion 不支持",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let client_finished_at_micros = parse_client_time_to_micros(&payload.client_finished_at)
|
||||||
|
.map_err(|message| bark_battle_bad_request(&request_context, &message))?;
|
||||||
|
let derived = &payload.derived_metrics;
|
||||||
|
let opponent_final_energy = derive_server_opponent_final_energy(derived);
|
||||||
|
let metrics_json = serde_json::to_string(&json!({
|
||||||
|
"clientStartedAt": payload.client_started_at,
|
||||||
|
"clientFinishedAt": payload.client_finished_at,
|
||||||
|
"durationMs": payload.duration_ms,
|
||||||
|
"derivedMetrics": payload.derived_metrics,
|
||||||
|
"clientResult": payload.client_result,
|
||||||
|
"sampleDigest": payload.sample_digest,
|
||||||
|
"clientRuntimeVersion": payload.client_runtime_version,
|
||||||
|
}))
|
||||||
|
.unwrap_or_else(|_| "{}".to_string());
|
||||||
|
let derived_metrics_json = serde_json::to_string(derived).unwrap_or_else(|_| "{}".to_string());
|
||||||
|
|
||||||
|
let run = state
|
||||||
|
.spacetime_client()
|
||||||
|
.finish_bark_battle_run(BarkBattleRunFinishRecordInput {
|
||||||
|
run_id,
|
||||||
|
run_token: payload.run_token,
|
||||||
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||||
|
work_id: payload.work_id.clone(),
|
||||||
|
config_version: u64::from(payload.config_version),
|
||||||
|
ruleset_version: payload.ruleset_version.clone(),
|
||||||
|
difficulty_preset: difficulty_to_spacetime_string(&payload.difficulty_preset)
|
||||||
|
.to_string(),
|
||||||
|
client_finished_at_micros,
|
||||||
|
server_finished_at_micros: current_utc_micros(),
|
||||||
|
duration_ms: payload.duration_ms,
|
||||||
|
trigger_count: u64::from(derived.trigger_count),
|
||||||
|
max_volume_millis: unit_to_millis(derived.max_volume),
|
||||||
|
average_volume_millis: unit_to_millis(derived.average_volume),
|
||||||
|
final_energy_millis: energy_to_millis(derived.final_energy),
|
||||||
|
opponent_final_energy_millis: energy_to_millis(opponent_final_energy),
|
||||||
|
max_combo: derived.combo_max,
|
||||||
|
metrics_json,
|
||||||
|
derived_metrics_json,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||||
|
})?;
|
||||||
|
let run = parse_run_record(run, &request_context)?;
|
||||||
|
|
||||||
|
Ok(json_success_body(
|
||||||
|
Some(&request_context),
|
||||||
|
map_finish_response(run, &payload.derived_metrics),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_finish_response(
|
||||||
|
run: BarkBattleRunSnapshotRecord,
|
||||||
|
fallback_metrics: &BarkBattleDerivedMetrics,
|
||||||
|
) -> BarkBattleRunFinishResponse {
|
||||||
|
let score_summary =
|
||||||
|
parse_score_summary(&run.metrics_json).unwrap_or_else(|| BarkBattleScoreSummary {
|
||||||
|
duration_ms: 0,
|
||||||
|
trigger_count: fallback_metrics.trigger_count,
|
||||||
|
max_volume: fallback_metrics.max_volume,
|
||||||
|
average_volume: fallback_metrics.average_volume,
|
||||||
|
final_energy: fallback_metrics.final_energy,
|
||||||
|
combo_max: fallback_metrics.combo_max,
|
||||||
|
});
|
||||||
|
BarkBattleRunFinishResponse {
|
||||||
|
status: parse_finish_status(&run.validation_status),
|
||||||
|
run_id: run.run_id,
|
||||||
|
work_id: run.work_id,
|
||||||
|
config_version: run.config_version.min(u64::from(u32::MAX)) as u32,
|
||||||
|
ruleset_version: run.ruleset_version,
|
||||||
|
difficulty_preset: parse_difficulty_lossy(&run.difficulty_preset),
|
||||||
|
server_result: parse_server_result_lossy(run.server_result.as_deref()),
|
||||||
|
score_summary,
|
||||||
|
leaderboard_score: run.leaderboard_score,
|
||||||
|
anti_cheat_flags: parse_string_vec(&run.anti_cheat_flags_json),
|
||||||
|
updated_at: format_timestamp_micros(
|
||||||
|
run.server_finished_at_micros
|
||||||
|
.unwrap_or(run.server_started_at_micros),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_run_record(
|
||||||
|
value: BarkBattleRunRecord,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
) -> Result<BarkBattleRunSnapshotRecord, Response> {
|
||||||
|
serde_json::from_value(value).map_err(|error| {
|
||||||
|
bark_battle_error_response(
|
||||||
|
request_context,
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||||
|
"message": format!("Bark Battle run JSON 解析失败: {error}"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_draft_snapshot_record(
|
||||||
|
value: Value,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
) -> Result<BarkBattleDraftConfigSnapshotRecord, Response> {
|
||||||
|
serde_json::from_value(value)
|
||||||
|
.map_err(|error| bark_battle_snapshot_parse_error(request_context, "draft config", error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_runtime_snapshot_record(
|
||||||
|
value: Value,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
) -> Result<BarkBattleRuntimeConfigSnapshotRecord, Response> {
|
||||||
|
serde_json::from_value(value)
|
||||||
|
.map_err(|error| bark_battle_snapshot_parse_error(request_context, "runtime config", error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_draft_config_record(
|
||||||
|
value: Value,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
) -> Result<BarkBattleDraftConfig, Response> {
|
||||||
|
let snapshot = parse_draft_snapshot_record(value, request_context)?;
|
||||||
|
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
|
||||||
|
Ok(BarkBattleDraftConfig {
|
||||||
|
draft_id: snapshot.draft_id,
|
||||||
|
title: editor_config.title,
|
||||||
|
description: editor_config.description,
|
||||||
|
theme_preset: editor_config.theme_preset,
|
||||||
|
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
||||||
|
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
||||||
|
difficulty_preset: editor_config.difficulty_preset,
|
||||||
|
leaderboard_enabled: editor_config.leaderboard_enabled,
|
||||||
|
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_runtime_config_record(
|
||||||
|
value: Value,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
) -> Result<shared_contracts::bark_battle::BarkBattleRuntimeConfig, Response> {
|
||||||
|
let snapshot = parse_runtime_snapshot_record(value, request_context)?;
|
||||||
|
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
|
||||||
|
let ruleset = BarkBattleRuleset::v1();
|
||||||
|
Ok(shared_contracts::bark_battle::BarkBattleRuntimeConfig {
|
||||||
|
work_id: snapshot.work_id,
|
||||||
|
config_version: snapshot.config_version.min(u64::from(u32::MAX)) as u32,
|
||||||
|
ruleset_version: snapshot.ruleset_version,
|
||||||
|
play_type_id: BARK_BATTLE_PLAY_TYPE_ID.to_string(),
|
||||||
|
duration_ms: ruleset.standard_duration_ms,
|
||||||
|
energy_min: 0.0,
|
||||||
|
energy_max: 100.0,
|
||||||
|
draw_threshold: ruleset.draw_threshold_energy as f32,
|
||||||
|
min_bark_gap_ms: ruleset.min_bark_gap_ms,
|
||||||
|
difficulty_preset: editor_config.difficulty_preset,
|
||||||
|
theme_preset: editor_config.theme_preset,
|
||||||
|
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
||||||
|
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
||||||
|
leaderboard_enabled: editor_config.leaderboard_enabled,
|
||||||
|
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_published_config_record(
|
||||||
|
value: Value,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
) -> Result<BarkBattlePublishedConfig, Response> {
|
||||||
|
let snapshot = parse_runtime_snapshot_record(value, request_context)?;
|
||||||
|
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
|
||||||
|
Ok(BarkBattlePublishedConfig {
|
||||||
|
work_id: snapshot.work_id,
|
||||||
|
draft_id: snapshot.source_draft_id,
|
||||||
|
config_version: snapshot.config_version.min(u64::from(u32::MAX)) as u32,
|
||||||
|
ruleset_version: snapshot.ruleset_version,
|
||||||
|
play_type_id: BARK_BATTLE_PLAY_TYPE_ID.to_string(),
|
||||||
|
title: editor_config.title,
|
||||||
|
description: editor_config.description,
|
||||||
|
theme_preset: editor_config.theme_preset,
|
||||||
|
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
||||||
|
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
||||||
|
difficulty_preset: editor_config.difficulty_preset,
|
||||||
|
leaderboard_enabled: editor_config.leaderboard_enabled,
|
||||||
|
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||||
|
published_at: format_timestamp_micros(snapshot.published_at_micros),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_editor_config_record(
|
||||||
|
config_json: &str,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
) -> Result<BarkBattleConfigEditorPayload, Response> {
|
||||||
|
serde_json::from_str(config_json).map_err(|error| {
|
||||||
|
bark_battle_error_response(
|
||||||
|
request_context,
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||||
|
"message": format!("Bark Battle configJson 解析失败: {error}"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bark_battle_snapshot_parse_error(
|
||||||
|
request_context: &RequestContext,
|
||||||
|
label: &str,
|
||||||
|
error: serde_json::Error,
|
||||||
|
) -> Response {
|
||||||
|
bark_battle_error_response(
|
||||||
|
request_context,
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||||
|
"message": format!("Bark Battle {label} JSON 解析失败: {error}"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bark_battle_json<T>(
|
||||||
|
payload: Result<Json<T>, JsonRejection>,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
) -> Result<Json<T>, Response> {
|
||||||
|
payload.map_err(|error| {
|
||||||
|
bark_battle_error_response(
|
||||||
|
request_context,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||||
|
"message": error.body_text(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_non_empty(
|
||||||
|
request_context: &RequestContext,
|
||||||
|
value: &str,
|
||||||
|
field_name: &str,
|
||||||
|
) -> Result<(), Response> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Err(bark_battle_bad_request(
|
||||||
|
request_context,
|
||||||
|
&format!("{field_name} is required"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bark_battle_bad_request(request_context: &RequestContext, message: &str) -> Response {
|
||||||
|
bark_battle_error_response(
|
||||||
|
request_context,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||||
|
"message": message,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_bark_battle_client_error(error: SpacetimeClientError) -> AppError {
|
||||||
|
let status = match &error {
|
||||||
|
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||||
|
SpacetimeClientError::Procedure(message)
|
||||||
|
if message.contains("不存在")
|
||||||
|
|| message.contains("not found")
|
||||||
|
|| message.contains("does not exist") =>
|
||||||
|
{
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
}
|
||||||
|
SpacetimeClientError::Procedure(message)
|
||||||
|
if message.contains("不能为空")
|
||||||
|
|| message.contains("不匹配")
|
||||||
|
|| message.contains("不支持")
|
||||||
|
|| message.contains("已结束")
|
||||||
|
|| message.contains("已存在") =>
|
||||||
|
{
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
}
|
||||||
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
|
};
|
||||||
|
|
||||||
|
AppError::from_status(status).with_details(json!({
|
||||||
|
"provider": "spacetimedb",
|
||||||
|
"message": error.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bark_battle_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||||
|
let mut response = error.into_response_with_context(Some(request_context));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
HeaderName::from_static("x-genarrative-provider"),
|
||||||
|
header::HeaderValue::from_static(BARK_BATTLE_RUNTIME_PROVIDER),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_client_time_to_micros(value: &str) -> Result<i64, String> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err("client timestamp is required".to_string());
|
||||||
|
}
|
||||||
|
if let Ok(micros) = trimmed.parse::<i64>() {
|
||||||
|
return Ok(micros);
|
||||||
|
}
|
||||||
|
parse_rfc3339(trimmed).map(offset_datetime_to_unix_micros)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_utc_micros() -> i64 {
|
||||||
|
let duration = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unit_to_millis(value: f32) -> u32 {
|
||||||
|
(value.clamp(0.0, 1.0) * 1_000.0).round() as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn energy_to_millis(value: f32) -> u32 {
|
||||||
|
(value.clamp(0.0, 100.0) * 1_000.0).round() as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_server_opponent_final_energy(metrics: &BarkBattleDerivedMetrics) -> f32 {
|
||||||
|
let ruleset = BarkBattleRuleset::v1();
|
||||||
|
let pressure = (metrics.average_volume * 24.0)
|
||||||
|
+ (metrics.max_volume * 16.0)
|
||||||
|
+ (metrics.trigger_count as f32 * 0.35)
|
||||||
|
+ (metrics.combo_max as f32 * 0.2);
|
||||||
|
(ruleset.max_final_energy - pressure).clamp(ruleset.min_final_energy, ruleset.max_final_energy)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn difficulty_to_spacetime_string(value: &BarkBattleDifficultyPreset) -> &'static str {
|
||||||
|
match value {
|
||||||
|
BarkBattleDifficultyPreset::Easy => "easy",
|
||||||
|
BarkBattleDifficultyPreset::Normal => "normal",
|
||||||
|
BarkBattleDifficultyPreset::Hard => "hard",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_difficulty(value: &str) -> Result<BarkBattleDifficultyPreset, AppError> {
|
||||||
|
match value {
|
||||||
|
"easy" => Ok(BarkBattleDifficultyPreset::Easy),
|
||||||
|
"normal" => Ok(BarkBattleDifficultyPreset::Normal),
|
||||||
|
"hard" => Ok(BarkBattleDifficultyPreset::Hard),
|
||||||
|
_ => Err(
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||||
|
"message": format!("Bark Battle difficultyPreset 不支持: {value}"),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_difficulty_lossy(value: &str) -> BarkBattleDifficultyPreset {
|
||||||
|
parse_difficulty(value).unwrap_or(BarkBattleDifficultyPreset::Normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_finish_status(value: &str) -> BarkBattleFinishStatus {
|
||||||
|
match value {
|
||||||
|
"accepted" => BarkBattleFinishStatus::Accepted,
|
||||||
|
"accepted_with_flags" => BarkBattleFinishStatus::AcceptedWithFlags,
|
||||||
|
"rejected" => BarkBattleFinishStatus::Rejected,
|
||||||
|
_ => BarkBattleFinishStatus::Rejected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_server_result_lossy(value: Option<&str>) -> BarkBattleServerResult {
|
||||||
|
match value {
|
||||||
|
Some("player_win") => BarkBattleServerResult::PlayerWin,
|
||||||
|
Some("opponent_win") => BarkBattleServerResult::OpponentWin,
|
||||||
|
Some("draw") => BarkBattleServerResult::Draw,
|
||||||
|
_ => BarkBattleServerResult::Draw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_score_summary(metrics_json: &str) -> Option<BarkBattleScoreSummary> {
|
||||||
|
let value: Value = serde_json::from_str(metrics_json).ok()?;
|
||||||
|
let derived = value.get("derivedMetrics")?;
|
||||||
|
Some(BarkBattleScoreSummary {
|
||||||
|
duration_ms: value.get("durationMs")?.as_u64()?,
|
||||||
|
trigger_count: derived
|
||||||
|
.get("triggerCount")?
|
||||||
|
.as_u64()?
|
||||||
|
.min(u64::from(u32::MAX)) as u32,
|
||||||
|
max_volume: derived.get("maxVolume")?.as_f64()? as f32,
|
||||||
|
average_volume: derived.get("averageVolume")?.as_f64()? as f32,
|
||||||
|
final_energy: derived.get("finalEnergy")?.as_f64()? as f32,
|
||||||
|
combo_max: derived.get("comboMax")?.as_u64()?.min(u64::from(u32::MAX)) as u32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_string_vec(value: &str) -> Vec<String> {
|
||||||
|
serde_json::from_str(value).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn format_rfc3339_or_timestamp_micros(micros: i64) -> String {
|
||||||
|
let seconds = micros.div_euclid(1_000_000);
|
||||||
|
let subsec_micros = micros.rem_euclid(1_000_000);
|
||||||
|
let Ok(value) = OffsetDateTime::from_unix_timestamp(seconds)
|
||||||
|
.map(|value| value + TimeDuration::microseconds(subsec_micros))
|
||||||
|
else {
|
||||||
|
return format_timestamp_micros(micros);
|
||||||
|
};
|
||||||
|
format_rfc3339(value).unwrap_or_else(|_| format_timestamp_micros(micros))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unit_and_energy_are_clamped_to_spacetime_millis() {
|
||||||
|
assert_eq!(unit_to_millis(0.625), 625);
|
||||||
|
assert_eq!(unit_to_millis(3.0), 1000);
|
||||||
|
assert_eq!(energy_to_millis(88.456), 88_456);
|
||||||
|
assert_eq!(energy_to_millis(120.0), 100_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_rfc3339_and_numeric_client_timestamps() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_client_time_to_micros("1713686401234567").unwrap(),
|
||||||
|
1_713_686_401_234_567
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_client_time_to_micros("2024-04-21T04:00:01.234567Z").unwrap(),
|
||||||
|
1_713_672_001_234_567
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,9 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
|||||||
if normalized.starts_with("/api/runtime/match3d") {
|
if normalized.starts_with("/api/runtime/match3d") {
|
||||||
return Some("match3d");
|
return Some("match3d");
|
||||||
}
|
}
|
||||||
|
if normalized.starts_with("/api/runtime/bark-battle") {
|
||||||
|
return Some("bark-battle");
|
||||||
|
}
|
||||||
if normalized.starts_with("/api/runtime/square-hole") {
|
if normalized.starts_with("/api/runtime/square-hole") {
|
||||||
return Some("square-hole");
|
return Some("square-hole");
|
||||||
}
|
}
|
||||||
@@ -90,6 +93,12 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
|||||||
if normalized.starts_with("/api/creation/visual-novel") {
|
if normalized.starts_with("/api/creation/visual-novel") {
|
||||||
return Some("visual-novel");
|
return Some("visual-novel");
|
||||||
}
|
}
|
||||||
|
if normalized.starts_with("/api/creation/edutainment/baby-object-match") {
|
||||||
|
return Some("baby-object-match");
|
||||||
|
}
|
||||||
|
if normalized.starts_with("/api/creation/edutainment/baby-love-drawing") {
|
||||||
|
return Some("baby-love-drawing");
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,40 +121,11 @@ pub(crate) fn test_creation_entry_config_response()
|
|||||||
title: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
|
title: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
|
||||||
description: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
|
description: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
|
||||||
},
|
},
|
||||||
creation_types: vec![
|
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
|
||||||
test_creation_type("rpg", false, true, 10),
|
|
||||||
test_creation_type("big-fish", false, true, 20),
|
|
||||||
test_creation_type("puzzle", true, true, 30),
|
|
||||||
test_creation_type("match3d", true, true, 40),
|
|
||||||
test_creation_type("square-hole", false, true, 50),
|
|
||||||
test_creation_type("visual-novel", false, false, 60),
|
|
||||||
test_creation_type("airp", true, false, 70),
|
|
||||||
test_creation_type("creative-agent", false, true, 80),
|
|
||||||
],
|
|
||||||
updated_at_micros: 0,
|
updated_at_micros: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn test_creation_type(
|
|
||||||
id: &str,
|
|
||||||
visible: bool,
|
|
||||||
open: bool,
|
|
||||||
sort_order: i32,
|
|
||||||
) -> module_runtime::CreationEntryTypeSnapshot {
|
|
||||||
module_runtime::CreationEntryTypeSnapshot {
|
|
||||||
id: id.to_string(),
|
|
||||||
title: id.to_string(),
|
|
||||||
subtitle: "测试入口".to_string(),
|
|
||||||
badge: "测试".to_string(),
|
|
||||||
image_src: format!("/creation-type-references/{id}.webp"),
|
|
||||||
visible,
|
|
||||||
open,
|
|
||||||
sort_order,
|
|
||||||
updated_at_micros: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -172,6 +152,33 @@ mod tests {
|
|||||||
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
||||||
Some("visual-novel"),
|
Some("visual-novel"),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
|
||||||
|
Some("bark-battle"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_creation_entry_route_id("/api/creation/edutainment/baby-object-match/assets"),
|
||||||
|
Some("baby-object-match"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_creation_entry_route_id("/api/creation/edutainment/baby-love-drawing/magic"),
|
||||||
|
Some("baby-love-drawing"),
|
||||||
|
);
|
||||||
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
|
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
|
||||||
|
let config = test_creation_entry_config_response();
|
||||||
|
let baby_object_match = config
|
||||||
|
.creation_types
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.id == "baby-object-match")
|
||||||
|
.expect("test creation entry config should include baby-object-match");
|
||||||
|
|
||||||
|
assert_eq!(baby_object_match.title, "宝贝识物");
|
||||||
|
assert!(baby_object_match.visible);
|
||||||
|
assert!(baby_object_match.open);
|
||||||
|
assert_eq!(baby_object_match.sort_order, 90);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
337
server-rs/crates/api-server/src/edutainment_baby_drawing.rs
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Extension, State, rejection::JsonRejection},
|
||||||
|
http::StatusCode,
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
|
use image::{ColorType, ImageEncoder, codecs::png::PngEncoder};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api_response::json_success_body,
|
||||||
|
http_error::AppError,
|
||||||
|
openai_image_generation::{
|
||||||
|
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
|
||||||
|
require_openai_image_settings,
|
||||||
|
},
|
||||||
|
request_context::RequestContext,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BABY_LOVE_DRAWING_PROVIDER: &str = "vector-engine-gpt-image-2";
|
||||||
|
const BABY_LOVE_DRAWING_IMAGE_SIZE: &str = "1024x1024";
|
||||||
|
const BABY_LOVE_DRAWING_MAX_STROKES: usize = 600;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct CreateBabyLoveDrawingMagicRequest {
|
||||||
|
original_image_src: String,
|
||||||
|
#[serde(default)]
|
||||||
|
stroke_trace: Vec<BabyLoveDrawingStrokePayload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BabyLoveDrawingStrokePayload {
|
||||||
|
stroke_id: String,
|
||||||
|
tool: String,
|
||||||
|
color: String,
|
||||||
|
#[serde(default)]
|
||||||
|
points: Vec<BabyLoveDrawingPointPayload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BabyLoveDrawingPointPayload {
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
t: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct CreateBabyLoveDrawingMagicResponse {
|
||||||
|
magic_image_src: String,
|
||||||
|
generation_provider: String,
|
||||||
|
prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_baby_love_drawing_magic(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
payload: Result<Json<CreateBabyLoveDrawingMagicRequest>, JsonRejection>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
let Json(payload) = payload.map_err(|error| {
|
||||||
|
baby_love_drawing_error_response(
|
||||||
|
&request_context,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "edutainment-baby-drawing",
|
||||||
|
"message": error.body_text(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
validate_magic_request(&payload)
|
||||||
|
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
|
||||||
|
|
||||||
|
let settings = require_openai_image_settings(&state)
|
||||||
|
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
|
||||||
|
let http_client = build_openai_image_http_client(&settings)
|
||||||
|
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
|
||||||
|
let prompt = build_baby_love_drawing_magic_prompt(payload.stroke_trace.as_slice());
|
||||||
|
let reference_images = vec![payload.original_image_src.trim().to_string()];
|
||||||
|
let generated = create_openai_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
prompt.as_str(),
|
||||||
|
Some(build_baby_love_drawing_negative_prompt()),
|
||||||
|
BABY_LOVE_DRAWING_IMAGE_SIZE,
|
||||||
|
1,
|
||||||
|
reference_images.as_slice(),
|
||||||
|
"宝贝爱画绘画魔法图片生成失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
|
||||||
|
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
|
||||||
|
baby_love_drawing_error_response(
|
||||||
|
&request_context,
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine",
|
||||||
|
"message": "宝贝爱画绘画魔法没有返回图片。",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let magic_image_src = build_png_data_url(generated_image)
|
||||||
|
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
|
||||||
|
|
||||||
|
Ok(json_success_body(
|
||||||
|
Some(&request_context),
|
||||||
|
CreateBabyLoveDrawingMagicResponse {
|
||||||
|
magic_image_src,
|
||||||
|
generation_provider: BABY_LOVE_DRAWING_PROVIDER.to_string(),
|
||||||
|
prompt,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_magic_request(payload: &CreateBabyLoveDrawingMagicRequest) -> Result<(), AppError> {
|
||||||
|
let original_image_src = payload.original_image_src.trim();
|
||||||
|
if !original_image_src.starts_with("data:image/") || !original_image_src.contains(";base64,") {
|
||||||
|
return Err(
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "edutainment-baby-drawing",
|
||||||
|
"message": "绘画原图必须是图片 Data URL。",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.stroke_trace.len() > BABY_LOVE_DRAWING_MAX_STROKES {
|
||||||
|
return Err(
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "edutainment-baby-drawing",
|
||||||
|
"message": "绘画笔触数量过多,请重新完成绘画后再使用魔法。",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_baby_love_drawing_magic_prompt(stroke_trace: &[BabyLoveDrawingStrokePayload]) -> String {
|
||||||
|
let stroke_count = stroke_trace.len();
|
||||||
|
let brush_count = stroke_trace
|
||||||
|
.iter()
|
||||||
|
.filter(|stroke| stroke.tool.trim() == "brush")
|
||||||
|
.count();
|
||||||
|
let eraser_count = stroke_trace
|
||||||
|
.iter()
|
||||||
|
.filter(|stroke| stroke.tool.trim() == "eraser")
|
||||||
|
.count();
|
||||||
|
let color_summary = summarize_stroke_colors(stroke_trace);
|
||||||
|
let trace_bounds = summarize_trace_bounds(stroke_trace);
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"根据参考图中的儿童绘画内容,为寓教于乐独立关卡“宝贝爱画”生成一张绘本风格图片。\n\
|
||||||
|
必须保留小朋友原始画面的主体构图、线条方向、颜色关系和童趣笔触,不要改成与原图无关的新内容。\n\
|
||||||
|
输出风格:明亮、温暖、柔和、卡通绘本风格,适合 4-8 岁儿童,画面干净,边缘柔和,有轻微纸面质感。\n\
|
||||||
|
笔触信息:总笔触 {stroke_count} 条,画笔 {brush_count} 条,橡皮 {eraser_count} 条,主要颜色 {color_summary},绘制范围 {trace_bounds}。\n\
|
||||||
|
不要生成文字、水印、Logo、按钮、UI 面板、真实照片风、恐怖或成人化内容。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_stroke_colors(stroke_trace: &[BabyLoveDrawingStrokePayload]) -> String {
|
||||||
|
let mut colors = Vec::new();
|
||||||
|
for stroke in stroke_trace {
|
||||||
|
if stroke.stroke_id.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let color = stroke.color.trim();
|
||||||
|
if color.is_empty() || colors.iter().any(|value| value == color) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
colors.push(color.to_string());
|
||||||
|
if colors.len() >= 5 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if colors.is_empty() {
|
||||||
|
"无明显颜色记录".to_string()
|
||||||
|
} else {
|
||||||
|
colors.join("、")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_trace_bounds(stroke_trace: &[BabyLoveDrawingStrokePayload]) -> String {
|
||||||
|
let mut min_x = 1.0_f64;
|
||||||
|
let mut min_y = 1.0_f64;
|
||||||
|
let mut max_x = 0.0_f64;
|
||||||
|
let mut max_y = 0.0_f64;
|
||||||
|
let mut has_point = false;
|
||||||
|
|
||||||
|
for point in stroke_trace.iter().flat_map(|stroke| stroke.points.iter()) {
|
||||||
|
if !(point.x.is_finite() && point.y.is_finite() && point.t.is_finite()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
has_point = true;
|
||||||
|
min_x = min_x.min(point.x.clamp(0.0, 1.0));
|
||||||
|
min_y = min_y.min(point.y.clamp(0.0, 1.0));
|
||||||
|
max_x = max_x.max(point.x.clamp(0.0, 1.0));
|
||||||
|
max_y = max_y.max(point.y.clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has_point {
|
||||||
|
return "无可用坐标记录".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("x {:.2}-{:.2}, y {:.2}-{:.2}", min_x, max_x, min_y, max_y)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_baby_love_drawing_negative_prompt() -> &'static str {
|
||||||
|
"文字,水印,Logo,按钮,UI,面板,复杂背景,真实照片风,恐怖元素,成人化内容,攻击性内容,替换原图主体,完全无关的新画面"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
|
||||||
|
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
|
||||||
|
Ok(format!(
|
||||||
|
"data:image/png;base64,{}",
|
||||||
|
BASE64_STANDARD.encode(png_bytes)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_generated_image_to_png(source: &[u8]) -> Result<Vec<u8>, AppError> {
|
||||||
|
let rgba_image = image::load_from_memory(source)
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine",
|
||||||
|
"message": format!("解析宝贝爱画魔法图片失败:{error}"),
|
||||||
|
}))
|
||||||
|
})?
|
||||||
|
.to_rgba8();
|
||||||
|
let (width, height) = rgba_image.dimensions();
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
let encoder = PngEncoder::new(&mut encoded);
|
||||||
|
encoder
|
||||||
|
.write_image(rgba_image.as_raw(), width, height, ColorType::Rgba8.into())
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine",
|
||||||
|
"message": format!("转换宝贝爱画魔法图片为 PNG 失败:{error}"),
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn baby_love_drawing_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||||
|
error.into_response_with_context(Some(request_context))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_request() -> CreateBabyLoveDrawingMagicRequest {
|
||||||
|
CreateBabyLoveDrawingMagicRequest {
|
||||||
|
original_image_src: "data:image/png;base64,abcd".to_string(),
|
||||||
|
stroke_trace: vec![BabyLoveDrawingStrokePayload {
|
||||||
|
stroke_id: "stroke-1".to_string(),
|
||||||
|
tool: "brush".to_string(),
|
||||||
|
color: "#ef4444".to_string(),
|
||||||
|
points: vec![
|
||||||
|
BabyLoveDrawingPointPayload {
|
||||||
|
x: 0.2,
|
||||||
|
y: 0.3,
|
||||||
|
t: 1.0,
|
||||||
|
},
|
||||||
|
BabyLoveDrawingPointPayload {
|
||||||
|
x: 0.7,
|
||||||
|
y: 0.8,
|
||||||
|
t: 2.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magic_prompt_keeps_child_drawing_and_picture_book_style() {
|
||||||
|
let request = sample_request();
|
||||||
|
let prompt = build_baby_love_drawing_magic_prompt(request.stroke_trace.as_slice());
|
||||||
|
|
||||||
|
assert!(prompt.contains("宝贝爱画"));
|
||||||
|
assert!(prompt.contains("绘本风格"));
|
||||||
|
assert!(prompt.contains("保留小朋友原始画面"));
|
||||||
|
assert!(prompt.contains("#ef4444"));
|
||||||
|
assert!(prompt.contains("x 0.20-0.70"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magic_request_requires_image_data_url() {
|
||||||
|
let request = sample_request();
|
||||||
|
assert!(validate_magic_request(&request).is_ok());
|
||||||
|
|
||||||
|
let invalid = CreateBabyLoveDrawingMagicRequest {
|
||||||
|
original_image_src: "https://example.test/image.png".to_string(),
|
||||||
|
..sample_request()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(validate_magic_request(&invalid).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_png_to_png_data_url() {
|
||||||
|
let mut source = Vec::new();
|
||||||
|
let pixels = vec![255u8; 4 * 2 * 2];
|
||||||
|
let encoder = PngEncoder::new(&mut source);
|
||||||
|
encoder
|
||||||
|
.write_image(pixels.as_slice(), 2, 2, ColorType::Rgba8.into())
|
||||||
|
.expect("test png should encode");
|
||||||
|
|
||||||
|
let image_src = build_png_data_url(DownloadedOpenAiImage {
|
||||||
|
bytes: source,
|
||||||
|
mime_type: "image/png".to_string(),
|
||||||
|
extension: "png".to_string(),
|
||||||
|
})
|
||||||
|
.expect("test png should normalize");
|
||||||
|
|
||||||
|
assert!(image_src.starts_with("data:image/png;base64,"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trace_summary_ignores_invalid_points() {
|
||||||
|
let mut request = sample_request();
|
||||||
|
request.stroke_trace[0]
|
||||||
|
.points
|
||||||
|
.push(BabyLoveDrawingPointPayload {
|
||||||
|
x: f64::NAN,
|
||||||
|
y: 0.1,
|
||||||
|
t: 3.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
summarize_trace_bounds(request.stroke_trace.as_slice()),
|
||||||
|
"x 0.20-0.70, y 0.30-0.80",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
642
server-rs/crates/api-server/src/edutainment_baby_object.rs
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Extension, State, rejection::JsonRejection},
|
||||||
|
http::StatusCode,
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
|
use futures_util::{StreamExt, stream::FuturesUnordered};
|
||||||
|
use image::{ColorType, ImageEncoder, codecs::png::PngEncoder};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api_response::json_success_body,
|
||||||
|
character_visual_assets::try_apply_background_alpha_to_png,
|
||||||
|
http_error::AppError,
|
||||||
|
openai_image_generation::{
|
||||||
|
DownloadedOpenAiImage, OpenAiImageSettings, build_openai_image_http_client,
|
||||||
|
create_openai_image_generation, require_openai_image_settings,
|
||||||
|
},
|
||||||
|
request_context::RequestContext,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BABY_OBJECT_MATCH_PROVIDER: &str = "vector-engine-gpt-image-2";
|
||||||
|
const BABY_OBJECT_MATCH_IMAGE_SIZE: &str = "1024x1024";
|
||||||
|
const BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE: &str = "1536x1024";
|
||||||
|
const BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS: u64 = 480_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct GenerateBabyObjectMatchAssetsRequest {
|
||||||
|
item_names: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct GenerateBabyObjectMatchAssetsResponse {
|
||||||
|
assets: Vec<BabyObjectMatchItemAssetPayload>,
|
||||||
|
visual_package: BabyObjectMatchVisualPackagePayload,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BabyObjectMatchItemAssetPayload {
|
||||||
|
item_id: String,
|
||||||
|
item_name: String,
|
||||||
|
image_src: String,
|
||||||
|
asset_object_id: Option<String>,
|
||||||
|
generation_provider: String,
|
||||||
|
prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum BabyObjectMatchVisualAssetKind {
|
||||||
|
Background,
|
||||||
|
UiFrame,
|
||||||
|
GiftBox,
|
||||||
|
Basket,
|
||||||
|
SmokePuff,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BabyObjectMatchVisualAssetKind {
|
||||||
|
fn asset_id(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Background => "baby-object-visual-background",
|
||||||
|
Self::UiFrame => "baby-object-visual-ui-frame",
|
||||||
|
Self::GiftBox => "baby-object-visual-gift-box",
|
||||||
|
Self::Basket => "baby-object-visual-basket",
|
||||||
|
Self::SmokePuff => "baby-object-visual-smoke-puff",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contract_kind(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Background => "background",
|
||||||
|
Self::UiFrame => "ui-frame",
|
||||||
|
Self::GiftBox => "gift-box",
|
||||||
|
Self::Basket => "basket",
|
||||||
|
Self::SmokePuff => "smoke-puff",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_transparency(self) -> bool {
|
||||||
|
!matches!(self, Self::Background)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_size(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Background => BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE,
|
||||||
|
Self::UiFrame | Self::GiftBox | Self::Basket | Self::SmokePuff => {
|
||||||
|
BABY_OBJECT_MATCH_IMAGE_SIZE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure_context(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Background => "宝贝识物背景环境图片生成失败",
|
||||||
|
Self::UiFrame => "宝贝识物 UI 装饰图片生成失败",
|
||||||
|
Self::GiftBox => "宝贝识物礼物盒图片生成失败",
|
||||||
|
Self::Basket => "宝贝识物篮子图片生成失败",
|
||||||
|
Self::SmokePuff => "宝贝识物烟雾特效图片生成失败",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BabyObjectMatchVisualPackagePayload {
|
||||||
|
theme_prompt: String,
|
||||||
|
assets: Vec<BabyObjectMatchVisualAssetPayload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BabyObjectMatchVisualAssetPayload {
|
||||||
|
asset_id: String,
|
||||||
|
asset_kind: String,
|
||||||
|
image_src: String,
|
||||||
|
asset_object_id: Option<String>,
|
||||||
|
generation_provider: String,
|
||||||
|
prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_baby_object_match_assets(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
payload: Result<Json<GenerateBabyObjectMatchAssetsRequest>, JsonRejection>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
let Json(payload) = payload.map_err(|error| {
|
||||||
|
baby_object_match_error_response(
|
||||||
|
&request_context,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "edutainment-baby-object",
|
||||||
|
"message": error.body_text(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let item_names = normalize_item_names(payload.item_names)
|
||||||
|
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
|
||||||
|
|
||||||
|
let settings = require_openai_image_settings(&state)
|
||||||
|
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
|
||||||
|
let settings = with_baby_object_match_image_timeout(settings);
|
||||||
|
let http_client = build_openai_image_http_client(&settings)
|
||||||
|
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
|
||||||
|
|
||||||
|
let request_started_at = Instant::now();
|
||||||
|
tracing::info!(
|
||||||
|
item_count = item_names.len(),
|
||||||
|
"宝贝识物 image-2 资源生成开始"
|
||||||
|
);
|
||||||
|
let (assets, visual_package) = tokio::try_join!(
|
||||||
|
build_baby_object_match_item_assets(&http_client, &settings, item_names.as_slice()),
|
||||||
|
build_baby_object_match_visual_package(&http_client, &settings, item_names.as_slice()),
|
||||||
|
)
|
||||||
|
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
|
||||||
|
tracing::info!(
|
||||||
|
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
|
||||||
|
"宝贝识物 image-2 资源生成完成"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(json_success_body(
|
||||||
|
Some(&request_context),
|
||||||
|
GenerateBabyObjectMatchAssetsResponse {
|
||||||
|
assets,
|
||||||
|
visual_package,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_item_names(item_names: Vec<String>) -> Result<Vec<String>, AppError> {
|
||||||
|
let normalized = item_names
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if normalized.len() != 2 || normalized.iter().any(|value| value.is_empty()) {
|
||||||
|
return Err(
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "edutainment-baby-object",
|
||||||
|
"message": "请填写两个物品名称。",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_baby_object_match_item_prompt(item_name: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"为儿童动作 Demo 玩法“宝贝识物”生成物品素材。关键词:{item_name}。\n\
|
||||||
|
风格必须与寓教于乐板块统一:明亮、温暖、卡通绘本质感,适合 4-8 岁儿童,物体边缘清晰,色彩干净,能自然放在草地舞台插画中。\n\
|
||||||
|
画面只允许出现一个围绕关键词“{item_name}”的单一物品主体,不要生成组合物、多个物体、人物、手、篮子、礼物盒或玩法 UI。\n\
|
||||||
|
不要生成背景、场景、氛围渲染、阴影地面、文字、水印、边框或按钮。背景必须是纯白或直接透明,便于服务端做透明抠图。\n\
|
||||||
|
输出为居中完整物品,留少量透明安全边距,最终素材将作为透明 PNG 进入游戏。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_baby_object_match_negative_prompt() -> &'static str {
|
||||||
|
"背景,场景,草地,天空,房间,光效氛围,多个物品,组合套装,人物,手,篮子,礼物盒,包装文字,标签文字,水印,Logo,UI,按钮,边框,真实照片风,复杂投影"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_baby_object_match_image_timeout(mut settings: OpenAiImageSettings) -> OpenAiImageSettings {
|
||||||
|
settings.request_timeout_ms = settings
|
||||||
|
.request_timeout_ms
|
||||||
|
.max(BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS);
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_baby_object_match_item_assets(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
settings: &OpenAiImageSettings,
|
||||||
|
item_names: &[String],
|
||||||
|
) -> Result<Vec<BabyObjectMatchItemAssetPayload>, AppError> {
|
||||||
|
let mut pending = FuturesUnordered::new();
|
||||||
|
|
||||||
|
// 中文注释:两个物品图互不依赖,并发生成可缩短创作等待时间。
|
||||||
|
for (index, item_name) in item_names.iter().cloned().enumerate() {
|
||||||
|
let prompt = build_baby_object_match_item_prompt(item_name.as_str());
|
||||||
|
pending.push(async move {
|
||||||
|
let asset_started_at = Instant::now();
|
||||||
|
tracing::info!(
|
||||||
|
asset_kind = "item",
|
||||||
|
item_index = index + 1,
|
||||||
|
item_name = %item_name,
|
||||||
|
"宝贝识物 image-2 物品资源生成开始"
|
||||||
|
);
|
||||||
|
let generated = create_openai_image_generation(
|
||||||
|
http_client,
|
||||||
|
settings,
|
||||||
|
prompt.as_str(),
|
||||||
|
Some(build_baby_object_match_negative_prompt()),
|
||||||
|
BABY_OBJECT_MATCH_IMAGE_SIZE,
|
||||||
|
1,
|
||||||
|
&[],
|
||||||
|
"宝贝识物物品图片生成失败",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine",
|
||||||
|
"message": "宝贝识物物品图片生成没有返回图片。",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let image_src = build_transparent_png_data_url(generated_image)?;
|
||||||
|
tracing::info!(
|
||||||
|
asset_kind = "item",
|
||||||
|
item_index = index + 1,
|
||||||
|
item_name = %item_name,
|
||||||
|
elapsed_ms = asset_started_at.elapsed().as_millis() as u64,
|
||||||
|
"宝贝识物 image-2 物品资源生成完成"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok::<_, AppError>(BabyObjectMatchItemAssetPayload {
|
||||||
|
item_id: format!("baby-object-item-{}", index + 1),
|
||||||
|
item_name,
|
||||||
|
image_src,
|
||||||
|
asset_object_id: None,
|
||||||
|
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
|
||||||
|
prompt,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut assets = Vec::with_capacity(item_names.len());
|
||||||
|
while let Some(result) = pending.next().await {
|
||||||
|
assets.push(result?);
|
||||||
|
}
|
||||||
|
assets.sort_by_key(|asset| asset.item_id.clone());
|
||||||
|
|
||||||
|
Ok(assets)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_baby_object_match_visual_package(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
settings: &OpenAiImageSettings,
|
||||||
|
item_names: &[String],
|
||||||
|
) -> Result<BabyObjectMatchVisualPackagePayload, AppError> {
|
||||||
|
let package_started_at = Instant::now();
|
||||||
|
let theme_prompt = build_baby_object_match_visual_theme_prompt(item_names);
|
||||||
|
let kinds = [
|
||||||
|
BabyObjectMatchVisualAssetKind::Background,
|
||||||
|
BabyObjectMatchVisualAssetKind::UiFrame,
|
||||||
|
BabyObjectMatchVisualAssetKind::GiftBox,
|
||||||
|
BabyObjectMatchVisualAssetKind::Basket,
|
||||||
|
BabyObjectMatchVisualAssetKind::SmokePuff,
|
||||||
|
];
|
||||||
|
let mut pending = FuturesUnordered::new();
|
||||||
|
tracing::info!(
|
||||||
|
asset_count = kinds.len(),
|
||||||
|
"宝贝识物 image-2 视觉主题包生成开始"
|
||||||
|
);
|
||||||
|
|
||||||
|
for kind in kinds.iter().copied() {
|
||||||
|
let prompt = build_baby_object_match_visual_asset_prompt(kind, item_names, &theme_prompt);
|
||||||
|
pending.push(async move {
|
||||||
|
let asset_started_at = Instant::now();
|
||||||
|
let asset_kind = kind.contract_kind();
|
||||||
|
tracing::info!(asset_kind, "宝贝识物 image-2 视觉资源生成开始");
|
||||||
|
let generated = create_openai_image_generation(
|
||||||
|
http_client,
|
||||||
|
settings,
|
||||||
|
prompt.as_str(),
|
||||||
|
Some(build_baby_object_match_visual_negative_prompt(kind)),
|
||||||
|
kind.image_size(),
|
||||||
|
1,
|
||||||
|
&[],
|
||||||
|
kind.failure_context(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine",
|
||||||
|
"message": format!("{}:VectorEngine 没有返回图片。", kind.failure_context()),
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let image_src = if kind.requires_transparency() {
|
||||||
|
build_transparent_png_data_url(generated_image)?
|
||||||
|
} else {
|
||||||
|
build_png_data_url(generated_image)?
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
asset_kind,
|
||||||
|
elapsed_ms = asset_started_at.elapsed().as_millis() as u64,
|
||||||
|
"宝贝识物 image-2 视觉资源生成完成"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok::<_, AppError>(BabyObjectMatchVisualAssetPayload {
|
||||||
|
asset_id: kind.asset_id().to_string(),
|
||||||
|
asset_kind: asset_kind.to_string(),
|
||||||
|
image_src,
|
||||||
|
asset_object_id: None,
|
||||||
|
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
|
||||||
|
prompt,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut assets = Vec::with_capacity(kinds.len());
|
||||||
|
while let Some(result) = pending.next().await {
|
||||||
|
assets.push(result?);
|
||||||
|
}
|
||||||
|
assets.sort_by_key(|asset| match asset.asset_kind.as_str() {
|
||||||
|
"background" => 0,
|
||||||
|
"ui-frame" => 1,
|
||||||
|
"gift-box" => 2,
|
||||||
|
"basket" => 3,
|
||||||
|
"smoke-puff" => 4,
|
||||||
|
_ => 5,
|
||||||
|
});
|
||||||
|
tracing::info!(
|
||||||
|
elapsed_ms = package_started_at.elapsed().as_millis() as u64,
|
||||||
|
"宝贝识物 image-2 视觉主题包生成完成"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(BabyObjectMatchVisualPackagePayload {
|
||||||
|
theme_prompt,
|
||||||
|
assets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_baby_object_match_visual_theme_prompt(item_names: &[String]) -> String {
|
||||||
|
let item_a = item_names.first().map(String::as_str).unwrap_or_default();
|
||||||
|
let item_b = item_names.get(1).map(String::as_str).unwrap_or_default();
|
||||||
|
let theme_hint = resolve_baby_object_match_theme_hint(item_names);
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"根据创作者填写的两个物品关键词“{item_a}”和“{item_b}”,为儿童动作 Demo 玩法“宝贝识物”生成一套完整游戏视觉包装。\n\
|
||||||
|
视觉必须保持寓教于乐板块统一的明亮、温暖、卡通绘本插画风,适合 4-8 岁儿童。\n\
|
||||||
|
主题匹配:{theme_hint}\n\
|
||||||
|
所有资源需要围绕这两个关键词形成统一主题,但不能改变物品识别和左右篮子固定规则。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_baby_object_match_theme_hint(item_names: &[String]) -> &'static str {
|
||||||
|
let joined = item_names.join(" ").to_lowercase();
|
||||||
|
let fruit_keywords = [
|
||||||
|
"苹果",
|
||||||
|
"橘子",
|
||||||
|
"桔子",
|
||||||
|
"香蕉",
|
||||||
|
"葡萄",
|
||||||
|
"草莓",
|
||||||
|
"西瓜",
|
||||||
|
"梨",
|
||||||
|
"桃",
|
||||||
|
"水果",
|
||||||
|
"apple",
|
||||||
|
"orange",
|
||||||
|
"banana",
|
||||||
|
"grape",
|
||||||
|
"strawberry",
|
||||||
|
"watermelon",
|
||||||
|
"fruit",
|
||||||
|
];
|
||||||
|
let character_keywords = [
|
||||||
|
"佩琪",
|
||||||
|
"小猪佩奇",
|
||||||
|
"小猪佩琪",
|
||||||
|
"奥特曼",
|
||||||
|
"动漫",
|
||||||
|
"动画",
|
||||||
|
"卡通",
|
||||||
|
"玩具",
|
||||||
|
"角色",
|
||||||
|
"公仔",
|
||||||
|
"peppa",
|
||||||
|
"ultraman",
|
||||||
|
"anime",
|
||||||
|
"cartoon",
|
||||||
|
"toy",
|
||||||
|
"doll",
|
||||||
|
"figure",
|
||||||
|
];
|
||||||
|
|
||||||
|
if fruit_keywords
|
||||||
|
.iter()
|
||||||
|
.any(|keyword| joined.contains(keyword))
|
||||||
|
{
|
||||||
|
return "若关键词属于水果,背景环境和 UI 元素匹配果园、自然、阳光、树叶等主题。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if character_keywords
|
||||||
|
.iter()
|
||||||
|
.any(|keyword| joined.contains(keyword))
|
||||||
|
{
|
||||||
|
return "若关键词属于动漫角色、玩具或公仔,背景环境和 UI 元素匹配动漫、玩具房、儿童玩具等主题。";
|
||||||
|
}
|
||||||
|
|
||||||
|
"根据关键词语义自然匹配合适主题,保持儿童寓教于乐插画风。"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_baby_object_match_visual_asset_prompt(
|
||||||
|
kind: BabyObjectMatchVisualAssetKind,
|
||||||
|
item_names: &[String],
|
||||||
|
theme_prompt: &str,
|
||||||
|
) -> String {
|
||||||
|
let item_a = item_names.first().map(String::as_str).unwrap_or_default();
|
||||||
|
let item_b = item_names.get(1).map(String::as_str).unwrap_or_default();
|
||||||
|
let base = format!(
|
||||||
|
"{theme_prompt}\n\
|
||||||
|
当前两个关键词:{item_a}、{item_b}。\n\
|
||||||
|
输出必须是无文字、无水印、无 Logo 的游戏美术资源。"
|
||||||
|
);
|
||||||
|
|
||||||
|
match kind {
|
||||||
|
BabyObjectMatchVisualAssetKind::Background => format!(
|
||||||
|
"{base}\n\
|
||||||
|
生成游戏背景环境图。背景需要根据关键词主题匹配环境,例如水果可偏果园自然,动漫角色或玩具可偏动漫玩具主题。\n\
|
||||||
|
保持中间、屏幕中下方和底部左右篮子区域清爽,给放大后的礼物盒、中央物品和左右大篮子预留足够空间,不能画入礼物盒、篮子、物品、人物、文字或操作 UI。"
|
||||||
|
),
|
||||||
|
BabyObjectMatchVisualAssetKind::UiFrame => format!(
|
||||||
|
"{base}\n\
|
||||||
|
生成透明 PNG 的 UI 装饰框资源,用于字幕条和计数器的风格化包装。\n\
|
||||||
|
只生成柔和装饰边框、贴纸感边缘和少量主题点缀,不生成任何文字、数字、按钮、图标说明或大面积背景。背景需要纯白或透明友好,便于抠图。"
|
||||||
|
),
|
||||||
|
BabyObjectMatchVisualAssetKind::GiftBox => format!(
|
||||||
|
"{base}\n\
|
||||||
|
生成透明 PNG 的大号礼物盒资源。礼物盒会在游戏中以约 2 倍视觉尺寸展示,需要主体饱满、轮廓清晰、中心构图、边缘安全留白少,打开动画时可被烟雾遮罩后移除。\n\
|
||||||
|
礼物盒要与关键词主题匹配,可以带主题贴纸感装饰,但不能出现任何文字、人物、手、篮子或待分类物品。背景需要纯白或透明友好,便于抠图。"
|
||||||
|
),
|
||||||
|
BabyObjectMatchVisualAssetKind::Basket => format!(
|
||||||
|
"{base}\n\
|
||||||
|
生成透明 PNG 的大号篮子资源,游戏左右两侧会复用同一个篮子造型并以约 1.5 倍视觉尺寸展示。篮子主体要饱满、开口清晰、可读性高、边缘安全留白少。\n\
|
||||||
|
篮子要与关键词主题匹配,可以有主题色和贴纸感边缘,但不能出现任何文字、礼物盒、人物、手或待分类物品。背景需要纯白或透明友好,便于抠图。"
|
||||||
|
),
|
||||||
|
BabyObjectMatchVisualAssetKind::SmokePuff => format!(
|
||||||
|
"{base}\n\
|
||||||
|
生成透明 PNG 的烟雾弹出特效资源,用于礼物盒打开瞬间。画面只允许出现一团柔和、圆润、儿童绘本风的云朵烟雾和少量主题色星点,不要生成礼物盒、篮子、物品、人物、手、文字或 UI。\n\
|
||||||
|
烟雾需要中心构图、边缘柔和、透明边界干净,适合覆盖礼物盒打开区域并衬托中央物品弹出。背景需要纯白或透明友好,便于抠图。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_baby_object_match_visual_negative_prompt(
|
||||||
|
kind: BabyObjectMatchVisualAssetKind,
|
||||||
|
) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
BabyObjectMatchVisualAssetKind::Background => {
|
||||||
|
"文字,数字,水印,Logo,按钮,说明面板,人物,手,礼物盒,篮子,中心物品,复杂前景遮挡,真实照片风,暗黑风"
|
||||||
|
}
|
||||||
|
BabyObjectMatchVisualAssetKind::UiFrame => {
|
||||||
|
"文字,数字,水印,Logo,按钮,复杂面板,大面积实心背景,人物,手,礼物盒,篮子,物品主体,真实照片风"
|
||||||
|
}
|
||||||
|
BabyObjectMatchVisualAssetKind::GiftBox => {
|
||||||
|
"文字,数字,水印,Logo,人物,手,篮子,待分类物品,大面积背景,场景,真实照片风"
|
||||||
|
}
|
||||||
|
BabyObjectMatchVisualAssetKind::Basket => {
|
||||||
|
"文字,数字,水印,Logo,人物,手,礼物盒,待分类物品,大面积背景,场景,真实照片风"
|
||||||
|
}
|
||||||
|
BabyObjectMatchVisualAssetKind::SmokePuff => {
|
||||||
|
"文字,数字,水印,Logo,人物,手,礼物盒,篮子,待分类物品,大面积背景,场景,真实照片风,硬边爆炸,火焰"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_transparent_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
|
||||||
|
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
|
||||||
|
let transparent_png_bytes =
|
||||||
|
try_apply_background_alpha_to_png(png_bytes.as_slice()).unwrap_or(png_bytes);
|
||||||
|
Ok(format!(
|
||||||
|
"data:image/png;base64,{}",
|
||||||
|
BASE64_STANDARD.encode(transparent_png_bytes)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
|
||||||
|
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
|
||||||
|
Ok(format!(
|
||||||
|
"data:image/png;base64,{}",
|
||||||
|
BASE64_STANDARD.encode(png_bytes)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_generated_image_to_png(source: &[u8]) -> Result<Vec<u8>, AppError> {
|
||||||
|
let rgba_image = image::load_from_memory(source)
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine",
|
||||||
|
"message": format!("解析宝贝识物物品图片失败:{error}"),
|
||||||
|
}))
|
||||||
|
})?
|
||||||
|
.to_rgba8();
|
||||||
|
let (width, height) = rgba_image.dimensions();
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
let encoder = PngEncoder::new(&mut encoded);
|
||||||
|
encoder
|
||||||
|
.write_image(rgba_image.as_raw(), width, height, ColorType::Rgba8.into())
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine",
|
||||||
|
"message": format!("转换宝贝识物物品图片为 PNG 失败:{error}"),
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn baby_object_match_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||||
|
error.into_response_with_context(Some(request_context))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_locks_single_transparent_object_constraints() {
|
||||||
|
let prompt = build_baby_object_match_item_prompt("苹果");
|
||||||
|
|
||||||
|
assert!(prompt.contains("苹果"));
|
||||||
|
assert!(prompt.contains("卡通绘本"));
|
||||||
|
assert!(prompt.contains("单一物品"));
|
||||||
|
assert!(prompt.contains("不要生成背景"));
|
||||||
|
assert!(prompt.contains("透明 PNG"));
|
||||||
|
assert!(prompt.contains("纯白或直接透明"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_theme_prompt_maps_fruit_keywords_to_nature_theme() {
|
||||||
|
let names = vec!["苹果".to_string(), "橘子".to_string()];
|
||||||
|
let prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
|
||||||
|
|
||||||
|
assert!(prompt.contains("寓教于乐"));
|
||||||
|
assert!(prompt.contains("卡通绘本"));
|
||||||
|
assert!(prompt.contains("果园"));
|
||||||
|
assert!(prompt.contains("自然"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_theme_prompt_maps_character_keywords_to_toy_theme() {
|
||||||
|
let names = vec!["小猪佩琪".to_string(), "奥特曼".to_string()];
|
||||||
|
let prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
|
||||||
|
|
||||||
|
assert!(prompt.contains("寓教于乐"));
|
||||||
|
assert!(prompt.contains("动漫"));
|
||||||
|
assert!(prompt.contains("玩具"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_asset_prompt_keeps_background_clear_for_playfield() {
|
||||||
|
let names = vec!["苹果".to_string(), "香蕉".to_string()];
|
||||||
|
let theme_prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
|
||||||
|
let prompt = build_baby_object_match_visual_asset_prompt(
|
||||||
|
BabyObjectMatchVisualAssetKind::Background,
|
||||||
|
names.as_slice(),
|
||||||
|
theme_prompt.as_str(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(prompt.contains("背景环境图"));
|
||||||
|
assert!(prompt.contains("中间"));
|
||||||
|
assert!(prompt.contains("屏幕中下方"));
|
||||||
|
assert!(prompt.contains("无文字"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_item_names_requires_two_non_empty_names() {
|
||||||
|
let names = normalize_item_names(vec![" 苹果 ".to_string(), "香蕉".to_string()])
|
||||||
|
.expect("two names should be valid");
|
||||||
|
|
||||||
|
assert_eq!(names, vec!["苹果".to_string(), "香蕉".to_string()]);
|
||||||
|
assert!(normalize_item_names(vec!["苹果".to_string()]).is_err());
|
||||||
|
assert!(normalize_item_names(vec!["苹果".to_string(), " ".to_string()]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn baby_object_match_image_timeout_keeps_long_generation_alive() {
|
||||||
|
let settings = with_baby_object_match_image_timeout(OpenAiImageSettings {
|
||||||
|
base_url: "https://vector.example".to_string(),
|
||||||
|
api_key: "secret".to_string(),
|
||||||
|
request_timeout_ms: 180_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
settings.request_timeout_ms,
|
||||||
|
BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_png_to_transparent_png_data_url() {
|
||||||
|
let mut source = Vec::new();
|
||||||
|
let pixels = vec![255u8; 4 * 2 * 2];
|
||||||
|
let encoder = PngEncoder::new(&mut source);
|
||||||
|
encoder
|
||||||
|
.write_image(pixels.as_slice(), 2, 2, ColorType::Rgba8.into())
|
||||||
|
.expect("test png should encode");
|
||||||
|
|
||||||
|
let image_src = build_transparent_png_data_url(DownloadedOpenAiImage {
|
||||||
|
bytes: source,
|
||||||
|
mime_type: "image/png".to_string(),
|
||||||
|
extension: "png".to_string(),
|
||||||
|
})
|
||||||
|
.expect("test png should normalize");
|
||||||
|
|
||||||
|
assert!(image_src.starts_with("data:image/png;base64,"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ mod auth_payload;
|
|||||||
mod auth_public_user;
|
mod auth_public_user;
|
||||||
mod auth_session;
|
mod auth_session;
|
||||||
mod auth_sessions;
|
mod auth_sessions;
|
||||||
|
mod bark_battle;
|
||||||
mod big_fish;
|
mod big_fish;
|
||||||
mod big_fish_agent_turn;
|
mod big_fish_agent_turn;
|
||||||
mod big_fish_draft_compiler;
|
mod big_fish_draft_compiler;
|
||||||
@@ -34,6 +35,8 @@ mod custom_world_asset_prompts;
|
|||||||
mod custom_world_foundation_draft;
|
mod custom_world_foundation_draft;
|
||||||
mod custom_world_result_prompts;
|
mod custom_world_result_prompts;
|
||||||
mod custom_world_rpg_draft_prompts;
|
mod custom_world_rpg_draft_prompts;
|
||||||
|
mod edutainment_baby_drawing;
|
||||||
|
mod edutainment_baby_object;
|
||||||
mod error_middleware;
|
mod error_middleware;
|
||||||
mod health;
|
mod health;
|
||||||
mod http_error;
|
mod http_error;
|
||||||
|
|||||||
14
server-rs/crates/module-bark-battle/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "module-bark-battle"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = { workspace = true }
|
||||||
162
server-rs/crates/module-bark-battle/src/domain.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const BARK_BATTLE_RULESET_VERSION_V1: &str = "bark-battle-ruleset-v1";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DifficultyPreset {
|
||||||
|
Easy,
|
||||||
|
Normal,
|
||||||
|
Hard,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DifficultyPreset {
|
||||||
|
pub fn ai_preset_key(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Easy => "bark-battle-ai-easy",
|
||||||
|
Self::Normal => "bark-battle-ai-normal",
|
||||||
|
Self::Hard => "bark-battle-ai-hard",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct RulesetThresholdsSignature {
|
||||||
|
pub standard_duration_ms: u64,
|
||||||
|
pub min_duration_ms: u64,
|
||||||
|
pub max_duration_ms: u64,
|
||||||
|
pub min_bark_gap_ms: u64,
|
||||||
|
pub trigger_count_tolerance: u32,
|
||||||
|
pub min_volume: f32,
|
||||||
|
pub max_volume: f32,
|
||||||
|
pub min_average_volume: f32,
|
||||||
|
pub max_average_volume: f32,
|
||||||
|
pub min_final_energy: f32,
|
||||||
|
pub max_final_energy: f32,
|
||||||
|
pub min_combo: u32,
|
||||||
|
pub max_combo: u32,
|
||||||
|
pub draw_threshold_energy: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct BarkBattleRuleset {
|
||||||
|
pub version: &'static str,
|
||||||
|
pub difficulty: DifficultyPreset,
|
||||||
|
pub ai_preset_key: &'static str,
|
||||||
|
pub standard_duration_ms: u64,
|
||||||
|
pub min_duration_ms: u64,
|
||||||
|
pub max_duration_ms: u64,
|
||||||
|
pub min_bark_gap_ms: u64,
|
||||||
|
pub trigger_count_tolerance: u32,
|
||||||
|
pub min_volume: f32,
|
||||||
|
pub max_volume: f32,
|
||||||
|
pub min_average_volume: f32,
|
||||||
|
pub max_average_volume: f32,
|
||||||
|
pub min_final_energy: f32,
|
||||||
|
pub max_final_energy: f32,
|
||||||
|
pub min_combo: u32,
|
||||||
|
pub max_combo: u32,
|
||||||
|
pub draw_threshold_energy: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BarkBattleRuleset {
|
||||||
|
pub fn v1() -> Self {
|
||||||
|
Self::for_difficulty(DifficultyPreset::Normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn for_difficulty(difficulty: DifficultyPreset) -> Self {
|
||||||
|
Self {
|
||||||
|
version: BARK_BATTLE_RULESET_VERSION_V1,
|
||||||
|
difficulty,
|
||||||
|
ai_preset_key: difficulty.ai_preset_key(),
|
||||||
|
standard_duration_ms: 30_000,
|
||||||
|
min_duration_ms: 28_000,
|
||||||
|
max_duration_ms: 35_000,
|
||||||
|
min_bark_gap_ms: 250,
|
||||||
|
trigger_count_tolerance: 2,
|
||||||
|
min_volume: 0.0,
|
||||||
|
max_volume: 1.0,
|
||||||
|
min_average_volume: 0.0,
|
||||||
|
max_average_volume: 1.0,
|
||||||
|
min_final_energy: 0.0,
|
||||||
|
max_final_energy: 100.0,
|
||||||
|
min_combo: 0,
|
||||||
|
max_combo: 999,
|
||||||
|
draw_threshold_energy: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn thresholds_signature(&self) -> RulesetThresholdsSignature {
|
||||||
|
RulesetThresholdsSignature {
|
||||||
|
standard_duration_ms: self.standard_duration_ms,
|
||||||
|
min_duration_ms: self.min_duration_ms,
|
||||||
|
max_duration_ms: self.max_duration_ms,
|
||||||
|
min_bark_gap_ms: self.min_bark_gap_ms,
|
||||||
|
trigger_count_tolerance: self.trigger_count_tolerance,
|
||||||
|
min_volume: self.min_volume,
|
||||||
|
max_volume: self.max_volume,
|
||||||
|
min_average_volume: self.min_average_volume,
|
||||||
|
max_average_volume: self.max_average_volume,
|
||||||
|
min_final_energy: self.min_final_energy,
|
||||||
|
max_final_energy: self.max_final_energy,
|
||||||
|
min_combo: self.min_combo,
|
||||||
|
max_combo: self.max_combo,
|
||||||
|
draw_threshold_energy: self.draw_threshold_energy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct BarkBattleFinishMetrics {
|
||||||
|
pub duration_ms: u64,
|
||||||
|
pub trigger_count: u64,
|
||||||
|
/// 归一化音量,合法范围为 0.0..=1.0。
|
||||||
|
pub max_volume: f32,
|
||||||
|
pub average_volume: f32,
|
||||||
|
pub final_energy: f32,
|
||||||
|
pub max_combo: u32,
|
||||||
|
pub finished_at_micros: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FinishValidationDecision {
|
||||||
|
Accepted,
|
||||||
|
AcceptedWithFlags,
|
||||||
|
Rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AntiCheatFlag {
|
||||||
|
DurationTooShort,
|
||||||
|
DurationTooLong,
|
||||||
|
TriggerCountTooHigh,
|
||||||
|
MaxVolumeOutOfRange,
|
||||||
|
AverageVolumeOutOfRange,
|
||||||
|
FinalEnergyOutOfRange,
|
||||||
|
MaxComboOutOfRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct FinishValidation {
|
||||||
|
pub decision: FinishValidationDecision,
|
||||||
|
pub anti_cheat_flags: Vec<AntiCheatFlag>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BattleResult {
|
||||||
|
PlayerWin,
|
||||||
|
OpponentWin,
|
||||||
|
Draw,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BarkBattleLeaderboardScore {
|
||||||
|
pub final_energy_millis: u32,
|
||||||
|
pub trigger_count: u64,
|
||||||
|
pub max_volume_millis: u32,
|
||||||
|
pub duration_closeness_ms: u64,
|
||||||
|
pub finished_at_micros: i64,
|
||||||
|
}
|
||||||
5
server-rs/crates/module-bark-battle/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod domain;
|
||||||
|
pub mod scoring;
|
||||||
|
|
||||||
|
pub use domain::*;
|
||||||
|
pub use scoring::*;
|
||||||
316
server-rs/crates/module-bark-battle/src/scoring.rs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
use crate::domain::*;
|
||||||
|
|
||||||
|
pub fn validate_finish_metrics(
|
||||||
|
ruleset: &BarkBattleRuleset,
|
||||||
|
metrics: &BarkBattleFinishMetrics,
|
||||||
|
) -> FinishValidation {
|
||||||
|
let mut flags = Vec::new();
|
||||||
|
let mut rejected = false;
|
||||||
|
|
||||||
|
if metrics.duration_ms < ruleset.min_duration_ms {
|
||||||
|
flags.push(AntiCheatFlag::DurationTooShort);
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
if metrics.duration_ms > ruleset.max_duration_ms {
|
||||||
|
flags.push(AntiCheatFlag::DurationTooLong);
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_trigger_count =
|
||||||
|
metrics.duration_ms / ruleset.min_bark_gap_ms + u64::from(ruleset.trigger_count_tolerance);
|
||||||
|
if metrics.trigger_count > max_trigger_count {
|
||||||
|
flags.push(AntiCheatFlag::TriggerCountTooHigh);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_in_range(metrics.max_volume, ruleset.min_volume, ruleset.max_volume) {
|
||||||
|
flags.push(AntiCheatFlag::MaxVolumeOutOfRange);
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
if !is_in_range(
|
||||||
|
metrics.average_volume,
|
||||||
|
ruleset.min_average_volume,
|
||||||
|
ruleset.max_average_volume,
|
||||||
|
) {
|
||||||
|
flags.push(AntiCheatFlag::AverageVolumeOutOfRange);
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
if !is_in_range(
|
||||||
|
metrics.final_energy,
|
||||||
|
ruleset.min_final_energy,
|
||||||
|
ruleset.max_final_energy,
|
||||||
|
) {
|
||||||
|
flags.push(AntiCheatFlag::FinalEnergyOutOfRange);
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
if metrics.max_combo < ruleset.min_combo || metrics.max_combo > ruleset.max_combo {
|
||||||
|
flags.push(AntiCheatFlag::MaxComboOutOfRange);
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decision = if rejected {
|
||||||
|
FinishValidationDecision::Rejected
|
||||||
|
} else if flags.is_empty() {
|
||||||
|
FinishValidationDecision::Accepted
|
||||||
|
} else {
|
||||||
|
FinishValidationDecision::AcceptedWithFlags
|
||||||
|
};
|
||||||
|
|
||||||
|
FinishValidation {
|
||||||
|
decision,
|
||||||
|
anti_cheat_flags: flags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adjudicate_result(
|
||||||
|
ruleset: &BarkBattleRuleset,
|
||||||
|
player_final_energy: f32,
|
||||||
|
opponent_final_energy: f32,
|
||||||
|
) -> BattleResult {
|
||||||
|
let delta = player_final_energy - opponent_final_energy;
|
||||||
|
if delta.abs() <= ruleset.draw_threshold_energy as f32 {
|
||||||
|
BattleResult::Draw
|
||||||
|
} else if delta > 0.0 {
|
||||||
|
BattleResult::PlayerWin
|
||||||
|
} else {
|
||||||
|
BattleResult::OpponentWin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_leaderboard_score(
|
||||||
|
ruleset: &BarkBattleRuleset,
|
||||||
|
metrics: &BarkBattleFinishMetrics,
|
||||||
|
validation: &FinishValidation,
|
||||||
|
result: BattleResult,
|
||||||
|
) -> Option<BarkBattleLeaderboardScore> {
|
||||||
|
if result != BattleResult::PlayerWin
|
||||||
|
|| validation.decision == FinishValidationDecision::Rejected
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(BarkBattleLeaderboardScore {
|
||||||
|
final_energy_millis: to_millis(metrics.final_energy),
|
||||||
|
trigger_count: metrics.trigger_count,
|
||||||
|
max_volume_millis: to_millis(metrics.max_volume),
|
||||||
|
duration_closeness_ms: metrics.duration_ms.abs_diff(ruleset.standard_duration_ms),
|
||||||
|
finished_at_micros: metrics.finished_at_micros,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_in_range(value: f32, min: f32, max: f32) -> bool {
|
||||||
|
value.is_finite() && value >= min && value <= max
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_millis(value: f32) -> u32 {
|
||||||
|
(value * 1_000.0).round().clamp(0.0, u32::MAX as f32) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
fn metrics(duration_ms: u64) -> BarkBattleFinishMetrics {
|
||||||
|
BarkBattleFinishMetrics {
|
||||||
|
duration_ms,
|
||||||
|
trigger_count: 10,
|
||||||
|
max_volume: 0.8,
|
||||||
|
average_volume: 0.6,
|
||||||
|
final_energy: 60.0,
|
||||||
|
max_combo: 5,
|
||||||
|
finished_at_micros: 1_000_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_uses_contract_snake_case_for_domain_enums() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(DifficultyPreset::Easy).expect("serialize difficulty"),
|
||||||
|
serde_json::json!("easy")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(FinishValidationDecision::AcceptedWithFlags)
|
||||||
|
.expect("serialize decision"),
|
||||||
|
serde_json::json!("accepted_with_flags")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(AntiCheatFlag::AverageVolumeOutOfRange)
|
||||||
|
.expect("serialize anti-cheat flag"),
|
||||||
|
serde_json::json!("average_volume_out_of_range")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(BattleResult::PlayerWin).expect("serialize battle result"),
|
||||||
|
serde_json::json!("player_win")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_duration_inside_28s_to_35s_window() {
|
||||||
|
let ruleset = BarkBattleRuleset::v1();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validate_finish_metrics(&ruleset, &metrics(28_000)).decision,
|
||||||
|
FinishValidationDecision::Accepted
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_finish_metrics(&ruleset, &metrics(35_000)).decision,
|
||||||
|
FinishValidationDecision::Accepted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_or_flags_extreme_duration() {
|
||||||
|
let ruleset = BarkBattleRuleset::v1();
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
validate_finish_metrics(&ruleset, &metrics(1_000)).decision,
|
||||||
|
FinishValidationDecision::Accepted
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
validate_finish_metrics(&ruleset, &metrics(300_000)).decision,
|
||||||
|
FinishValidationDecision::Accepted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flags_trigger_count_above_physical_limit_with_tolerance() {
|
||||||
|
let ruleset = BarkBattleRuleset::v1();
|
||||||
|
let mut input = metrics(30_000);
|
||||||
|
input.trigger_count = input.duration_ms / ruleset.min_bark_gap_ms
|
||||||
|
+ u64::from(ruleset.trigger_count_tolerance)
|
||||||
|
+ 1;
|
||||||
|
|
||||||
|
let validation = validate_finish_metrics(&ruleset, &input);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validation.decision,
|
||||||
|
FinishValidationDecision::AcceptedWithFlags
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validation
|
||||||
|
.anti_cheat_flags
|
||||||
|
.contains(&AntiCheatFlag::TriggerCountTooHigh)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_final_energy_outside_range() {
|
||||||
|
let ruleset = BarkBattleRuleset::v1();
|
||||||
|
let mut input = metrics(30_000);
|
||||||
|
input.final_energy = ruleset.max_final_energy + 0.1;
|
||||||
|
|
||||||
|
let validation = validate_finish_metrics(&ruleset, &input);
|
||||||
|
|
||||||
|
assert_eq!(validation.decision, FinishValidationDecision::Rejected);
|
||||||
|
assert!(
|
||||||
|
validation
|
||||||
|
.anti_cheat_flags
|
||||||
|
.contains(&AntiCheatFlag::FinalEnergyOutOfRange)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaderboard_score_only_for_player_win_and_not_rejected() {
|
||||||
|
let ruleset = BarkBattleRuleset::v1();
|
||||||
|
let input = metrics(30_000);
|
||||||
|
let validation = validate_finish_metrics(&ruleset, &input);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
compute_leaderboard_score(&ruleset, &input, &validation, BattleResult::PlayerWin)
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
compute_leaderboard_score(&ruleset, &input, &validation, BattleResult::Draw).is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
compute_leaderboard_score(&ruleset, &input, &validation, BattleResult::OpponentWin)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
let rejected = FinishValidation {
|
||||||
|
decision: FinishValidationDecision::Rejected,
|
||||||
|
anti_cheat_flags: vec![AntiCheatFlag::DurationTooShort],
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
compute_leaderboard_score(&ruleset, &input, &rejected, BattleResult::PlayerWin)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjudicates_draw_threshold_boundaries() {
|
||||||
|
let ruleset = BarkBattleRuleset::v1();
|
||||||
|
assert_eq!(adjudicate_result(&ruleset, 53.0, 50.0), BattleResult::Draw);
|
||||||
|
assert_eq!(
|
||||||
|
adjudicate_result(&ruleset, 53.1, 50.0),
|
||||||
|
BattleResult::PlayerWin
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
adjudicate_result(&ruleset, 46.9, 50.0),
|
||||||
|
BattleResult::OpponentWin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_inclusive_metric_boundaries_and_rejects_non_finite() {
|
||||||
|
let ruleset = BarkBattleRuleset::v1();
|
||||||
|
let mut input = metrics(30_000);
|
||||||
|
input.trigger_count = input.duration_ms / ruleset.min_bark_gap_ms
|
||||||
|
+ u64::from(ruleset.trigger_count_tolerance);
|
||||||
|
input.max_volume = ruleset.min_volume;
|
||||||
|
input.final_energy = ruleset.max_final_energy;
|
||||||
|
input.max_combo = ruleset.max_combo;
|
||||||
|
assert_eq!(
|
||||||
|
validate_finish_metrics(&ruleset, &input).decision,
|
||||||
|
FinishValidationDecision::Accepted
|
||||||
|
);
|
||||||
|
|
||||||
|
input.max_volume = f32::NAN;
|
||||||
|
assert_eq!(
|
||||||
|
validate_finish_metrics(&ruleset, &input).decision,
|
||||||
|
FinishValidationDecision::Rejected
|
||||||
|
);
|
||||||
|
input.max_volume = 0.8;
|
||||||
|
input.average_volume = ruleset.max_average_volume + 0.1;
|
||||||
|
let validation = validate_finish_metrics(&ruleset, &input);
|
||||||
|
assert_eq!(validation.decision, FinishValidationDecision::Rejected);
|
||||||
|
assert!(
|
||||||
|
validation
|
||||||
|
.anti_cheat_flags
|
||||||
|
.contains(&AntiCheatFlag::AverageVolumeOutOfRange)
|
||||||
|
);
|
||||||
|
input.average_volume = 0.6;
|
||||||
|
input.final_energy = f32::INFINITY;
|
||||||
|
assert_eq!(
|
||||||
|
validate_finish_metrics(&ruleset, &input).decision,
|
||||||
|
FinishValidationDecision::Rejected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaderboard_score_allows_flagged_but_accepted_player_wins() {
|
||||||
|
let ruleset = BarkBattleRuleset::v1();
|
||||||
|
let input = metrics(30_000);
|
||||||
|
let validation = FinishValidation {
|
||||||
|
decision: FinishValidationDecision::AcceptedWithFlags,
|
||||||
|
anti_cheat_flags: vec![AntiCheatFlag::TriggerCountTooHigh],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
compute_leaderboard_score(&ruleset, &input, &validation, BattleResult::PlayerWin)
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn difficulty_changes_only_ai_preset_key() {
|
||||||
|
let easy = BarkBattleRuleset::for_difficulty(DifficultyPreset::Easy);
|
||||||
|
let normal = BarkBattleRuleset::for_difficulty(DifficultyPreset::Normal);
|
||||||
|
let hard = BarkBattleRuleset::for_difficulty(DifficultyPreset::Hard);
|
||||||
|
|
||||||
|
assert_eq!(easy.thresholds_signature(), normal.thresholds_signature());
|
||||||
|
assert_eq!(normal.thresholds_signature(), hard.thresholds_signature());
|
||||||
|
assert_eq!(easy.ai_preset_key, "bark-battle-ai-easy");
|
||||||
|
assert_eq!(normal.ai_preset_key, "bark-battle-ai-normal");
|
||||||
|
assert_eq!(hard.ai_preset_key, "bark-battle-ai-hard");
|
||||||
|
}
|
||||||
|
}
|
||||||