26 Commits

Author SHA1 Message Date
e36a562098 feat: support mini program phone authorization binding 2026-05-12 22:30:24 +08:00
26139f80d3 test: add wechat miniprogram auth smoke 2026-05-12 18:57:27 +08:00
aec9142481 Merge origin/master into codex/wechat 2026-05-12 16:20:45 +08:00
d41f260a2a feat: add baby object match edutainment flow
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 16:08:59 +08:00
cf074837a4 docs: ignore local load test artifacts
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 15:14:11 +08:00
ed7a6f48d0 Merge pull request 'hermes/hermes-1e775b03' (#13) from hermes/hermes-1e775b03 into master
Some checks failed
CI / verify (push) Has been cancelled
Reviewed-on: http://82.157.175.59:3000/GenarrativeAI/Genarrative/pulls/13
2026-05-12 15:11:49 +08:00
8c6ec9e6e4 Merge remote-tracking branch 'origin/master' into hermes/hermes-1e775b03
Some checks failed
CI / verify (pull_request) Has been cancelled
# Conflicts:
#	docs/technical/README.md
#	src/components/custom-world-home/CustomWorldCreationHub.tsx
#	src/components/custom-world-home/creationWorkShelf.ts
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
2026-05-12 15:02:47 +08:00
33c9079d3b feat: complete bark battle playable demo 2026-05-12 14:42:58 +08:00
7b4ba61b4d Merge remote-tracking branch 'origin/master' into hermes/hermes-3337436a
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 14:18:30 +08:00
4dfa8452db docs: add disaster recovery plan draft
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 14:13:53 +08:00
22810245f5 refactor match3d runtime adapters 2026-05-12 14:02:42 +08:00
eb76bfc031 Merge remote-tracking branch 'origin/master'
Some checks failed
CI / verify (push) Has been cancelled
2026-05-12 13:59:28 +08:00
183e78d475 perf: batch recent play counts for gallery lists 2026-05-12 10:59:51 +08:00
612d105a23 fix: resolve k6 loadtest data path 2026-05-11 22:18:43 +08:00
b994acf635 test: add k6 works list load test 2026-05-11 21:31:24 +08:00
ef4f91a75e 1
Some checks failed
CI / verify (push) Has been cancelled
2026-05-11 20:57:16 +08:00
481a27fc53 1 2026-05-11 20:27:41 +08:00
5cb5329f4e feat: add bark battle debug feedback 2026-05-11 18:32:02 +08:00
2b046656dc feat: add bark battle browser prototype 2026-05-11 18:01:55 +08:00
bf72c2e48d docs: add bark battle backend BDD and TDD 2026-05-11 16:17:22 +08:00
e30b733b17 1 2026-05-11 16:15:48 +08:00
fa61eeb0b0 docs: add bark battle BDD acceptance scenarios 2026-05-11 16:10:48 +08:00
2ca096f821 docs: add bark battle backend ddd plan 2026-05-11 15:52:20 +08:00
2b6087de4c docs: add bark battle 2d runtime plan 2026-05-11 15:21:51 +08:00
ce98a29c4d feat: add wechat miniprogram webview login 2026-05-03 19:05:45 +08:00
9baa515a75 Add WeChat miniprogram web-view shell 2026-05-03 16:29:42 +08:00
247 changed files with 25629 additions and 2726 deletions

View File

@@ -1 +0,0 @@
C:/proj/Genarrative/.hermes/skills/behavior-driven-development

View File

@@ -0,0 +1 @@
C:/proj/Genarrative/.hermes/skills/behavior-driven-development

5
.env Normal file
View File

@@ -0,0 +1,5 @@
# 微信小程序 web-view 登录配置。
# 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。
WECHAT_MINI_PROGRAM_APP_ID=""
WECHAT_MINI_PROGRAM_APP_SECRET=""
WECHAT_JS_CODE_SESSION_ENDPOINT=""

View File

@@ -103,6 +103,9 @@ WECHAT_REDIRECT_PATH="/"
WECHAT_AUTHORIZE_ENDPOINT="https://open.weixin.qq.com/connect/qrconnect" WECHAT_AUTHORIZE_ENDPOINT="https://open.weixin.qq.com/connect/qrconnect"
WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token" WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token"
WECHAT_USER_INFO_ENDPOINT="https://api.weixin.qq.com/sns/userinfo" WECHAT_USER_INFO_ENDPOINT="https://api.weixin.qq.com/sns/userinfo"
WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session"
WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token"
WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber"
WECHAT_STATE_TTL_MINUTES="15" WECHAT_STATE_TTL_MINUTES="15"
WECHAT_MOCK_USER_ID="wx-mock-user" WECHAT_MOCK_USER_ID="wx-mock-user"
WECHAT_MOCK_UNION_ID="wx-mock-union" WECHAT_MOCK_UNION_ID="wx-mock-union"

View File

@@ -56,8 +56,6 @@ LLM_DEBUG_LOG="true"
ALIYUN_OSS_BUCKET="xushi-dev" ALIYUN_OSS_BUCKET="xushi-dev"
ALIYUN_OSS_REGION="oss-cn-beijing" ALIYUN_OSS_REGION="oss-cn-beijing"
ALIYUN_OSS_ENDPOINT="oss-cn-beijing.aliyuncs.com" ALIYUN_OSS_ENDPOINT="oss-cn-beijing.aliyuncs.com"
ALIYUN_OSS_ACCESS_KEY_ID="LTAI5t7aiyw6uDFW4miJvU8f"
ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS"
# Local Rust backend target for Vite dev proxy. # Local Rust backend target for Vite dev proxy.
RUST_SERVER_TARGET="http://127.0.0.1:8082" RUST_SERVER_TARGET="http://127.0.0.1:8082"

View File

@@ -37,6 +37,12 @@ module.exports = {
'simple-import-sort/exports': 'off', 'simple-import-sort/exports': 'off',
}, },
}, },
{
files: ['src/components/match3d-runtime/Match3DPhysicsBoard.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
], ],
plugins: [ plugins: [
'@typescript-eslint', '@typescript-eslint',

8
.gitignore vendored
View File

@@ -32,3 +32,11 @@ temp*build*/
/logs /logs
.worktrees/ .worktrees/
.env.secrets.local .env.secrets.local
# Local load-test data extracted from private migration files
scripts/loadtest/data/*.local.json
# Local load-test run artifacts
scripts/loadtest/data/k6-*.log
scripts/loadtest/data/k6-*summary*.md
scripts/loadtest/data/latest-*-prefix.txt

View File

@@ -0,0 +1,561 @@
# 声控狗叫对战 2D 浏览器游戏设计与实现计划
## 目标
基于用户提供的视频:
`C:\Users\DSK\Videos\一款双方比狗叫的游戏 - 1.一款双方比狗叫的游戏(Av116504192360177,P1).mp4`
提取其中“双方比狗叫”的核心玩法,并按照 BDD / TDD / DDD 的方法,为 Genarrative 中可运行于浏览器的 2D 游戏方案生成一份可落地设计与实现思路。实现方向遵循仓库内 `game-studio` 插件工作流,默认采用 2D Phaser + TypeScript + Vite + DOM HUD 的浏览器游戏架构。
本计划仅做方案设计,不直接编码。
## 当前上下文与输入分析
### 已识别视频核心画面
通过抽帧观察,视频中的游戏呈现出以下稳定特征:
- 画面是横版 2D 手绘舞台,场景包括公园、海边等固定关卡背景。
- 双方各有一只狗作为对战角色,站在左右两侧。
- 中央有明显倒计时,例如 `30``28`
- 顶部有红蓝双方拉锯式能量条 / 进度条。
- 中央提示出现:`对着麦克风汪一声``用声音大小 + 叫声次数推动能量条!`
- 玩家输入不是传统键鼠,而是麦克风声音。
- 玩家需要模仿狗叫,系统根据声音大小与叫声次数推动能量条。
- 屏幕会根据叫声出现 `BARK``WOOF``WAN``WANGOOF` 等拟声词与冲击波视觉反馈。
- 回合结束时,根据能量条偏向或推进结果判定胜负。
### 提炼出的核心玩法
这是一个“声控拔河式狗叫对战”小游戏:
- 两名玩家 / 一名玩家对 AI 分别代表左右两只狗。
- 每局限时 30 秒。
- 玩家通过麦克风持续发出狗叫声。
- 游戏实时分析音量峰值、叫声次数、叫声节奏。
- 声音越大、叫声越密集,己方推动力越强。
- 顶部能量条在双方推动力差值下左右移动。
- 时间结束后,能量条偏向哪一方,哪一方获胜。
### 需要合理抽象的地方
视频中存在直播弹幕、贴图、表情包、遮挡层,这些不是游戏本体机制。本方案只吸收游戏本体核心:
- 双方狗狗对叫
- 麦克风输入
- 声音强度 + 次数判定
- 红蓝拉锯能量条
- 限时回合
- 夸张拟声词与冲击波反馈
## game-studio 插件路线
根据仓库内 `.hermes/plugins/game-studio` 技能:
- 早期游戏工作先走 `game-studio` 总入口。
- 2D 浏览器游戏默认选择 Phaser。
- 架构上需要分离 simulation 与 renderer。
- HUD / 菜单 / 设置优先使用 DOM overlay不把密集文字塞进 canvas。
- 玩法状态不应由 Phaser Scene 直接持有Scene 只负责渲染、动画、相机、输入适配。
因此本方案采用:
- RuntimePhaser 3
- LanguageTypeScript
- BuildVite
- UIReact/DOM HUD overlay 或项目现有 DOM UI 层
- Audio inputWeb Audio API + MediaDevices.getUserMedia
- Simulation纯 TS domain/service 层
- RendererPhaser Scene 读取 simulation snapshot 并播放动画/特效
## 游戏概念设计
### 游戏名建议
- 中文:`汪汪声浪大作战`
- 英文代号:`bark-battle`
- Play type ID 建议:`bark-battle`
### 玩家幻想
玩家不是通过按键战斗,而是真的对着麦克风“汪汪叫”,把自己的狗狗声浪推向对手。游戏目标是在倒计时结束前用更响、更密集、更有节奏的叫声赢得声浪拔河。
### 核心动词
- 叫:对麦克风发出狗叫声。
- 推:通过叫声推动能量条。
- 压制:让能量条持续向对手方向倾斜。
- 爆发:短时间内连续高质量叫声触发冲击波。
- 防守:对手强势时通过持续叫声把能量条拉回。
### 单局流程
1. 准备阶段
- 展示双方狗狗、地图、麦克风权限提示。
- 用户授权麦克风。
- 系统检测环境噪音并校准阈值。
2. 倒计时阶段
- 3、2、1 或中央 `30` 倒计时开始。
- 玩家看到提示:`对着麦克风汪一声`
3. 对战阶段
- 每帧或固定 tick 采集麦克风音量。
- 根据音量峰值与短促叫声次数计算本方 barkPower。
- AI 或远端对手产生 opponentPower。
- 能量条根据 `playerPower - opponentPower` 拉锯。
- 狗狗张嘴动画、拟声词、冲击波按声音强度生成。
4. 结算阶段
- 30 秒结束。
- 能量条偏玩家侧则胜利,偏对手侧则失败,接近中线则平局。
- 展示叫声次数、最大音量、平均节奏、声浪评分。
5. 重开 / 返回
- 支持再来一局。
- 支持返回玩法入口或结果页。
## 规则设计
### 关键状态
```ts
type BarkBattlePhase = 'permission' | 'calibration' | 'countdown' | 'playing' | 'finished'
type BarkBattleSnapshot = {
phase: BarkBattlePhase
remainingMs: number
energy: number // -100 到 100负数偏对手正数偏玩家
player: BarkSideState
opponent: BarkSideState
winner: 'player' | 'opponent' | 'draw' | null
}
type BarkSideState = {
barkCount: number
currentVolume: number
recentPeak: number
combo: number
power: number
isBarking: boolean
}
```
### 输入判定
#### 音量采样
- 使用 Web Audio API 创建 `AnalyserNode`
- 每个 simulation tick 读取频域或时域数据。
- 计算 RMS 或 peak volume。
- 根据校准后的环境噪音设置动态阈值。
#### 一次“叫声”的判定
一次有效叫声建议满足:
- 音量超过 `barkThreshold`
- 与上一次叫声峰值至少间隔 `minBarkGapMs`,避免持续噪音被无限计数。
- 持续时长在合理范围,例如 80ms 到 1200ms。
- 可选频谱能量集中在中高频不强制做复杂语音识别MVP 先用音量 + 峰值节奏。
#### 推动力计算
```text
playerPower = volumeScore * 0.65 + barkRateScore * 0.35 + comboBonus
opponentPower = aiPower 或远端玩家 power
energyDelta = (playerPower - opponentPower) * deltaTime * balanceFactor
energy = clamp(energy + energyDelta, -100, 100)
```
### AI 对手 MVP
若先做单机浏览器版,右侧对手可由 AI 模拟:
- 简单难度周期性小叫power 低。
- 普通难度有节奏地爆发power 中等。
- 困难难度:根据玩家领先程度自适应追赶,但不得作弊到不可赢。
后续可扩展为多人实时对战。
## BDD 行为场景
### 功能: 麦克风授权与准备
```gherkin
功能: 狗叫对战麦克风准备
场景: 玩家允许麦克风权限后进入准备倒计时
假如
那么
而且
场景: 玩家拒绝麦克风权限
假如
那么
而且
而且
```
### 功能: 声音推动能量条
```gherkin
功能: 声音大小和叫声次数推动能量条
场景: 玩家发出一次有效狗叫
假如 playing
而且
那么 1
而且
而且
场景: 玩家连续大声狗叫压制对手
假如 playing
而且
那么
而且
场景: 环境噪音低于阈值不计入叫声
假如 playing
那么
而且
```
### 功能: 限时胜负结算
```gherkin
功能: 狗叫对战胜负结算
场景: 倒计时结束时玩家侧占优
假如
而且
那么
而且
场景: 倒计时结束时双方接近平衡
假如
而且
那么
而且
```
### 功能: 移动端与无麦克风降级
```gherkin
功能: 声控游戏移动端与无麦克风降级
场景: 当前浏览器不支持麦克风 API
假如 getUserMedia
那么
而且
场景: 移动端进入对战页面
假如使
那么
而且
```
## DDD 领域划分
### 领域层bark-battle domain
职责:只处理玩法规则,不依赖 Phaser、DOM、Web Audio、后端。
建议模块:
- `BarkBattleSession`
- 管理 phase、remainingMs、energy、winner。
- `BarkDetector`
- 根据音量样本判断是否形成一次有效叫声。
- `EnergyTugOfWar`
- 根据双方 power 更新能量条。
- `BarkBattleScoring`
- 计算最大音量、叫声次数、combo、评分。
- `OpponentStrategy`
- 单机 AI 对手策略接口。
领域规则必须可用纯单元测试验证。
### 应用层use case / controller
职责编排麦克风输入、simulation tick、AI 对手、结果输出。
建议用例:
- `requestMicrophonePermission()`
- `calibrateAmbientNoise()`
- `startBarkBattleSession()`
- `submitAudioSample(sample)`
- `tickBarkBattle(deltaMs)`
- `finishBarkBattle()`
### 基础设施层
职责:浏览器 API 与引擎适配。
- `BrowserMicrophoneInput`
- 封装 `navigator.mediaDevices.getUserMedia`
- 输出 normalized volume samples。
- `PhaserBarkBattleScene`
- 渲染狗狗、背景、拟声词、冲击波。
- 不持有核心玩法规则。
- `DomBarkBattleHud`
- 展示倒计时、能量条、权限提示、结算面板。
### 表现层
- Phaser Canvas地图、狗狗、声浪、粒子、拟声词。
- DOM HUD顶部能量条、倒计时、权限/结算/设置面板。
## TDD 落地顺序
### 第一轮:领域规则 RED-GREEN-REFACTOR
先写纯 TS 单元测试,不接 Phaser不接麦克风。
目标测试:
- `BarkDetector`:超过阈值且间隔足够时计为一次叫声。
- `BarkDetector`:持续噪音不会无限增加叫声次数。
- `EnergyTugOfWar`:玩家 power 高于对手时 energy 向玩家侧移动。
- `EnergyTugOfWar`energy 被 clamp 在 -100 到 100。
- `BarkBattleSession`:倒计时归零后进入 finished。
- `BarkBattleSession`:根据 energy 判定 player/opponent/draw。
### 第二轮:应用层测试
- 模拟音频 sample 输入,验证 session snapshot 更新。
- 模拟 AI 对手 power验证能量条拉锯。
- 模拟权限失败,验证 phase 不进入 playing。
### 第三轮:组件 / 集成测试
- HUD 根据 snapshot 显示倒计时。
- HUD 根据 energy 渲染红蓝能量条比例。
- 权限拒绝时显示重试入口。
- 结算阶段显示胜负与再来一局。
### 第四轮:浏览器 smoke / playtest
- 本地启动页面。
- 授权麦克风。
- 对麦克风发声后看到拟声词与能量条变化。
- 移动端宽度下主游戏画面不被 HUD 遮挡。
## 建议文件结构
如果作为独立前端玩法原型,可采用:
```text
src/games/bark-battle/
domain/
BarkBattleSession.ts
BarkDetector.ts
EnergyTugOfWar.ts
BarkBattleScoring.ts
OpponentStrategy.ts
application/
BarkBattleController.ts
BrowserMicrophoneInput.ts
phaser/
BarkBattleScene.ts
BarkBattlePreloadScene.ts
barkBattleAssets.ts
ui/
BarkBattleHud.tsx
BarkBattleResultPanel.tsx
BarkBattlePermissionPanel.tsx
tests/
BarkDetector.test.ts
EnergyTugOfWar.test.ts
BarkBattleSession.test.ts
```
如果接入 Genarrative 玩法类型闭环,后续还需要按 `genarrative-play-type-integration` 扩展:
```text
src/components/bark-battle-runtime/BarkBattleRuntimeShell.tsx
src/components/bark-battle-result/BarkBattleResultView.tsx
src/services/barkBattleRuntimeClient.ts
packages/shared/src/contracts/barkBattle.ts
server-rs/crates/shared-contracts/src/bark_battle.rs
```
MVP 阶段建议先做浏览器单机 runtime 原型,再决定是否进入创作入口、作品发布、广场和后端持久化。
## UI / 视觉方向
### 画面
- 横版固定舞台。
- 左右两只狗对峙。
- 背景可先做公园一张图,后续扩展海边、街区等地图。
- 狗狗用 2D sprite 或简单骨架帧动画。
### HUD
- 顶部:红蓝声浪能量条。
- 中央:大号倒计时,只在开局和关键时间突出显示。
- 左右:双方狗狗状态,不堆叠复杂面板。
- 底部或角落:麦克风状态、小型重试按钮。
- 结算:居中弹出简洁面板,显示胜负和关键数据。
### 动效
- 叫声触发狗狗张嘴。
- 声音越大,拟声词越大,冲击波越宽。
- combo 时触发短暂屏幕震动,但不能遮挡能量条。
- 尊重 reduced motion非必要动画可降级。
## 测试映射
| BDD 场景 | 测试层级 | 目标文件 | 状态 |
| --- | --- | --- | --- |
| 玩家允许麦克风权限后进入准备倒计时 | application/component | `BarkBattleController.test.ts`, `BarkBattlePermissionPanel.test.tsx` | planned |
| 玩家拒绝麦克风权限 | application/component | `BarkBattleController.test.ts`, `BarkBattlePermissionPanel.test.tsx` | planned |
| 玩家发出一次有效狗叫 | unit | `BarkDetector.test.ts` | planned |
| 玩家连续大声狗叫压制对手 | unit/integration | `EnergyTugOfWar.test.ts`, `BarkBattleController.test.ts` | planned |
| 环境噪音低于阈值不计入叫声 | unit | `BarkDetector.test.ts` | planned |
| 倒计时结束时玩家侧占优 | unit | `BarkBattleSession.test.ts` | planned |
| 倒计时结束时双方接近平衡 | unit | `BarkBattleSession.test.ts` | planned |
| 当前浏览器不支持麦克风 API | component | `BarkBattlePermissionPanel.test.tsx` | planned |
| 移动端进入对战页面 | visual/smoke | Playwright 或人工 playtest 清单 | planned |
## 验证命令建议
具体命令以后续实际落地位置为准,建议包括:
```bash
npm run test -- --run src/games/bark-battle/**/*.test.ts
npm run test -- --run src/games/bark-battle/**/*.test.tsx
npm run typecheck
npm run check:encoding
```
若接入 Genarrative 后端或玩法配置,还需要追加:
```bash
cd server-rs && cargo check -p api-server -p shared-contracts --no-default-features
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts
```
## 实施阶段拆分
### Phase 0产品与技术定稿
- 确认玩法 ID`bark-battle`
- 确认 MVP 只做单机玩家 vs AI不做实时多人。
- 确认是否只做 runtime 原型,还是接入 Genarrative 创作入口。
- 确认是否允许浏览器麦克风权限作为核心输入。
### Phase 1纯领域模型
- 建立 bark-battle domain。
- 按 TDD 写 `BarkDetector``EnergyTugOfWar``BarkBattleSession` 测试。
- 实现最小规则让测试通过。
### Phase 2麦克风输入适配
- 封装 Web Audio API。
- 支持权限请求、权限失败、环境噪音校准。
- 使用 mock input 完成自动化测试,真实麦克风做 smoke。
### Phase 3Phaser 2D runtime
- 新建 Phaser Scene。
- 绘制或占位加载公园背景、左右狗狗、声浪特效。
- Scene 只消费 snapshot不写规则。
- 接入 DOM HUD。
### Phase 4反馈与结算
- 加入拟声词、冲击波、狗狗张嘴动画。
- 加入结算面板。
- 加入再来一局与返回入口。
### Phase 5Genarrative 集成可选项
若要正式接入玩法类型:
-`shared-contracts` 中 bark-battle runtime/result DTO。
- 补前端 service 与 runtime shell。
- 补入口配置数据库 seed。
- 补作品架 / 发布 / 广场链路,若需要持久化成绩或作品。
-`genarrative-play-type-integration` 执行完整闭环验证。
## 风险与权衡
### 麦克风权限风险
浏览器麦克风权限受 HTTPS、浏览器策略、用户设置影响。MVP 需要明确:
- 本地开发可在 localhost 使用。
- 线上必须 HTTPS。
- 权限拒绝需要可恢复。
### 声音识别准确性风险
MVP 不建议做复杂“是否真的是狗叫”的 AI 识别,否则实现成本高、误判多。建议先用:
- 音量阈值
- 峰值次数
- 节奏间隔
- 环境噪音校准
后续再考虑加入频谱特征或 ML 分类。
### 噪音作弊风险
玩家可以喊叫、拍桌子或播放音频。若是娱乐派对玩法可以接受;若要竞技公平,需要后续加入:
- 频谱特征
- 输入冷却
- 异常持续噪音削弱
- 本地/服务端反作弊策略
### 移动端兼容风险
移动端 Web Audio 可能需要用户手势激活 AudioContext。计划中需把“开始”按钮作为显式用户手势避免自动启动失败。
### UI 遮挡风险
视频原型中的核心可读信息非常少:倒计时、能量条、狗狗、拟声词。实现时应避免把说明文案、复杂面板长期铺在画面上。
## 开放问题
1. MVP 是“玩家 vs AI”还是需要从第一版开始支持双人同屏 / 联机?
2. 是否要作为 Genarrative 新玩法入口完整接入,还是先做独立 runtime 原型?
3. 是否需要记录成绩、发布作品、进入作品架和广场?
4. 狗狗与背景素材是使用临时占位、AI 生成,还是需要复用项目既有素材系统?
5. 是否允许游戏强依赖麦克风权限,还是必须提供键盘备用输入?
## 推荐下一步
建议下一步先执行 Phase 0 + Phase 1
1. 明确 MVP 边界:单机玩家 vs AI。
2.`BarkDetector` / `EnergyTugOfWar` / `BarkBattleSession` 的 BDD 对应单元测试。
3. 不接 Phaser、不接麦克风先把核心规则用 TDD 跑通。
4. 规则稳定后再接 Web Audio 与 Phaser runtime。

View File

@@ -0,0 +1,709 @@
# bark-battle 三阶段实施计划:浏览器原型 → AI 创作入口 → 数据库落地
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** 按“三阶段”推进 `bark-battle / 汪汪声浪大作战`:第一阶段先做纯浏览器可运行游戏原型并验证玩法跑通;第二阶段接入 Genarrative 创作入口,用 AI 生成可试玩内容;第三阶段再打通后端数据库、发布、成绩和作品闭环。
**Architecture:** 第一阶段只在前端 runtime 内闭环,优先落 `src/games/bark-battle/` 与直达路由,不依赖后端和 SpacetimeDB。第二阶段在已有创作入口、Agent flow controller、结果页和 runtime shell 上接入 `bark-battle`AI 只生成配置化草稿,不承接正式业务真相。第三阶段按 `server-rs + Axum + SpacetimeDB` DDD 分层落库,前端只展示后端投影和调用后端 API。
**Tech Stack:** React 19、TypeScript、Vite、Vitest、Testing Library第一阶段优先 DOM/Canvas 原型,可在验证玩法后再引入 Phaser 3后端阶段使用 `server-rs`、Axum、SpacetimeDB、shared-contracts。
---
## 0. 当前上下文 / 假设
- 现有需求与技术文档:
- `docs/prd/BARK_BATTLE_BDD_2026-05-11.md`
- `docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md`
- `docs/technical/BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md`
- 用户明确要求阶段顺序:
1. 第一阶段:先制作纯浏览器运行的游戏原型,需要测试游戏功能是否能跑通。
2. 第二阶段:打通创作入口,使用 AI 赋能游戏内容创作。
3. 第三阶段:最后打通数据库落地。
- 因此本计划调整原技术方案中的落地优先级:
- 第一阶段不新增后端表、不接发布、不接作品架。
- 第一阶段可以用 mock / local draft 配置与直达路由 `/bark-battle` 完成 playable prototype。
- 第一阶段若 Phaser 依赖尚未安装,优先用 React DOM + Canvas/CSS 2D 原型跑通功能;待核心规则验证后再决定是否引入 Phaser避免第一阶段被依赖安装和素材管线阻塞。
- 当前仓库 `package.json` 还没有 `phaser` 依赖;如实现者选择 Phaser需要单独评估依赖引入、包体和测试影响。
- 本计划只写计划,不直接实现代码。
## 1. 总体分阶段验收口径
### Phase 1纯浏览器游戏原型
目标:打开本地前端路由即可玩到一局 `bark-battle`,并通过自动测试确认核心规则跑通。
必须满足:
- 可从 `/bark-battle` 进入独立原型页面。
- 不登录、不请求后端、不依赖数据库。
- 支持开发 mock input点击/按键/按钮可模拟音量峰值;有真实麦克风时可走 Web Audio。
- 能完成:权限/开始 → 校准或 mock 准备 → 倒计时 → 30 秒 playing → 结算 → 再来一局。
- 低于阈值输入不计数;有效叫声计数;能量条向玩家或对手移动;结算胜/负/平。
- 移动端至少能看到能量条、倒计时、双方狗狗、主要按钮和结算。
### Phase 2AI 创作入口
目标:创作者能从创作中心选择 `bark-battle`,用 AI 生成玩法配置草稿,并进入结果页试玩。
必须满足:
- 后端入口配置中出现 `bark-battle`,按开关展示/可点击。
- 前端类型分流、SelectionStage、工作台、结果页、runtime 入口齐全。
- AI 生成内容仅限配置化草稿标题、主题、狗狗外观描述、背景风格、难度、局长、AI 对手参数、提示文案 key 等。
- 生成结果可在本地 runtime 中试玩。
- 未落库前可先用 session/local state 保存草稿,但要清楚标识为“未发布草稿”。
### Phase 3数据库落地与正式作品闭环
目标:`bark-battle` 草稿、发布态配置、runtime start/finish、成绩和作品级游玩埋点都进入后端 DDD / SpacetimeDB 链路。
必须满足:
- `shared-contracts``module-bark-battle``spacetime-module``spacetime-client``api-server` 分层清晰。
- 发布为稳定作品 IDruntime 从后端读取发布态 config。
- start 成功写 `work_play_start``scope_kind=work``scope_id=稳定作品 ID`、metadata 包含 `playType/workId/sourceRoute/userId`
- finish 只上传派生指标,不保存原始麦克风音频、波形或可还原语音内容。
- 作品架/广场/分享/排行榜如启用,均来自后端投影。
---
## 2. Phase 1纯浏览器运行游戏原型
### Task 1.1:补齐阶段边界文档
**Objective:** 在现有技术方案中明确“先浏览器原型,后 AI 创作,最后数据库”的落地顺序,避免实现时过早接后端。
**Files:**
- Modify: `docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md`
- Modify: `docs/prd/BARK_BATTLE_BDD_2026-05-11.md`
**Steps:**
1. 在 runtime 技术方案中新增“三阶段落地顺序”小节。
2. 明确 Phase 1 不接后端、不接数据库、不接创作入口事实源。
3. 在 BDD 中补充“浏览器原型 smoke”验收场景。
4. 运行:
```bash
npm run check:encoding -- docs/prd/BARK_BATTLE_BDD_2026-05-11.md docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md
git diff --check
```
**Expected:** 编码检查和 diff 空白检查通过。
### Task 1.2:建立 Phase 1 目录骨架和类型
**Objective:** 建立不依赖 React/DOM/Web Audio 的核心类型,后续所有测试和 UI 都围绕这些类型。
**Files:**
- Create: `src/games/bark-battle/domain/BarkBattleTypes.ts`
- Create: `src/games/bark-battle/application/BarkBattleConfig.ts`
**Key design:**
- `BarkBattlePhase = 'permission' | 'calibration' | 'countdown' | 'playing' | 'finished' | 'unavailable'`
- `MicrophoneFailureReason` 覆盖已有文档中的 9 类失败原因。
- `BarkBattleSnapshot` 包含 `phase/uiState/errorReason/statusMessageKey/remainingMs/energy/player/opponent/winner/result/lastEvents`。
- `BarkBattleConfig` 包含 `roundDurationMs/drawThreshold/minBarkGapMs/minBarkDurationMs/maxBarkDurationMs/balanceFactor/calibrationMaxWaitMs`。
**Tests:**
- 本任务可先不写运行时逻辑,但需要让 typecheck 能引用这些类型。
**Validation:**
```bash
npm run typecheck
npm run check:encoding -- src/games/bark-battle/domain/BarkBattleTypes.ts src/games/bark-battle/application/BarkBattleConfig.ts
```
### Task 1.3TDD 实现叫声检测 BarkDetector
**Objective:** 用纯函数/纯类把音频样本转换为有效叫声事件。
**Files:**
- Create: `src/games/bark-battle/domain/BarkDetector.ts`
- Create: `src/games/bark-battle/domain/__tests__/BarkDetector.test.ts`
**Test cases:**
1. 超过阈值、持续时长合规、间隔足够时计为一次有效叫声。
2. 持续噪音不在每个 tick 无限计数。
3. 低于阈值的背景噪音不计数。
4. `minBarkGapMs` 内连续峰值不重复计数。
5. 过短脉冲不计数;过长持续声削弱为单段输入。
**Validation:**
```bash
npm run test -- --run src/games/bark-battle/domain/__tests__/BarkDetector.test.ts
npm run typecheck
```
### Task 1.4TDD 实现能量条 EnergyTugOfWar
**Objective:** 验证玩家/对手推动力能稳定改变 `energy`,并 clamp 到 `-100..100`。
**Files:**
- Create: `src/games/bark-battle/domain/EnergyTugOfWar.ts`
- Create: `src/games/bark-battle/domain/__tests__/EnergyTugOfWar.test.ts`
**Test cases:**
1. 玩家 power 高于对手时 `energy` 增加。
2. 对手 power 高于玩家时 `energy` 减少。
3. energy 不超过 `100`。
4. energy 不低于 `-100`。
5. power 相等时变化不超过浮点误差。
**Validation:**
```bash
npm run test -- --run src/games/bark-battle/domain/__tests__/EnergyTugOfWar.test.ts
npm run typecheck
```
### Task 1.5TDD 实现单局状态机 BarkBattleSession
**Objective:** 跑通 permission/calibration/countdown/playing/finished/unavailable 状态流转和结算。
**Files:**
- Create: `src/games/bark-battle/domain/BarkBattleSession.ts`
- Create: `src/games/bark-battle/domain/BarkBattleScoring.ts`
- Create: `src/games/bark-battle/domain/OpponentStrategy.ts`
- Create: `src/games/bark-battle/domain/__tests__/BarkBattleSession.test.ts`
- Create: `src/games/bark-battle/domain/__tests__/BarkBattleScoring.test.ts`
**Test cases:**
1. 校准完成后进入 countdown。
2. countdown 结束后进入 playing。
3. playing 中 `remainingMs` 随 tick 递减。
4. `remainingMs <= 0` 后进入 finished。
5. `energy > drawThreshold` 判定玩家胜。
6. `energy < -drawThreshold` 判定对手胜。
7. `abs(energy) <= drawThreshold` 判定平局。
8. finished 后新输入不再改变本局计数和能量。
**Validation:**
```bash
npm run test -- --run src/games/bark-battle/domain/__tests__/BarkBattleSession.test.ts src/games/bark-battle/domain/__tests__/BarkBattleScoring.test.ts
npm run typecheck
```
### Task 1.6:实现 mock-first Application Controller
**Objective:** 不依赖真实麦克风,先用 mock audio sample 驱动完整 snapshot。
**Files:**
- Create: `src/games/bark-battle/application/BarkBattleController.ts`
- Create: `src/games/bark-battle/application/BarkBattleSnapshotStore.ts`
- Create: `src/games/bark-battle/application/__tests__/BarkBattleController.test.ts`
**Behavior:**
- `startWithMockInput()` 进入校准完成或直接 countdown。
- `submitMockSample(sample)` 更新玩家输入。
- `tick(deltaMs)` 推进对手、能量条、倒计时。
- `restart()` 重置状态。
- `failMicrophone(reason)` 进入 `phase: 'unavailable'`,并设置 `errorReason/statusMessageKey`。
**Test cases:**
1. mock start 后能进入 countdown/playing。
2. 提交 mock 峰值后 bark count 增加。
3. tick 后 energy 可变化。
4. finish 后生成 result。
5. `failMicrophone('permission-denied')` 不进入 playing。
**Validation:**
```bash
npm run test -- --run src/games/bark-battle/application/__tests__/BarkBattleController.test.ts
npm run typecheck
```
### Task 1.7:实现浏览器原型 UI Shell不接平台
**Objective:** 提供 `/bark-battle` 可访问的 playable prototype。
**Files:**
- Create: `src/BarkBattlePlaygroundApp.tsx`
- Create: `src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`
- Create: `src/games/bark-battle/ui/BarkBattleHud.tsx`
- Create: `src/games/bark-battle/ui/BarkBattleResultPanel.tsx`
- Create: `src/games/bark-battle/ui/BarkBattleHud.css`
- Modify: `src/routing/appRoutes.tsx`
**Behavior:**
- 新增路由匹配:`/bark-battle`。
- 首屏只有清爽开始面板,不常驻大段规则。
- 提供开发原型按钮:开始、模拟叫声、模拟对手增强、再来一局。
- playing 画面展示:顶部能量条、倒计时、玩家/对手狗狗、叫声次数、麦克风/mock 状态。
- 结算面板独立居中,不追加在当前面板下方。
**UI constraints:**
- 移动端优先。
- 正常 playing 阶段不在 playfield 常驻规则说明。
- 大动效不遮挡顶部能量条和倒计时。
**Validation:**
```bash
npm run test -- --run src/games/bark-battle/ui/**/*.test.tsx
npm run typecheck
npm run dev:web
# 手动 smoke: 访问 /bark-battle → 开始 → 模拟叫声 → energy 变化 → 结算 → 再来一局
```
### Task 1.8:实现 HUD 组件测试
**Objective:** 自动验证核心 UI 状态,不只依赖人工试玩。
**Files:**
- Create: `src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx`
- Create: `src/games/bark-battle/ui/__tests__/BarkBattleResultPanel.test.tsx`
**Test cases:**
1. playing 阶段展示倒计时和能量条。
2. energy 正值时玩家侧占比更大。
3. energy 负值时对手侧占比更大。
4. permission-denied 展示重试授权入口。
5. unsupported 不展示开始声控按钮。
6. finished 展示胜负、叫声次数、再来一局。
**Validation:**
```bash
npm run test -- --run src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx src/games/bark-battle/ui/__tests__/BarkBattleResultPanel.test.tsx
npm run typecheck
```
### Task 1.9:接入真实 Web Audio可晚于 mock 原型)
**Objective:** 在支持麦克风的浏览器中真实采样并驱动 controller同时保留 mock fallback 便于测试。
**Files:**
- Create: `src/games/bark-battle/infrastructure/BrowserMicrophoneInput.ts`
- Create: `src/games/bark-battle/infrastructure/AudioAnalyserSampler.ts`
- Create: `src/games/bark-battle/infrastructure/MicrophonePermission.ts`
- Create: `src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts`
- Create: `src/games/bark-battle/infrastructure/__tests__/AudioAnalyserSampler.test.ts`
**Behavior:**
- 用户点击开始后才请求麦克风。
- 用户手势后创建/resume `AudioContext`。
- 输出归一化 `BarkAudioSample`。
- 捕获并映射unsupported、permission-denied、non-secure-context、not-found、not-readable、audio-context-blocked、unknown。
- stop/restart/page unload 时停止 tracks。
**Validation:**
```bash
npm run test -- --run src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts src/games/bark-battle/infrastructure/__tests__/AudioAnalyserSampler.test.ts
npm run typecheck
npm run dev:web
# 手动 smoke: 真实麦克风授权 → 校准 → 发声 → 结算
```
### Task 1.10Phase 1 收口验证
**Objective:** 确认“纯浏览器原型”已经可以交给产品/测试试玩。
**Commands:**
```bash
npm run test -- --run src/games/bark-battle/domain/**/*.test.ts src/games/bark-battle/application/**/*.test.ts src/games/bark-battle/infrastructure/**/*.test.ts src/games/bark-battle/ui/**/*.test.tsx
npm run typecheck
npm run lint:eslint
npm run check:encoding
npm run dev:web
```
**Manual smoke checklist:**
- [ ] `/bark-battle` 能打开。
- [ ] mock 模式可完整完成一局。
- [ ] 真实麦克风模式可授权、校准、发声、结算。
- [ ] 拒绝权限后不会进入 playing。
- [ ] 移动端窄屏能看到核心信息并能点击主要按钮。
- [ ] 再来一局不会继承上一局 energy/barkCount/result。
---
## 3. Phase 2打通创作入口用 AI 赋能内容创作
### Task 2.1:定义 `bark-battle` 草稿契约(前端本地版)
**Objective:** 在接后端前,先定义 AI 可生成的 runtime draft shape。
**Files:**
- Create: `packages/shared/src/contracts/barkBattle.ts`(临时前端共享类型,后端阶段再对齐 Rust shared-contracts
- Create: `src/services/bark-battle-creation/barkBattleDraftDefaults.ts`
- Create: `src/services/bark-battle-creation/barkBattleDraftValidation.ts`
**Draft fields:**
- `title`
- `description`
- `themePrompt`
- `playerDogName`
- `opponentDogName`
- `backgroundStyle`
- `difficulty`
- `roundDurationMs`
- `drawThreshold`
- `opponentConfig`
- `audioSensitivityPreset`
- `visualStyle`
**Validation:**
```bash
npm run test -- --run src/services/bark-battle-creation/**/*.test.ts
npm run typecheck
```
### Task 2.2:新增创作入口配置
**Objective:** 让 `bark-battle` 出现在创作中心入口中,但可通过后端入口配置开关控制。
**Files likely to change:**
- `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
- `server-rs/crates/module-runtime/src/domain.rs`
- `server-rs/crates/module-runtime/src/application.rs`
- `server-rs/crates/shared-contracts/src/creation_entry_config.rs`
- `src/components/platform-entry/platformEntryCreationTypes.ts`
- `src/components/platform-entry/platformEntryCreationTypes.test.ts`
**Plan:**
1. 在入口 seed 中新增 `bark-battle`,首轮可设:`visible: true`、`open: true`(若需要灰度则 `open: false`)。
2. 前端展示派生只消费 API 返回,不恢复旧静态入口事实源。
3. 更新排序和锁定态测试。
**Validation:**
```bash
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts
npm run typecheck
cd server-rs && cargo check -p api-server -p spacetime-module --no-default-features
```
### Task 2.3:扩展 SelectionStage 与流程分流
**Objective:** 点击 `bark-battle` 入口后进入对应创作工作台。
**Files likely to change:**
- `src/components/platform-entry/platformEntryTypes.ts`
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
- `src/components/platform-entry/usePlatformCreationAgentFlowController.ts`(如复用通用 agent flow
**Stages:**
- `bark-battle-agent-workspace`
- `bark-battle-generating`(可选)
- `bark-battle-result`
- `bark-battle-runtime`
**Validation:**
```bash
npm run test -- src/components/platform-entry/**/*.test.tsx src/components/platform-entry/**/*.test.ts
npm run typecheck
```
### Task 2.4:实现 AI 创作工作台
**Objective:** 用对话式或表单式输入生成 `BarkBattleDraft`。
**Files:**
- Create: `src/components/bark-battle-creation/BarkBattleAgentWorkspace.tsx`
- Create: `src/services/bark-battle-creation/barkBattleCreationClient.ts`
- Create: `src/services/bark-battle-creation/barkBattlePromptBuilder.ts`
- Create: `src/services/bark-battle-creation/__tests__/barkBattlePromptBuilder.test.ts`
**Behavior:**
- 用户输入一句主题,例如“柴犬在赛博公园比谁叫得响”。
- AI 返回结构化草稿。
- 前端校验并填默认值,不让非法 roundDuration/difficulty 进入 runtime。
- 错误时保留用户输入和已生成草稿。
**Validation:**
```bash
npm run test -- --run src/services/bark-battle-creation/**/*.test.ts
npm run typecheck
```
### Task 2.5:实现结果页与试玩入口
**Objective:** AI 草稿生成后可查看、返回编辑、进入 Phase 1 runtime 试玩。
**Files:**
- Create: `src/components/bark-battle-result/BarkBattleResultView.tsx`
- Create: `src/components/bark-battle-result/BarkBattleResultView.test.tsx`
- Modify: `src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`(允许传入 draft config
**Behavior:**
- 展示标题、主题、狗狗名、背景风格、难度、局长。
- 提供“返回编辑”“试玩”按钮。
- 暂不展示发布按钮,或发布按钮显示为后端阶段能力。
**Validation:**
```bash
npm run test -- --run src/components/bark-battle-result/BarkBattleResultView.test.tsx
npm run typecheck
npm run dev:web
# 手动 smoke: 创作入口 → AI 草稿 → 结果页 → 试玩 → 返回编辑
```
### Task 2.6Phase 2 收口验证
**Commands:**
```bash
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/bark-battle-result/BarkBattleResultView.test.tsx src/services/bark-battle-creation/**/*.test.ts src/games/bark-battle/**/*.test.ts src/games/bark-battle/**/*.test.tsx
npm run typecheck
npm run lint:eslint
npm run check:encoding
```
**Manual smoke checklist:**
- [ ] 创作中心展示 `bark-battle`。
- [ ] 点击入口进入工作台。
- [ ] AI 可生成草稿。
- [ ] 草稿结果页可展示并返回编辑。
- [ ] 试玩使用草稿配置影响 runtime 表现。
- [ ] 未接数据库前不会假装发布成功。
---
## 4. Phase 3数据库落地与正式作品闭环
### Task 3.1:补齐 shared contracts
**Objective:** 前后端共享 bark-battle DTO避免前端手写正式契约漂移。
**Files likely to change:**
- `server-rs/crates/shared-contracts/src/bark_battle.rs`
- `server-rs/crates/shared-contracts/src/lib.rs`
- `packages/shared/src/contracts/barkBattle.ts`
**DTO:**
- `BarkBattleDraft`
- `BarkBattlePublishedConfig`
- `CreateBarkBattleSessionRequest/Response`
- `BarkBattleRuntimeStartRequest/Response`
- `BarkBattleRuntimeFinishRequest/Response`
- `BarkBattleRunResult`
- `BarkBattleScoreSummary`
- `BarkBattleLeaderboardEntry`
**Validation:**
```bash
npm run typecheck
cd server-rs && cargo check -p shared-contracts --no-default-features
```
### Task 3.2:新增 `module-bark-battle` 纯领域模块
**Objective:** 后端正式分数、配置校验、提交合法性不写在 api-server handler 里。
**Files:**
- Create: `server-rs/crates/module-bark-battle/`
- Modify: `server-rs/Cargo.toml`
**Responsibilities:**
- 配置合法性校验。
- run start/finish 状态约束。
- 派生指标范围校验。
- 分数与排行榜排序分计算。
- 不接 Axum、不接 SpacetimeDB、不接 HTTP。
**Validation:**
```bash
cd server-rs && cargo test -p module-bark-battle --no-default-features
cd server-rs && cargo check -p module-bark-battle --no-default-features
```
### Task 3.3SpacetimeDB 表、reducer、migration
**Objective:** 保存草稿、发布态配置、run、result、leaderboard 投影。
**Files likely to change:**
- `server-rs/crates/spacetime-module/src/runtime/bark_battle.rs`(或按现有模块目录命名)
- `server-rs/crates/spacetime-module/src/migration.rs`
- `server-rs/crates/spacetime-module/src/lib.rs`
- 生成绑定目录(通过命令生成,不手改生成物)
**Tables draft:**
- `bark_battle_draft`
- `bark_battle_published_config`
- `bark_battle_run`
- `bark_battle_run_result`
- `bark_battle_leaderboard_entry`
**Reducers/procedures:**
- `create_bark_battle_draft`
- `publish_bark_battle_config`
- `start_bark_battle_run`
- `finish_bark_battle_run`
- `list_bark_battle_leaderboard`
**Validation:**
```bash
npm run spacetime:generate
cd server-rs && cargo check -p spacetime-module --no-default-features
npm run check:server-rs-ddd
```
### Task 3.4spacetime-client facade
**Objective:** api-server 通过 facade 调用 SpacetimeDB不直接散落 reducer 细节。
**Files likely to change:**
- `server-rs/crates/spacetime-client/src/runtime.rs`
- `server-rs/crates/spacetime-client/src/mapper.rs`
- `server-rs/crates/spacetime-client/src/lib.rs`
**Validation:**
```bash
cd server-rs && cargo check -p spacetime-client --no-default-features
```
### Task 3.5api-server BFF 路由
**Objective:** 提供创作、发布态 runtime start/finish、leaderboard API。
**Files likely to change:**
- `server-rs/crates/api-server/src/bark_battle.rs`
- `server-rs/crates/api-server/src/main.rs` 或路由注册文件
**Routes draft:**
- `POST /api/bark-battle/sessions`
- `GET /api/bark-battle/sessions/:sessionId`
- `POST /api/bark-battle/runtime/start`
- `POST /api/bark-battle/runtime/finish`
- `GET /api/bark-battle/works/:workId/runtime-config`
- `GET /api/bark-battle/works/:workId/leaderboard`
**Tracking:**
- runtime start 成功后主动写 `work_play_start`。
- `scope_kind=work`。
- `scope_id=稳定作品 ID`。
- metadata 包含 `playType=bark-battle`、`workId`、`sourceRoute`、`userId`。
**Validation:**
```bash
npm run api-server
# 另一个终端检查 /healthz并执行对应 API smoke
cd server-rs && cargo check -p api-server --no-default-features
```
### Task 3.6:前端正式 client 与 runtime 切换
**Objective:** runtime 从本地草稿模式升级为可读取后端发布态 config并提交正式派生结果。
**Files likely to change:**
- Create: `src/services/bark-battle-runtime/barkBattleRuntimeClient.ts`
- Create: `src/services/bark-battle-works/barkBattleWorksClient.ts`
- Modify: `src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`
- Modify: `src/components/bark-battle-result/BarkBattleResultView.tsx`
- Modify: `src/components/custom-world-home/creationWorkShelf.ts`
- Modify: `src/components/custom-world-home/CustomWorldCreationHub.tsx`
- Modify: `src/services/publicWorkCode.ts`
**Behavior:**
- 本地 preview 仍可使用 draft config。
- 正式作品 runtime 必须先调用 start API拿 run token/session。
- finish 只提交派生 metrics。
- 发布后刷新作品架/广场。
**Validation:**
```bash
npm run test -- src/services/bark-battle-runtime/**/*.test.ts src/games/bark-battle/**/*.test.ts src/games/bark-battle/**/*.test.tsx
npm run typecheck
npm run check:encoding
```
### Task 3.7Phase 3 收口验证
**Commands:**
```bash
npm run test -- src/games/bark-battle/**/*.test.ts src/games/bark-battle/**/*.test.tsx src/services/bark-battle-runtime/**/*.test.ts src/services/bark-battle-creation/**/*.test.ts
npm run typecheck
npm run lint:eslint
npm run check:encoding
npm run check:server-rs-ddd
cd server-rs && cargo test -p module-bark-battle --no-default-features
cd server-rs && cargo check -p api-server -p spacetime-module -p spacetime-client -p shared-contracts --no-default-features
npm run api-server
```
**Manual smoke checklist:**
- [ ] 创作者生成并发布 bark-battle 作品。
- [ ] 玩家从作品页进入 runtime。
- [ ] start API 成功并写 `work_play_start`。
- [ ] 浏览器本地完成一局。
- [ ] finish API 只上传派生指标。
- [ ] 成绩/排行榜/作品架刷新来自后端投影。
- [ ] 拒绝麦克风权限时不会创建非法 finished result。
---
## 5. 文件清单总览
### Phase 1 likely files
- `src/routing/appRoutes.tsx`
- `src/BarkBattlePlaygroundApp.tsx`
- `src/games/bark-battle/domain/BarkBattleTypes.ts`
- `src/games/bark-battle/domain/BarkDetector.ts`
- `src/games/bark-battle/domain/EnergyTugOfWar.ts`
- `src/games/bark-battle/domain/BarkBattleSession.ts`
- `src/games/bark-battle/domain/BarkBattleScoring.ts`
- `src/games/bark-battle/domain/OpponentStrategy.ts`
- `src/games/bark-battle/application/BarkBattleConfig.ts`
- `src/games/bark-battle/application/BarkBattleController.ts`
- `src/games/bark-battle/application/BarkBattleSnapshotStore.ts`
- `src/games/bark-battle/infrastructure/BrowserMicrophoneInput.ts`
- `src/games/bark-battle/infrastructure/AudioAnalyserSampler.ts`
- `src/games/bark-battle/infrastructure/MicrophonePermission.ts`
- `src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`
- `src/games/bark-battle/ui/BarkBattleHud.tsx`
- `src/games/bark-battle/ui/BarkBattleResultPanel.tsx`
- `src/games/bark-battle/ui/BarkBattleHud.css`
### Phase 2 likely files
- `packages/shared/src/contracts/barkBattle.ts`
- `src/components/platform-entry/platformEntryTypes.ts`
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
- `src/components/platform-entry/platformEntryCreationTypes.ts`
- `src/components/bark-battle-creation/BarkBattleAgentWorkspace.tsx`
- `src/components/bark-battle-result/BarkBattleResultView.tsx`
- `src/services/bark-battle-creation/*`
### Phase 3 likely files
- `server-rs/crates/shared-contracts/src/bark_battle.rs`
- `server-rs/crates/module-bark-battle/*`
- `server-rs/crates/spacetime-module/src/runtime/bark_battle.rs`
- `server-rs/crates/spacetime-module/src/migration.rs`
- `server-rs/crates/spacetime-client/src/runtime.rs`
- `server-rs/crates/spacetime-client/src/mapper.rs`
- `server-rs/crates/api-server/src/bark_battle.rs`
- `src/services/bark-battle-runtime/*`
- `src/services/bark-battle-works/*`
- `src/components/custom-world-home/creationWorkShelf.ts`
- `src/services/publicWorkCode.ts`
---
## 6. 风险、取舍与开放问题
### 风险
1. **麦克风权限和移动端 AudioContext 差异大。** 需要 mock input 保底,否则自动化和本地开发会被真实设备阻塞。
2. **第一阶段过早引入 Phaser 可能拖慢验证。** 当前仓库没有 `phaser` 依赖;建议先用 DOM/Canvas 跑通玩法,再决定是否引入 Phaser。
3. **AI 草稿和正式发布配置容易漂移。** Phase 2 临时 TS 类型必须在 Phase 3 与 Rust shared-contracts 对齐。
4. **不能保存原始音频。** 后端阶段只能保存派生指标,任何音频片段、波形、频谱明细都不应落库。
5. **入口配置事实源在后端/SpacetimeDB。** Phase 2 接入口时不要恢复旧前端静态入口配置。
### 取舍
- Phase 1 先把“游戏是否好玩、功能是否跑通”作为第一目标,不追求正式作品闭环。
- Phase 2 让 AI 生成内容配置,而不是让 AI 直接生成任意代码或不受控规则。
- Phase 3 再把正式业务真相交给后端,避免前端 runtime 先背上发布、成绩、排行榜的复杂度。
### 开放问题
1. Phase 1 是否必须使用 Phaser如果只是验证玩法可先使用 DOM/CSS/Canvas 原型,后续再替换 renderer。
2. `bark-battle` 的正式中文名是否固定为“汪汪声浪大作战”?如果名称要改,需先统一文档、入口配置和分享标题。
3. AI 创作阶段是否需要生成图片/狗狗视觉资产,还是只生成风格描述和使用占位素材?
4. 是否需要排行榜作为 Phase 3 必选,还是作为数据库落地后的增强项?
5. 真实麦克风 smoke 需要哪些目标设备Chrome 桌面、Android Chrome、iOS Safari 是否都纳入首批验收?
---
## 7. 建议执行方式
1. 先按 Phase 1 执行,且每个 domain/application task 坚持 TDD先失败测试再实现。
2. Phase 1 合并前不要接数据库,不要新增后端表,不要把入口配置切到 open。
3. Phase 1 验证通过后,让产品/团队试玩 `/bark-battle`,确认玩法数值和 UI 方向。
4. 再进入 Phase 2把 AI 创作工作台接到同一个 runtime draft config。
5. 最后进入 Phase 3按后端 DDD 文档做数据库、发布、成绩和追踪闭环。

View File

@@ -0,0 +1,310 @@
# K6 作品列表压测计划(使用 spacetime-migration-7.json 作为数据源)
## 目标
使用 K6 对 Genarrative 的“作品列表”相关接口进行压测,并将用户提供的 `spacetime-migration-7.json` 作为压测数据源;数据处理时**只导入作品列表相关数据**,不导入用户、会话、钱包、埋点、运行存档等非作品表,避免把敏感或无关数据带入压测环境。
## 当前上下文
- 工作区:`/c/proj/Genarrative`
- 原始迁移文件:`C:\Users\DSK\AppData\Local\hermes\cache\documents\doc_150e84029b2d_spacetime-migration-7.json`
- 已确认原始迁移文件结构:
- `schema_version = 1`
- `tables = 53`
- 作品相关表中当前有数据的重点表:
- `puzzle_work_profile`80 行
- `custom_world_profile`1 行
- `match3d_work_profile`0 行
- `big_fish_*`:当前样本中相关表为 0 行
- 原始文件还包含 `user_account``auth_identity``refresh_session``profile_wallet_ledger``asset_object`、运行记录等数据,压测导入时必须过滤。
- 当前仓库未发现现成 K6 脚本或 `k6` 相关文件,需要新增压测脚本与数据提取脚本。
- `package.json` 当前有 `dev/dev:rust/test/check` 等脚本,未发现 K6 npm script。
## 范围约束
### 本次只导入/使用
1. 作品列表表:
- `puzzle_work_profile`
- `custom_world_profile`
- 后续若接口覆盖其他玩法,可扩展:
- `match3d_work_profile`
- `square_hole_work_profile`(以实际 SpacetimeDB 表名为准)
- `big_fish_work_profile`(以实际 SpacetimeDB 表名为准)
- `visual_novel_work_profile`(以实际 SpacetimeDB 表名为准)
2. 为作品列表卡片展示所需的最小字段:
- 稳定 ID`profile_id``work_id``public_work_code`
- 标题:`work_title` / `level_name` / `world_name`
- 描述:`work_description` / `summary` / `summary_text` / `subtitle`
- 作者:`owner_user_id``author_display_name``author_public_user_code`
- 封面:`cover_image_src``cover_asset_id`(如果接口只返回 asset id则压测阶段不额外导入二进制 asset
- 状态与计数:`publication_status``published_at``play_count``like_count``remix_count`
- 作品内容摘要:`levels_json``profile_payload_json``theme_tags_json` 等列表渲染或进入作品详情可能需要的 JSON 字段
### 本次不导入/不使用
- 认证与账号:`user_account``auth_identity``refresh_session``auth_store_snapshot`
- 用户资产与钱包:`profile_wallet_ledger``profile_dashboard_state``profile_redeem_*``profile_invite_*`
- 游玩历史/存档/运行态:`profile_played_world``public_work_play_daily_stat``puzzle_runtime_run``profile_save_archive``runtime_snapshot`
- AI 任务过程:`ai_task``ai_task_stage``ai_text_chunk`
- asset 二进制与绑定:`asset_object``asset_entity_binding`,除非后续确认作品列表接口强依赖它们;即便需要,也只导入作品列表封面所需的最小 metadata不导入原始大对象。
## 推荐目录与文件
建议新增:
```text
.hermes/plans/2026-05-11_195214-k6-works-list-load-test-plan.md # 本计划
scripts/loadtest/extract-works-list-data.mjs # 从迁移文件提取作品列表数据
scripts/loadtest/k6-works-list.js # K6 压测脚本
scripts/loadtest/data/works-list.sample.json # 过滤后的样例数据(不要提交敏感原始迁移全量)
scripts/loadtest/README.md # 执行说明与指标阈值
```
可选新增 npm scripts
```json
{
"loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs",
"loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js"
}
```
## 数据提取方案
### 输入
默认读取:
```bash
node scripts/loadtest/extract-works-list-data.mjs \
--input "C:/Users/DSK/AppData/Local/hermes/cache/documents/doc_150e84029b2d_spacetime-migration-7.json" \
--output scripts/loadtest/data/works-list.local.json
```
### 输出结构
建议输出为 K6 直接可读的 JSON
```json
{
"source": "spacetime-migration-7.json",
"generatedAt": "<iso datetime>",
"tables": {
"puzzle_work_profile": [
{
"profile_id": "...",
"work_id": "...",
"owner_user_id": "...",
"work_title": "...",
"work_description": "...",
"publication_status": "Published",
"published_at": { "__timestamp_micros_since_unix_epoch__": 0 },
"play_count": 0,
"like_count": 0,
"levels_json": "..."
}
],
"custom_world_profile": []
},
"workIds": {
"puzzle": ["<profile_id>"],
"customWorld": ["<profile_id>"]
}
}
```
### 过滤原则
1.`tables[].name` 白名单过滤,只保留作品 profile 表。
2. 对每个 row 再按字段白名单过滤避免误带账号、手机号、token、钱包流水等字段。
3. 对特别大的字段进行处理:
- `cover_image_src` 如果是 `data:image/...base64`,默认替换为占位符或截断,避免压测数据文件过大。
- `levels_json``profile_payload_json` 保留原文,但可以记录大小;如果过大,再提供 `--compact` 选项只保留摘要。
4. 输出 `.local.json` 默认加入 `.gitignore`;如果要提交样例数据,只提交脱敏/裁剪后的 `works-list.sample.json`
## K6 压测接口矩阵
需要先确认本地 api-server 实际端口。默认以 `http://127.0.0.1:8787` 为例,实际运行时通过环境变量覆盖:
```bash
BASE_URL=http://127.0.0.1:<actual-api-port> k6 run scripts/loadtest/k6-works-list.js
```
初版建议覆盖以下“作品列表”读接口,具体路径以仓库服务端路由为准,实施时需要通过搜索 api-server 路由确认:
| 场景 | 目的 | 候选路径 |
| --- | --- | --- |
| 拼图作品列表 | 作品列表主场景之一,当前数据量最多 | `/api/creation/puzzle/works` 或实际 puzzle works list route |
| RPG/自定义世界作品列表 | 使用 `custom_world_profile` 数据 | `/api/creation/custom-world/works` 或实际 custom world works route |
| 作品详情/启动前读取 | 模拟用户从列表点进作品 | `/api/creation/*/works/:profileId``/api/runtime/*/works/:profileId` |
| 公开作品库 | 如果首页/发现页依赖 | `/api/runtime/*/works` 或 gallery/list route |
> 注意:不要凭空固定 endpoint。实施阶段先用 `search_files` / 路由源码确认真实路径,再写入 K6 脚本。
## K6 场景设计
### 阶段 1基线 smoke
目的:确认脚本、数据和目标服务可用。
```js
export const options = {
scenarios: {
smoke: {
executor: 'constant-vus',
vus: 1,
duration: '30s'
}
},
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<800']
}
};
```
### 阶段 2常规读压
目的:模拟日常列表浏览。
- `constant-vus`: 10/25/50 三档
- 每个 VU 随机选择作品类型和列表分页参数
- `sleep(0.5~2s)` 模拟用户停留
- 阈值建议:
- `http_req_failed < 1%`
- `p95 < 800ms`
- `p99 < 1500ms`
### 阶段 3峰值/突刺
目的:模拟首页入口或活动导致的作品列表突增。
- `ramping-arrival-rate`
- 从 5 RPS 增长到 100 RPS维持 2~5 分钟,再降回
- 单独输出 `checks`:列表接口状态码、响应 JSON shape、items 数量
### 阶段 4容量探索
目的:找瓶颈,不作为每次回归必跑。
- 每轮提升 RPS 或 VU
- 观察api-server CPU/内存、SpacetimeDB 日志、错误率、p95/p99
- 一旦 `http_req_failed >= 5%` 或 p95 持续超过 2s停止继续加压并记录容量点。
## K6 脚本设计要点
1. 使用 `SharedArray` 加载 `works-list.local.json`,避免每个 VU 重复解析大 JSON。
2. 基于数据源里的 `profile_id` / `work_id` 随机抽样,保证请求覆盖真实作品 ID。
3. 对列表接口添加分页/排序 query例如
- `?limit=20&offset=0`
- `?pageSize=20&cursor=...`(以真实 API 为准)
4. 使用 `check()` 验证:
- HTTP 200
- 响应体是 JSON
- `items``works` 是数组
- 列表项包含 `profileId/profile_id`、标题字段、状态字段
5. 使用 `Trend` / `Rate` 细分指标:
- `works_list_duration`
- `works_detail_duration`
- `works_list_shape_error_rate`
6. 支持环境变量:
```bash
BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=scripts/loadtest/data/works-list.local.json \
SCENARIO=baseline \
k6 run scripts/loadtest/k6-works-list.js
```
## 实施步骤
1. **确认路由**
- 搜索 api-server / BFF 的作品列表路由。
- 明确各玩法对应 endpoint、鉴权要求、分页参数、返回字段。
2. **实现数据提取脚本**
- 新增 `scripts/loadtest/extract-works-list-data.mjs`
- 只按表白名单读取作品列表 profile 表。
- 对字段做白名单与脱敏/截断。
- 输出 `works-list.local.json`
3. **生成本地压测数据**
- 用用户提供的迁移文件生成 `scripts/loadtest/data/works-list.local.json`
- 验证输出只包含作品表和作品字段。
4. **实现 K6 脚本**
- 新增 `scripts/loadtest/k6-works-list.js`
- 支持 `BASE_URL``WORKS_DATA``SCENARIO`
- 覆盖列表接口,必要时增加详情/启动前读取接口。
5. **新增执行说明**
-`scripts/loadtest/README.md` 写明:安装 K6、启动本地 dev 栈、提取数据、运行 smoke/baseline/spike、查看结果。
6. **本地验证**
- 启动 Genarrative dev 栈;注意端口可能漂移,使用实际 api-server 端口。
- 跑 smoke`SCENARIO=smoke`
- 确认失败率、p95、响应 shape。
7. **可选集成 npm scripts**
- 如果团队希望标准化入口,再加入 `package.json` scripts。
8. **记录结果**
- 将 smoke/baseline/spike 的结果摘要追加到 `scripts/loadtest/README.md` 或单独保存到 `.hermes/plans/` 的结果文档中。
## 启动与运行建议
本地服务启动按当前 Genarrative dev 栈约定:
```bash
npm run dev
```
如果 SpacetimeDB/API/Vite 端口被占用,项目脚本会寻找可用端口;压测时必须从启动日志中读取实际 api-server 地址,并传给 K6
```bash
BASE_URL=http://127.0.0.1:<actual-api-port> \
WORKS_DATA=scripts/loadtest/data/works-list.local.json \
SCENARIO=smoke \
k6 run scripts/loadtest/k6-works-list.js
```
## 验证标准
### 数据源验证
- `works-list.local.json` 中只出现作品 profile 表。
- 不出现以下字段或内容:
- `password_hash`
- `refresh_token_hash`
- `phone_number_e164`
- `phone_number_masked`
- `wallet_ledger_id`
- `auth_identity`
- `user_account`
- `puzzle_work_profile` 行数应接近原始文件中的 80 行。
- `custom_world_profile` 行数应接近原始文件中的 1 行。
### K6 smoke 验证
- 所有目标接口返回 2xx。
- `http_req_failed < 1%`
- 响应 JSON shape 与 shared contracts 对齐:`items``works` 数组。
- K6 输出中能区分不同 endpoint 的耗时。
### 性能阈值初稿
- Smoke`p95 < 800ms`,失败率 `< 1%`
- Baseline`p95 < 1000ms``p99 < 2000ms`,失败率 `< 1%`
- Spike允许短暂 p95 抖动,但 1 分钟内应恢复;失败率 `< 5%`
阈值后续需要结合本地机器性能、SpacetimeDB 本地模式和正式部署规格调整。
## 风险与注意事项
1. **原始迁移文件包含敏感数据。** 必须只提取作品列表白名单字段,禁止把原始 JSON 全量提交到仓库。
2. **base64 封面可能导致压测数据膨胀。** 默认截断或替换为占位符,除非本次明确要测封面 payload 对响应体积的影响。
3. **本地 SpacetimeDB 与 api-server 端口会漂移。** 不要硬编码端口,运行时通过 `BASE_URL` 注入。
4. **列表接口可能需要鉴权。** 若实际接口要求登录,不要导入真实 refresh session应使用本地测试账号或专门的压测 token 生成流程。
5. **作品表名/接口路径可能与候选名称不完全一致。** 实施前必须以源码路由为准。
6. **本计划仅保存压测方案,不执行实际压测。** 后续执行时再创建/修改脚本、导出过滤数据、跑 K6 并记录结果。
## 开放问题
1. 压测目标是本地 dev 栈、测试环境,还是预发/生产只读接口?不同环境阈值和安全边界不同。
2. “作品列表”是否只包含拼图和自定义世界,还是要覆盖 match3d、square-hole、big-fish、visual-novel 的统一列表入口?
3. 是否允许使用专门压测账号/token如果接口无鉴权则无需处理。
4. 是否需要测封面/asset 加载,还是只测作品列表 JSON API

View File

@@ -0,0 +1,447 @@
# Genarrative 容灾方案设计计划
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** 基于当前 Genarrative 单机生产部署、Jenkins 流水线、SpacetimeDB 与 Rust `api-server` 架构,补齐一套可落地、可演练、可审计的容灾方案。
**Architecture:** 首版容灾不引入复杂多活系统,优先围绕现有 `systemd + Nginx + SpacetimeDB + api-server + Jenkins` 单机生产推荐方案做“备份可恢复、版本可回滚、故障可切换、演练可复盘”。方案采用分层容灾入口层、静态资源层、API 服务层、SpacetimeDB 数据层、外部服务与密钥层、Jenkins/发布链路层。
**Tech Stack:** Nginx、systemd、SpacetimeDB self-hosting、Rust `api-server` / Axum、Jenkins Pipeline、Shell/Node.js 运维脚本、仓库 `deploy/``docs/technical/` 文档体系。
---
## 1. 当前上下文与已确认事实
### 1.1 当前生产部署口径
来自 `docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` 的现状:
- 生产为单机推荐方案,不使用 Docker。
- 公网入口为 Nginx负责 HTTPS、静态站点、后台静态页面、维护页、`/admin/api/` 与临时 `/api/*` 反向代理。
- SpacetimeDB 作为 systemd 服务运行:
- `spacetimedb.service`
- 监听:`127.0.0.1:3101`
- 数据根目录:`/stdb`
- Rust `api-server` 作为 systemd 服务运行:
- `genarrative-api.service`
- 监听:`127.0.0.1:8082`
- 环境文件:`/etc/genarrative/api-server.env`
- 静态站点发布到 release/current 目录:
- `/opt/genarrative/releases/<version>/`
- `/opt/genarrative/current`
- `/srv/genarrative/web`
- 已有维护模式:
- 开关文件:`/var/lib/genarrative/maintenance/enabled`
- API 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更必须进入维护模式。
- 已有数据库导入导出 Jenkins Job
- `Genarrative-Database-Export`
- `Genarrative-Database-Import`
- 对应文件:`jenkins/Jenkinsfile.production-database-export``jenkins/Jenkinsfile.production-database-import`
- 已有回滚基本口径:
- Web 回滚:切 `/srv/genarrative/web``/opt/genarrative/current` 到上一版本并 reload Nginx。
- API 回滚:切 `/opt/genarrative/current` 到上一版本并重启 `genarrative-api.service`
- SpacetimeDB 模块回滚:发布上一版本 `spacetime_module.wasm`
- 数据回滚:使用导入流水线恢复指定备份,必须进入维护模式。
### 1.2 关键风险
- 当前是单机生产拓扑,单机磁盘、系统盘、`/stdb`、Nginx 或公网 IP 故障会造成整体不可用。
- SpacetimeDB 是核心业务真相,容灾重点必须围绕 `/stdb`、数据库导出产物、schema 迁移与导入验证。
- `/etc/genarrative/api-server.env` 持有生产密钥,不能进入 Git也不能写进普通备份明文归档。
- Jenkins controller/agent 同时承担构建、发布、备份、导入导出编排Jenkins 不可用时仍需要有最小人工恢复路径。
- 外部 LLM、图片、语音、3D 网关不是本仓库可控系统,容灾只能做到配置降级、超时隔离、能力熔断与可观测告警。
---
## 2. 容灾目标
### 2.1 恢复目标建议
| 灾难类型 | 目标 RTO | 目标 RPO | 首版策略 |
| --- | ---: | ---: | --- |
| Web 静态资源发布失败 | 5 分钟 | 0 | release/current 原子切换回滚 |
| API 发布失败 | 10 分钟 | 0 | 维护模式 + 上一版二进制回滚 |
| SpacetimeDB wasm 发布失败 | 15 分钟 | 0 或按迁移前备份 | 发布前导出 + 上一版 wasm 回滚 |
| 数据误写 / 迁移失败 | 30-60 分钟 | 最近一次导出点 | 导入流水线从备份恢复 |
| 生产机磁盘损坏 | 2-4 小时 | 最近一次异地备份 | 新机器 provision + 拉取 release 包 + 恢复数据库 |
| Jenkins controller 不可用 | 1-2 小时 | 不影响线上数据 | 手工脚本恢复 + Jenkins 备份恢复 |
| 第三方模型网关不可用 | 5-15 分钟内降级 | 不丢核心数据 | 配置切换 / 功能熔断 / 队列失败可重试 |
### 2.2 首版不做
- 不做跨地域双活写入。
- 不做 SpacetimeDB 在线主从复制,除非后续官方能力与项目压测验证支持。
- 不让前端绕过 `api-server` 直接承担正式业务真相。
- 不把生产密钥、Token、数据库 dump、Jenkins secret 写入 Git。
- 不恢复旧 `server-node`、Express、PostgreSQL 或 Docker 一体化部署方案。
---
## 3. 总体容灾设计
### 3.1 分层策略
1. **入口层Nginx / DNS / HTTPS**
- 保留 Nginx 配置模板在 Git`deploy/nginx/genarrative.conf``deploy/nginx/genarrative-dev-http.conf`
- 为 release 环境建立 Nginx 配置备份与证书恢复流程。
- 明确 DNS 切换预案:生产机不可恢复时,将域名指向灾备机公网 IP。
2. **静态资源层Web / Admin Web**
- 依赖 `web.tar.gz``web.tar.gz.sha256``release-manifest.json`
- 保留最近 N 个 release 目录与构建产物指针。
- 回滚只切软链,不重新构建。
3. **API 服务层Rust `api-server`**
- 依赖归档的 `api-server` 二进制、checksum、`release-manifest.json`
- `/etc/genarrative/api-server.env` 通过加密备份或密钥管理恢复,不进入 release 包。
- systemd unit 由 `deploy/systemd/genarrative-api.service` 重新安装。
4. **数据层SpacetimeDB**
- 每次高风险发布前强制导出数据库。
- 定时导出:建议每天至少 1 次;高活跃期可每 4 小时 1 次。
- 导出产物同时保存在Jenkins 归档 + 生产机 `SERVER_BACKUP_DIRECTORY` + 异地对象存储/备份机。
- 导入前自动生成安全备份,保留当前实现口径。
5. **发布编排层Jenkins**
- Jenkins Job、Jenkinsfile 在 Git 中可恢复。
- Jenkins controller 配置、凭据、插件清单需要额外备份。
- 发布 agent 使用 inbound + systemd 自恢复agent secret 仅存在目标机或 Jenkins 凭据。
6. **密钥与外部服务层**
- `/etc/genarrative/api-server.env`、Jenkins Secret Text、SSH PEM、agent secret 不进 Git。
- 制定密钥清单和恢复责任人,但不在仓库记录明文。
- 外部服务配置按 `docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md` 维护必配项。
---
## 4. 建议新增/更新的文档
### Task 1: 新增生产容灾技术方案文档
**Objective:** 形成团队可共享、可执行的容灾总纲。
**Files:**
- Create: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md`
- Modify: `docs/technical/README.md`(若已有技术索引,应加入该文档入口)
- Optional Modify: `.hermes/shared-memory/project-overview.md`(只加稳定索引,不写敏感信息)
**文档必须覆盖:**
1. 容灾目标RTO/RPO 表。
2. 生产资产清单Nginx、systemd、release/current、`/stdb``/etc/genarrative/api-server.env`、Jenkins、构建产物。
3. 备份策略:
- 数据库导出。
- release 产物保留。
- Nginx/systemd/env 配置备份。
- Jenkins 配置备份。
4. 恢复流程:
- Web 回滚。
- API 回滚。
- Stdb module 回滚。
- 数据恢复。
- 整机重建。
5. 演练计划:每月一次数据库恢复演练,每季度一次整机重建演练。
6. 安全边界:密钥不进 Git备份加密最小权限。
7. 验收命令与人工检查清单。
**Verification:**
```bash
npm run check:encoding
```
Expected: PASS无中文乱码、无 BOM/CRLF 问题。
---
## 5. 建议新增/更新的脚本与流水线
### Task 2: 增强数据库定时备份流水线
**Objective:** 把现有人工导出扩展为可定时执行、可异地保存、可审计的备份流程。
**Files:**
- Modify: `jenkins/Jenkinsfile.production-database-export`
- Modify: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md`
- Optional Create: `scripts/deploy/production-backup-sync.sh`
**Implementation notes:**
- 在 Jenkins Job 中保留人工触发能力,同时建议配置 cron
- development每天凌晨。
- release每天凌晨或业务低峰。
- 增加备份命名规范:
- `spacetime-migration-<database>-<yyyyMMdd-HHmmss>-<source_commit>.json`
- 增加 `SERVER_BACKUP_DIRECTORY` 默认建议:
- `/var/backups/genarrative/spacetimedb/<database>/`
- 增加备份保留策略:
- 本机保留 7-14 天。
- 异地保留 30-90 天。
- 如实现 `production-backup-sync.sh`,只做同步框架,不硬编码真实 bucket、账号、endpoint 或密钥。
**Verification:**
```bash
bash -n scripts/deploy/production-backup-sync.sh
npm run check:encoding
```
Expected: shell 语法通过;文档编码检查通过。
---
### Task 3: 增加灾备恢复 Runbook
**Objective:** 在真正故障时不依赖临场推理,按清单执行恢复。
**Files:**
- Create: `docs/operations/PRODUCTION_DR_RUNBOOK_2026-05-11.md`
- Modify: `docs/operations/README.md`(如果存在)
**Runbook sections:**
1. 故障分级P0/P1/P2。
2. 第一响应:
- 判断 Nginx 是否在线。
- 判断 `genarrative-api.service` 是否在线。
- 判断 `spacetimedb.service` 是否在线。
- 判断磁盘是否满。
- 判断 Jenkins agent 是否在线。
3. 快速止血:
- 开维护模式。
- 禁止继续发布。
- 保留现场日志。
4. 回滚流程:
- Web 回滚命令。
- API 回滚命令。
- Stdb wasm 回滚命令。
5. 数据恢复流程:
- 选择备份。
- dry-run 导入。
- 确认导入。
- smoke test。
6. 整机重建流程:
- 新机器 provision。
- 恢复 `/etc/genarrative/api-server.env`
- 恢复 SpacetimeDB 数据。
- 发布最近稳定 release。
- DNS 切换。
7. 复盘模板。
**Verification:**
```bash
npm run check:encoding
```
Expected: PASS。
---
### Task 4: 增加备份健康检查与恢复演练记录模板
**Objective:** 防止“有备份但不可恢复”。
**Files:**
- Create: `docs/operations/DR_DRILL_REPORT_TEMPLATE.md`
- Optional Create: `scripts/deploy/verify-database-backup.sh`
- Modify: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md`
**建议检查项:**
- 备份文件存在且大小非 0。
- 备份文件 checksum 可验证。
- 备份文件可被 `Genarrative-Database-Import` dry-run 解析。
- 最近一次备份时间未超过 RPO 阈值。
- 导入后 `/healthz` 可用。
- 首页、后台登录页、关键 API smoke 可用。
**Verification:**
```bash
bash -n scripts/deploy/verify-database-backup.sh
npm run check:encoding
```
Expected: PASS。
---
## 6. 具体恢复流程草案
### 6.1 Web 静态资源回滚
1. 进入目标机。
2. 查看 release 目录:`/opt/genarrative/releases/`
3. 选择上一个稳定版本。
4. 切换 `/srv/genarrative/web``/opt/genarrative/current` 软链。
5. 执行 Nginx 配置检查与 reload。
6. 访问首页与后台静态入口。
验收:
- `/` 返回最新稳定页面。
- `/admin/` 返回后台页面。
- 静态资源无 404。
### 6.2 API 回滚
1. 开维护模式。
2.`/opt/genarrative/current` 到上一版包含稳定 `api-server` 的 release。
3. 重启 `genarrative-api.service`
4. 本机检查 `http://127.0.0.1:8082/healthz`
5. 检查 Nginx 反代路径。
6. 解除维护模式。
验收:
- `systemctl status genarrative-api.service` 正常。
- `/healthz` 正常。
- 后台 `/admin/api/*` 基础接口正常。
### 6.3 SpacetimeDB 模块回滚
1. 开维护模式。
2. 确认目标数据库名与当前 API 环境一致:`GENARRATIVE_SPACETIME_DATABASE`
3. 选择上一版 `spacetime_module.wasm`
4. 使用 `spacetimedb` 服务用户发布上一版 wasm。
5. 重启或检查 `spacetimedb.service`
6. 检查 `api-server` 对目标数据库访问。
7. 解除维护模式。
注意:如果 schema 已迁移且旧 wasm 不兼容当前数据,需要走数据恢复,不应直接盲目发布旧 wasm。
### 6.4 数据恢复
1. 开维护模式。
2. 从 Jenkins 归档或 `SERVER_BACKUP_DIRECTORY` 选择备份。
3. 先执行导入 dry-run。
4. 真正导入前生成当前数据库安全备份。
5. 执行导入。
6. 执行 smoke test。
7. 解除维护模式。
必须记录:
- 备份文件名。
- 来源 Job/build number。
- 恢复目标 database。
- 恢复开始/结束时间。
- 恢复后验证结果。
### 6.5 整机重建
1. 准备新 Linux 机器。
2. 接入 Jenkins release deploy agent或准备人工 SSH 运维路径。
3. 运行 `Genarrative-Server-Provision`
- 创建用户和目录。
- 安装 SpacetimeDB。
- 安装 systemd unit。
- 安装 Nginx 配置。
4. 恢复 `/etc/genarrative/api-server.env`
5. 发布最近稳定 Web/API/Stdb 产物。
6. 导入最近一次有效数据库备份。
7. smoke test。
8. 切 DNS。
9. 观察 30-60 分钟。
---
## 7. 文件可能变更清单
首版落地建议按以下文件收口:
- Create: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md`
- Create: `docs/operations/PRODUCTION_DR_RUNBOOK_2026-05-11.md`
- Create: `docs/operations/DR_DRILL_REPORT_TEMPLATE.md`
- Modify: `docs/technical/README.md`
- Modify: `docs/operations/README.md`(若存在)
- Modify: `.hermes/shared-memory/project-overview.md`(仅增加文档索引)
- Optional Modify: `jenkins/Jenkinsfile.production-database-export`
- Optional Modify: `jenkins/Jenkinsfile.production-database-import`
- Optional Create: `scripts/deploy/production-backup-sync.sh`
- Optional Create: `scripts/deploy/verify-database-backup.sh`
---
## 8. 测试与验收
### 8.1 文档与编码
```bash
npm run check:encoding
```
Expected: PASS。
### 8.2 Shell 脚本语法
如新增 shell 脚本:
```bash
bash -n scripts/deploy/production-backup-sync.sh
bash -n scripts/deploy/verify-database-backup.sh
```
Expected: PASS。
### 8.3 Jenkinsfile 静态检查
建议在 Jenkins UI 或本地 Jenkins Pipeline Linter 中检查:
- `jenkins/Jenkinsfile.production-database-export`
- `jenkins/Jenkinsfile.production-database-import`
Expected: Pipeline syntax valid。
### 8.4 演练验收
至少完成一次 development 目标演练:
1. 触发 `Genarrative-Database-Export`
2. 确认备份产物存在并归档。
3. 使用 `Genarrative-Database-Import` dry-run 验证备份可解析。
4. 不覆盖生产数据的前提下,记录演练报告。
release 目标演练应在业务低峰进行,并先确认通知渠道可用。
---
## 9. 风险、取舍与开放问题
### 9.1 风险
- 单机生产仍存在物理机级单点故障,首版只能通过“快速重建 + 异地备份”降低恢复时间。
- SpacetimeDB schema 回滚不一定可逆,必须把发布前备份作为强约束。
- Jenkins controller 若在本地 Windowscontroller 自身备份和恢复需要单独制定,不应只依赖 agent 自恢复。
- 外部模型网关失败可能影响创作能力,但不应影响已发布作品浏览和后台基础能力。
### 9.2 取舍
- 选择先做可执行 runbook 和备份恢复演练,而不是直接引入复杂多活。
- 选择继续复用现有 Jenkins 导入导出流水线,降低工程改造风险。
- 选择不把密钥恢复细节写死到 Git 文档,避免泄露。
### 9.3 开放问题
1. release 环境是否已经有独立备份机或对象存储?如果有,需要补充备份同步目标,但不能提交密钥。
2. Jenkins controller 的 `JENKINS_HOME` 当前实际部署在哪里?是否已有周期备份?
3. 生产域名 DNS TTL 当前是多少?是否可降低到适合故障切换的值?
4. `/stdb` 所在磁盘是否独立于系统盘?是否已有磁盘水位告警?
5. release 环境的通知渠道除邮件外是否需要接入企业微信/飞书/Telegram
---
## 10. 推荐实施顺序
1. 先只落文档:技术方案 + runbook + 演练模板。
2. 在 development 目标做一次数据库导出 + dry-run 导入演练。
3. 根据演练结果补脚本:备份同步、备份健康检查。
4. 再把 release 备份设置为定时任务。
5. 最后规划整机重建演练与 DNS 切换演练。
首版完成标准:
- 团队任一成员打开 runbook即可在 30 分钟内完成 Web/API 回滚或数据库备份 dry-run 恢复。
- 最近一次数据库备份时间、备份位置、checksum、恢复演练结果可追溯。
- 生产密钥仍只存在于服务器/Jenkins 凭据/加密备份中,不进入 Git。

View File

@@ -0,0 +1,206 @@
# 远端作品列表压测排查报告
时间2026-05-12 06:16 CST
目标:`http://82.157.175.59`
SSH远端生产机 root 账号(具体私钥路径仅保留在本机环境,不写入仓库)
## 背景
远端 `k6-works-list.js` 压测中:
- smoke 通过。
- baseline 10 VU无 HTTP 错误,但 p95/p99 超阈值。
- 50 RPS spike`http_req_failed` / `works_list_shape_error_rate` 约 21.99%。
- 100 RPS spike`http_req_failed` / `works_list_shape_error_rate` 约 25.47%。
- 从 k6 check 看,失败主要集中在 `puzzle_gallery_list``custom_world_gallery_list` 基本正常。
## 已完成排查
### 1. 服务器进程与资源
远端服务监听:
- Rust api-server`127.0.0.1:8082`systemd 服务 `genarrative-api.service`
- SpacetimeDB`127.0.0.1:3101`systemd 服务 `spacetimedb.service`
- Nginx公网 80 反代 `/api/*``127.0.0.1:8082`
服务器规格/状态:
- 2 vCPU。
- 内存约 1.9GiB。
- Swap 约 1.9GiB,已有约 600MiB 使用。
- `/` 磁盘约 69%。
- Rust api-server 当前 CPU 不高。
- SpacetimeDB 当前 CPU 不高。
发现一个独立异常:
- PM2 下旧 `server-node` 进程 `genarrative` 正在重启风暴。
- cwd`/work/Genarrative/server-node`
- 错误:连接 `127.0.0.1:5432` PostgreSQL 被拒绝。
- PM2 restart 次数已超过 33 万。
- 该进程不是当前公网 `/api/*` 使用的 Rust api-server但会制造额外 CPU/内存/日志抖动。
### 2. 压测窗口服务端日志
子任务聚合了 2026-05-12 04:50-05:05 的 nginx 与 api-server 日志。
nginx access
- `/api/runtime/puzzle/gallery`4661 次,全部 200。
- `/api/runtime/custom-world-gallery`4659 次,全部 200。
api-server journal
`/api/runtime/puzzle/gallery`
- completed4661
- status200 全部
- slow_request0
- latency_msmin 13 / p50 30 / p90 43 / p95 50 / p99 62 / max 88
`/api/runtime/custom-world-gallery`
- completed4659
- status200 全部
- slow_request0
- latency_msmin 0 / p50 1 / p90 5 / p95 7 / p99 13 / max 49
结论:
- 在服务端视角,两个接口在该窗口都没有 5xx也没有慢请求。
- 这与 k6 客户端侧 30s timeout / failed check 存在明显不一致。
- 需要进一步区分:客户端侧网络/连接耗尽/本机 k6 执行环境问题,还是 k6 统计混合/响应解析问题。
### 3. k6 脚本行为
文件:`scripts/loadtest/k6-works-list.js`
`AUTH_TOKEN` 时,每轮 iteration 顺序请求两个接口:
1. `GET /api/runtime/puzzle/gallery`
2. `GET /api/runtime/custom-world-gallery`
`DETAIL_RATIO=0` 时不会请求详情。
`works_list_shape_error_rate` 不只代表字段结构错误,只要下面任意 check 失败都会计入:
- status is 200
- returns json object
- has collection
- list item shape
因此 timeout、非 JSON、非 200、响应结构不符合都会表现为 shape error。
数据文件实际路径:
- `scripts/loadtest/data/works-list.local.json`
脚本里 `data/works-list.local.json` 是相对 k6 脚本文件解析的,因此本身合理。
### 4. 代码层疑似瓶颈
虽然这次远端服务端日志没有复现慢请求,但代码层仍发现一个真实性能隐患。
`/api/runtime/puzzle/gallery` 调用链:
- `server-rs/crates/api-server/src/app.rs:1192`
- `server-rs/crates/api-server/src/puzzle.rs:1385-1409`
- `server-rs/crates/spacetime-client/src/puzzle.rs:367-381`
- `server-rs/crates/spacetime-module/src/puzzle.rs:430-443`
- `server-rs/crates/spacetime-module/src/puzzle.rs:1393-1404`
关键实现:
- `list_puzzle_gallery_tx``puzzle_work_profile().iter()` 全表扫描。
- 再过滤 `publication_status == Published`
- 对每个公开作品调用 `build_puzzle_work_profile_from_row_with_recent_count`
- 该函数调用 `count_recent_public_work_plays(ctx, "puzzle", &row.profile_id, now_micros)`
`count_recent_public_work_plays`
- 文件:`server-rs/crates/spacetime-module/src/runtime/profile.rs:1296-1321`
- 当前实现对 `public_work_play_daily_stat().iter()` 全表扫描过滤。
- 但表定义已有复合索引:
- `server-rs/crates/spacetime-module/src/runtime/profile.rs:242-248`
- `by_public_work_play_daily_stat_work_day(source_type, profile_id, played_day)`
- 当前统计函数未使用该索引。
复杂度风险:
```text
puzzle gallery ~= O(puzzle_work_profile 全表扫描 + Published作品数 * public_work_play_daily_stat 全表扫描)
```
`custom-world-gallery` 与 puzzle 的差异:
- custom-world 使用 `CustomWorldGalleryEntry` 公开读模型表。
- puzzle 直接从 `puzzle_work_profile` 即席拼装。
- 两者都调用 recent count但 puzzle 更容易受作品表规模和统计表规模影响。
## 当前判断
本次排查有两个层面的结论:
1. 生产服务端日志没有证明 `puzzle/gallery` 在 04:50-05:05 窗口真的 30s 慢或 5xx。
- api-server 记录的 p95 只有 50ms。
- nginx 看到两个接口都是 200。
- 所以 k6 侧的 30s timeout 需要进一步从客户端网络、连接池、Windows/k6 执行环境、summary 混合统计角度验证。
2. 代码层确实存在可修的性能隐患。
- `count_recent_public_work_plays` 未使用已有索引。
- puzzle gallery 对每个作品重复做 recent count。
- puzzle gallery 未使用 `publication_status` 索引或读模型。
## 建议下一步
### A. 先处理服务器 PM2 重启风暴
建议确认旧 Node 服务是否仍需要。
如果不需要,应停止并禁用 PM2 中的旧 `server-node`
```bash
PM2_HOME=/home/ubuntu/.pm2 pm2 stop genarrative
PM2_HOME=/home/ubuntu/.pm2 pm2 delete genarrative
PM2_HOME=/home/ubuntu/.pm2 pm2 save
```
这是生产侧操作,执行前需要确认。
### B. 单接口短压验证客户端/服务端不一致
不要继续用混合脚本大压。
建议新增或临时使用单接口 k6 脚本,分别只测:
- `/api/runtime/puzzle/gallery`
- `/api/runtime/custom-world-gallery`
并在同一时间窗口并行采集:
- k6 客户端 summary
- nginx access 请求数/状态码
- api-server journal latency
- 本机到服务器网络错误/timeout
目标是确认 timeout 是不是发生在客户端侧连接/网络,而不是服务端处理慢。
### C. 修复代码性能隐患
优先级建议:
1. `count_recent_public_work_plays` 改为使用 `by_public_work_play_daily_stat_work_day` 复合索引,或至少改成批量统计,避免 N 次全表扫描。
2. `list_puzzle_gallery_tx` 使用 `by_puzzle_work_publication_status` 索引查询 Published或参考 custom-world 建立 `puzzle_gallery_entry` 公开读模型。
3. gallery 列表页不要实时逐条扫描统计表,可维护读模型或批量聚合 `recent_play_count_7d`
### D. 调整 k6 脚本输出
建议 k6 summary 按 endpoint tag 输出或新增单接口模式,否则 overall 指标会把 puzzle/custom-world 混在一起。
建议增加:
- `ENDPOINT=puzzle_gallery_list`
- `ENDPOINT=custom_world_gallery_list`
让脚本只跑一个 endpoint避免诊断时混淆。

BIN
.hermes/plans/frame_003.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
.hermes/plans/frame_010.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
.hermes/plans/frame_020.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
.hermes/plans/frame_035.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -16,14 +16,46 @@
--- ---
## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权
- 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。
- 决策:小程序壳在 `pending_bind_phone` 时暂不打开 H5先展示原生 `button open-type="getPhoneNumber"`;用户同意后把 `bindgetphonenumber` 返回的 `code` 作为 `wechatPhoneCode` 调用 `/api/auth/wechat/bind-phone`。后端通过微信 `stable_token``getuserphonenumber` 换取平台验证后的手机号,再复用现有微信待绑定账号合并逻辑并重新签发 active 系统 token。H5 旧短信验证码绑定流程继续作为非小程序环境兜底。
- 影响范围:`miniprogram/pages/web-view/index.*``server-rs/crates/platform-auth``server-rs/crates/api-server/src/wechat_auth.rs`、认证共享契约、微信小程序 web-view 壳技术文档。
- 验证方式:执行 `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`
## 2026-05-11 拼图与抓大鹅结果页音频资产复用通用创作音频链路
- 背景:拼图和抓大鹅结果页需要接入 Suno 背景音乐,抓大鹅还需要物体点击音效,但当前两类作品没有独立的作品级音频表或 metadata 字段。
- 决策:新增 `/api/creation/audio/*` 通用创作音频路由,后端统一负责 VectorEngine 音频任务、OSS 转存、`asset_object``asset_entity_binding` 写入;视觉小说旧路由保留并复用同一持久化逻辑。拼图背景音乐暂存到首关 `levels_json[0].backgroundMusic/background_music`;抓大鹅背景音乐暂存到 `generated_item_assets_json[0].backgroundMusic/background_music`,单物体点击音效存到对应 item 的 `clickSound/click_sound`。本轮不新增 SpacetimeDB 表和字段。
- 影响范围:拼图结果页、抓大鹅结果页、抓大鹅运行态音频播放、通用创作音频 shared contracts、`api-server` 音频路由和资产绑定。
- 验证方式:执行拼图/抓大鹅结果页定向测试、`npm run typecheck``cargo test -p api-server vector_engine_audio_generation``cargo test -p shared-contracts creation_audio``cargo check -p api-server`,真实生成需配置 VectorEngine 与 OSS 私密环境。
- 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`
## 2026-05-11 寓教于乐公开作品使用独立 `edutainment` 来源接入
- 背景:`宝贝识物` 首关需要通过创作模板发布后进入寓教于乐板块,同时关闭入口时必须从发现页、搜索、详情深链、作品号和历史入口完全不可见;若继续落入 RPG 默认公共作品链路,容易出现误启动、误改造或近似标签误归类。
- 决策:寓教于乐公开作品在前端公共作品模型中使用 `sourceType = edutainment`,当前只承接 `templateId = baby-object-match``templateName = 宝贝识物`;进入“发现 / 寓教于乐”频道仍必须携带精确等于 `寓教于乐` 的公开标签,不因模板名或近似标签自动归类。公开详情、推荐运行态、改造、编辑、点赞和分享链路都必须显式识别 `edutainment`,不得回落到 RPG 默认处理。
- 影响范围:公开作品卡、发现页频道、作品号搜索、公开详情深链、分享、作品架聚合、后续儿童动作 Demo 模板的发布结果展示。
- 验证方式执行第4线程定向单测、前端类型检查、ESLint 与编码检查;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时确认精确 `寓教于乐` 作品不可通过任何公开入口访问。
- 关联文档:`docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md``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-10 儿童动作 Demo 视觉资产统一为绘本草地舞台 ## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台
- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要预留 image-2 真实背景图的固定接入位 - 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径
- 决策:热身舞台统一采用绘本草地视觉语言真实背景图默认输出到 `public/child-motion-demo/picture-book-grass-stage.webp`生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`。在缺少 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。 - 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色轮廓和 UI 已拆分为 v2 用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v2.png``picture-book-character-outline-v2.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。
- 影响范围:`src/index.css``src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。 - 影响范围:`src/index.css``src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。
- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 应能写出默认背景文件 - 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` `--live --only <asset-id>` 应能写出对应 PNG并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 HUD、状态条或底部草坪
- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md``docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md` - 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md``docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`
## 2026-05-10 方洞挑战从创作页入口和作品架隐藏
- 背景:运营节奏要求创作页完全隐藏方洞挑战,不能只隐藏新建入口后仍从创作页作品架暴露已有方洞草稿或已发布作品。
- 决策SpacetimeDB `creation_entry_type_config``square-hole.visible=false` 作为创作页统一开关;创作 Tab 模板入口、旧选择弹层、创作 Hub 卡带和创作页作品架都基于该开关隐藏方洞挑战。既有方洞详情、作品号、广场和运行态链路暂不删除api-server 路由熔断只按 `open=false` 禁用玩法 API。
- 影响范围SpacetimeDB 入口配置默认种子、`platformEntryCreationTypes``CustomWorldCreationHub``PlatformEntryFlowShellImpl` 以及创作入口相关文档和回归测试。
- 验证方式:执行入口配置、创作 Hub 和平台入口交互定向测试,确认看不到“方洞挑战” Tab、按钮和作品架条目。
- 关联文档:`docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md``docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`
## 2026-05-10 运行态输入设备抽象层全项目通用化 ## 2026-05-10 运行态输入设备抽象层全项目通用化
- 背景:拼图运行态接入 mocap 后,鼠标/触控和 mocap 各自维护输入逻辑会导致合并大块、拖拽语义和取消会话行为不一致;后续其他玩法也需要复用体感、摇杆、键盘等设备输入。 - 背景:拼图运行态接入 mocap 后,鼠标/触控和 mocap 各自维护输入逻辑会导致合并大块、拖拽语义和取消会话行为不一致;后续其他玩法也需要复用体感、摇杆、键盘等设备输入。
@@ -118,7 +150,7 @@
- 背景:平台计划新增 2048 游戏玩法模板,需要同时适配前端 stage、HTTP 路由、Rust 模块、SpacetimeDB 表和公开作品号;裸 `2048` 不适合作为模块或文件命名前缀。 - 背景:平台计划新增 2048 游戏玩法模板,需要同时适配前端 stage、HTTP 路由、Rust 模块、SpacetimeDB 表和公开作品号;裸 `2048` 不适合作为模块或文件命名前缀。
- 决策:面向用户展示名保持 `2048`,工程玩法 ID 固定为 `twenty-forty-eight`Rust 模块与表前缀使用 `twenty_forty_eight`,公开作品号前缀使用 `TF-`;玩法按完整闭环设计,包含 Agent 创作、结果页、试玩、发布、公开运行、后端棋盘裁决、排行榜和作品架 / 广场接入。 - 决策:面向用户展示名保持 `2048`,工程玩法 ID 固定为 `twenty-forty-eight`Rust 模块与表前缀使用 `twenty_forty_eight`,公开作品号前缀使用 `TF-`;玩法按完整闭环设计,包含 Agent 创作、结果页、试玩、发布、公开运行、后端棋盘裁决、排行榜和作品架 / 广场接入。
- 影响范围:后续 `src/config/newWorkEntryConfig.ts`、平台 `SelectionStage`、前端 `twenty-forty-eight-*` 组件与 service、`module-twenty-forty-eight``shared-contracts``spacetime-module` 表、`spacetime-client` facade、`api-server` 路由、作品号和 PRD 索引。 - 影响范围:后续 SpacetimeDB 创作入口配置、平台 `SelectionStage`、前端 `twenty-forty-eight-*` 组件与 service、`module-twenty-forty-eight``shared-contracts``spacetime-module` 表、`spacetime-client` facade、`api-server` 路由、作品号和 PRD 索引。
- 验证方式:后续落地时确认用户可见标题为 `2048`,代码、路由和表统一使用 `twenty-forty-eight` / `twenty_forty_eight`;移动、合并、生成新方块、目标达成、失败和榜单成绩由后端正式裁决,前端不伪造分数或目标达成。 - 验证方式:后续落地时确认用户可见标题为 `2048`,代码、路由和表统一使用 `twenty-forty-eight` / `twenty_forty_eight`;移动、合并、生成新方块、目标达成、失败和榜单成绩由后端正式裁决,前端不伪造分数或目标达成。
- 关联文档:`docs/prd/AI_NATIVE_2048_GAMEPLAY_TEMPLATE_PRD_2026-05-05.md` - 关联文档:`docs/prd/AI_NATIVE_2048_GAMEPLAY_TEMPLATE_PRD_2026-05-05.md`
@@ -221,10 +253,26 @@
## 2026-05-10 视觉小说入口收敛为单句创作 + 画风选择 ## 2026-05-10 视觉小说入口收敛为单句创作 + 画风选择
- 背景:视觉小说入口页要对齐抓大鹅式的线性创作入口,只保留最小可用输入,避免再暴露文档 / 空白 / 对话式工作台。 - 背景:视觉小说入口页要对齐抓大鹅式的线性创作入口,只保留最小可用输入,避免再暴露文档 / 空白 / 对话式工作台。
- 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风``画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result` - 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风``画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`画风卡片主视觉固定消费 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2-all` 生成的静态参考图,不在前端运行时现场调用生图接口。
- 影响范围:`VisualNovelAgentWorkspace``PlatformEntryFlowShellImpl``platformEntryTypes`、视觉小说 PRD;不新增后端字段或数据库结构。 - 影响范围:`VisualNovelAgentWorkspace``visualNovelEntryGeneration``PlatformEntryFlowShellImpl`、视觉小说 PRD 和创作 Tab 设计文档;不新增后端字段或数据库结构。
- 验证方式:视觉小说工作台单测通过,`npm run check:encoding` 通过`npm run typecheck` 仍受仓库里 `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` 的既有类型错误影响 - 验证方式:执行 `npm run test -- VisualNovelAgentWorkspace`、视觉小说工作台相关 ESLint、`npx prettier --check``npm run check:encoding``npm run typecheck` 若失败需先区分是否来自无关 Match3D / RPG 既有改动
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md` - 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`
## 2026-05-10 用户标签只做后端白名单投影
- 背景:运营邀请码需要给账号打标签,但标签默认不能暴露到前端通用用户资料;拼图排行榜仅需展示特定标签。
- 决策:`user_account.user_tags` 保存账号标签,数据库默认 `None`,业务按空数组读取;后台预置邀请码使用后授予的标签不再使用独立列,统一存放并解析自 `profile_invite_code.metadata_json.userTags`,兼容读取 `user_tags`。通用登录态和个人资料不返回原始标签。首版只在拼图排行榜 `visibleTags` 中白名单投影 `北科`
- 影响范围:用户认证表、邀请码后台、邀请兑换事务、拼图排行榜响应和 UI。
- 验证方式:表结构变更需同步 `migration.rs``SPACETIMEDB_TABLE_CATALOG.md` 和 SpacetimeDB bindings后端运行 `cargo check -p api-server`,后台运行 `npm run admin-web:typecheck`
- 关联文档:`docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md`
## 2026-05-10 抓大鹅草稿元信息由 gpt-4o 生成
- 背景:抓大鹅草稿生成需要基于入口题材设定生成作品名称,结果页作品信息要对齐拼图草稿,不再把封面和作品名称拆成两个模块。
- 决策:`match3d_compile_draft` 使用 `gpt-4o` 生成 `gameName` 与 3 到 6 个标签;`summary` 默认保持空字符串;标签可由结果页 `作品信息` Tab 手动编辑或再次 AI 生成。草稿生成会产出 3 个物品图片并立即调用 Rodin 生成 GLB图片和模型一起写入 `generated_item_assets_json`,运行态必须优先消费 `generatedItemAssets[].modelSrc` / `modelObjectKey`,默认积木只做兜底。
- 影响范围:`api-server` Match3D 编译、Match3D works 标签接口、结果页 `作品信息``3D素材` Tab、运行态 `Match3DRuntimeShell` / `Match3DPhysicsBoard`、生成进度和 Match3D 技术文档。
- 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``npm run test -- src/services/miniGameDraftGenerationProgress.test.ts``cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``npm run check:encoding`,并用 `npm run api-server` 检查 `/healthz`
- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md``docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md`
## 2026-05-07 移动端整页缩放由入口统一锁定 ## 2026-05-07 移动端整页缩放由入口统一锁定

View File

@@ -35,6 +35,14 @@
- 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。 - 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。
- 关联:`AGENTS.md``npm run check:encoding` - 关联:`AGENTS.md``npm run check:encoding`
## 忘记密码后仍提示手机号或密码错误先查认证快照同步
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
- 原因:重置/修改密码会更新 `password_hash``password_login_enabled``token_version`,如果 API 层只更新本地 `InMemoryAuthStore`,没有调用 `sync_auth_store_snapshot_to_spacetime()``api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态。
- 处理:`POST /api/auth/password/change``POST /api/auth/password/reset` 成功后必须同步认证快照;启动恢复时从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照,本地文件更新时尝试回写 SpacetimeDB。
- 验证:执行 `cargo test -p module-auth password --manifest-path server-rs/Cargo.toml``cargo test -p api-server password --manifest-path server-rs/Cargo.toml`;手测时重设密码后旧密码应失败,新密码应成功,重启后仍应保持。
- 关联:`server-rs/crates/api-server/src/password_management.rs``server-rs/crates/api-server/src/state.rs``docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`
## `.hermes` 只放共享内容,不放个人 Hermes 配置 ## `.hermes` 只放共享内容,不放个人 Hermes 配置
- 现象:团队成员误把个人 Hermes 配置、会话或密钥复制进仓库。 - 现象:团队成员误把个人 Hermes 配置、会话或密钥复制进仓库。
@@ -51,14 +59,31 @@
- 验证:运行 `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 真实绘本背景图未生成先查 VectorEngine 配置 ## 宝贝识物选篮误触发先查多套判定和残余轨迹
- 现象:`/child-motion-demo` 已经呈现绘本草地风格,但 `public/child-motion-demo/picture-book-grass-stage.webp` 不存在Network 里该图返回 404或运行 `npm run assets:child-motion-demo -- --live` 返回缺少 VectorEngine 配置 - 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮
- 原因:儿童动作 Demo 的真实背景图使用 VectorEngine `gpt-image-2-all` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key缺配置时页面只能使用 CSS 草地绘本兜底 - 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名和手部轨迹,或在 `correct` / `wrong` 反馈阶段继续累计手部路径,会把抓握、反馈期间残留移动或未知侧别手部误算成下一次选篮
- 处理:在本地私密环境补齐 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai` `VECTOR_ENGINE_API_KEY`,不要把 key 写入 Git先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt再运行 `npm run assets:child-motion-demo -- --live` 生成默认背景图 - 处理:宝贝识物选篮只使用明确 `leftHand` / `rightHand` 的连续横向轨迹阈值;侧别为 `unknown` 的手部轨迹不参与选篮;礼物盒打开和反馈阶段清空轨迹,不在非 `active` 阶段累计路径。礼物盒激活仍使用 `open_palm -> grab` 抓握序列
- 验证:生成后确认 `public/child-motion-demo/picture-book-grass-stage.webp` 存在,重新打开 `/child-motion-demo` 可看到真实绘本草地背景;`npm run check:encoding` 仍通过 - 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物选篮前需要换算为用户身体视角;`rightHand` 轨迹代表玩家左手并进入左篮,`leftHand` 轨迹代表玩家右手并进入右篮。键鼠调试不走该换算,仍保持鼠标左键=左篮、右键=右篮
- 验证:运行 `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`
## 儿童动作 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 配置。
- 原因:儿童动作 Demo 的真实背景、地面、UI、地面指示环和角色轮廓资源都使用 VectorEngine `gpt-image-2-all` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key缺配置时页面只能使用 CSS 草地绘本兜底。
- 处理:在本地私密环境补齐 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai``VECTOR_ENGINE_API_KEY`,不要把 key 写入 Git先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt再运行 `npm run assets:child-motion-demo -- --live``npm run assets:child-motion-demo -- --live --only ui-panel` 等小批量命令生成资源。透明资源的品红底源图写入 `tmp/child-motion-demo-assets/`,不要把源图或预览图放入 `public/child-motion-demo/` 作为正式资产。
- 验证:生成后确认 `public/child-motion-demo/` 只保留页面引用的最终 PNG重新打开 `/child-motion-demo` 可看到真实绘本草地背景、地面、圆环、角色轮廓和 UI 资源;`npm run check:encoding` 仍通过。
- 关联:`scripts/generate-child-motion-demo-assets.mjs``src/index.css``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md` - 关联:`scripts/generate-child-motion-demo-assets.mjs``src/index.css``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## 儿童动作 Demo 绘本资源变形先查用途拆分和透明后处理
- 现象:`/child-motion-demo` 背景风格正确,但底部草坪被拉成厚色块、顶部 HUD 或右下状态条像方形面板被横向拉伸,或旧 `picture-book-ui-panel.png` 与新资源叠在一起。
- 原因:早期资源中 `picture-book-ui-panel.png` 是接近方形画布,`picture-book-grass-floor.png` 也含大量透明边界;若 CSS 用 `background-size: 100% 100%` 把同一资源强行铺成 HUD、状态条、开始面板或底部地板就会出现变形和层叠观感。
- 处理:使用 v2 用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v2.png``picture-book-character-outline-v2.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`CSS 按资源比例等比缩放底部草坪只覆盖下沿HUD / 状态条 / 开始托盘分别引用各自资源。若只需修透明裁切或品红边,运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>`,不重新请求 image-2。
- 验证:用横屏截图检查没有新旧资源叠加、没有方形面板拉成长条、角色和地面指示环不被前景草坪埋住;同时运行 `npm run check:encoding`
- 关联:`scripts/generate-child-motion-demo-assets.mjs``src/index.css``public/child-motion-demo/``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## GPT-image-2 不再读 APIMart 图片配置 ## GPT-image-2 不再读 APIMart 图片配置
- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY`RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls` - 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY`RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`
@@ -155,6 +180,14 @@
- 验证:重新发布日志应显示创建新的数据库,而不是更新旧数据库;若仍显示更新或继续 `401`,继续检查数据目录、库名和 CLI 身份。 - 验证:重新发布日志应显示创建新的数据库,而不是更新旧数据库;若仍显示更新或继续 `401`,继续检查数据目录、库名和 CLI 身份。
- 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md``docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md` - 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md``docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md`
## SpacetimeDB 模块 publish 报 `wasm-bindgen detected`
- 现象:`spacetime publish` 已经完成 Rust 编译,但随后报 `wasm-bindgen detected`,提示依赖树里有面向 Web 平台的 wasm-bindgen。
- 原因SpacetimeDB 模块是数据库内 WASM不允许拉入 Web/HTTP client 链路;常见误因是 `spacetime-module -> module-* -> shared-contracts -> platform-* -> reqwest -> wasm-bindgen` 这类反向依赖。
- 处理:执行 `cargo tree -i wasm-bindgen --manifest-path server-rs/Cargo.toml -p spacetime-module --target wasm32-unknown-unknown` 找到链路;把平台实现类型从 `shared-contracts``module-*` 中移除,只保留公开 DTO平台响应到 DTO 的转换放回 `api-server` 等 adapter 层。
- 验证:上述 `cargo tree` 输出 `warning: nothing to print``cargo check -p shared-contracts``cargo check -p api-server` 通过;重新 `spacetime publish ... --module-path server-rs/crates/spacetime-module` 不再报 wasm-bindgen。
- 关联:`docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md``server-rs/crates/shared-contracts/src/assets.rs``server-rs/crates/api-server/src/assets.rs`
## Vite SPA fallback 吞掉 API 请求 ## Vite SPA fallback 吞掉 API 请求
- 现象:本地请求 `/api/profile/*` 等接口时返回 HTML被前端当 JSON 解析报错。 - 现象:本地请求 `/api/profile/*` 等接口时返回 HTML被前端当 JSON 解析报错。
@@ -437,13 +470,21 @@
- 验证:`cargo test -p api-server accepts_opaque_subscription_key_without_length_cap --manifest-path server-rs/Cargo.toml` - 验证:`cargo test -p api-server accepts_opaque_subscription_key_without_length_cap --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/hyper3d_generation.rs``docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md` - 关联:`server-rs/crates/api-server/src/hyper3d_generation.rs``docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`
## 抓大鹅草稿生成不要阻塞在 Rodin 模型下载 ## 抓大鹅草稿生成恢复 Rodin 后要并行生成模型、同步长超时和 GLB 私有读取
- 现象:抓大鹅草稿生成时 Hyper3D 状态已完成,但下载列表为空或没有可用模型文件,`/api/creation/match3d/sessions/{sessionId}/actions` 返回 `502 Bad Gateway`,前端提示 `Hyper3D 已完成但未返回可下载模型文件` - 现象:抓大鹅草稿生成重新接回 Rodin 后,前端可能在模型轮询和 GLB 转存完成前超时;或 Hyper3D 控制台显示 3 个任务已完成,但草稿进度页仍停留在 `生成3D模型`;或结果页把 `/generated-match3d-assets/.../model.glb` 直接当浏览器 URL 加载导致私有 OSS/CORS 读取失败
- 原因:草稿生成链路曾在切割图片后立即并行调用 Rodin 图生模型,并把模型下载成功作为草稿完成前置条件;上游完成态和可下载文件列表不是强一致,容易把本来可用的图片草稿卡死 - 原因:`match3d_compile_draft` 会完成作品元信息、素材图、切图上传、3 件 Rodin 图生模型、GLB 下载和 OSS 转存,耗时远长于普通 Agent action如果 3 件 Rodin 模型逐个提交和轮询,等待时间会线性叠加;同时 generated 私有资产不能被 Three.js 直接 `fetch`
- 处理:草稿阶段只生成物品名、素材图、切割独立图片并上传 OSS返回 `status = image_ready`Rodin 3D 模型生成留到结果页 `3D素材` Tab 手动触发 - 处理:切图和图片入库后,所有 Rodin 图生模型任务必须并行提交、并行轮询、并行下载转存Match3D creation client 的 `executeAction` 必须给长超时,当前为 20 分钟;生成进度页要包含 `生成3D模型` 阶段,但它不是 Hyper3D task 订阅页,而是在长 action 执行期间旁路轮询 session / work detail并用 profile 的 `generatedItemAssets` 更新完成数量;控制台看到 Rodin `Done` 后仍需等待下载列表、GLB 下载、OSS 转存和草稿 JSON 写回。结果页模型预览、场内运行态和备选栏预览都必须通过 `/api/assets/read-bytes` 读取 GLB 字节后交给 Three.js GLTFLoader不要直接请求裸 generated 路径。排查时按同一个 session/profile 查看 api-server 日志:`抓大鹅 Rodin 状态轮询返回``抓大鹅 Rodin 下载列表轮询返回``抓大鹅 Rodin GLB 下载完成``抓大鹅 Rodin GLB 转存 OSS 完成`;同时检查前端 work detail 响应里的 `generatedItemAssets[].status/modelObjectKey/error`
- 验证:草稿响应中的 `generatedItemAssets[].imageSrc` 有值、`modelSrc` 为空、状态为 `image_ready`;结果页显示 `图片已就绪``0 文件`,不会自动请求 Hyper3D 下载 - 验证:`npm run test -- src\services\miniGameDraftGenerationProgress.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx``npm run typecheck`;真实联调需配置 `VECTOR_ENGINE_API_KEY``HYPER3D_API_KEY` 和 OSS 变量
- 关联:`server-rs/crates/api-server/src/match3d.rs``src/components/match3d-result/Match3DResultView.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md` - 关联:`src/services/match3d-creation/match3dCreationClient.ts``src/services/creation-agent/creationAgentClientFactory.ts``src/components/match3d-result/Match3DModelPreview.tsx``src/components/match3d-runtime/Match3DPhysicsBoard.tsx``server-rs/crates/api-server/src/match3d.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## Rodin 完成态后下载列表可能延迟或字段漂移
- 现象:抓大鹅草稿生成时 Rodin 状态已完成,但 `/api/creation/match3d/sessions/{sessionId}/actions` 返回 502提示 `{物品名} 3D 模型已完成但未返回可下载模型文件:{taskUuid}`
- 原因Hyper3D Rodin 官方 `check-status_reset_v` 示例要求看 `jobs` 列表,只有所有 job 都 `Done` 才能进入下载;`download-results_reset_v` 还明确要求用生成响应顶层 `uuid` 作为 `task_uuid`,不要用 `jobs.uuids` 子任务 uuid。旧聚合若只看 root status 或第一个 job可能在 preview job 完成但模型 job 仍在生成时提前下载。另外任务完成和下载列表文件发布不是强同步的;上游下载结果还可能使用 `fileUrl``signedUrl``presignedUrl``fileName` 等字段别名,旧解析器只识别 `url/downloadUrl/name/file_name/filename` 时会得到空列表。
- 处理:`query_task_status` 聚合状态必须以 `jobs` 为准:任一 failed 即 failed全部 done 才 done`match3d_compile_draft` 在状态完成后对 `query_downloads` 继续轮询;下载解析兼容常见 URL 和文件名字段别名;模型选择优先 `.glb`,可兜底到非图片下载文件,但只有 preview/png/jpg/webp 这类预览图时必须继续失败,不能伪装成 GLB。
- 验证:`cargo test -p api-server match3d_model_download --manifest-path server-rs/Cargo.toml``cargo test -p api-server extracts_download_files --manifest-path server-rs/Cargo.toml``cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``cargo test -p api-server hyper3d --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/match3d.rs``server-rs/crates/api-server/src/hyper3d_generation.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅切图路径不能只用中文物品名 ## 抓大鹅切图路径不能只用中文物品名
@@ -461,6 +502,30 @@
- 验证:`cargo test -p spacetime-client match3d --manifest-path server-rs/Cargo.toml``cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``npm run test -- src/components/match3d-result/Match3DResultView.test.tsx` - 验证:`cargo test -p spacetime-client match3d --manifest-path server-rs/Cargo.toml``cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`
- 关联:`server-rs/crates/spacetime-module/src/match3d/*``server-rs/crates/spacetime-client/src/mapper.rs``server-rs/crates/api-server/src/match3d.rs``src/components/match3d-result/Match3DResultView.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md` - 关联:`server-rs/crates/spacetime-module/src/match3d/*``server-rs/crates/spacetime-client/src/mapper.rs``server-rs/crates/api-server/src/match3d.rs``src/components/match3d-result/Match3DResultView.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅试玩和正式运行态不要只读草稿页本地模型预览
- 现象:历史草稿页 `3D素材` Tab 能看到水果模型,但点击试玩或从推荐 / 公开作品进入正式抓大鹅时,局内仍显示默认积木素材。
- 原因:结果页手动 `重新生成` 曾只更新本地 `assetDrafts.downloads`,没有把新的 GLB 写回 `generatedItemAssets`;历史数据还可能只有 `modelObjectKey` 而没有 `modelSrc`;推荐流内嵌运行态若只读卡片摘要,卡片缺素材时会把已持久化 profile 模型丢掉;本次生成 response 的 draft 也可能比 profile 旧,只带图片而不带模型。
- 处理:结果页模型预览和运行态都按 `modelSrc || modelObjectKey` 读取;手动重新生成成功后把素材草稿重新序列化并写回作品 profile`Match3DResultView` 合并同 `itemId` 的 draft/profile 素材,用 profile 已有模型补齐旧 draft推荐流内嵌运行态启动前若卡片摘要没有生成素材补读 `getMatch3DWorkDetail(profileId)` 并把详情资产传给 `Match3DRuntimeShell`
- 验证:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`,并检查历史草稿和公开 M3 作品的 Network 响应里 `generatedItemAssets[].modelSrc/modelObjectKey`
- 关联:`src/components/match3d-result/Match3DResultView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/match3d-runtime/Match3DPhysicsBoard.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅标签清洗不要把 `3D素材` 当编号剥掉
- 现象AI 或兜底生成的 `3D素材` 标签在后端规范化后变成 `D素材`
- 原因:标签清洗在去掉编号列表前缀后,又无条件剥离开头数字和标点,把合法标签中的 `3D` 当成列表编号处理。
- 处理:只移除明确的编号列表前缀,例如 `1. 标签``1、标签``1) 标签`;不要对普通标签开头数字做二次剥离。
- 验证:`cargo test -p api-server match3d_tag_normalization --manifest-path server-rs/Cargo.toml`,并保留 `normalize_match3d_tag("3D素材") == "3D素材"` 的单测。
- 关联:`server-rs/crates/api-server/src/match3d.rs`
## 用户标签不要直接外显SpacetimeDB Vec 字段不要写 default 宏
- 现象:给 `user_account.user_tags` 或邀请码独立标签列写 `#[default(Vec::<String>::new())]`SpacetimeDB WASM 构建报 `destructor of Vec<String> cannot be evaluated at compile-time`
- 原因SpacetimeDB 的 table default 宏会走编译期常量求值,不能直接使用有析构逻辑的堆分配类型默认值。
- 处理:`user_account.user_tags` 使用 `Option<Vec<String>>` + `#[default(None::<Vec<String>>)]` 表达数据库默认空,业务层统一把 `None` 归一化为空数组;邀请码授予标签复用 `metadata_json.userTags` 存储和解析,不再新增独立 Vec 列。用户标签原始值不得进入登录态、个人资料等通用响应,只能在明确业务白名单里投影,例如拼图排行榜 `visibleTags` 首版仅允许 `北科`
- 验证:`npm run spacetime:generate -- --rust-only` 能通过;`user_account` 旧迁移 JSON 缺字段时能导入,`profile_invite_code``metadata_json` 时按 `{}` 兼容。
- 关联:`docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md``docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
## 公开作品详情深链找不到作品不能停在空详情页 ## 公开作品详情深链找不到作品不能停在空详情页
- 现象:直接访问 `/works/detail?work=PZ-...`,作品不存在或已下架时会弹出“作品不存在或已下架,将返回首页。”;关闭提示后仍可能停在大白屏。 - 现象:直接访问 `/works/detail?work=PZ-...`,作品不存在或已下架时会弹出“作品不存在或已下架,将返回首页。”;关闭提示后仍可能停在大白屏。

View File

@@ -12,6 +12,20 @@
- 仓库 `.hermes/` 只保存可进入 Git 的团队共享内容;禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。 - 仓库 `.hermes/` 只保存可进入 Git 的团队共享内容;禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。
-`.hermes/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。 -`.hermes/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
## Agent skills
### Issue tracker
Issues are tracked in the self-hosted Gitea remote for this repo. Use Gitea Issues via the configured Gitea UI/API or `tea` CLI when available; do not use GitHub `gh` or GitLab `glab` unless the repo is migrated. See `docs/agents/issue-tracker.md`.
### Triage labels
Use the default canonical triage labels: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context layout: read root `CONTEXT.md` when present and architecture decisions from `docs/adr/`. See `docs/agents/domain.md`.
## 项目约束 ## 项目约束
- 代码需要有完善的中文注释 - 代码需要有完善的中文注释
- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。 - 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。

View File

@@ -182,7 +182,6 @@ export interface AdminUpsertProfileRedeemCodeRequest {
export interface AdminUpsertProfileInviteCodeRequest { export interface AdminUpsertProfileInviteCodeRequest {
inviteCode: string; inviteCode: string;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
grantedUserTags: string[];
startsAt?: string | null; startsAt?: string | null;
expiresAt?: string | null; expiresAt?: string | null;
} }
@@ -229,7 +228,6 @@ export interface ProfileInviteCodeAdminResponse {
userId: string; userId: string;
inviteCode: string; inviteCode: string;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
grantedUserTags: string[];
startsAt?: string | null; startsAt?: string | null;
expiresAt?: string | null; expiresAt?: string | null;
status: 'pending' | 'active' | 'expired'; status: 'pending' | 'active' | 'expired';

View File

@@ -78,10 +78,13 @@ export function AdminInviteCodePage({
setIsSaving(true); setIsSaving(true);
try { try {
const metadata = withMetadataUserTags(
parseMetadata(metadataText),
parseUserTags(grantedTagsText),
);
const payload: AdminUpsertProfileInviteCodeRequest = { const payload: AdminUpsertProfileInviteCodeRequest = {
inviteCode: inviteCode.trim(), inviteCode: inviteCode.trim(),
metadata: parseMetadata(metadataText), metadata,
grantedUserTags: parseUserTags(grantedTagsText),
startsAt: startsAt ? toIsoDateTime(startsAt) : null, startsAt: startsAt ? toIsoDateTime(startsAt) : null,
expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null, expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null,
}; };
@@ -117,7 +120,7 @@ export function AdminInviteCodePage({
setInviteCode(entry.inviteCode); setInviteCode(entry.inviteCode);
setStartsAt(toDateTimeLocalValue(entry.startsAt)); setStartsAt(toDateTimeLocalValue(entry.startsAt));
setExpiresAt(toDateTimeLocalValue(entry.expiresAt)); setExpiresAt(toDateTimeLocalValue(entry.expiresAt));
setGrantedTagsText(entry.grantedUserTags.join('、')); setGrantedTagsText(metadataUserTags(entry.metadata).join('、'));
setMetadataText(JSON.stringify(entry.metadata, null, 2)); setMetadataText(JSON.stringify(entry.metadata, null, 2));
} }
@@ -252,7 +255,7 @@ export function AdminInviteCodePage({
</button> </button>
</td> </td>
<td> <td>
<TagList tags={entry.grantedUserTags} /> <TagList tags={metadataUserTags(entry.metadata)} />
</td> </td>
<td> <td>
<span className={`admin-status ${inviteValidityClass(entry)}`}> <span className={`admin-status ${inviteValidityClass(entry)}`}>
@@ -291,7 +294,7 @@ export function AdminInviteCodePage({
<div> <div>
<dt></dt> <dt></dt>
<dd> <dd>
<TagList tags={result.grantedUserTags} /> <TagList tags={metadataUserTags(result.metadata)} />
</dd> </dd>
</div> </div>
<div> <div>
@@ -338,6 +341,29 @@ function TagList({tags}: {tags: string[]}) {
); );
} }
function metadataUserTags(metadata: Record<string, unknown>) {
const raw = metadata.userTags ?? metadata.user_tags;
if (!Array.isArray(raw)) {
return [];
}
return parseUserTags(raw.filter((value): value is string => typeof value === 'string').join('、'));
}
function withMetadataUserTags(
metadata: Record<string, unknown>,
tags: string[],
): Record<string, unknown> {
const next = {...metadata};
delete next.user_tags;
if (tags.length) {
next.userTags = tags;
} else {
delete next.userTags;
}
return next;
}
function parseUserTags(value: string) { function parseUserTags(value: string) {
const tags: string[] = []; const tags: string[] = [];
for (const raw of value.split(/[\n,;;、]+/)) { for (const raw of value.split(/[\n,;;、]+/)) {

36
docs/agents/domain.md Normal file
View File

@@ -0,0 +1,36 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Layout
This repo uses a **single-context** layout for Matt Pocock engineering skills:
- `CONTEXT.md` at the repo root, when present, is the primary domain glossary/context file.
- `docs/adr/`, when present, contains architecture decision records.
- If either path does not exist, proceed silently; do not block the task just to create it.
## Before exploring, read these
1. Root `CONTEXT.md`, if present.
2. Relevant ADRs under `docs/adr/`, if present.
3. Existing project context that predates this setup:
- `.hermes/README.md`
- `.hermes/shared-memory/project-overview.md`
- `.hermes/shared-memory/team-conventions.md`
- `.hermes/shared-memory/development-workflow.md`
- `.hermes/shared-memory/decision-log.md`
- `.hermes/shared-memory/pitfalls.md`
- Relevant files under `docs/technical/`, `docs/prd/`, `docs/design/`, and `docs/experience/`
Follow `AGENTS.md` when it is more specific than this file. If older docs conflict with current code or newer technical docs, treat current code and newer docs as authoritative and update stale docs when the task requires it.
## Use the glossary's vocabulary
When your output names a domain concept in an issue title, refactor proposal, diagnosis, test name, or implementation plan, use the term as defined in `CONTEXT.md` when available. Do not drift to synonyms the glossary explicitly avoids.
If the concept you need is not in the glossary yet, either use the established vocabulary from `.hermes/shared-memory/` and `docs/`, or note the gap for a future documentation pass.
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding it.

View File

@@ -0,0 +1,35 @@
# Issue tracker: Gitea
Issues and PRDs for this repo live as issues in the self-hosted Gitea remote:
- Remote: `http://82.157.175.59:3000/GenarrativeAI/Genarrative.git`
- Tracker type: Gitea Issues
## Conventions
- Prefer the Gitea `tea` CLI when it is installed and configured for this host.
- Do not use GitHub `gh` or GitLab `glab` for this repo unless the repository is explicitly migrated to those platforms.
- If `tea` is unavailable, use the Gitea Web UI or Gitea REST API for the same operations.
## Common operations with `tea`
Exact flags can vary by `tea` version. Run `tea issues --help` or `tea issue --help` before using a command in a new environment.
- Create an issue: `tea issues create --title "..." --body "..."`
- Read an issue: `tea issues view <number>`
- List issues: `tea issues list`
- Comment on an issue: use the installed `tea` issue comment command shown by `tea issues --help`; if unavailable, use the Gitea Web UI or REST API.
- Apply labels: use the installed `tea` issue update/edit command shown by `tea issues --help`; if unavailable, use the Gitea Web UI or REST API.
- Close an issue: use the installed `tea` issue close/update command shown by `tea issues --help`; if unavailable, use the Gitea Web UI or REST API.
## When a skill says "publish to the issue tracker"
Create a Gitea issue in `GenarrativeAI/Genarrative` with the requested title, body, labels, and links back to any relevant docs or branch.
## When a skill says "fetch the relevant ticket"
Read the Gitea issue body and comments/notes for the referenced issue number. Include labels and current open/closed state in the working context.
## Authentication
Use the locally configured Gitea credentials for the current developer. Do not commit tokens, cookies, `.env`, or local credential files.

View File

@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's Gitea issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role, use the corresponding Gitea label string from this table.
If the Gitea repository later adopts Chinese labels or a different naming scheme, edit the right-hand column here rather than letting skills create duplicate labels.

View File

@@ -108,3 +108,42 @@ output/imagegen/baimeng-expo-rollup/baimeng-rollup-final-cn-preview.png
2. 若需要放二维码,应放在底部独立留白区,不遮挡产品心智和关键技术段。 2. 若需要放二维码,应放在底部独立留白区,不遮挡产品心智和关键技术段。
3. 若展会现场观众偏投资人或B端合作方可以把“产品心智”段压缩放大“关键技术”与平台愿景。 3. 若展会现场观众偏投资人或B端合作方可以把“产品心智”段压缩放大“关键技术”与平台愿景。
4. 若观众偏玩家或普通创作者可以把“关键技术”段压缩放大“10分钟创作、玩过就改、发布分享”的闭环。 4. 若观众偏玩家或普通创作者可以把“关键技术”段压缩放大“10分钟创作、玩过就改、发布分享”的闭环。
## 6. 公司招聘版 2026-05-11
2026-05-11 根据线下招聘场景,将海报方向从“纯产品宣传”调整为“公司 + 产品 + 岗位”的整体宣传。
新版定位:
```text
北京亓盒网络科技有限公司
岗位名称AI 原生游戏产品/内容实习生
行业方向AI 原生游戏 × UGC 内容创作 × 互动叙事
产品:百梦 AI互动内容创作平台
```
新版保留百梦气泡色彩、轻盈白底和创作流动感但新增校园实验室、AI 游戏创作、作品卡、产品测试与内容设计氛围。版面结构调整为:
1. 顶部:公司名、岗位名、行业方向与招聘主标题。
2. 中上:百梦产品主张与三枚产品能力标签。
3. 中部:按 `游玩 -> 改造 -> 创作` 顺序展示产品体验闭环。
4. 中下:介绍“我们正在做的事「百梦」”。
5. 下部:实习生参与内容、加分项、团队背景和联系方式。
6. 底部:预留两个方形二维码占位,收尾文案为 `百梦 | 让每个人都能做自己的游戏`
新版使用当前仓库 `VectorEngine gpt-image-2-all` 路径生成底图:
```text
model: gpt-image-2-all
size: 1536x3840
reference image 1: 用户提供的上一版海报截图
reference image 2: 百梦气泡共创logo方向图
output: output/imagegen/baimeng-recruitment-rollup/baimeng-recruitment-rollup-background-gpt-image-2-all.png
```
最终输出:
```text
output/imagegen/baimeng-recruitment-rollup/baimeng-recruitment-rollup-final-cn.png
output/imagegen/baimeng-recruitment-rollup/baimeng-recruitment-rollup-final-cn-preview.png
```

View File

@@ -48,7 +48,7 @@
1. 发现页隐藏“寓教于乐”标签; 1. 发现页隐藏“寓教于乐”标签;
2. 隐藏“寓教于乐”标签下内容; 2. 隐藏“寓教于乐”标签下内容;
3. 该内容线内容不进入推荐、今日、分类、排行和搜索结果; 3. 该内容线内容不进入推荐、今日、分类、排行和搜索结果;
4. 该内容线内容完全不可见,公开作品搜索、作品号搜索直达、公开详情深链、浏览历史入口等平台公开入口都不能打开该内容。 4. 该内容线内容完全不可见,公开作品搜索、作品号搜索直达、公开详情深链、浏览历史入口、创作入口和创作页作品架等平台入口都不能打开或展示该内容。
## 4. 内容识别规则 ## 4. 内容识别规则
@@ -114,4 +114,23 @@ no
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 已复用同一过滤 helper避免推荐运行态自动启动寓教于乐作品并在公开详情、作品号直达和公开详情深链等公开入口保留不可见保护。 4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 已复用同一过滤 helper避免推荐运行态自动启动寓教于乐作品并在公开详情、作品号直达和公开详情深链等公开入口保留不可见保护。
5. 浏览历史入口会优先按当前公开作品集合匹配作品标签;匹配到“寓教于乐”作品且开关关闭时不再展示历史入口。 5. 浏览历史入口会优先按当前公开作品集合匹配作品标签;匹配到“寓教于乐”作品且开关关闭时不再展示历史入口。
6. `/child-motion-demo` 本地动作 Demo 直达路由也复用同一开关;开关关闭时不匹配独立 Demo 应用,回落到主站入口。 6. `/child-motion-demo` 本地动作 Demo 直达路由也复用同一开关;开关关闭时不匹配独立 Demo 应用,回落到主站入口。
7. 定向回归覆盖在 `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``src/components/platform-entry/platformEdutainmentVisibility.test.ts``src/routing/appRoutes.test.ts`包含频道顺序、开关关闭、普通列表过滤、搜索过滤、作品号直达拦截、Demo 直达路由拦截和精确标签识别。 7. `宝贝识物` 创作入口和创作页作品架也复用同一开关;开关关闭时不展示模板入口,也不展示本地宝贝识物草稿或已发布卡片。
8. 定向回归覆盖在 `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``src/components/platform-entry/platformEdutainmentVisibility.test.ts``src/components/platform-entry/platformEntryCreationTypes.test.ts``src/routing/appRoutes.test.ts`包含频道顺序、开关关闭、普通列表过滤、搜索过滤、作品号直达拦截、Demo 直达路由拦截、创作入口隐藏和精确标签识别。
## 9. 第 4 项作品架 / 广场接入边界
`宝贝识物` 首关的公开作品展示接入按以下口径收口:
1. 平台公共作品模型新增 `sourceType = edutainment`,当前只承接 `templateId = baby-object-match``templateName = 宝贝识物`
2. `宝贝识物` 作品仍必须携带精确等于“寓教于乐”的公开标签,才会进入“发现 / 寓教于乐”频道。
3. `宝贝识物` 不因为模板名自动归入寓教于乐,也不因为近似标签归入寓教于乐。
4. 第 4 项只负责公开作品卡片、发现页专属频道、公开详情、分享作品号和开关隐藏保护。
5. 创作模板、image-2 资产生成、发布接口、运行时开始游戏和关卡状态由对应线程接入;当前公共作品卡直接透传后续数据源提供的 `publicWorkCode`,不在前端新增最终作品号前缀规则。
6. 在创作和运行时链路真正接入前,公开详情内的启动、改造、编辑和点赞只做保护性占位,不新增玩法规则。
当前工程落点:
1. `src/components/rpg-entry/rpgEntryWorldPresentation.ts` 定义 `PlatformEdutainmentGalleryCard``isEdutainmentGalleryEntry`
2. `src/components/rpg-entry/RpgEntryHomeView.tsx``宝贝识物` 卡片识别为寓教于乐公开作品,并继续从推荐、今日、分类、排行和搜索结果中过滤。
3. `src/components/platform-entry/PlatformWorkDetailView.tsx` 在公开详情中显示 `宝贝识物` 类型标签,并继续复用作品号复制和分享链路。
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 已识别 `edutainment` 公共作品,避免落入 RPG 默认详情、推荐运行态或错误的改造链路。

View File

@@ -7,7 +7,7 @@
创作 Tab 恢复为模板选择入口,但不回到旧的大卡片选择面板: 创作 Tab 恢复为模板选择入口,但不回到旧的大卡片选择面板:
1. 首屏保留现有创作页布局骨架顶部标题固定为“10分钟创作一个精品互动玩法”。 1. 首屏保留现有创作页布局骨架顶部标题固定为“10分钟创作一个精品互动玩法”。
2. 选择模板入口改为横向 Tab数据来自 `src/config/newWorkEntryConfig.ts` 的可见玩法配置。 2. 选择模板入口改为横向 Tab数据来自 `GET /api/creation-entry/config` 返回的可见玩法配置。
3. 默认选中“拼图”模板,并在创作 Tab 内直接展示拼图创作表单。 3. 默认选中“拼图”模板,并在创作 Tab 内直接展示拼图创作表单。
4. 智能创作入口从可见模板中隐藏,保留既有 `creative-agent` 运行链路用于后续内部恢复或草稿目标打开。 4. 智能创作入口从可见模板中隐藏,保留既有 `creative-agent` 运行链路用于后续内部恢复或草稿目标打开。
5. 草稿、发现、我的等一级 Tab 职责不变,作品管理仍在草稿 Tab。 5. 草稿、发现、我的等一级 Tab 职责不变,作品管理仍在草稿 Tab。
@@ -18,7 +18,7 @@
```text ```text
标题10分钟创作一个精品互动玩法 标题10分钟创作一个精品互动玩法
模板 Tab拼图 / 方洞挑战 / 视觉小说 / AIRP 模板 Tab拼图 / 抓大鹅 / 视觉小说(敬请期待)/ AIRP
默认内容:拼图创作表单 默认内容:拼图创作表单
``` ```
@@ -34,21 +34,24 @@
1. 打开“创作”一级 Tab 时默认停留在拼图 Tab不主动创建拼图 session。 1. 打开“创作”一级 Tab 时默认停留在拼图 Tab不主动创建拼图 session。
2. 点击拼图表单“生成草稿”后,才创建拼图 session 并执行 `compile_puzzle_draft` 2. 点击拼图表单“生成草稿”后,才创建拼图 session 并执行 `compile_puzzle_draft`
3. 拼图表单内的模板按钮使用 `tablist / tab` 语义,点击后只填充画面描述。 3. 拼图表单内的模板按钮使用 `tablist / tab` 语义,点击后只填充画面描述。
4. 点击非拼图且已开放的模板 Tab 时,进入该玩法既有工作台;未开放模板保持禁用。 4. 点击非拼图且已开放的模板 Tab 时,进入该玩法既有工作台;视觉小说与 AIRP 当前保持敬请期待禁用
5. `creative-agent` 不出现在模板 Tab 和选择弹层中,不再作为创作 Tab 首屏入口。 5. `creative-agent` 不出现在模板 Tab 和选择弹层中,不再作为创作 Tab 首屏入口。
6. 方洞挑战暂时从创作页完全隐藏,不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中;既有作品链路继续保留。
## 4. 验收 ## 4. 验收
1. 点击“创作”后首屏出现“10分钟创作一个精品互动玩法”。 1. 点击“创作”后首屏出现“10分钟创作一个精品互动玩法”。
2. 顶部选择模板入口为 Tab拼图 Tab 默认 `aria-selected=true` 2. 顶部选择模板入口为 Tab拼图 Tab 默认 `aria-selected=true`
3. 创作 Tab 默认显示拼图创作表单内容且不显示旧“Hi, 朋友”、输入框或智能创作快捷按钮。 3. 创作 Tab 默认显示拼图创作表单内容且不显示旧“Hi, 朋友”、输入框或智能创作快捷按钮。
4. 隐藏的智能创作类型不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中。 4. 隐藏的智能创作类型与方洞挑战不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中。
5. 草稿页返回创作页后仍回到同一模板入口,并可保留拼图表单草稿内容。 5. 草稿页返回创作页后仍回到同一模板入口,并可保留拼图表单草稿内容。
## 5. 嵌入式表单 UI 细节 ## 5. 嵌入式表单 UI 细节
2026-05-10 补充:抓大鹅与视觉小说作为创作 Tab 内嵌表单时,风格类横滑选择器应统一使用浅底卡片、柔和玫瑰色选中态和小圆点确认标记。不要使用大面积黑色渐变、黑底胶囊标签或高饱和红色外框,以免在输入框下方误读为错误提示。 2026-05-10 补充:抓大鹅与视觉小说作为创作 Tab 内嵌表单时,风格类横滑选择器应统一使用浅底卡片、柔和玫瑰色选中态和小圆点确认标记。不要使用大面积黑色渐变、黑底胶囊标签或高饱和红色外框,以免在输入框下方误读为错误提示。
2026-05-10 追加:视觉小说画风选项已改为使用 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2-all` 生成的参考图,作为横向卡片主视觉。
嵌入式表单控件保持以下口径: 嵌入式表单控件保持以下口径:
1. 大文本输入框使用白底、低饱和边框和轻量 focus ring。 1. 大文本输入框使用白底、低饱和边框和轻量 focus ring。

View File

@@ -114,3 +114,13 @@
- 未登录用户点击推荐页封面时,再次打开同一个登录弹窗;登录成功后由既有受保护动作继续进入作品详情或玩法入口。 - 未登录用户点击推荐页封面时,再次打开同一个登录弹窗;登录成功后由既有受保护动作继续进入作品详情或玩法入口。
- 未登录状态下点击“下一个”只切换下一张推荐封面,不触发登录弹窗,也不启动玩法。 - 未登录状态下点击“下一个”只切换下一张推荐封面,不触发登录弹窗,也不启动玩法。
- 已登录用户继续沿用推荐页内嵌运行态、上下滑切换和底部“下一个”行为。 - 已登录用户继续沿用推荐页内嵌运行态、上下滑切换和底部“下一个”行为。
## 10. 2026-05-11 草稿生成中与新完成标记
草稿生成过程页允许用户直接返回创作中心并自由使用平台其它功能:
- 点击生成过程页的返回按钮时,当前生成任务继续在后台执行,页面回到创作中心,不清空生成状态。
- 用户再进入草稿 Tab 并点击同一草稿时,若生成仍未完成,进入对应生成过程页查看最新进度;若已完成,直接进入对应结果页。
- 草稿作品卡在生成中展示“生成中”状态标记;新生成完成且用户尚未查看的草稿在卡片右上角展示红点。
- 底部一级“草稿”Tab 在存在未查看新完成草稿时展示红点用户点击查看带红点的作品后该作品红点消失。若草稿页已无任何带红点作品底部“草稿”Tab 红点同步消失。
- 生成完成时如果用户仍停留在对应生成过程页,可自动进入结果页;如果用户已经回到创作中心或其它功能页,不打断当前操作。

View File

@@ -124,7 +124,7 @@ Match3D 必须形成独立玩法域,后续技术方案至少需要覆盖:
3. 不做排行榜正式展示。 3. 不做排行榜正式展示。
4. 不做道具,但需要预留功能口。 4. 不做道具,但需要预留功能口。
5. 不做洗牌、重置、旋转、放大等局内操作。 5. 不做洗牌、重置、旋转、放大等局内操作。
6. 不做真实 3D 模型。 6. 不做多批次真实 3D 模型生成;当前草稿生成只固定产出 `3` 个 GLB 模型并写入 OSS
7. 不做真实 3D 物理遮挡。 7. 不做真实 3D 物理遮挡。
8. 不做真实物理碰撞结算。 8. 不做真实物理碰撞结算。
9. 不做必须试玩通关才能发布的门槛。 9. 不做必须试玩通关才能发布的门槛。
@@ -161,7 +161,7 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。 题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。
首版 demo 不接入真实图片生成。当前运行态可消除物统一使用题材方向的 25 个积木件类型表现,不使用透明气泡,也不在图案上放文字标识。前端首版用差异化颜色、积木造型和 3D 程序化模型表现可消除物,避免玩家在堆叠状态下难以辨认。 当前抓大鹅草稿生成会固定生成 `3` 个题材物品:素材图切割出的独立图片会作为 Rodin 图生 3D 参考图,生成出的 GLB 模型必须转存 OSS并随作品 profile 的 `generatedItemAssets` 持久化。运行态优先使用这些生成模型;只有模型缺失、加载失败或未进入 3D 渲染模式时,才回退到 25 个默认积木件视觉键。默认素材不使用透明气泡,也不在图案上放文字标识。
可消除物尺寸使用五档相对体积规则XL 型相对体积为 `1.60~2.30`L 型为 `1.25~1.60`M 型为 `1.00`XS 型为 `0.65~0.85`S 型为 `0.35~0.50`。单局中 XL / L / M / XS / S 按本局使用的消除物类型数的 `20% / 30% / 30% / 15% / 5%` 分配;非整数配额按最大余数补齐,确保总数等于本局使用类型数量。同一关卡内同一个颜色和造型的物品只能对应一个尺寸档位;可存在同尺寸但不同颜色和造型的物品。后端运行态通过 `radius` 下发权威尺寸,前端只按快照表现。 可消除物尺寸使用五档相对体积规则XL 型相对体积为 `1.60~2.30`L 型为 `1.25~1.60`M 型为 `1.00`XS 型为 `0.65~0.85`S 型为 `0.35~0.50`。单局中 XL / L / M / XS / S 按本局使用的消除物类型数的 `20% / 30% / 30% / 15% / 5%` 分配;非整数配额按最大余数补齐,确保总数等于本局使用类型数量。同一关卡内同一个颜色和造型的物品只能对应一个尺寸档位;可存在同尺寸但不同颜色和造型的物品。后端运行态通过 `radius` 下发权威尺寸,前端只按快照表现。
@@ -195,7 +195,7 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
## 6.3 参考图片 ## 6.3 参考图片
抓大鹅入口页不展示参考图片上传。题材表现由题材文本和草稿切割图片链路承接;后续需要 3D 模型时,在结果页 `3D素材` Tab 以切割图片作为图生模型参考图手动触发 抓大鹅入口页不展示参考图片上传。题材表现由题材文本和草稿切割图片链路承接;草稿生成阶段会直接以切割图片作为 Rodin 图生模型参考图生成首批 GLB。结果页 `3D素材` Tab 仍可对单个素材触发重新生成
--- ---
@@ -222,9 +222,9 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
## 7.3 素材生成边界 ## 7.3 素材生成边界
抓大鹅草稿生成链路会生成首批 `3` 个题材物品素材文本模型生成物品名VectorEngine 生成 `2*2` 素材图并切割独立图片。入口页选择的 `assetStylePrompt` 必须写入素材图提示词;结果页手动 Rodin 图生模型时,继续以该物品图片和默认提示词作为起点。 抓大鹅草稿生成链路会生成首批 `3` 个题材物品素材文本模型生成物品名VectorEngine 生成 `2*2` 素材图并切割独立图片,再以每张独立图片调用 Rodin 图生 3D下载 `.glb` 并转存 OSS。入口页选择的 `assetStylePrompt` 必须写入素材图提示词;结果页手动 Rodin 图生模型时,继续以该物品图片和默认提示词作为起点。
生成出的独立图片作为草稿页 `3D素材` Tab 的预览资产返回,状态为 `image_ready`模型文件为空。正式平台资产绑定、Rodin 生成模型转存和二次编辑流程以后续技术方案为准。 生成出的独立图片与 GLB 模型都必须作为草稿页 `3D素材` Tab 的预览资产返回。模型生成成功时 `generatedItemAssets[].status = model_ready`,并携带 `modelSrc``modelObjectKey``modelFileName``taskUuid``subscriptionKey`;正式平台资产绑定和更完整的二次编辑流程以后续技术方案为准。
## 7.4 发布前试玩 ## 7.4 发布前试玩
@@ -297,12 +297,14 @@ itemTypeCount = clearCount <= 25 ? clearCount : 25
## 8.5 物品资产 ## 8.5 物品资产
首版 demo 使用 2D 图案素材 当前 demo 使用生成 GLB 优先、默认积木兜底的物品资产策略
1. demo 至少提供 `25` 种彼此不同的颜色与几何造型组合素材,支撑 `clearCount > 25` 时的类型上限。 1. demo 至少提供 `25` 种彼此不同的颜色与几何造型组合默认素材,支撑 `clearCount > 25` 时的类型上限和 GLB 缺失兜底
2. 当前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版必须把这些视觉键映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记 2. `generatedItemAssets[].modelSrc``modelObjectKey` 时,运行态与备选栏必须优先读取该 GLB默认积木件只作为加载失败、模型缺失或 2D 回退时的兜底素材池
3. 后续可以尝试替换为伪 3D 或 3D 模型 3. 前端读取生成模型必须通过 `/api/assets/read-bytes` 获取私有 OSS 字节,再交给 Three.js `GLTFLoader` 解析;不得直接把 `/generated-match3d-assets/...` 当裸 URL 请求
4. 用户题材主题后续会映射为符合常识预期的物品集合 4. 当前固定 `clearCount = 3` 的生成草稿中,运行态 `match3d-type-01/02/03` 按类型编号顺序映射到生成出的 `3` 个模型;后续恢复更大生成数量时,模型列表顺序必须继续与类型编号稳定对应
5. 默认积木视觉键仍需映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记。
6. 用户题材主题后续会映射为符合常识预期的物品集合。
示例:水果题材可以对应红色苹果、黄色香蕉、紫色葡萄等。 示例:水果题材可以对应红色苹果、黄色香蕉、紫色葡萄等。
@@ -706,10 +708,10 @@ GET /api/runtime/match3d/runs/:runId
3. 入口页不展示参考图上传。 3. 入口页不展示参考图上传。
4. 内置风格显示画风参考图,自定义风格通过独立面板填写并进入提交 payload。 4. 内置风格显示画风参考图,自定义风格通过独立面板填写并进入提交 payload。
5. 移动端入口页所有内容一屏展示,不产生纵向滚动。 5. 移动端入口页所有内容一屏展示,不产生纵向滚动。
6. 系统可生成待发布结果页,并在草稿中返回首批切割图片素材预览。 6. 系统可生成待发布结果页,并在草稿中返回首批切割图片与 OSS GLB 模型素材预览。
7. 用户可编辑游戏名称、标签、封面图等基础信息。 7. 用户可编辑游戏名称、标签、封面图等基础信息。
8. 用户可发布前试玩,且试玩失败不阻断发布。 8. 用户可发布前试玩,且试玩失败不阻断发布。
9. 运行态能展示圆形空间、倒计时、物品和 `7` 格备选栏。 9. 运行态能展示圆形空间、倒计时、物品和 `7` 格备选栏;存在 `generatedItemAssets` 模型时必须优先展示生成 GLB而不是默认积木素材
10. 物品可重叠、遮挡、堆叠。 10. 物品可重叠、遮挡、堆叠。
11. 被完全遮挡物品不可点击,露出可点击区域的物品可点击。 11. 被完全遮挡物品不可点击,露出可点击区域的物品可点击。
12. 点击通过后物品飞入备选栏。 12. 点击通过后物品飞入备选栏。

View File

@@ -51,7 +51,7 @@
外部仓库文件只作为玩法语义参考,不能作为目录结构迁入依据: 外部仓库文件只作为玩法语义参考,不能作为目录结构迁入依据:
| 外部参考 | 可参考内容 | Genarrative 落点 | | 外部参考 | 可参考内容 | Genarrative 落点 |
| --- | --- | --- | | ----------------------------------------------- | ---------------------------- | ----------------------------------------------------------------- |
| `interface/routes/visual.js` | 视觉小说创作和运行时路由语义 | `server-rs/crates/api-server/src/visual_novel.rs` | | `interface/routes/visual.js` | 视觉小说创作和运行时路由语义 | `server-rs/crates/api-server/src/visual_novel.rs` |
| `interface/handlers/visual/sendActionStream.js` | 流式动作推进事件 | Axum SSE handler + `shared-contracts` typed envelope | | `interface/handlers/visual/sendActionStream.js` | 流式动作推进事件 | Axum SSE handler + `shared-contracts` typed envelope |
| `interface/handlers/visual/getHistory.js` | 历史记录读取 | 平台 runtime history API | | `interface/handlers/visual/getHistory.js` | 历史记录读取 | 平台 runtime history API |
@@ -69,7 +69,7 @@
### 3.1 必须完成的模板闭环 ### 3.1 必须完成的模板闭环
1. 平台创作中心展示 `视觉小说` 入口,并在实现完成后从 `open: false` 改为 `open: true` 1. 平台创作中心展示 `视觉小说` 入口2026-05-11 起当前运营状态回退为 `open: false`,入口显示敬请期待,不允许创建新视觉小说草稿
2. 创作者可选择 `idea``document``blank` 三种起点创建视觉小说底稿。 2. 创作者可选择 `idea``document``blank` 三种起点创建视觉小说底稿。
3. Agent 或表单工作台生成 / 编辑同一份 `VisualNovelResultDraft` 3. Agent 或表单工作台生成 / 编辑同一份 `VisualNovelResultDraft`
4. 结果页可编辑世界观、角色、场景、剧情阶段、资产和运行时配置。 4. 结果页可编辑世界观、角色、场景、剧情阶段、资产和运行时配置。
@@ -160,8 +160,8 @@
5. 玩家身份。 5. 玩家身份。
6. 3 到 6 个主要角色。 6. 3 到 6 个主要角色。
7. 3 到 8 个可用场景。 7. 3 到 8 个可用场景。
7. 3 到 6 个剧情阶段。 8. 3 到 6 个剧情阶段。
8. 初始场景、初始旁白和第一轮可选行动。 9. 初始场景、初始旁白和第一轮可选行动。
### 5.2 文档创建 `document` ### 5.2 文档创建 `document`
@@ -399,7 +399,15 @@ export type VisualNovelAgentActionKind =
```ts ```ts
export type VisualNovelAgentStreamEvent = export type VisualNovelAgentStreamEvent =
| { type: 'start'; sessionId: string } | { type: 'start'; sessionId: string }
| { type: 'phase'; phase: 'perception' | 'reasoning' | 'drafting' | 'reflection' | 'finalizing' } | {
type: 'phase';
phase:
| 'perception'
| 'reasoning'
| 'drafting'
| 'reflection'
| 'finalizing';
}
| { type: 'text_delta'; text: string } | { type: 'text_delta'; text: string }
| { type: 'draft_patch'; patch: VisualNovelDraftPatch } | { type: 'draft_patch'; patch: VisualNovelDraftPatch }
| { type: 'action_required'; action: VisualNovelAgentPendingAction } | { type: 'action_required'; action: VisualNovelAgentPendingAction }
@@ -559,7 +567,7 @@ export type VisualNovelRuntimeStreamEvent =
### 9.1 创作 session ### 9.1 创作 session
| 方法 | 路由 | 用途 | | 方法 | 路由 | 用途 |
| --- | --- | --- | | ------ | ------------------------------------------------------------------ | ------------------------ |
| `POST` | `/api/creation/visual-novel/sessions` | 创建视觉小说创作 session | | `POST` | `/api/creation/visual-novel/sessions` | 创建视觉小说创作 session |
| `GET` | `/api/creation/visual-novel/sessions/{session_id}` | 读取 session snapshot | | `GET` | `/api/creation/visual-novel/sessions/{session_id}` | 读取 session snapshot |
| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages` | 非流式发送创作消息 | | `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages` | 非流式发送创作消息 |
@@ -570,7 +578,7 @@ export type VisualNovelRuntimeStreamEvent =
### 9.2 作品草稿与发布 ### 9.2 作品草稿与发布
| 方法 | 路由 | 用途 | | 方法 | 路由 | 用途 |
| --- | --- | --- | | ----------- | ------------------------------------------------------- | -------------------------------- |
| `GET` | `/api/creation/visual-novel/works` | 读取当前用户视觉小说作品草稿列表 | | `GET` | `/api/creation/visual-novel/works` | 读取当前用户视觉小说作品草稿列表 |
| `GET` | `/api/creation/visual-novel/works/{profile_id}` | 读取作品详情 | | `GET` | `/api/creation/visual-novel/works/{profile_id}` | 读取作品详情 |
| `PUT/PATCH` | `/api/creation/visual-novel/works/{profile_id}` | 更新作品草稿 | | `PUT/PATCH` | `/api/creation/visual-novel/works/{profile_id}` | 更新作品草稿 |
@@ -580,7 +588,7 @@ export type VisualNovelRuntimeStreamEvent =
### 9.3 运行时 ### 9.3 运行时
| 方法 | 路由 | 用途 | | 方法 | 路由 | 用途 |
| --- | --- | --- | | ------ | -------------------------------------------------------- | ------------------------------------ |
| `GET` | `/api/runtime/visual-novel/gallery` | 读取平台聚合后的视觉小说公开作品列表 | | `GET` | `/api/runtime/visual-novel/gallery` | 读取平台聚合后的视觉小说公开作品列表 |
| `POST` | `/api/runtime/visual-novel/works/{profile_id}/runs` | 创建测试或正式 run | | `POST` | `/api/runtime/visual-novel/works/{profile_id}/runs` | 创建测试或正式 run |
| `GET` | `/api/runtime/visual-novel/runs/{run_id}` | 读取 run snapshot | | `GET` | `/api/runtime/visual-novel/runs/{run_id}` | 读取 run snapshot |
@@ -628,7 +636,7 @@ GET /api/runtime/profile/save-archives
### 10.1 crate 职责 ### 10.1 crate 职责
| crate / 层 | 职责 | | crate / 层 | 职责 |
| --- | --- | | -------------------------------------- | ------------------------------------------------------------- |
| `server-rs/crates/module-visual-novel` | 纯领域规则草稿校验、step 解析、run 状态推进、历史重生成边界 | | `server-rs/crates/module-visual-novel` | 纯领域规则草稿校验、step 解析、run 状态推进、历史重生成边界 |
| `server-rs/crates/shared-contracts` | DTO、请求响应、SSE envelope、草稿和运行时契约 | | `server-rs/crates/shared-contracts` | DTO、请求响应、SSE envelope、草稿和运行时契约 |
| `server-rs/crates/spacetime-module` | 表、reducer、procedure、migration 和事务编排 | | `server-rs/crates/spacetime-module` | 表、reducer、procedure、migration 和事务编排 |
@@ -655,7 +663,7 @@ GET /api/runtime/profile/save-archives
新增表必须同步 `migration.rs`、表目录和 bindings 新增表必须同步 `migration.rs`、表目录和 bindings
| 表 | 用途 | | 表 | 用途 |
| --- | --- | | ------------------------------------ | ------------------------------- |
| `visual_novel_agent_session` | 创作 session 主表 | | `visual_novel_agent_session` | 创作 session 主表 |
| `visual_novel_agent_message` | 创作消息和模型回复 | | `visual_novel_agent_message` | 创作消息和模型回复 |
| `visual_novel_work_profile` | 视觉小说作品草稿 / 发布 profile | | `visual_novel_work_profile` | 视觉小说作品草稿 / 发布 profile |
@@ -684,27 +692,27 @@ GET /api/runtime/profile/save-archives
### 11.1 入口配置 ### 11.1 入口配置
当前 `src/config/newWorkEntryConfig.ts` 已存在 入口配置事实源已经迁移到 SpacetimeDB 的 `creation_entry_type_config` 表,默认种子位于 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`。2026-05-11 起视觉小说当前默认状态为
```ts ```ts
{ {
id: 'visual-novel', id: 'visual-novel',
title: '视觉小说', title: '视觉小说',
subtitle: '敬请期待', subtitle: '分支叙事体验',
badge: '敬请期待', badge: '敬请期待',
visible: true, visible: true,
open: false, open: false,
} }
``` ```
实现完成后更新为: 重新开放创建时再通过后台入口开关或默认种子更新为:
```ts ```ts
{ {
id: 'visual-novel', id: 'visual-novel',
title: '视觉小说', title: '视觉小说',
subtitle: 'AI 生成可玩的视觉小说', subtitle: '分支叙事体验',
badge: '新玩法', badge: '可创建',
visible: true, visible: true,
open: true, open: true,
} }
@@ -781,6 +789,8 @@ service client 要复用现有请求封装、鉴权和错误提示风格,不
3. 生成草稿按钮。 3. 生成草稿按钮。
4. 错误、忙碌与禁用态。 4. 错误、忙碌与禁用态。
视觉画风卡片使用 `public/visual-novel-style-references/` 下的 `gpt-image-2-all` 参考图,分别对应映画动画、水彩绘本、像素霓虹、水墨幻想、柔彩校园和暗色哥特。卡片只承载图像和标签,不额外展示说明文案。
点击生成草稿后进入 `visual-novel-generating` 过程页,过程页复用平台已有生成进度面板,展示一句话输入、画风和草稿生成阶段;完成后自动进入 `visual-novel-result` 草稿页。 点击生成草稿后进入 `visual-novel-generating` 过程页,过程页复用平台已有生成进度面板,展示一句话输入、画风和草稿生成阶段;完成后自动进入 `visual-novel-result` 草稿页。
不展示: 不展示:
@@ -976,7 +986,7 @@ V1 默认提供平台统一存档能力;如果平台存档 UI 当前按槽位
### 16.1 并行批次总览 ### 16.1 并行批次总览
| 批次 | 可并行任务 | 进入条件 | 汇合点 | | 批次 | 可并行任务 | 进入条件 | 汇合点 |
| --- | --- | --- | --- | | ------- | ---------------------------------- | --------------------------------------------- | -------------------------------------- |
| Batch 0 | `VN-00` | PRD 已冻结 | 所有人以本文为唯一口径 | | Batch 0 | `VN-00` | PRD 已冻结 | 所有人以本文为唯一口径 |
| Batch 1 | `VN-01``VN-02``VN-03``VN-04` | `VN-00` 完成 | 契约、领域、UI 骨架、Prompt 口径可对齐 | | Batch 1 | `VN-01``VN-02``VN-03``VN-04` | `VN-00` 完成 | 契约、领域、UI 骨架、Prompt 口径可对齐 |
| Batch 2 | `VN-05``VN-06``VN-07``VN-08` | `VN-01` 产出契约初稿;`VN-02` 产出表草案 | 后端 API、前端创作、前端运行时可联调 | | Batch 2 | `VN-05``VN-06``VN-07``VN-08` | `VN-01` 产出契约初稿;`VN-02` 产出表草案 | 后端 API、前端创作、前端运行时可联调 |
@@ -993,7 +1003,7 @@ V1 默认提供平台统一存档能力;如果平台存档 UI 当前按槽位
### 16.2 并行任务具体要求速查表 ### 16.2 并行任务具体要求速查表
| 任务 | 可并行窗口 | 输入依赖 | 必须完成 | 禁止事项 | 验收口径 | | 任务 | 可并行窗口 | 输入依赖 | 必须完成 | 禁止事项 | 验收口径 |
| --- | --- | --- | --- | --- | --- | | ----------------------------- | ---------------- | --------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `VN-00` 口径冻结 | Batch 0 | 本 PRD、旧 TXT 文档、Hermes 决策记录 | 冻结 `visual-novel` 只做模板玩法、平台接口、删除回放;标注旧文档冲突口径 | 不再使用“原样迁入外部平台工程”作为实现目标 | 文档和 Hermes 记录明确三条硬边界;`npm run check:encoding` 通过 | | `VN-00` 口径冻结 | Batch 0 | 本 PRD、旧 TXT 文档、Hermes 决策记录 | 冻结 `visual-novel` 只做模板玩法、平台接口、删除回放;标注旧文档冲突口径 | 不再使用“原样迁入外部平台工程”作为实现目标 | 文档和 Hermes 记录明确三条硬边界;`npm run check:encoding` 通过 |
| `VN-01` 契约与领域 | Batch 1 | PRD 第 6 到 8 章契约 | TS contracts、Rust shared-contracts、`module-visual-novel`、领域单测 | 不写 HTTP、DB reducer、LLM、UI不出现 replay 类型 | TS/Rust 契约一致草稿校验、step 解析、状态推进、重生成测试通过 | | `VN-01` 契约与领域 | Batch 1 | PRD 第 6 到 8 章契约 | TS contracts、Rust shared-contracts、`module-visual-novel`、领域单测 | 不写 HTTP、DB reducer、LLM、UI不出现 replay 类型 | TS/Rust 契约一致草稿校验、step 解析、状态推进、重生成测试通过 |
| `VN-02` SpacetimeDB 与 facade | Batch 1 | `VN-01` 契约草案、SpacetimeDB 约束文档 | 表、reducer/procedure、migration、表目录、bindings、`spacetime-client` facade | 不手改 bindings 绕过 schema不建 replay 表或私有 save 表 | schema 可生成;表目录写明 event 不是回放;`cargo check -p spacetime-client` 通过 | | `VN-02` SpacetimeDB 与 facade | Batch 1 | `VN-01` 契约草案、SpacetimeDB 约束文档 | 表、reducer/procedure、migration、表目录、bindings、`spacetime-client` facade | 不手改 bindings 绕过 schema不建 replay 表或私有 save 表 | schema 可生成;表目录写明 event 不是回放;`cargo check -p spacetime-client` 通过 |
@@ -1744,7 +1754,7 @@ VN-11 从 Batch 1 开始持续运行,最终阻塞发布。
### 17.1 正向验收 ### 17.1 正向验收
1. `visual-novel` 入口可见并可点击创建。 1. `visual-novel` 入口当前可见但处于敬请期待禁用态;重新开放 `open=true` 后,再验收点击创建闭环
2. 一句话创建能生成可编辑底稿。 2. 一句话创建能生成可编辑底稿。
3. 文档创建能读取平台文档资产并生成底稿。 3. 文档创建能读取平台文档资产并生成底稿。
4. 空白创建能进入结果页。 4. 空白创建能进入结果页。

View File

@@ -0,0 +1,99 @@
# 宝贝识物寓教于乐模板 PRD 2026-05-11
## 1. 目标
新增寓教于乐内容线的创作模板:
```text
宝贝识物
```
创作者必须通过该模板创作并发布作品后,用户才能在寓教于乐板块体验对应关卡。
本模板只服务儿童动作 Demo 内容线,不把普通教育题材作品自动归入寓教于乐。
## 2. 创作输入
创作者必须填写两个物品名称:
1. 物品 A 名称;
2. 物品 B 名称。
两个名称都必须去除首尾空白后非空。当前阶段不新增题材、难度、计时、失败次数、分数、体力或递增规则。
## 3. 生成规则
提交后生成一份宝贝识物草稿,草稿包含:
1. 模板 ID`baby-object-match`
2. 模板名称:`宝贝识物`
3. 两个物品;
4. 两个物品图;
5. 作品标签。
物品图使用 VectorEngine `gpt-image-2-all` / image-2 生成。图片生成只能走后端或后续后端预留接口,前端不得泄露 `VECTOR_ENGINE_API_KEY`
本地 Demo 阶段若真实生图接口未接入完成,允许前端 service 返回明确标记为 `placeholder` 的占位图形,用于打通创作到结果页的交互链路;该占位结果不得伪装成正式 image-2 资产。
## 4. 标签规则
发布作品必须携带精确标签:
```text
寓教于乐
```
标签识别只接受精确等于 `寓教于乐`。不接受 `儿童教育``动作教育``寓教于乐 ` 等近似标签。
宝贝识物草稿与发布 payload 中都必须保留该标签。发布后的公开展示、搜索、深链和入口开关继续遵循 `CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md`
## 5. 结果页能力
结果页展示:
1. 作品名称;
2. 两个物品名称;
3. 两个物品图;
4. 标签;
5. 保存草稿;
6. 发布;
7. 试玩。
结果页不展示长规则说明文案。试玩按钮直接进入宝贝识物首关本地运行态。
试玩按钮进入宝贝识物首关运行态,运行态消费当前草稿中的两个物品名称和两张物品图,不重新生成或改写物品内容。
## 6. 发布后体验
发布完成后作品应进入寓教于乐内容线,并在寓教于乐入口开启时可被板块消费。
入口关闭时,发布作品完全不可见,不能通过推荐、发现普通频道、搜索、作品号、公开详情深链或浏览历史访问。
## 7. 与运行时线程的边界
本 PRD 同步约束首关运行态,已确认规则包括:
1. 礼物盒打开在本地调试绑定 `F` 键;
2. 每轮仅中间礼物盒跳出的物品随机;左右两侧篮子固定为当前草稿两个物品的顺序;
3. 下一关按钮当前占位;
4. 不新增用户未确认的计时、失败次数、分数、体力或难度递增。
5. 屏幕中上方字幕固定为“将物品放入对应的篮子里”。
6. 礼物盒位于屏幕中下方,任意手抬起后打开并跳出下一个随机物品。
7. 屏幕下方左侧和右侧分别展示两个固定篮子,左侧固定使用草稿第一个物品图,右侧固定使用草稿第二个物品图。
8. 明确左手连续横向移动达到阈值时将当前物品送入左侧篮子,明确右手连续横向移动达到阈值时将当前物品送入右侧篮子;选篮不使用动作名判定,侧别未知的手部轨迹不参与选篮。
9. 正确时展示“真棒”字幕和正确特效;错误时展示“再想一想吧”字幕和错误特效,物品回到中央。
10. 成功 20 次后展示“恭喜你!小朋友!”字幕和特效,并展示“再来一次”和“下一关”按钮。
11. 当前本地 Demo 阶段音效与语音播报接口只预留调用点,不在前端写死外部硬件或服务接口。
## 8. 验收
1. 创作入口显示 `宝贝识物` 并可进入模板表单。
2. 未填写任一物品名称时不能生成草稿。
3. 生成草稿后进入结果页,展示两个物品名称和物品图。
4. 草稿标签中始终包含精确 `寓教于乐`
5. 发布 payload 始终包含精确 `寓教于乐`
6. 发布完成后出现分享弹窗或发布完成状态。
7. 前端不读取或暴露 VectorEngine 密钥。
8. 结果页试玩进入宝贝识物运行态,不再显示“试玩关卡正在接入中”。
9. 运行态可通过 `F` 打开礼物盒,通过鼠标左键拖动映射左手横向移动,通过鼠标右键拖动映射右手横向移动。
10. 成功 20 次后出现“再来一次”和“下一关”按钮。

View File

@@ -0,0 +1,412 @@
# 汪汪声浪大作战 / bark-battle BDD 验收场景
## 背景
- 需求来源:用户提供的视频 `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`
- 核心玩法:双方狗狗在 30 秒限时内通过麦克风输入“狗叫声”进行声浪拔河;系统依据声音强度、有效叫声次数和叫声节奏计算推动力,实时推动顶部红蓝能量条;倒计时结束后按能量条位置判定胜负或平局。
- 文档目的:为产品、测试、前端、后端在编码前统一可验证验收口径;本文只定义 PRD/BDD 级行为与测试映射,不实现工程代码。
## 角色与目标
### 主要角色
- 浏览器玩家:进入 `bark-battle` 玩法,用麦克风发声参与对战。
- 移动端玩家:在手机浏览器中进入玩法,需要看到核心游戏信息并完成授权、对战、结算。
- 无麦克风或无 API 支持玩家:设备或浏览器不支持声控输入时,需要得到明确降级反馈并可返回。
- 测试人员:依据本文场景验证权限、校准、叫声识别、能量条、胜负结算和布局兼容。
- 前端实现人员:依据本文拆分 Web Audio 输入、领域规则、Phaser/DOM HUD 表现和自动化测试。
### 用户目标
- 玩家可以在开局前完成麦克风授权和环境噪音校准。
- 玩家发出有效狗叫时,能看到叫声计数、狗狗动画、拟声词/冲击波以及能量条变化。
- 低于阈值的背景噪音不会被误计为有效叫声。
- 单局在 30 秒后给出明确胜负、平局和关键数据。
- 移动端和不支持麦克风的环境不会进入不可操作状态。
### 非目标
- MVP 不要求识别“是否真实狗叫”,不引入机器学习声纹/物种分类;有效输入以音量阈值、峰值间隔、持续时间和校准结果为准。
- MVP 不要求实时联机对战;可先按“玩家 vs AI 对手”完成单机浏览器 runtime。
- MVP 不要求成绩持久化、作品发布、作品架、广场和排行榜;若后续接入 Genarrative 作品闭环,需要另补玩法类型集成 PRD/技术文档。
- MVP 不要求在 UI 中长期展示大段规则说明;游戏界面应保持倒计时、能量条、狗狗、麦克风状态和结算信息为主。
- MVP 不处理竞技级反作弊;播放录音、拍桌、喊叫等非狗叫输入只作为后续公平性风险记录。
## 业务规则口径
- 单局时长:默认 30 秒,从正式进入 `playing` 阶段开始计时。
- 能量条:使用 `-100``100` 的连续值表示,负数偏对手侧,正数偏玩家侧,`0` 为中线。
- 平局阈值:倒计时结束时,若能量条绝对值小于或等于 `drawThreshold`,判定平局;具体数值由实现配置,但测试需可注入固定阈值。
- 有效叫声:一次有效叫声至少满足:音量超过校准后的有效阈值、与上一次有效峰值间隔不小于 `minBarkGapMs`、持续时长在 `minBarkDurationMs``maxBarkDurationMs` 之间。
- 背景噪音:校准阶段采集到的环境声用于计算动态阈值;低于阈值的输入不得增加叫声次数,也不得让能量条出现可见推进。
- 推动力:玩家推动力由音量分数、有效叫声频率和连击加成组成;能量条按玩家推动力与对手推动力差值移动,并被限制在 `-100``100`
- UI 反馈:有效叫声应触发可观察反馈,包括玩家侧狗狗张嘴/吠叫动画、拟声词或冲击波;反馈不应遮挡倒计时和顶部能量条。
## 中文 Gherkin 场景
### 功能: 麦克风授权与开局准备
```gherkin
功能: 狗叫对战麦克风准备
背景:
假如 bark-battle
而且 navigator.mediaDevices.getUserMedia
场景: 玩家允许麦克风权限后进入环境噪音校准
那么
而且 playing
而且
场景: 校准完成后进入开局倒计时
假如
而且
那么
而且 30
而且线
场景: 玩家拒绝麦克风权限后不能开始声控对战
那么
而且
而且 playing
```
### 功能: 环境噪音校准
```gherkin
功能: 环境噪音校准
场景: 安静环境生成低但非零的有效阈值
假如 RMS 线
那么
而且
场景: 嘈杂环境生成更高的有效阈值
假如 RMS 线
那么
而且
场景: 校准期间无法获得有效音频样本
假如
那么
而且
而且
```
### 功能: 有效叫声计数
```gherkin
功能: 有效叫声计数
背景:
假如 30 playing
而且
场景: 单次超过阈值且间隔足够的叫声计数加一
假如 0
而且 minBarkGapMs
那么 1
而且
而且
场景: 持续噪音不会被无限计数
假如 1
那么 tick
而且
场景: 间隔过短的连续峰值不重复计数
假如
minBarkGapMs
那么
而且
```
### 功能: 声音大小和连续叫声推动能量条
```gherkin
功能: 声浪推动能量条
背景:
假如 30 playing
而且线
场景: 玩家推动力高于对手时能量条向玩家侧移动
假如
而且
simulation tick
那么
而且
场景: 连续大声叫声触发更强反馈
假如
那么
而且
但是
场景: 能量条到达边界后不会越界
假如
而且
simulation tick
那么 100
而且
场景: 对手推动力高于玩家时能量条向对手侧移动
假如
simulation tick
那么
而且
```
### 功能: 低噪音和无效输入过滤
```gherkin
功能: 背景噪音过滤
背景:
假如 30 playing
而且
场景: 低于阈值的背景噪音不计数
那么
而且
而且
场景: 过短脉冲不计为有效叫声
假如
但是 minBarkDurationMs
那么
而且
场景: 过长持续声被削弱为单段输入
假如
但是 maxBarkDurationMs
那么
而且
```
### 功能: 倒计时与胜负结算
```gherkin
功能: 狗叫对战胜负结算
30
背景:
假如 30 playing
场景: 倒计时每秒递减并在归零时停止对战输入
30 0
那么
而且 finished
而且
场景: 玩家侧占优时判定玩家胜利
假如 drawThreshold
那么
而且
而且
场景: 对手侧占优时判定玩家失败
假如 -drawThreshold
那么
而且
而且
场景: 能量条接近平衡时判定平局
假如 -drawThreshold drawThreshold
那么
而且
而且
```
### 功能: 再来一局与状态重置
```gherkin
功能: 对战重开
场景: 结算后点击再来一局重置本局状态
假如 finished
而且
那么 30
而且线
而且
而且
场景: 结算后返回玩法入口
假如 finished
那么
而且
```
### 功能: 移动端布局
```gherkin
功能: 移动端 bark-battle 布局
场景: 移动端进入对战页面时核心元素可见
假如使 430px
bark-battle
那么
而且
而且
而且
场景: 移动端授权和开始必须由用户手势触发
假如使
那么
而且 AudioContext
场景: 移动端结算面板不遮挡主要操作
假如
那么
而且
```
### 功能: 无 getUserMedia 或不可用环境降级
```gherkin
功能: 无麦克风 API 降级
API
退
场景: 当前浏览器不支持 getUserMedia
假如 navigator.mediaDevices.getUserMedia
bark-battle
那么
而且
而且
场景: getUserMedia 调用失败但浏览器 API 存在
假如 getUserMedia
但是 NotFoundError NotReadableError
那么
而且
而且 playing
场景: 非安全上下文导致麦克风不可用
假如
bark-battle
那么使
而且使
而且
```
### 功能: 刷新与退出时释放麦克风资源
```gherkin
功能: 麦克风资源释放
场景: 对战中离开页面停止采集
假如 playing
bark-battle
那么 MediaStream
而且 simulation tick
场景: 刷新页面后不沿用旧局临时状态
假如 playing
bark-battle
那么
而且沿
```
## 测试映射
| 场景 | 测试层级 | 建议目标文件 | 自动化状态 |
| --- | --- | --- | --- |
| 玩家允许麦克风权限后进入环境噪音校准 | application/component | `src/games/bark-battle/application/BarkBattleController.test.ts`, `src/games/bark-battle/ui/BarkBattlePermissionPanel.test.tsx` | planned |
| 校准完成后进入开局倒计时 | application/unit | `src/games/bark-battle/application/BarkBattleController.test.ts`, `src/games/bark-battle/domain/BarkBattleSession.test.ts` | 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/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 |
| 单次超过阈值且间隔足够的叫声计数加一 | 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/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/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/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 |
| 结算后点击再来一局重置本局状态 | application/component | `src/games/bark-battle/application/BarkBattleController.test.ts`, `src/games/bark-battle/ui/BarkBattleResultPanel.test.tsx` | planned |
| 结算后返回玩法入口 | integration/smoke | `src/games/bark-battle/application/BarkBattleController.test.ts`, Playwright 或人工 smoke 清单 | planned |
| 移动端进入对战页面时核心元素可见 | component/visual/smoke | `src/games/bark-battle/ui/BarkBattleHud.test.tsx`, Playwright 移动端视口 smoke | planned |
| 移动端授权和开始必须由用户手势触发 | infrastructure/application/e2e-smoke | `src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts`, `src/games/bark-battle/application/__tests__/BarkBattleController.test.ts`, Playwright 移动端 smoke | planned |
| 移动端结算面板不遮挡主要操作 | component/visual/smoke | `src/games/bark-battle/ui/__tests__/BarkBattleResultPanel.test.tsx`, Playwright 移动端视口 smoke | planned |
| 当前浏览器不支持 getUserMedia | infrastructure/component | `src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts`, `src/games/bark-battle/ui/__tests__/BarkBattlePermissionPanel.test.tsx` | planned |
| getUserMedia 调用失败但浏览器 API 存在 | infrastructure/application/component | `src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts`, `src/games/bark-battle/application/__tests__/BarkBattleController.test.ts`, `src/games/bark-battle/ui/__tests__/BarkBattlePermissionPanel.test.tsx` | planned |
| 非安全上下文导致麦克风不可用 | infrastructure/application/component | `src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts`, `src/games/bark-battle/application/__tests__/BarkBattleController.test.ts`, `src/games/bark-battle/ui/__tests__/BarkBattlePermissionPanel.test.tsx` | planned |
| 对战中离开页面停止采集 | infrastructure/application/integration | `src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts`, `src/games/bark-battle/application/__tests__/BarkBattleController.test.ts` | planned |
| 刷新页面后不沿用旧局临时状态 | integration/smoke | Playwright 或人工 smoke 清单 | planned |
## 验收清单
- [ ] 权限允许、拒绝、非安全上下文、API 不支持、麦克风未找到/不可读、AudioContext 被拦截、校准超时或样本不可读均有明确状态,且不会误进入 playing。
- [ ] 校准阶段会影响有效叫声阈值,低噪音不会增加叫声计数。
- [ ] 有效叫声计数具备阈值、峰值间隔、持续时长约束。
- [ ] 能量条根据双方推动力差值双向移动,并限制在 `-100``100`
- [ ] 30 秒归零后停止本局输入影响,并按玩家胜利、对手胜利、平局三类结果结算。
- [ ] 移动端核心元素可见,非关键设置收起,不在主画面堆叠长规则说明。
- [ ] 离开页面或返回入口时停止麦克风采集。
- [ ] 每个编码依据场景已在测试映射中标注测试层级和建议文件。
## 开放问题
1. MVP 是否确认只做“玩家 vs AI”还是第一版需要双人同屏或联机对战
2. `drawThreshold``minBarkGapMs``minBarkDurationMs``maxBarkDurationMs` 的首版默认值由产品/调参阶段确认,还是先采用开发可配置默认值?
3. 是否允许无麦克风设备提供键盘/点击备用输入?若允许,需要另补非声控模式场景;若不允许,当前降级只提供返回入口。
4. 是否需要在结算中记录或上报成绩、最高音量、叫声次数和声浪评分?若需要,需补埋点/后端持久化场景。
5. bark-battle 是否作为 Genarrative 正式 play type 接入创作入口、作品发布和广场,还是先作为独立 runtime 原型验证?
6. 狗狗、背景、拟声词和冲击波素材来源是临时占位、AI 生成,还是复用项目现有素材管线?

View File

@@ -4,6 +4,7 @@
## 重点入口 ## 重点入口
- [宝贝识物寓教于乐模板 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 原生视觉小说模板 PRDTXT 玩法平台化接入](./AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md):参考 `Interactive-fiction-backend` / `Interactive-fiction-frontend` 的 TXT 玩法经验,但只保留视觉小说模板创作与运行闭环,完全使用 Genarrative 平台接口,并明确删除回放和外部平台功能。 - [AI 原生视觉小说模板 PRDTXT 玩法平台化接入](./AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md):参考 `Interactive-fiction-backend` / `Interactive-fiction-frontend` 的 TXT 玩法经验,但只保留视觉小说模板创作与运行闭环,完全使用 Genarrative 平台接口,并明确删除回放和外部平台功能。
- [AI 原生幸存者类游戏模板 PRD](./AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md):定义 `survivor` 幸存者挑战模板,从 Agent 创作、结果页、资产、试玩、发布到后端权威配置与前端高频运行表现的完整闭环。 - [AI 原生幸存者类游戏模板 PRD](./AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md):定义 `survivor` 幸存者挑战模板,从 Agent 创作、结果页、资产、试玩、发布到后端权威配置与前端高频运行表现的完整闭环。

View File

@@ -65,7 +65,7 @@ Admin Web
-> spacetime-module creation_entry_type_config 表 -> spacetime-module creation_entry_type_config 表
``` ```
`visible=false` 会让创作中心不展示对应入口;`open=false` 会让前端展示锁定态api-server 的运行态熔断继续以 `visible && open` 判断路由是否可用 `visible=false` 会让创作中心不展示对应入口;`open=false` 会让前端展示锁定态,并让 api-server 熔断对应玩法创作 / 运行态 API。隐藏入口但仍保留既有作品号、广场详情或试玩链路时应只关闭 `visible`,不要关闭 `open`
## 注意 ## 注意

View File

@@ -0,0 +1,164 @@
# 宝贝识物创作发布实现方案 2026-05-11
## 1. 范围
本方案对应第 2 线程:创作发布线程。
本线程落地:
1. 创作入口配置;
2. 模板表单;
3. 本地草稿生成 service
4. 结果页;
5. 发布 payload 约束;
6. 本地 Demo 运行态;
7. 后端 image-2 / 作品持久化 / 运行态接口预留形状。
本阶段运行态先做浏览器本地 Demo并消费现有本地 mocap 动作数据源;正式硬件接口和摄像头调教在后续接口稳定后继续接入。
## 2. 前端接入点
新增玩法 ID
```text
baby-object-match
```
用户展示名:
```text
宝贝识物
```
入口文件:
1. `src/config/newWorkEntryConfig.ts`
2. `src/components/platform-entry/platformEntryCreationTypes.ts`
3. `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
`baby-object-match` 必须复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时,创作类型弹层不展示 `宝贝识物`,创作页作品架不展示本地宝贝识物草稿或已发布作品卡,公开发现、搜索、详情、作品号和浏览历史也继续完全不可见。
新增阶段:
```text
baby-object-match-workspace
baby-object-match-generating
baby-object-match-result
baby-object-match-runtime
```
## 3. 契约
前端共享契约放在:
```text
packages/shared/src/contracts/edutainmentBabyObject.ts
```
核心字段:
1. `BabyObjectMatchDraft.templateId = "baby-object-match"`
2. `BabyObjectMatchDraft.templateName = "宝贝识物"`
3. `BabyObjectMatchDraft.themeTags` 必须包含精确 `寓教于乐`
4. `BabyObjectMatchItemAsset.generationProvider` 首版允许为 `vector-engine-gpt-image-2``placeholder`
5. `BabyObjectMatchPublishRequest.draft.themeTags` 发布前必须归一化补齐 `寓教于乐`
## 4. Service 边界
前端 service 放在:
```text
src/services/edutainment-baby-object/babyObjectMatchClient.ts
```
首版提供:
1. `createBabyObjectMatchDraft(payload)`
2. `saveBabyObjectMatchDraft(draft)`
3. `publishBabyObjectMatchWork(payload)`
当前后端正式接口未在本线程扩表落地,因此 service 先走本地 Demo 存储,并把 asset 结果标记为 `placeholder`。后续后端接入时,应替换为:
```text
POST /api/creation/edutainment/baby-object-match/drafts
PUT /api/creation/edutainment/baby-object-match/drafts/{draftId}
POST /api/creation/edutainment/baby-object-match/drafts/{draftId}/publish
```
图片生成必须在后端调用 VectorEngine `gpt-image-2-all`,不得从前端直接调用外部图片接口。
## 5. UI 边界
工作台只展示两个必填输入和生成按钮。
结果页只展示草稿核心信息、两个物品、保存草稿、发布、试玩。不在 UI 内写玩法说明长文案。
移动端优先:表单和结果页使用单列布局,桌面端自然扩展为双列。
## 6. 运行态边界
前端运行态放在:
```text
src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx
```
运行态直接消费 `BabyObjectMatchDraft`,必须使用草稿中的两个物品名称和物品图。
每轮只随机当前从礼物盒跳出的物品;左右篮子不随机交换,左侧固定为草稿 `itemAssets[0]`,右侧固定为草稿 `itemAssets[1]`
首关状态机:
1. `waiting`:礼物盒关闭,等待任意手抬起;
2. `active`:当前物品停留在屏幕中央;
3. `correct`:展示“真棒”反馈,成功次数加 1
4. `wrong`:展示“再想一想吧”反馈,当前物品回到中央;
5. `complete`:成功次数达到 20展示“恭喜你小朋友”和按钮。
动作输入:
1. 任意手完成一次 `open_palm -> grab` 抓握序列:打开礼物盒并生成当前物品;
2. 左手连续横向移动达到阈值:将当前物品送入左侧篮子;
3. 右手连续横向移动达到阈值:将当前物品送入右侧篮子。
运行态直接通过 `useMocapInput` 消费本地 mocap WebSocket `/stream`。选篮只使用明确 `leftHand``rightHand` 的连续横向轨迹阈值,不再通过 `wave_left_hand``wave_right_hand``wave` 等动作名触发;侧别为 `unknown` 的手部轨迹也不参与选篮,以避免多套判定误命中和连续误触发。当前本地 mocap 输出的 handedness 按摄像头视角标记,宝贝识物运行态必须先换算为用户身体视角:`rightHand` 轨迹映射玩家左手并进入左侧篮子,`leftHand` 轨迹映射玩家右手并进入右侧篮子。草稿试玩、发布后正式体验和热身关后的本地 Demo 都复用同一个运行态,因此三条入口都必须具备同一套动作控制能力。
开发者调试输入:
1. `F`:映射任意手抬起,打开礼物盒并生成当前物品;
2. 鼠标左键按下并拖动:映射左手轨迹,抬起后将当前物品送入左侧篮子;
3. 鼠标右键按下并拖动:映射右手轨迹,抬起后将当前物品送入右侧篮子。
运行态不得新增计时、失败次数、分数、体力或难度递增规则。
音效和语音播报当前只保留接口预留边界,正式语音接口后续接入。
## 7. 发布约束
发布前必须执行:
1. 两个物品名非空;
2. 两个物品名对应的 asset 存在;
3. 标签补齐精确 `寓教于乐`
4. `publicationStatus``draft` 变为 `published`
发布后首版本地响应返回 `publicWorkCode`,用于分享弹窗;正式后端接入时 public code 生成规则需要纳入统一作品号服务。
## 8. 热身关衔接
`/child-motion-demo` 热身完成后的“开始游戏”按钮进入同一个 `BabyObjectMatchRuntimeShell`
热身关独立 Demo 没有创作者草稿上下文,因此使用固定本地 Demo 草稿承载两个物品,仅用于热身关后验证首关体验;正式平台体验仍必须从 `宝贝识物` 模板创作发布后进入寓教于乐板块。
## 9. 验收命令
```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
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
npm run check:encoding
npm run typecheck
npm run build:raw
```
若后续接入真实 Rust API 和 SpacetimeDB 表,再补充 `npm run api-server``/healthz`、Rust contract / api-server / spacetime-client 定向测试和 migration 表目录更新。

View File

@@ -0,0 +1,729 @@
# bark-battle 2D Runtime 前端技术方案2026-05-11
## 1. 背景与目标
本方案基于 `.hermes/plans/2026-05-11_144229-bark-battle-2d-game-bdd-ddd-tdd-plan.md`,为“汪汪声浪大作战 / bark-battle”细化前端与浏览器游戏 runtime 的技术实现路线。
本任务只产出技术方案,不直接实现代码。后续编码应以本文作为 runtime 层设计约束,再按 TDD 小步落地。
### 1.1 玩法定位
`bark-battle` 是一个声控拔河式 2D 浏览器小游戏:玩家对麦克风发出狗叫声,系统根据音量峰值、有效叫声次数和节奏计算本方声浪推动力,在限时 30 秒内推动顶部红蓝能量条,时间结束后按能量条偏向判定胜负。
### 1.2 本文范围
本文覆盖:
- Phaser + TypeScript + Vite 栈选择
- simulation / render / HUD 边界
- 建议目录结构
- 核心 domain 类型
- Web Audio 输入适配
- Phaser Scene 切分
- DOM HUD 设计
- 移动端与权限降级
- 测试与验证命令
本文不覆盖:
- 后端表结构、持久化成绩、作品发布、广场接入
- 实时多人对战协议
- 复杂 AI 狗叫识别模型
- 美术素材正式生产流程
## 2. 技术栈选择
### 2.1 推荐栈
```text
Runtime Renderer: Phaser 3
Language: TypeScript
Build: Vite
Host UI: React / DOM overlay
Audio Input: Web Audio API + MediaDevices.getUserMedia
Test: Vitest + Testing Library + 浏览器 smoke / Playwright 可选
```
### 2.2 选择理由
1. 玩法是横版 2D 舞台,核心表现是狗狗 sprite、声浪、拟声词、粒子、屏幕震动与能量条反馈Phaser 对 2D 渲染、时间循环、sprite animation、camera、粒子和 Scene 生命周期支持成熟。
2. 当前项目主前端已使用 TypeScript + Vite继续复用现有构建和测试体系避免引入独立构建链。
3. 文字密集、权限提示、结算、设置和移动端响应式布局适合 DOM HUDCanvas 保持负责 playfield 和动态特效。
4. Web Audio API 可以在浏览器端完成 MVP 所需音量采样、RMS/peak 计算、环境噪音校准和输入归一化,不需要首版接入后端音频处理。
### 2.3 不选择其它路线的原因
- 不使用 Three.js / 3D当前玩法画面是 2D 横版舞台,不需要 3D 相机、模型和材质管线。
- 不把 HUD 全部塞入 Phaser Canvas权限说明、重试、结算、移动端布局和可访问性更适合 DOM。
- 不在前端实现正式业务真相:浏览器 runtime 可承载单局即时 simulation但若后续涉及成绩、作品、排行榜、发布和奖励必须交给后端投影/API 裁决。
## 3. 总体架构
### 3.1 分层总览
```text
React Runtime Shell
├─ DOM HUD / Panels
│ ├─ PermissionPanel
│ ├─ TopEnergyBar
│ ├─ TimerChip
│ └─ ResultPanel
├─ Application Controller
│ ├─ permission / calibration orchestration
│ ├─ simulation tick
│ ├─ audio sample submission
│ └─ snapshot publish
├─ Pure Domain / Simulation
│ ├─ BarkBattleSession
│ ├─ BarkDetector
│ ├─ EnergyTugOfWar
│ ├─ BarkBattleScoring
│ └─ OpponentStrategy
├─ Infrastructure Adapters
│ ├─ BrowserMicrophoneInput
│ ├─ AudioAnalyserSampler
│ └─ PhaserGameHost
└─ Phaser Renderer
├─ BootScene
├─ PreloadScene
├─ BattleScene
├─ FxScene / DebugScene可选
└─ Asset manifest
```
### 3.2 强制边界
1. `domain/` 不依赖 Phaser、Web Audio、DOM、React、浏览器全局对象或后端 API。
2. `domain/` 只接收 plain data例如时间增量、归一化音量样本、对手 power、配置参数。
3. `application/` 负责编排权限、校准、音频输入、AI 对手、tick 和 snapshot 分发。
4. Phaser Scene 只消费 `BarkBattleSnapshot`,把 snapshot 映射成 sprite、动画、粒子、camera 和 sound effect不得持有核心胜负、计数和能量条规则。
5. DOM HUD 只消费 snapshot 和少量 runtime UI 状态,负责展示、按钮和弹层;不得重复实现核心胜负规则。
6. 若后续接入平台作品/成绩/排行榜,前端只调用后端 API 和展示投影,不在本地绕过后端生成正式结论。
## 4. 建议目录结构
首版建议以独立 runtime 原型落在 `src/games/bark-battle/`,避免提前侵入平台创作链路。
```text
src/games/bark-battle/
domain/
BarkBattleTypes.ts
BarkBattleSession.ts
BarkDetector.ts
EnergyTugOfWar.ts
BarkBattleScoring.ts
OpponentStrategy.ts
__tests__/
BarkDetector.test.ts
EnergyTugOfWar.test.ts
BarkBattleSession.test.ts
BarkBattleScoring.test.ts
application/
BarkBattleController.ts
BarkBattleConfig.ts
BarkBattleSnapshotStore.ts
__tests__/
BarkBattleController.test.ts
infrastructure/
BrowserMicrophoneInput.ts
AudioAnalyserSampler.ts
MicrophonePermission.ts
__tests__/
BrowserMicrophoneInput.test.ts
AudioAnalyserSampler.test.ts
phaser/
BarkBattleGameHost.ts
scenes/
BarkBattleBootScene.ts
BarkBattlePreloadScene.ts
BarkBattleScene.ts
BarkBattleFxScene.ts
assets/
barkBattleAssetManifest.ts
ui/
BarkBattleRuntimeShell.tsx
BarkBattleHud.tsx
BarkBattlePermissionPanel.tsx
BarkBattleResultPanel.tsx
BarkBattleMobileControls.tsx
BarkBattleHud.css
__tests__/
BarkBattleHud.test.tsx
BarkBattlePermissionPanel.test.tsx
BarkBattleResultPanel.test.tsx
```
若后续进入 Genarrative 正式玩法类型闭环,再按 `genarrative-play-type-integration` 扩展到:
```text
src/components/bark-battle-runtime/BarkBattleRuntimeShell.tsx
src/components/bark-battle-result/BarkBattleResultView.tsx
src/services/barkBattleRuntimeClient.ts
packages/shared/src/contracts/barkBattle.ts
server-rs/crates/shared-contracts/src/bark_battle.rs
```
首版不建议直接新增后端表或正式作品链路,除非产品明确要求成绩、发布和广场能力。
## 5. 核心 Domain 类型
### 5.1 基础枚举与数值约定
```ts
export type BarkBattlePhase =
| 'permission'
| 'calibration'
| 'countdown'
| 'playing'
| 'finished'
| 'unavailable'
export type BarkBattleSide = 'player' | 'opponent'
export type BarkBattleWinner = BarkBattleSide | 'draw' | null
export type BarkBattleDifficulty = 'easy' | 'normal' | 'hard'
export type BarkBattleUiState =
| 'idle'
| 'permission-ready'
| 'microphone-authorized'
| 'calibrating'
| 'ready-countdown'
| 'playing'
| 'finished'
| 'microphone-unavailable'
export type MicrophoneFailureReason =
| 'unsupported'
| 'permission-denied'
| 'non-secure-context'
| 'not-found'
| 'not-readable'
| 'audio-context-blocked'
| 'calibration-timeout'
| 'calibration-sample-unreadable'
| 'unknown'
```
关键数值:
- `energy`: `-100..100`,正数偏玩家侧,负数偏对手侧。
- `currentVolume`: `0..1`,音频采样归一化后的瞬时音量。
- `recentPeak`: `0..1`,短窗口内峰值。
- `power`: `0..1``0..100` 二选一,建议 domain 内统一 `0..1`HUD 显示再转百分比。
- `remainingMs`: 单局剩余毫秒。
### 5.2 Snapshot
```ts
export type BarkBattleSnapshot = {
phase: BarkBattlePhase
uiState: BarkBattleUiState
errorReason: MicrophoneFailureReason | null
statusMessageKey: BarkBattleStatusMessageKey | null
elapsedMs: number
remainingMs: number
countdownMs: number
energy: number
player: BarkSideState
opponent: BarkSideState
winner: BarkBattleWinner
result: BarkBattleResult | null
lastEvents: BarkBattleVisualEvent[]
}
export type BarkSideState = {
side: BarkBattleSide
barkCount: number
currentVolume: number
recentPeak: number
combo: number
power: number
isBarking: boolean
lastBarkAtMs: number | null
maxVolume: number
}
export type BarkBattleResult = {
winner: BarkBattleWinner
finalEnergy: number
playerBarkCount: number
playerMaxVolume: number
playerAveragePower: number
score: number
}
export type BarkBattleStatusMessageKey =
| 'microphone-unsupported'
| 'microphone-permission-denied'
| 'microphone-non-secure-context'
| 'microphone-not-found'
| 'microphone-not-readable'
| 'microphone-audio-context-blocked'
| 'microphone-calibration-timeout'
| 'microphone-calibration-sample-unreadable'
| 'microphone-unknown-error'
```
### 5.3 输入样本与叫声事件
```ts
export type BarkAudioSample = {
atMs: number
volume: number
peak: number
rms: number
}
export type BarkDetectedEvent = {
id: string
atMs: number
side: BarkBattleSide
volume: number
strength: number
combo: number
}
```
### 5.4 视觉事件
视觉事件由 domain 或 application 生成,但只包含 plain data不包含 Phaser 对象:
```ts
export type BarkBattleVisualEvent =
| {
type: 'bark-word'
id: string
side: BarkBattleSide
atMs: number
strength: number
text: 'BARK' | 'WOOF' | 'WAN' | 'WANGOOF'
}
| {
type: 'shockwave'
id: string
side: BarkBattleSide
atMs: number
strength: number
}
| {
type: 'combo-burst'
id: string
side: BarkBattleSide
atMs: number
combo: number
}
```
Phaser 只根据这些事件播放一次性特效,并维护已消费事件 ID避免重复播放。
## 6. Domain 模块职责
### 6.1 BarkDetector
职责:把连续音频样本转换为有效叫声事件。
输入:
- `BarkAudioSample`
- 校准后的 `ambientNoiseFloor`
- `barkThreshold`
- `minBarkGapMs`
- `minBarkDurationMs`
- `maxBarkDurationMs`
规则建议:
1. 音量超过动态阈值进入 candidate 状态。
2. 峰值回落到阈值以下或持续时长达到上限时结束 candidate。
3. candidate 持续时间在 `80ms..1200ms` 且与上一次有效叫声间隔足够时,记为一次叫声。
4. 长时间持续噪音不应无限计数,只能按冷却和峰值回落形成新事件。
5. MVP 不要求识别“是否真狗叫”,先基于音量峰值、时长和间隔判断。
### 6.2 EnergyTugOfWar
职责:更新红蓝拉锯条。
建议公式:
```text
playerPower = volumeScore * 0.65 + barkRateScore * 0.35 + comboBonus
opponentPower = opponentStrategy.tick(...)
energyDelta = (playerPower - opponentPower) * deltaSeconds * balanceFactor
energy = clamp(energy + energyDelta, -100, 100)
```
约束:
- `EnergyTugOfWar` 不知道玩家来自麦克风还是 mock input。
- `EnergyTugOfWar` 不知道 Phaser 能量条宽度。
- 平衡参数集中在 `BarkBattleConfig`,不要散落在 Scene 或 HUD 中。
### 6.3 BarkBattleSession
职责:管理局内 phase、计时、胜负和 snapshot。
状态机建议:
```text
permission → calibration → countdown → playing → finished
↘ unavailable
```
`phase` 只表达 runtime 是否可继续参与局内流程;所有麦克风不可用、权限失败、非安全上下文和校准失败都统一收敛到 `phase: 'unavailable'`,再通过 `uiState: 'microphone-unavailable'``errorReason` 区分 HUD 展示和重试策略,避免把基础设施错误枚举直接扩散成 domain 阶段。
关键规则:
- `countdown` 结束才进入 `playing`
- `playing``remainingMs` 随 tick 递减。
- `remainingMs <= 0` 后进入 `finished`
- `energy > drawThreshold` 判定玩家胜利。
- `energy < -drawThreshold` 判定对手胜利。
- `abs(energy) <= drawThreshold` 判定平局。
### 6.4 OpponentStrategy
职责:为单机 MVP 提供对手推动力。
```ts
export interface OpponentStrategy {
tick(input: OpponentTickInput): OpponentTickOutput
}
```
普通难度建议:
- 周期性小叫声提供基础压力。
- 每 3~6 秒一次短爆发。
- 玩家大幅领先时可轻微增强,但不能追到不可赢。
## 7. Web Audio 输入适配
### 7.1 BrowserMicrophoneInput
职责:封装浏览器麦克风权限与音频流生命周期。
建议 API
```ts
export interface MicrophoneInputPort {
isSupported(): boolean
requestPermission(): Promise<MicrophoneSession>
stop(): void
}
export interface MicrophoneSession {
sample(atMs: number): BarkAudioSample
stop(): void
}
```
实现要点:
1. 使用 `navigator.mediaDevices?.getUserMedia({ audio: true })`
2. 在用户点击“开始”后创建或 resume `AudioContext`,避免移动端自动播放策略拦截。
3. 使用 `AnalyserNode` 读取时域数据,计算 RMS 与 peak。
4. 输出归一化样本,不把 `MediaStream``AudioContext``AnalyserNode` 泄漏到 domain。
5. 退出、重开、页面卸载时停止 track避免麦克风占用残留。
### 7.2 校准流程
`calibration` 阶段建议持续 `800ms..1500ms`
1. 收集静默环境样本。
2. 计算 `ambientNoiseFloor`,例如 `p75` 或均值 + 标准差。
3. 设置动态阈值:
```text
barkThreshold = clamp(ambientNoiseFloor + 0.12, 0.18, 0.55)
```
4. 若环境噪音过高HUD 给出简短提示和“继续 / 重新校准”入口,但不要把长说明常驻在画面上。
### 7.3 权限与错误分类
```ts
export type MicrophoneFailureReason =
| 'unsupported'
| 'permission-denied'
| 'non-secure-context'
| 'not-found'
| 'not-readable'
| 'audio-context-blocked'
| 'calibration-timeout'
| 'calibration-sample-unreadable'
| 'unknown'
```
错误来源与分层归属:
| 失败原因 | 主要检测位置 | controller snapshot 表达 | HUD 可区分状态 |
| --- | --- | --- | --- |
| 浏览器无 `mediaDevices.getUserMedia` | `BrowserMicrophoneInput.isSupported()` | `phase: 'unavailable'`, `uiState: 'microphone-unavailable'`, `errorReason: 'unsupported'` | 设备或浏览器不支持麦克风输入,只提供返回入口,不展示可开始声控按钮 |
| 非安全上下文 | `BrowserMicrophoneInput.isSupported()``MicrophonePermission` 预检 `window.isSecureContext` | `phase: 'unavailable'`, `errorReason: 'non-secure-context'` | 当前环境无法使用麦克风,提示使用受支持的安全环境或返回 |
| 用户拒绝授权 | `BrowserMicrophoneInput.requestPermission()` 捕获 `NotAllowedError` / `SecurityError` | `phase: 'unavailable'`, `errorReason: 'permission-denied'` | 提供重新授权或返回入口,不进入 calibration/countdown/playing |
| 未检测到设备 | `getUserMedia` 捕获 `NotFoundError` / `DevicesNotFoundError` | `phase: 'unavailable'`, `errorReason: 'not-found'` | 展示麦克风不可用,可重试授权或返回 |
| 设备被占用或不可读 | `getUserMedia` 捕获 `NotReadableError` / `TrackStartError` | `phase: 'unavailable'`, `errorReason: 'not-readable'` | 展示麦克风不可用,可重试授权或返回 |
| AudioContext 被移动端策略拦截 | 用户手势后创建 / resume `AudioContext` 失败 | `phase: 'unavailable'`, `errorReason: 'audio-context-blocked'` | 提示点击重试,不自动循环请求 |
| 校准超时 | `BarkBattleController` 在 calibration 阶段等待样本超出 `calibrationMaxWaitMs` | `phase: 'unavailable'`, `errorReason: 'calibration-timeout'` | 展示麦克风输入不可用,提供重试校准入口 |
| 校准样本不可读 | `AudioAnalyserSampler.sample()` 持续返回空样本、NaN 或无法读取 buffer | `phase: 'unavailable'`, `errorReason: 'calibration-sample-unreadable'` | 展示麦克风输入不可用,提供重试校准入口 |
前端只根据错误分类展示可操作状态:重试授权、重试校准、返回、或使用调试备用输入。不要把浏览器原始错误堆栈展示给玩家。
## 8. Phaser Scene 切分
### 8.1 BarkBattleBootScene
职责:
- 初始化 Phaser 全局配置。
- 注册 scale、background color、全局事件桥。
- 不加载重资源,不处理玩法规则。
### 8.2 BarkBattlePreloadScene
职责:
- 根据 `barkBattleAssetManifest` 加载背景、狗狗 sprite、声浪 FX、拟声词 bitmap / atlas、轻量音效。
- 使用稳定 manifest key不在 gameplay 代码中散写文件路径。
- 加载完成后进入 `BarkBattleScene`
### 8.3 BarkBattleScene
职责:
- 创建横版舞台、左右狗狗、背景层、声浪层、拟声词层。
- 每帧读取最新 `BarkBattleSnapshot`
- 根据 snapshot 更新:
- 狗狗 idle / bark / win / lose 动画
- 声浪强度
- camera shake
- transient bark words
- shockwave
- 把可选的调试输入 action 传给 controller但不处理麦克风和规则。
不得在 Scene 中实现:
- 叫声计数
- 胜负判定
- 能量条规则
- 权限流程
- 结算数据计算
### 8.4 BarkBattleFxScene可选
如果特效复杂,可拆出叠加 Scene
- 专门处理拟声词、粒子、冲击波和 camera shake。
- 通过视觉事件 ID 去重。
-`prefers-reduced-motion` 或低端设备降级。
首版也可以先把 FX 保持在 `BarkBattleScene` 内,但必须仍然只消费 snapshot / visual events。
## 9. DOM HUD 设计
### 9.1 HUD 层级
DOM HUD 建议覆盖在 Phaser Canvas 上方:
```text
BarkBattleRuntimeShell
├─ <div className="bark-battle-canvas-host" />
└─ <BarkBattleHud snapshot={snapshot} uiState={uiState} />
```
HUD 分区:
- 顶部:红蓝声浪能量条 + 小型剩余时间。
- 中央:仅在倒计时、关键提示或结算时短暂展示大号状态。
- 左右边缘:双方简洁状态,例如叫声次数 / combo chip。
- 底部角落:麦克风状态、重试、小菜单。
- 结算:独立居中面板,显示胜负、叫声次数、最大音量、评分、再来一局、返回。
### 9.2 Playfield 保护
遵循 game UI 约束:
1. 正常 playing 阶段保持中心和下中部 playfield 清爽,不常驻长文案。
2. 不把规则说明、长控制说明、多段提示默认铺在画面上。
3. 权限、设置、结算使用独立面板或弹层,不在当前面板下面展开一大块内容。
4. 移动端优先保证顶部能量条、倒计时、狗狗和重试入口可见可点。
5. 大动效不能遮挡顶部能量条和倒计时。
### 9.3 CSS 设计建议
- 使用局部 CSS class 或 CSS module避免污染全站。
- 使用 CSS 变量定义主题:
- `--bark-player-color`
- `--bark-opponent-color`
- `--bark-panel-bg`
- `--bark-safe-bottom`
- 使用 `dvh` / `svh` 和 safe-area inset 处理移动端地址栏与刘海。
- `pointer-events` 分层HUD 容器默认 `pointer-events: none`,按钮和面板恢复 `pointer-events: auto`
## 10. 移动端与权限降级
### 10.1 移动端输入约束
移动端浏览器通常要求用户手势才能启动 AudioContext。开局流程必须是
```text
玩家点击“开始” → requestPermission → 创建/恢复 AudioContext → calibration → countdown → playing
```
不要在页面加载时自动请求或自动启动 AudioContext。
### 10.2 响应式布局
移动端建议:
- 横屏优先呈现完整舞台;竖屏可保持舞台居中并缩小 HUD。
- 顶部能量条高度保持可读,但不要占满大面积。
- 结算面板宽度使用 `min(92vw, 420px)`
- 底部按钮避开 `env(safe-area-inset-bottom)`
- 非关键设置折叠进小菜单。
### 10.3 权限失败降级
权限失败时:
- `unsupported`:展示“当前浏览器不支持麦克风输入”,提供返回入口,不展示开始声控按钮。
- `non-secure-context`:展示“当前环境无法使用麦克风”,提示切换到受支持的安全环境或返回。
- `permission-denied`:展示简短说明和“重新授权”入口。
- `not-found`:提示未检测到麦克风,提供重试授权或返回入口。
- `not-readable`:提示麦克风被占用或暂时不可读,提供重试授权或返回入口。
- `audio-context-blocked`:提示点击重试。
- `calibration-timeout` / `calibration-sample-unreadable`:提示麦克风输入不可用,提供“重新校准”和返回入口。
可选开发调试降级:
- 本地 dev 可启用键盘 mock input例如按住空格模拟音量峰值。
- mock input 必须标记为开发/调试能力,不作为正式竞技能力。
## 11. 测试策略
### 11.1 Domain 单元测试(优先)
目标:不接 Phaser、不接 DOM、不接 Web Audio。
建议测试:
- `BarkDetector`:超过阈值且间隔足够时计为一次有效叫声。
- `BarkDetector`:持续噪音不会无限计数。
- `BarkDetector`:低于环境噪音阈值不计入叫声。
- `EnergyTugOfWar`:玩家 power 高于对手时 energy 向玩家侧移动。
- `EnergyTugOfWar`energy clamp 在 `-100..100`
- `BarkBattleSession`:倒计时结束进入 playing。
- `BarkBattleSession`:剩余时间归零进入 finished。
- `BarkBattleSession`:按 energy 和 drawThreshold 判定胜 / 负 / 平。
### 11.2 Application 测试
目标验证输入样本、AI、tick 和 snapshot 编排。
建议测试:
- 权限允许后进入校准,再进入倒计时。
- 权限拒绝后 `phase``unavailable``errorReason``permission-denied`,不进入 playing。
- 非安全上下文、设备未找到、设备不可读、AudioContext 被拦截时controller snapshot 都进入 `phase: 'unavailable'`,并保留可供 HUD 区分的 `errorReason`
- 校准超时或样本持续不可读时controller snapshot 使用 `errorReason: 'calibration-timeout'``calibration-sample-unreadable`,并提供重试校准动作。
- 提交 mock audio sample 后 snapshot 中玩家状态更新。
- AI 对手 power 参与能量条拉锯。
- `lastEvents` 只发布新增视觉事件。
### 11.3 HUD 组件测试
目标:验证 snapshot 到 DOM 的展示映射。
建议测试:
- playing 阶段展示倒计时和能量条。
- energy 正负值映射到玩家 / 对手侧比例。
- `errorReason: 'permission-denied'` 展示重试授权入口。
- `errorReason: 'unsupported'` 展示返回入口且不展示开始声控按钮。
- `not-found``not-readable``non-secure-context``audio-context-blocked``calibration-timeout``calibration-sample-unreadable` 分别映射到可区分的简短状态文案和对应操作。
- finished 展示胜负、叫声次数和再来一局。
- 移动端 class / 结构不依赖 Phaser Canvas 才能渲染。
### 11.4 Phaser 集成与 smoke
自动化层面不强行在 Vitest 中完整启动 Phaser。建议
- 用 adapter mock 测试 Scene 消费 snapshot 的纯映射函数。
- 浏览器 smoke 验证真实 Canvas、Web Audio 和动画。
- 若后续引入 Playwright再做最小视觉和交互 smoke。
## 12. 验证命令建议
文档阶段只需要编码检查和 diff 检查:
```bash
npm run check:encoding
git diff -- docs/prd/BARK_BATTLE_BDD_2026-05-11.md docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md
```
后续实现 domain 后建议:
```bash
npm run test -- --run src/games/bark-battle/domain/**/*.test.ts
npm run typecheck
npm run check:encoding
```
后续实现 infrastructure/application 错误状态后建议:
```bash
npm run test -- --run src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts src/games/bark-battle/application/__tests__/BarkBattleController.test.ts
npm run typecheck
npm run check:encoding
```
后续实现 HUD 后建议:
```bash
npm run test -- --run src/games/bark-battle/ui/**/*.test.tsx
npm run lint:eslint
npm run typecheck
npm run check:encoding
```
后续接入浏览器 runtime 后建议:
```bash
npm run dev:web
# 人工 smoke授权麦克风 → 校准 → 发声 → 能量条变化 → 结算 → 再来一局
```
若未来接入 Genarrative 正式玩法类型、后端持久化或发布链路再追加对应契约、api-server 和 SpacetimeDB 验证;首版 runtime 原型不应提前新增这些命令作为门槛。
## 13. 后续落地顺序
建议后续实现按以下顺序推进:
1. 先建 `domain/` 和纯单元测试。
2. 实现 `BarkDetector``EnergyTugOfWar``BarkBattleSession` 的最小规则。
3.`application/` controller用 mock audio sample 跑通 snapshot。
4. 实现 DOM HUD 的 permission / energy / timer / result 展示。
5. 接入 `BrowserMicrophoneInput` 和校准流程。
6. 接入 Phaser host、Scene 和 asset manifest占位素材先跑通视觉反馈。
7. 做移动端视口和权限失败 smoke。
8. 产品确认后再决定是否进入正式玩法类型、作品发布和后端真相链。
## 14. 关键技术决策
1. 默认采用 Phaser 3 + TypeScript + Vite符合 2D 浏览器游戏默认路线。
2. 核心 simulation 放在纯 TypeScript domain严格不依赖 Phaser / Web Audio / DOM。
3. Web Audio 只作为输入 adapter输出归一化 `BarkAudioSample`
4. Phaser Scene 是 renderer只消费 snapshot 和 visual events不承载规则真相。
5. HUD 使用 DOM overlay承载权限、能量条、倒计时、结算和移动端响应式布局。
6. MVP 不做复杂狗叫语义识别,先用音量峰值、持续时长、冷却和环境噪音校准。
7. MVP 建议玩家 vs AI 单机 runtime正式成绩、排行榜、发布和奖励后续再交给后端链路。

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,7 @@
用户完成热身关所有步骤后,进入关卡选择。 用户完成热身关所有步骤后,进入关卡选择。
当前后续游戏仍在设计中。热身结束后可先展示“开始游戏”按钮作为关卡选择占位,用户点击后进入下一关占位界面 热身结束后展示“开始游戏”按钮,用户点击后进入宝贝识物首关本地 Demo。该入口只用于热身关后的本地体验验证正式平台体验仍必须通过“宝贝识物”创作模板发布后在寓教于乐板块进入
### 3.3 固定流程顺序 ### 3.3 固定流程顺序
@@ -642,7 +642,7 @@
1. `src/ChildMotionDemoApp.tsx` 挂载独立 Demo 应用壳。 1. `src/ChildMotionDemoApp.tsx` 挂载独立 Demo 应用壳。
2. `src/components/child-motion-demo/childMotionWarmupModel.ts` 维护热身步骤、圆环目标、2 秒保持判定、热身校准记录和当前运行时会话完成标记。 2. `src/components/child-motion-demo/childMotionWarmupModel.ts` 维护热身步骤、圆环目标、2 秒保持判定、热身校准记录和当前运行时会话完成标记。
3. `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 实现横屏舞台、背景虚化占位层、角色剪影、绿色圆环、手势引导、热身记录面板、热身完成后的“开始游戏”按钮和下一关占位界面 3. `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 实现横屏舞台、背景虚化占位层、角色剪影、绿色圆环、手势引导、热身记录面板、热身完成后的“开始游戏”按钮,并复用宝贝识物运行态进入首关本地 Demo
4. `src/services/child-motion-demo/childMotionDebugInput.ts` 保留开发者调试输入适配层,后续可被正式动作识别 SDK 适配层替换或并行接入。 4. `src/services/child-motion-demo/childMotionDebugInput.ts` 保留开发者调试输入适配层,后续可被正式动作识别 SDK 适配层替换或并行接入。
5. `src/routing/appRoutes.tsx` 新增 `/child-motion-demo` 独立路由,并复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时不允许通过该直达路径进入 Demo。 5. `src/routing/appRoutes.tsx` 新增 `/child-motion-demo` 独立路由,并复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时不允许通过该直达路径进入 Demo。
@@ -669,19 +669,27 @@
当前未接入但已保留边界: 当前未接入但已保留边界:
1. 正式语音播报接口暂不接入,当前先展示热身文案。 1. 正式语音播报接口暂不接入,当前先展示热身文案。
2. 正式 gpt-image-2 视觉资源暂不接入,当前使用 CSS 占位表达相同位置和状态 2. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和宝贝识物首关本地 Demo 衔接
3. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和下一关按钮占位。
## 16. 当前视觉资产与生图口径补充 ## 16. 当前视觉资产与生图口径补充
儿童动作 Demo 的视觉口径已经统一收敛到绘本风格草地舞台: 儿童动作 Demo 的视觉口径已经统一收敛到绘本风格草地舞台:
1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。 1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。
2. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格重做,未生成真实背景图时由 CSS 兜底 2. 该卡通绘本草地风格是儿童动作 Demo 后续场景、物品、UI 资源的全局风格要求;新增资源不得切回暗色科技风、真实照片风或后台面板风
3. 真实背景图的默认输出路径固定为 `public/child-motion-demo/picture-book-grass-stage.webp` 3. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格接入真实资源;资源加载失败时保留 CSS 兜底
4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2-all` 调用 VectorEngine `POST /v1/images/generations` 4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2-all` 调用 VectorEngine `POST /v1/images/generations`,透明资源先生成品红底源图,再在本地移除色键,源图写入 `tmp/child-motion-demo-assets/`
5. 当前本机工作区未检测到 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY`,因此暂时只能完成 dry-run 或代码层接入,不能直接产出真实 image-2 资产。 5. 当前已生成并接入以下正式 Demo 资源:
6. 若后续补齐 VectorEngine 私密配置,再运行 live 生成即可把真实绘本背景写入上述固定路径,页面会自动读取 - `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-ground-ring-v2.png`已按透视绘制的地面椭圆指示环CSS 只等比缩放。
- `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-calibration-strip-v2.png`:右下角五格热身状态条。
- `public/child-motion-demo/picture-book-start-panel-v2.png`:开始按钮背后的轻盈托盘。
- `public/child-motion-demo/picture-book-ui-button-v2.png`:开始按钮绘本风按钮底图。
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`、源图或中间预览图提交到仓库。
已执行的定向验证命令: 已执行的定向验证命令:

View File

@@ -36,7 +36,7 @@ SpacetimeDB 新增两张表:
其中: 其中:
- `visible=false`:前端隐藏入口。 - `visible=false`:前端隐藏入口。
- `open=false`:前端展示为锁定/暂不可创建api-server 也可据此熔断运行时入口 - `open=false`:前端展示为锁定/暂不可创建api-server 据此熔断对应玩法 API只隐藏创作页入口但保留既有作品链路时不要关闭 `open`
- `sort_order`:数据库排序字段,前端只做可见/锁定分组派生。 - `sort_order`:数据库排序字段,前端只做可见/锁定分组派生。
## API ## API

View File

@@ -13,6 +13,8 @@
- `https://developer.hyper3d.ai/api-specification/rodin-generation-gen2` - `https://developer.hyper3d.ai/api-specification/rodin-generation-gen2`
- `https://developer.hyper3d.ai/api-specification/check-status` - `https://developer.hyper3d.ai/api-specification/check-status`
- `https://developer.hyper3d.ai/api-specification/download-results` - `https://developer.hyper3d.ai/api-specification/download-results`
- `https://developer.hyper3d.ai/api-specification/check-status_reset_v`
- `https://developer.hyper3d.ai/api-specification/download-results_reset_v`
上游接口: 上游接口:
@@ -24,6 +26,11 @@ POST https://api.hyper3d.com/api/v2/download
Rodin Gen-2 提交接口必须使用 `multipart/form-data`。文本生成时提交 `prompt`;图片生成时提交一个或多个 `images` 文件,可选 `prompt` 作为辅助描述。两种模式均固定提交 `tier=Gen-2` Rodin Gen-2 提交接口必须使用 `multipart/form-data`。文本生成时提交 `prompt`;图片生成时提交一个或多个 `images` 文件,可选 `prompt` 作为辅助描述。两种模式均固定提交 `tier=Gen-2`
官方 `*_reset_v` 文档对状态和下载有两个关键约束:
1. 生成接口返回的顶层 `uuid` 是后续下载接口的 `task_uuid`,不要使用 `jobs.uuids` 中的子任务 uuid 作为下载参数。
2. 状态接口使用 `subscription_key` 查询,并返回 `jobs[]`;只有所有 job 的 `status` 都为 `Done` 才能进入下载,任一 job `Failed` 都应视为任务失败。
## 3. 环境变量 ## 3. 环境变量
```text ```text
@@ -111,7 +118,7 @@ RODIN_MODEL_REQUEST_TIMEOUT_MS
} }
``` ```
状态查询会把上游 `Waiting / Generating / Done / Failed` 归一化为 `waiting / generating / done / failed / unknown`。下载接口只返回上游 `list.name``list.url`,不在后端转存文件 状态查询会把上游 `Waiting / Generating / Done / Failed` 归一化为 `waiting / generating / done / failed / unknown`,整体状态必须以 `jobs[]` 聚合结果为准。下载接口只返回上游 `list.name``list.url`,不在 Hyper3D 代理路由中转存文件;具体玩法若需要持久化模型,应在玩法编排层等待 `Done` 后再下载并转存
## 7. 验收 ## 7. 验收

View File

@@ -11,19 +11,22 @@
入口仍复用 `Match3DAgentWorkspace` 表单。点击 `生成抓大鹅草稿` 后: 入口仍复用 `Match3DAgentWorkspace` 表单。点击 `生成抓大鹅草稿` 后:
1. 创建 Match3D session。 1. 创建 Match3D session。
2. 进入 `match3d-generating` 生成过程页 2. 后端先用当前题材和本地兜底元信息创建同一个 Match3D 草稿 profile草稿 Tab 必须立即能看到这份存档
3. 过程页复用拼图生成页的 `CustomWorldGenerationView` 结构 3. 进入 `match3d-generating` 生成过程页
4. 生成成功后自动进入 `match3d-result` 4. 过程页复用拼图生成页的 `CustomWorldGenerationView` 结构
5. 生成失败时停留在生成过程页,允许重新生成或返回创作中心 5. 生成成功后自动进入 `match3d-result`
6. 生成失败时停留在生成过程页,允许重新生成或返回创作中心;重新生成必须复用同一个 session / profile并从缺失的素材阶段继续不新建第二份草稿。
生成页步骤固定为: 生成页步骤固定为:
```text ```text
生成游戏名称 -> 生成物品名称 -> 生成素材图 -> 切割独立图片 -> 上传图片资产 -> 写入草稿页 生成游戏名称 -> 生成物品名称 -> 生成素材图 -> 切割独立图片 -> 上传图片资产 -> 生成3D模型 -> 写入草稿页
``` ```
生成页只展示题材和物品数量,不展示玩法规则说明。 生成页只展示题材和物品数量,不展示玩法规则说明。
当前 `match3d-generating` 进度页不是后端 task 状态订阅页,而是一个覆盖 `match3d_compile_draft` 长 action 的本地时间进度页:前端每 500ms 以本地时间刷新阶段展示,真正的生成完成仍以 action 返回为准。为避免长 action 未返回时页面完全无感,生成页在 `match3d_compile_draft` 执行期间每 3 秒旁路读取一次 session 和 work detail并用 profile 中已写回的 `generatedItemAssets` 更新 `生成3D模型` 的完成数量。Hyper3D 控制台中看到 3 个 Rodin 任务已经 `Done` 后,页面仍可能继续停留在 `生成3D模型`,此时通常表示后端还在等待下载列表、下载 GLB、转存 OSS 或写回 `generated_item_assets_json`;若 `generatedItemAssets` 已出现 `model_ready`,前端应逐步显示完成数量。排查时应看 api-server 日志中的 `抓大鹅 Rodin 状态轮询返回``抓大鹅 Rodin 下载列表轮询返回``抓大鹅 Rodin GLB 下载完成``抓大鹅 Rodin GLB 转存 OSS 完成`
## 3. 后端编排边界 ## 3. 后端编排边界
外部模型和 OSS 上传全部由 `api-server` 编排,不进入 SpacetimeDB reducer。SpacetimeDB 继续只负责 Match3D 会话、草稿和作品 profile 的确定性写入。 外部模型和 OSS 上传全部由 `api-server` 编排,不进入 SpacetimeDB reducer。SpacetimeDB 继续只负责 Match3D 会话、草稿和作品 profile 的确定性写入。
@@ -32,17 +35,19 @@
1. 读取 session config。 1. 读取 session config。
2. 将本次 MVP 的 `clearCount` 固定为 `3`,并同步用于草稿编译。 2. 将本次 MVP 的 `clearCount` 固定为 `3`,并同步用于草稿编译。
3. 基于入口页题材设定文本调用文本模型生成作品元信息。模型固定请求 `gpt-4o`,只返回 JSON其中 `gameName` 为 4 到 12 个中文字符的游戏名称,`tags` 为 3 到 6 个中文短标签;`summary` 首版必须保持空字符串,结果页 `作品描述` 默认留给用户填写 3. 先调用 SpacetimeDB compile procedure 写入草稿。首次执行使用新 `profileId`;重试时复用 session draft / work profile 中已有 `profileId`。这一步不能等待 LLM、图片、OSS 或 Rodin 成功后才执行
4. 调用文本模型生成 `3` 个题材下的短物品名称 4. 基于入口页题材设定文本调用文本模型生成作品元信息。模型固定请求 `gpt-4o`,只返回 JSON其中 `gameName` 为 4 到 12 个中文字符的游戏名称,`tags` 为 3 到 6 个中文短标签;`summary` 首版必须保持空字符串,结果页 `作品描述` 默认留给用户填写。文本模型不可用时保留第 3 步的本地兜底,不阻断草稿
5. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成一张 `1:1` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine不重新接入 APIMart 图片网关 5. 调用文本模型生成 `3` 个题材下的短物品名称
6. 将素材图按 `n*n` 网格切割成独立图片。当前 `3` 件物品使用 `2*2` 网格,取前 `3` 6. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成一张 `1:1` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine不重新接入 APIMart 图片网关
7. 将素材图和每张独立图片上传到 OSS其中独立图片作为草稿页素材预览和后续 Rodin 图生模型参考图 7. 将素材图`n*n` 网格切割成独立图片。当前 `3` 件物品使用 `2*2` 网格,取前 `3`
8. 调用现有 SpacetimeDB compile procedure 写入草稿,并把本次生成的独立物品图片列表序列化写入 `match3d_work_profile.generated_item_assets_json`。这一步对标拼图的 `save_puzzle_generated_images`:生成资产不能只挂在本次 HTTP response 上,否则退出结果页后从草稿架读取 `getMatch3DWorkDetail` 会丢失素材列表 8. 将素材图和每张独立图片上传到 OSS其中独立图片作为草稿页素材预览和 Rodin 图生模型参考图;每次获得可恢复的图片资产后,都要回写 `match3d_work_profile.generated_item_assets_json`
9. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息,独立图片状态为 `image_ready`,模型字段保持为空;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材 9. 使用每张独立图片作为参考图,并行调用 Hyper3D Rodin 图生模型;所有 3D 模型任务必须在同一阶段同时提交、同时轮询状态、同时下载并转存 OSS禁止逐个物品串行等待模型完成。每个任务按官方 `check-status_reset_v` / `download-results_reset_v` 文档轮询状态和下载:状态查询使用 `subscription_key`,整体完成态以 `jobs[]` 聚合为准;下载查询使用生成响应顶层 `uuid` 作为 `task_uuid`,不能使用 `jobs.uuids` 子任务 uuid。只有 `jobs` 全部进入 `Done` 才能视为任务完成,任一 job `Failed` 则失败。完成后选择 `.glb` 下载文件,并把 GLB 转存到 OSS。Rodin 的 `subscriptionKey` 是上游 opaque token不做 256 字符这类短文本长度限制。Rodin 任务状态进入完成态后,下载列表仍可能延迟发布;后端必须对下载列表继续轮询,并兼容 `url``downloadUrl``fileUrl``signedUrl` 等下载字段别名,只有预览图而没有模型文件时不能伪装成 GLB 成功
10. Rodin 每批完成后继续回写 `generated_item_assets_json`。成功素材状态为 `model_ready`;失败素材保留图片引用并记录 `error`,下次 `match3d_compile_draft` 只继续缺失模型的素材,不重复生成已完成的 GLB。
11. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材。
若文本模型不可用或返回无法解析,后端必须降级为 `{themeText}抓大鹅` 与本地标签兜底,不阻断素材生成;但描述仍保持空字符串。 若文本模型不可用或返回无法解析,后端必须降级为 `{themeText}抓大鹅` 与本地标签兜底,不阻断素材生成;但描述仍保持空字符串。
草稿生成阶段调用 Hyper3D Rodin,不等待 `subscriptionKey`也不下载模型文件Rodin 生成只在结果页 `3D素材` Tab 由用户手动触发。手动生成得到的上游下载 URL 仍不得直接写入 Match3D profile,后续正式资产绑定以独立技术方案为准。 草稿生成阶段调用 Hyper3D Rodin等待 GLB 下载完成;前端 `match3d_compile_draft` action 请求超时必须覆盖该长耗时链路,当前 Match3D client 使用 20 分钟超时。Rodin 单模型状态轮询预算为 10 分钟,下载列表发布轮询预算为 5 分钟GLB 下载和 OSS PutObject 各自设置 3 分钟 HTTP 超时,避免上游下载或转存连接长期悬挂。由于 3 个模型并行生成,总耗时按最慢模型计算,不能按模型数量线性叠加。结果页 `3D素材` Tab 直接加载已生成模型;用户点击 `重新生成` 时再复用 Rodin 安全代理,首版重新生成只更新当前页面内预览状态,后续正式资产绑定以独立技术方案为准。
## 4. 图片提示词 ## 4. 图片提示词
@@ -83,16 +88,47 @@ generated-match3d-assets
```text ```text
generated-match3d-assets/{sessionId}/{profileId}/material-sheet/{taskId}/sheet.png generated-match3d-assets/{sessionId}/{profileId}/material-sheet/{taskId}/sheet.png
generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/image.png generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/image/image.png
generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/model/{taskUuid}/model.glb
``` ```
`itemSlug` 必须带 `itemId` 前缀,例如 `match3d-item-1-item`。中文物品名清洗后可能都退回 `item`,不能只用物品名做路径,否则多张切割图会写到同一个 object key导致草稿页预览图全部一致。 `itemSlug` 必须带 `itemId` 前缀,例如 `match3d-item-1-item`。中文物品名清洗后可能都退回 `item`,不能只用物品名做路径,否则多张切割图会写到同一个 object key导致草稿页预览图全部一致。
HTTP DTO 同时返回 `imageSrc``imageObjectKey`空的 `modelSrc`空的 `modelObjectKey``status = image_ready`。前端预览图片继续走 `ResolvedAssetImage` 换签;后续手动生成的模型文件也必须通过 `useResolvedAssetReadUrl` / `/api/assets/read-url` 换签后打开,不直接请求裸 `/generated-match3d-assets/...` 路径。 HTTP DTO 同时返回 `imageSrc``imageObjectKey``modelSrc``modelObjectKey``modelFileName``taskUuid``subscriptionKey``status`。模型生成成功后 `status = model_ready`;若后续允许部分模型失败降级,失败素材必须带 `error`,且不能伪装成可预览模型。前端模型预览必须通过 `/api/assets/read-bytes` 读取私有 GLB 字节并转成 Blob URL 后交给 Three.js,不直接请求裸 `/generated-match3d-assets/...` 路径。
## 5.1 运行态模型消费
生成模型不仅用于结果页预览,也必须进入游戏运行态。运行态入口的传递链路为:
```text
Match3DWorkProfile / PlatformMatch3DGalleryCard
-> Match3DRuntimeShell(generatedItemAssets)
-> Match3DPhysicsBoard / Match3DTrayPreviewBoard
```
`Match3DPhysicsBoard``Match3DTrayPreviewBoard` 按运行快照中的 `itemTypeId` 稳定排序后,把生成出的模型顺序映射到对应类型。当前 MVP 固定 `clearCount = 3`,因此 `match3d-type-01/02/03` 分别对应生成列表的第 `1/2/3` 个模型;后续恢复更多物品生成时,后端必须继续保证 `generatedItemAssets` 顺序与类型编号一致。
前端加载规则:
1. 优先读取 `modelSrc`;为空时使用 `modelObjectKey`
2. 通过 `readAssetBytes` 调用 `/api/assets/read-bytes`,由同源后端读取 OSS 私有对象字节。
3. 使用 Three.js `GLTFLoader.parseAsync` 解析 GLB 字节,并按物品类型缓存模板。
4. 场内每个物品和备选栏预览都从模板 clone 独立对象,点击命中继续写入 `itemInstanceId`
5. 物理碰撞和边界仍沿用现有 `visualKey` 的程序化几何,生成 GLB 只替换视觉模型,不承接规则真相。
6. 模型缺失、读取失败或 WebGL 回退时,继续使用默认积木素材,不能阻断开局、点击、入槽或结算;调试模式下需要输出加载失败的 `itemTypeId`、模型来源和错误信息便于区分“资产没有传入”和“GLB 字节读取或解析失败”。
结果页点击 `试玩` 时,前端必须把当前结果页可见的 `generatedItemAssets` 带入运行态启动入参。`PUT /api/runtime/match3d/works/{profileId}` 若因为并发或旧快照返回了缺少素材的 profile`Match3DResultView` 需要把当前 draft / profile 的素材重新合并到运行态 profile并在启动试玩前调用生成素材保存接口把当前可见的 `generatedItemAssets` 写回作品 profile不能只在内存里把素材补到 `onStartTestRun(profile)`。发布同理必须先落库当前素材,再调用 `publish_match3d_work`,否则公开推荐流和正式运行态只能读到旧 profile 快照,历史草稿尤其容易表现为结果页有 3D 模型、正式游戏仍是默认积木。若历史草稿同时存在旧 `draft.generatedItemAssets` 和较新的 `profile.generatedItemAssets`,同 `itemId` 下以 profile 中已有的 `modelSrc` / `modelObjectKey` 补齐 draft不能让旧 draft 把模型状态覆盖回 `image_ready``PlatformEntryFlowShellImpl` 在渲染 `match3d-runtime` 时按 `run.profileId` 优先使用当前 `match3dProfile.generatedItemAssets`,只有 profileId 不匹配时才读取 `selectedPublicWorkDetail.generatedItemAssets`。推荐流内嵌正式运行态也必须走同一解析器;当推荐卡片摘要缺少素材时,启动前补读 `getMatch3DWorkDetail(profileId)`,把详情里的生成模型写入 `match3dProfile` 后再传给运行态。这样可以避免从公开详情页残留状态或推荐卡片旧摘要进入试玩 / 正式游戏时,把已生成草稿的 3D 模型覆盖成空列表。
## 6. 自动保存与草稿恢复 ## 6. 自动保存与草稿恢复
抓大鹅结果页的基础信息自动保存继续调用 `PUT /api/runtime/match3d/works/{profileId}` 更新名称、题材、描述、标签、封面、消除数和难度;该保存不得清空 `generated_item_assets_json`。SpacetimeDB `update_match3d_work` / `publish_match3d_work` 必须保留当前行的生成素材 JSON。 点击 `生成抓大鹅草稿` 后,草稿存档创建与素材生成解耦:
1. 首次 compile 必须先写 `match3d_work_profile` 草稿行即使后续卡在文本模型、图片生成、OSS 上传、Rodin 生成或下载转存任意阶段。
2. 失败态前端要重新读取 session / work detail并刷新草稿作品架保证用户离开生成页后仍能在草稿 Tab 找到这份作品。
3. 重新生成时优先使用当前 session 的 `draft.profileId``publishedProfileId`,不得重新创建 session后端读取同一 profile 的 `generated_item_assets_json` 后,只补齐缺失图片或缺失模型的阶段。
4. 已有 `status = model_ready` 且带 `modelSrc` / `modelObjectKey` 的素材视为完成,不再重复调用 Rodin。
抓大鹅结果页的基础信息自动保存继续调用 `PUT /api/runtime/match3d/works/{profileId}` 更新名称、题材、描述、标签、封面、消除数和难度;该保存不得清空 `generated_item_assets_json`。结果页 `3D素材` Tab 手动点击 `重新生成` 并拿到 GLB 下载文件后,必须把当前素材草稿重新序列化成 `generatedItemAssets` 并写回作品 profile否则页面内预览会显示新模型但试玩、发布和重进草稿仍会读取旧的空模型快照。SpacetimeDB `update_match3d_work` / `publish_match3d_work` 必须保留当前行的生成素材 JSON。
草稿架重进路径为: 草稿架重进路径为:
@@ -100,7 +136,7 @@ HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、空的 `modelSrc`、空的
草稿 Tab -> getMatch3DWorkDetail(profileId) -> Match3DResultView(profile.generatedItemAssets) 草稿 Tab -> getMatch3DWorkDetail(profileId) -> Match3DResultView(profile.generatedItemAssets)
``` ```
因此 `map_match3d_work_summary_response` / `map_match3d_work_profile_response` 需要从 work profile snapshot 反序列化 `generated_item_assets_json` 并输出 `generatedItemAssets`。前端 `Match3DResultView` 仍保持现有优先级:本次生成流程内有 `draft.generatedItemAssets` 时用 draft从草稿架重进没有 draft 时,用 `profile.generatedItemAssets`;两者都没有才回退到默认 3D 素材占位。 因此 `map_match3d_work_summary_response` / `map_match3d_work_profile_response` 需要从 work profile snapshot 反序列化 `generated_item_assets_json` 并输出 `generatedItemAssets`。前端 `Match3DResultView` 的读取顺序为:有 `draft.generatedItemAssets` 时先用 draft 保留本次生成顺序和图片;同 `itemId``profile.generatedItemAssets` 中已有模型字段时,用 profile 模型字段补齐 draft从草稿架重进没有 draft 时,用 `profile.generatedItemAssets`;两者都没有才回退到默认 3D 素材占位。
结果页 `作品信息` Tab 字段命名对齐拼图草稿: 结果页 `作品信息` Tab 字段命名对齐拼图草稿:
@@ -111,7 +147,7 @@ HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、空的 `modelSrc`、空的
`3D素材` 详情页只保留: `3D素材` 详情页只保留:
1. 模型预览区:优先加载 `modelSrc` 对应 GLB支持拖动旋转没有模型时展示空预览。 1. 模型预览区:优先加载 `modelSrc` 对应 GLB缺失时加载 `modelObjectKey`支持拖动旋转;没有模型时展示空预览。
2. 素材名称输入。 2. 素材名称输入。
3. `重新生成` 按钮。 3. `重新生成` 按钮。
@@ -125,6 +161,8 @@ HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、空的 `modelSrc`、空的
npm run check:encoding npm run check:encoding
npm run test -- src\services\miniGameDraftGenerationProgress.test.ts npm run test -- src\services\miniGameDraftGenerationProgress.test.ts
npm run test -- src\components\match3d-result\Match3DResultView.test.tsx npm run test -- src\components\match3d-result\Match3DResultView.test.tsx
npm run test -- src\components\match3d-runtime\Match3DRuntimeShell.test.tsx
npm run test -- src\components\rpg-entry\RpgEntryFlowShell.agent.interaction.test.tsx
npm run typecheck npm run typecheck
cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml
cargo test -p spacetime-client match3d --manifest-path server-rs\Cargo.toml cargo test -p spacetime-client match3d --manifest-path server-rs\Cargo.toml
@@ -135,4 +173,4 @@ cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml
cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml
``` ```
真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_API_KEY` 和 OSS 访问变量;`HYPER3D_API_KEY` 只在结果页手动生成 3D 模型时需要。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz` 真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_API_KEY``HYPER3D_API_KEY` 和 OSS 访问变量。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`

View File

@@ -18,13 +18,13 @@
## 3. Rodin 任务边界 ## 3. Rodin 任务边界
前端只维护当前页面内的临时重新生成任务状态: 前端只维护当前页面内的临时重新生成任务状态;草稿生成得到的正式模型资产从 `generatedItemAssets.modelSrc` 恢复
1. 素材槽位名称。 1. 素材槽位名称。
2. 模型预览。草稿生成的 `/generated-match3d-assets/...` GLB 必须通过同源 `/api/assets/read-bytes` 由后端换签并读取字节,前端再转成 Blob URL 后交给 Three.js GLTFLoader避免浏览器直接 `fetch` OSS 签名 URL 时被 CORS 拦截。 2. 模型预览。草稿生成的 `/generated-match3d-assets/...` GLB 必须通过同源 `/api/assets/read-bytes` 由后端换签并读取字节,前端再转成 Blob URL 后交给 Three.js GLTFLoader避免浏览器直接 `fetch` OSS 签名 URL 时被 CORS 拦截。
3. 图生模型参考图只作为重新生成的隐藏输入来源,不在详情页展示。上传图片在前端直接读成 Data URL草稿生成的 `/generated-match3d-assets/...` 图片必须通过 `/api/assets/read-bytes` 转成 Data URL 后提交给 Hyper3D。 3. 图生模型参考图只作为重新生成的隐藏输入来源,不在详情页展示。上传图片在前端直接读成 Data URL草稿生成的 `/generated-match3d-assets/...` 图片必须通过 `/api/assets/read-bytes` 转成 Data URL 后提交给 Hyper3D。
4. Hyper3D `taskUuid``subscriptionKey` 4. Hyper3D `taskUuid``subscriptionKey` 仅用于重新生成过程,不在详情页展示
5. 查询到的状态、进度与下载文件列表。 5. 查询到的状态、进度与下载文件列表仅作为内部状态,不在详情页展示
正式资产链后续再接: 正式资产链后续再接:

View File

@@ -2,18 +2,19 @@
## 背景 ## 背景
创作中心的模板 Tab、平台创作类型弹层和旧“新建作品”卡片配置都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在 `src/components/platform-entry/platformEntryCreationTypes.ts` 与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致 创作中心的模板 Tab、平台创作类型弹层和旧“新建作品”卡片配置都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在前端配置与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。当前入口配置事实源已经迁移到 SpacetimeDB`api-server` 通过 `GET /api/creation-entry/config` 下发
## 落地规则 ## 落地规则
1. 新建作品入口配置统一放在 `src/config/newWorkEntryConfig.ts` 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` 控制玩法是否允许点击创建`open: false` 时入口保持展示但禁用 3. `open` 控制玩法是否允许点击创建以及对应创作 / runtime API 路由是否放行;`open: false` 时入口保持展示但禁用,并由 `api-server` 熔断对应玩法 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` 控制平台创作类型弹层标题和描述。
7. 入口排序仍遵循“可创建玩法在前,未开放玩法在后”;同组内部沿用配置顺序。 7. 入口排序仍遵循“可创建玩法在前,未开放玩法在后”;同组内部沿用配置顺序。
8. `creative-agent` 可以继续保留运行链路,但默认 `visible: false`,不出现在创作 Tab 模板入口。 8. `creative-agent` 可以继续保留运行链路,但默认 `visible: false`,不出现在创作 Tab 模板入口。
9. 前端 `src/components/platform-entry/platformEntryCreationTypes.ts` 只做展示派生,不再承载默认入口配置。
## 当前状态 ## 当前状态
@@ -23,17 +24,17 @@
| 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 | | 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 |
| 拼图 | 是 | 是 | 创作 Tab 默认选中并内嵌展示拼图创作表单,提交后进入拼图草稿生成 | | 拼图 | 是 | 是 | 创作 Tab 默认选中并内嵌展示拼图创作表单,提交后进入拼图草稿生成 |
| 抓大鹅 | 是 | 是 | 点击后进入抓大鹅 Match3D Agent 共创工作台 | | 抓大鹅 | 是 | 是 | 点击后进入抓大鹅 Match3D Agent 共创工作台 |
| 方洞挑战 | | 是 | 点击后进入方洞挑战 Agent 共创工作台,支持草稿、结果页、发布、试玩、作品架与广场 | | 方洞挑战 | | 是 | 创作页入口暂时完全隐藏,既有草稿、结果页、发布、试玩、作品架与广场链路保留 |
| AIRP | 是 | 否 | 保留入口,显示敬请期待 | | AIRP | 是 | 否 | 保留入口,显示敬请期待 |
| 视觉小说 | 是 | | 点击后进入视觉小说创作工作台 | | 视觉小说 | 是 | | 保留入口,显示敬请期待,暂不允许创建视觉小说草稿 |
| 智能创作 | 否 | 是 | 入口隐藏,既有 `creative-agent` 链路保留 | | 智能创作 | 否 | 是 | 入口隐藏,既有 `creative-agent` 链路保留 |
## 验收 ## 验收
1. 修改 `src/config/newWorkEntryConfig.ts` 后,创作 Tab 模板入口、创作中心顶部卡带和平台创作类型弹层应同步变化。 1. 修改 SpacetimeDB 入口配置后,创作 Tab 模板入口、创作中心顶部卡带和平台创作类型弹层应同步变化。
2. 隐藏玩法不触发入口预加载,也不出现在新建作品入口中。 2. 隐藏玩法不触发入口预加载,也不出现在新建作品入口中。
3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。 3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。
4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。 4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。
5. 创作 Tab 首屏应显示“10分钟创作一个精品互动玩法”并默认展示拼图创作表单。 5. 创作 Tab 首屏应显示“10分钟创作一个精品互动玩法”并默认展示拼图创作表单。
6. 智能创作入口隐藏后不应出现“Hi, 朋友”“问一问百梦”或“一句话生成闪应用”等旧首页入口。 6. 智能创作入口隐藏后不应出现“Hi, 朋友”“问一问百梦”或“一句话生成闪应用”等旧首页入口。
7. 方洞挑战作品发布后应生成 `SH-` 作品号,并能从作品架、广场详情和试玩 runtime 回到同一作品详情 7. 方洞挑战入口隐藏后,不应出现在创作 Tab 模板入口、创作中心顶部卡带、平台创作类型弹层和创作页作品架中;既有 `SH-` 作品号、广场详情和试玩 runtime 链路不因此删除

View File

@@ -76,3 +76,16 @@
3. 只有用户显式修改或重置密码后,才允许密码登录。 3. 只有用户显式修改或重置密码后,才允许密码登录。
后续迁移到 SpacetimeDB 表时,保持同一语义:密码哈希字段允许为空,密码登录 reducer 不承担注册能力,验证码登录 reducer 承担“无账号则自动注册”的唯一注册入口。 后续迁移到 SpacetimeDB 表时,保持同一语义:密码哈希字段允许为空,密码登录 reducer 不承担注册能力,验证码登录 reducer 承担“无账号则自动注册”的唯一注册入口。
## 5. 2026-05-12 快照同步修复
重置密码和修改密码都会改变认证真相:`password_hash``password_login_enabled``token_version`,重置密码还会立即创建新的 refresh session。因此 API 层在 `POST /api/auth/password/change``POST /api/auth/password/reset` 成功后,必须和密码登录、手机号登录、刷新、退出一样调用 `sync_auth_store_snapshot_to_spacetime()`
若只更新本地 `InMemoryAuthStore` 而不同步 SpacetimeDB 认证快照,`api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态,表现为用户已通过忘记密码重设成功,但再次密码登录仍返回“手机号或密码错误”。启动恢复时应从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照;当本地文件更新且远端表无更新时间戳时,优先使用本地文件并尝试回写 SpacetimeDB避免旧远端状态覆盖刚重设的密码。
验证命令:
```bash
cargo test -p module-auth password --manifest-path server-rs/Cargo.toml
cargo test -p api-server password --manifest-path server-rs/Cargo.toml
```

View File

@@ -0,0 +1,89 @@
# 拼图与抓大鹅结果页音乐 Tab 2026-05-11
## 1. 范围
本方案把 VectorEngine 音频生成能力从视觉小说结果页扩展到拼图与抓大鹅结果页:
1. 拼图结果页新增 `音乐` Tab支持通过 Suno 生成作品背景音乐。
2. 抓大鹅结果页新增 `音乐` Tab支持通过 Suno 生成作品背景音乐。
3. 抓大鹅 `3D素材` Tab 支持为每个生成物体通过 Vidu 生成点击音效。
本轮不新增 SpacetimeDB 表,不修改表字段,不把供应商密钥下发到前端。
## 2. 通用音频接口
后端在既有视觉小说音频路由外新增通用创作音频路由:
| 方法 | 路由 | 用途 |
| --- | --- | --- |
| `POST` | `/api/creation/audio/background-music` | 提交 Suno 背景音乐任务 |
| `POST` | `/api/creation/audio/background-music/{task_id}/asset` | 查询并转存 Suno 音频资产 |
| `POST` | `/api/creation/audio/sound-effect` | 提交 Vidu 音效任务 |
| `POST` | `/api/creation/audio/sound-effect/{task_id}/asset` | 查询并转存 Vidu 音效资产 |
通用转存请求由前端传入 `entityKind``entityId``slot``assetKind``profileId`。后端仍负责:
1. 校验 VectorEngine 与 OSS 环境变量。
2. 轮询供应商任务结果。
3. 下载音频字节。
4. 写入 OSS 私有对象。
5. 确认 `asset_object` 并绑定 `asset_entity_binding`
视觉小说原路由保持兼容,内部继续复用同一套提交、轮询、转存逻辑。
## 3. 数据落点
### 3.1 拼图
拼图作品没有独立作品级 metadata 字段。背景音乐随 `levels_json` 保存到首个 `PuzzleDraftLevel.backgroundMusic`
```json
{
"levelId": "puzzle-level-1",
"backgroundMusic": {
"taskId": "suno-task",
"provider": "vector-engine-suno",
"assetObjectId": "assetobj_1",
"assetKind": "puzzle_background_music",
"audioSrc": "/generated-puzzle-assets/..."
}
}
```
运行态后续可从当前关卡快照或作品详情读取该字段作为背景音乐源;若字段为空,继续使用现有程序化背景音乐兜底。
### 3.2 抓大鹅
抓大鹅作品级音频与物体点击音效复用 `generated_item_assets_json` 数组保存,不新增表字段:
1. 作品背景音乐暂存到第一个 `Match3DGeneratedItemAsset.backgroundMusic`,表示当前 work profile 的作品级背景音乐。
2. 单个物体点击音效保存到对应 `Match3DGeneratedItemAsset.clickSound`
这是一个兼容性折中:当前 Match3D work profile 没有 work-level metadata 字段,而 `generated_item_assets_json` 已经随作品详情、草稿架、运行态入口稳定传递。后续若新增正式作品 metadata 表达,应迁移 `backgroundMusic` 到作品级字段。
## 4. 前端交互
结果页 UI 保持轻量:
1. `音乐` Tab 只展示必要输入、生成按钮、状态与音频预览,不展示供应商规则说明。
2. 生成完成后立即写回本地草稿状态,并触发既有保存链路或专用保存接口。
3. 抓大鹅每个物体音效生成入口放在对应素材详情面板内,不在列表下方展开大段配置。
## 5. 验收
建议执行:
```powershell
npm run check:encoding
npm run test -- src\components\puzzle-result\PuzzleResultView.test.tsx
npm run test -- src\components\match3d-result\Match3DResultView.test.tsx
npm run typecheck
cargo test -p shared-contracts creation_audio --manifest-path server-rs\Cargo.toml
cargo test -p shared-contracts puzzle --manifest-path server-rs\Cargo.toml
cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml
cargo test -p api-server vector_engine_audio_generation --manifest-path server-rs\Cargo.toml
cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml
cargo check -p api-server --manifest-path server-rs\Cargo.toml
```
真实生成 smoke 需要本地私密环境配置 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 与 OSS 变量。后端改动后使用 `npm run api-server` 启动,并确认 `/healthz`

View File

@@ -4,10 +4,14 @@
## 文档列表 ## 文档列表
- [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 和后续原生化边界。
- [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 输入适配、移动端权限降级和后续测试验证命令。
- [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` 空状态白屏。
- [BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md](./BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md):冻结寓教于乐 `宝贝识物` 模板创作发布线程的前端入口、契约、service、结果页、发布标签和后端 image-2 接口预留边界。
- [CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md):冻结儿童动作识别互动玩法 Demo 固定热身关的开发落地规格,覆盖横屏展示、摄像头背景虚化、角色剪影、绿色圆环 2 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。 - [CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md):冻结儿童动作识别互动玩法 Demo 固定热身关的开发落地规格,覆盖横屏展示、摄像头背景虚化、角色剪影、绿色圆环 2 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。
- [RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md](./RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md)记录运行态输入设备抽象层明确鼠标、触控、mocap 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。 - [RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md](./RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md)记录运行态输入设备抽象层明确鼠标、触控、mocap 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。
- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。 - [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异;同时冻结 `shared-contracts` 不得反向依赖 `platform-*`,避免 SpacetimeDB 模块发布时拉入 `wasm-bindgen`
- [DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md](./DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md):记录本地完整 Rust 栈启动时 `api-server`、主站 Vite 和后台 Vite 端口占用的误判根因、脚本预检策略和 Windows 排障命令。 - [DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md](./DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md):记录本地完整 Rust 栈启动时 `api-server`、主站 Vite 和后台 Vite 端口占用的误判根因、脚本预检策略和 Windows 排障命令。
- [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。 - [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。
- [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、debug 构建参数口径和手动排障命令。 - [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、debug 构建参数口径和手动排障命令。

View File

@@ -16,7 +16,9 @@
4. 成员 crate 只保留自身需要表达的差异,例如 `features``optional = true` 或 target-specific dependency。 4. 成员 crate 只保留自身需要表达的差异,例如 `features``optional = true` 或 target-specific dependency。
5. 需要关闭 default features 的依赖,应优先在 workspace 根依赖中声明;成员 crate 不再重复覆盖同一项。 5. 需要关闭 default features 的依赖,应优先在 workspace 根依赖中声明;成员 crate 不再重复覆盖同一项。
6. `module-assets` 这类有默认服务端 feature 的领域 crate在 workspace 根内按 `default-features = false` 维护;需要服务端 OSS/HTTP 能力的 adapter crate 显式启用 `features = ["server-service"]` 6. `module-assets` 这类有默认服务端 feature 的领域 crate在 workspace 根内按 `default-features = false` 维护;需要服务端 OSS/HTTP 能力的 adapter crate 显式启用 `features = ["server-service"]`
7. 面向 SpacetimeDB WASM 的依赖链不得隐式启用原生 HTTP / OSS / Web 平台依赖;例如 `shared-contracts``assets` 模块通过 `oss-contracts` feature 暴露给 `api-server``spacetime-module` 路径只消费关闭默认 feature 后的纯 DTO 子集 7. `shared-contracts` 只能承载前后端公开 DTO 和轻量枚举,禁止直接依赖 `platform-*` 服务实现 crate需要把平台实现响应转换为公开 DTO 时,转换函数放在 `api-server` 等 adapter 层
8. 面向 SpacetimeDB WASM 的依赖链不得隐式启用原生 HTTP / OSS / Web 平台依赖;例如 `shared-contracts``assets` 模块通过不依赖 `platform-oss``oss-contracts` feature 暴露给 `api-server``spacetime-module` 路径只消费关闭默认 feature 后的纯 DTO 子集。
9. `spacetime-module` 的传递依赖不能包含 `reqwest``web-sys``js-sys``wasm-bindgen` 等 Web/HTTP 客户端链路;发布前可用 `cargo tree -i wasm-bindgen --manifest-path server-rs/Cargo.toml -p spacetime-module --target wasm32-unknown-unknown` 排查。
## 3. 本次收敛范围 ## 3. 本次收敛范围
@@ -57,14 +59,33 @@ npm.cmd run check:encoding -- docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDA
## 6. SpacetimeDB WASM 依赖边界 ## 6. SpacetimeDB WASM 依赖边界
2026-05-11 本地重置 SpacetimeDB 并重新发布 `xushi-p4wfr` 时,`spacetime publish` 在 Rust 编译成功后报 `wasm-bindgen detected`。排查命令显示链路为:
```text
spacetime-module -> module-runtime -> shared-contracts -> platform-oss -> reqwest -> wasm-bindgen
```
根因是 `shared-contracts` 为了复用 OSS 直传/读签名返回类型,直接依赖了 `platform-oss`。这违反 DDD 分层边界:契约 crate 不能依赖平台副作用实现,否则所有引用契约的纯领域和 SpacetimeDB 模块都会被迫拉入 HTTP client。
`spacetime publish` 会构建 `spacetime-module``wasm32-unknown-unknown` 目标。这个目标不能包含 `wasm-bindgen`,也不应通过 DTO crate 间接拉入 `reqwest``web-sys` 或浏览器 WebAssembly 平台依赖。 `spacetime publish` 会构建 `spacetime-module``wasm32-unknown-unknown` 目标。这个目标不能包含 `wasm-bindgen`,也不应通过 DTO crate 间接拉入 `reqwest``web-sys` 或浏览器 WebAssembly 平台依赖。
修正口径:
1. `shared-contracts::assets` 定义独立的公开 DTO 和 `DirectUploadObjectAccess` 轻量枚举。
2. `platform-oss` 保持 OSS 签名、读写请求和错误分类实现,不被契约层引用。
3. `api-server::assets` 负责把 `platform_oss::OssPostObjectResponse` / `OssSignedGetObjectUrlResponse` 转成 `shared-contracts` DTO。
4. 后续新增外部平台能力时,重复使用这个边界:平台 crate 不得被 `shared-contracts``module-*``spacetime-module` 反向依赖。
已验证的排查命令: 已验证的排查命令:
```powershell ```powershell
cargo tree -i wasm-bindgen --manifest-path server-rs\Cargo.toml -p spacetime-module --target wasm32-unknown-unknown
cargo tree -i wasm-bindgen --manifest-path server-rs\crates\spacetime-module\Cargo.toml --target wasm32-unknown-unknown cargo tree -i wasm-bindgen --manifest-path server-rs\crates\spacetime-module\Cargo.toml --target wasm32-unknown-unknown
cargo tree --manifest-path server-rs\crates\spacetime-module\Cargo.toml --target wasm32-unknown-unknown | Select-String -Pattern 'wasm-bindgen|platform-oss|reqwest' cargo tree --manifest-path server-rs\crates\spacetime-module\Cargo.toml --target wasm32-unknown-unknown | Select-String -Pattern 'wasm-bindgen|platform-oss|reqwest'
cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml --target wasm32-unknown-unknown cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml --target wasm32-unknown-unknown
cargo check -p shared-contracts --manifest-path server-rs\Cargo.toml
cargo check -p api-server --manifest-path server-rs\Cargo.toml
spacetime publish xushi-p4wfr --server local --module-path server-rs\crates\spacetime-module --build-options="--debug" -c=on-conflict --yes
``` ```
若反向树显示 `reqwest -> platform-oss -> shared-contracts -> module-* -> spacetime-module`,优先检查新增的 `shared-contracts` 或领域 crate 依赖是否忘记关闭默认 feature。原生 `api-server` 需要资产上传契约时,应在自身 `Cargo.toml` 显式启用 `shared-contracts``oss-contracts` feature而不是让 workspace 根依赖默认启用。 若反向树显示 `reqwest -> platform-oss -> shared-contracts -> module-* -> spacetime-module`,优先检查新增的 `shared-contracts` 或领域 crate 依赖是否忘记关闭默认 feature,或 `shared-contracts` feature 是否错误依赖了平台实现 crate。原生 `api-server` 需要资产上传契约时,应在自身 `Cargo.toml` 显式启用 `shared-contracts``oss-contracts` feature而不是让 workspace 根依赖默认启用。

View File

@@ -72,8 +72,8 @@ SELECT * FROM auth_store_snapshot WHERE snapshot_id = 'default';
### `user_account` ### `user_account`
- 作用用户账号主表保存用户名、公开百梦号、手机号掩码、登录方式、密码登录开关、token 版本和默认不前端展示的运营标签。 - 作用用户账号主表保存用户名、公开百梦号、手机号掩码、登录方式、密码登录开关、token 版本和默认不前端展示的运营标签。
- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `avatar_url: Option<String>`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`, `user_tags: Vec<String>` - 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `avatar_url: Option<String>`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`, `user_tags: Option<Vec<String>>`
- 说明:`user_tags` 默认空数组,只允许后端白名单投影到特定业务接口不得在登录态、个人资料等通用前端响应中直接暴露。 - 说明:`user_tags` 数据库默认 `None`,业务读取时按空数组归一化;只允许后端白名单投影到特定业务接口不得在登录态、个人资料等通用前端响应中直接暴露。
- 索引:`username`, `public_user_code` - 索引:`username`, `public_user_code`
```sql ```sql
@@ -256,11 +256,11 @@ SELECT * FROM profile_redeem_code_usage WHERE user_id = '<user_id>';
### `profile_invite_code` ### `profile_invite_code`
- 作用:用户邀请中心的邀请码主表,也承载后台运营预置邀请码和使用后授予账号的运营标签。 - 作用:用户邀请中心的邀请码主表,也承载后台运营预置邀请码和使用后授予账号的运营标签配置
- 结构:`user_id PK: String`, `invite_code: String`, `metadata_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`, `starts_at: Option<Timestamp>`, `expires_at: Option<Timestamp>`, `granted_user_tags: Vec<String>` - 结构:`user_id PK: String`, `invite_code: String`, `metadata_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`, `starts_at: Option<Timestamp>`, `expires_at: Option<Timestamp>`
- 索引:主键 `user_id`,唯一索引 `invite_code` - 索引:主键 `user_id`,唯一索引 `invite_code`
- 后台读取:`GET /admin/api/profile/invite-codes` 只返回 `user_id``admin:` 开头的后台预置码;普通用户自己的邀请码不得进入后台运营列表。 - 后台读取:`GET /admin/api/profile/invite-codes` 只返回 `user_id``admin:` 开头的后台预置码;普通用户自己的邀请码不得进入后台运营列表。
- 说明:`granted_user_tags` 默认空数组;用户注册填写该邀请码后合并进 `user_account.user_tags`,不回改历史用户。 - 说明:使用该邀请码后授予的标签存放在 `metadata_json.userTags`,服务端兼容读取 `metadata_json.user_tags`;用户注册填写该邀请码后合并进 `user_account.user_tags`,不回改历史用户。
```sql ```sql
SELECT * FROM profile_invite_code WHERE user_id = '<user_id>'; SELECT * FROM profile_invite_code WHERE user_id = '<user_id>';
@@ -665,7 +665,7 @@ SELECT * FROM match3d_agent_message WHERE session_id = '<session_id>' ORDER BY c
- 作用:抓大鹅 Match3D 作品主表,保存作品基础信息、配置、发布状态、游玩次数和草稿生成出的独立物品素材引用。 - 作用:抓大鹅 Match3D 作品主表,保存作品基础信息、配置、发布状态、游玩次数和草稿生成出的独立物品素材引用。
- 结构:`profile_id PK: String`, `owner_user_id: String`, `source_session_id: String`, `author_display_name: String`, `game_name: String`, `theme_text: String`, `summary_text: String`, `tags_json: String`, `cover_image_src: String`, `cover_asset_id: String`, `clear_count: u32`, `difficulty: u32`, `config_json: String`, `publication_status: String`, `play_count: u32`, `updated_at: Timestamp`, `published_at: Option<Timestamp>`, `generated_item_assets_json: Option<String>` - 结构:`profile_id PK: String`, `owner_user_id: String`, `source_session_id: String`, `author_display_name: String`, `game_name: String`, `theme_text: String`, `summary_text: String`, `tags_json: String`, `cover_image_src: String`, `cover_asset_id: String`, `clear_count: u32`, `difficulty: u32`, `config_json: String`, `publication_status: String`, `play_count: u32`, `updated_at: Timestamp`, `published_at: Option<Timestamp>`, `generated_item_assets_json: Option<String>`
- 说明:`generated_item_assets_json` 保存 `Match3DGeneratedItemAsset` 数组 JSON用于草稿页退出后从作品架重进时恢复 `3D素材` Tab 中的切割图片预览;基础信息自动保存和发布必须保留该字段。 - 说明:`generated_item_assets_json` 保存 `Match3DGeneratedItemAsset` 数组 JSON用于草稿页退出后从作品架重进时恢复 `3D素材` Tab 中的切割图片和 GLB 模型预览;运行态也通过该字段拿到 `modelSrc` / `modelObjectKey` 并优先渲染生成模型。基础信息自动保存和发布必须保留该字段。
- 索引:`owner_user_id`, `publication_status` - 索引:`owner_user_id`, `publication_status`
```sql ```sql

View File

@@ -1,13 +1,13 @@
# 用户标签、邀请码授予与拼图榜单展示方案 # 用户标签、邀请码授予与拼图榜单展示方案
更新时间:`2026-05-10` 更新时间:`2026-05-11`
## 1. 目标 ## 1. 目标
本次新增用户标签系统的最小闭环: 本次新增用户标签系统的最小闭环:
1. `user_account` 增加账号标签字段,默认空 1. `user_account` 增加账号标签字段,数据库默认空,业务读取时按空数组处理
2. 后台预置邀请码可配置授予标签。 2. 后台预置邀请码可通过原有 `metadata` 字段配置授予标签。
3. 用户填写带标签的邀请码后,把标签合并到自己的账号。 3. 用户填写带标签的邀请码后,把标签合并到自己的账号。
4. 标签默认不在前端资料页、邀请中心或通用接口展示。 4. 标签默认不在前端资料页、邀请中心或通用接口展示。
5. 拼图排行榜仅对白名单标签做展示,首版只展示 `北科` 5. 拼图排行榜仅对白名单标签做展示,首版只展示 `北科`
@@ -16,19 +16,21 @@
### `user_account.user_tags` ### `user_account.user_tags`
- 类型:`Vec<String>` - 类型:`Option<Vec<String>>`
- 默认:空数组。 - 默认:`None`,业务层读取时统一按空数组处理
- 语义:账号级运营标签,属于后台与服务端投影数据,不作为普通前端个人资料字段。 - 语义:账号级运营标签,属于后台与服务端投影数据,不作为普通前端个人资料字段。
- 写入:首版只由邀请码兑换链路合并写入。 - 写入:首版只由邀请码兑换链路合并写入。
- 迁移:旧迁移包和旧数据库按空数组兼容 - 迁移:旧迁移包和旧数据库按 `null` 兼容,再由业务层归一化为空数组。
### `profile_invite_code.granted_user_tags` ### `profile_invite_code.metadata_json.userTags`
- 类型:`Vec<String>` - 类型:`metadata_json` 对象里的 `userTags: string[]`
- 默认:空数组 - 默认:字段缺失或空数组时不授予标签
- 语义:使用该邀请码后授予被邀请账号的标签列表。 - 语义:使用该邀请码后授予被邀请账号的标签列表。
- 范围:后台运营预置码和普通用户个人邀请码都可存字段,但后台表单首版只允许管理员配置预置码。 - 范围:后台运营预置码和普通用户个人邀请码都可存字段,但后台表单首版只允许管理员配置预置码。
- 迁移:旧邀请码按空数组兼容 - 存储:不再新增或使用独立的邀请码标签列;后台保存时把用户标签写回 `metadata.userTags`
- 解析:服务端优先读取 `metadata_json.userTags`,并兼容解析 `metadata_json.user_tags`
- 迁移:旧邀请码缺少 `metadata_json` 时按 `{}` 兼容;旧迁移包里已废弃的独立字段会在导入时丢弃。
## 3. 标签归一化 ## 3. 标签归一化
@@ -45,35 +47,38 @@
1. 写入 `profile_referral_relation` 1. 写入 `profile_referral_relation`
2. 发放原有双方奖励。 2. 发放原有双方奖励。
3. 读取 `profile_invite_code.granted_user_tags` 3. `profile_invite_code.metadata_json` 解析 `userTags` / `user_tags`
4. 将这些标签合并进 `user_account.user_tags` 4. 将这些标签合并进 `user_account.user_tags`
管理员更新邀请码时,`grantedUserTags` 代表覆盖该邀请码之后授予的标签集合;空数组代表不授予标签。更新邀请码不会回溯修改已经使用过该邀请码的账号。 管理员更新邀请码时,后台表单里的用户标签会覆盖写入 `metadata.userTags`;空数组代表不授予标签。更新邀请码不会回溯修改已经使用过该邀请码的账号。
## 5. API 契约 ## 5. API 契约
后台邀请码 upsert 请求增加 后台邀请码 upsert 请求继续只提交 `metadata`,标签写在 `metadata.userTags`
```json ```json
{ {
"inviteCode": "BEIKE2026", "inviteCode": "BEIKE2026",
"grantedUserTags": ["北科"], "metadata": {
"metadata": {}, "userTags": ["北科"]
},
"startsAt": null, "startsAt": null,
"expiresAt": null "expiresAt": null
} }
``` ```
后台邀请码列表和 upsert 返回增加同名字段 后台邀请码列表和 upsert 返回继续回传 `metadata`
```json ```json
{ {
"inviteCode": "BEIKE2026", "inviteCode": "BEIKE2026",
"grantedUserTags": ["北科"] "metadata": {
"userTags": ["北科"]
}
} }
``` ```
用户侧邀请中心、账号资料、登录返回和普通 profile 接口不返回 `userTags` 后台表单展示时从 `metadata.userTags` 回显用户标签;用户侧邀请中心、账号资料、登录返回和普通 profile 接口不返回 `userTags`
## 6. 拼图排行榜展示 ## 6. 拼图排行榜展示
@@ -94,9 +99,10 @@
## 7. 验收 ## 7. 验收
1. 新账号 `user_account.user_tags` 默认为空 1. 新账号 `user_account.user_tags` 数据库默认为 `None`,业务读取为空数组
2. 后台创建邀请码时可填写 `北科`列表和结果面板可回显。 2. 后台创建邀请码时可填写 `北科`请求和返回的 `metadata.userTags` 可回显。
3. 用户填写该邀请码后,账号表 `user_tags` 包含 `北科` 3. 用户填写该邀请码后,账号表 `user_tags` 包含 `北科`
4. 不带标签的邀请码不改变账号标签。 4. 不带标签的邀请码不改变账号标签。
5. 拼图排行榜中带 `北科` 的用户昵称下方显示 `北科`,其它标签不显示。 5. 拼图排行榜中带 `北科` 的用户昵称下方显示 `北科`,其它标签不显示。
6. 执行 `npm run check:encoding``cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`,并按影响范围执行后台 typecheck / 拼图前端测试。 6. 生成 SpacetimeDB bindings 后,不再出现独立的邀请码标签字段。
7. 执行 `npm run check:encoding``cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`,并按影响范围执行后台 typecheck / 拼图前端测试。

View File

@@ -92,6 +92,25 @@
2. 若是并入已有手机号正式账号,则返回目标正式账号快照,当前实现会保持其账号主登录方式,例如 `loginMethod = phone` 2. 若是并入已有手机号正式账号,则返回目标正式账号快照,当前实现会保持其账号主登录方式,例如 `loginMethod = phone`
3. 但 access token 中的 `provider` 仍按**本次登录来源**签发,不依赖账号主登录方式推断,因此微信绑定后的当前会话仍会签发 `provider = wechat` 3. 但 access token 中的 `provider` 仍按**本次登录来源**签发,不依赖账号主登录方式推断,因此微信绑定后的当前会话仍会签发 `provider = wechat`
### 3.4 `POST /api/auth/wechat/miniprogram-login`
职责固定为:
1. 接收微信小程序原生壳通过 `wx.login` 拿到的 `code`
2.`Axum` 内调用微信 `jscode2session`,兑换 `openid/unionid`
3. 复用 `resolve_login` 处理 `unionid/openid -> user_id` 的查找、补写和待绑定账号创建。
4. 签发本系统 access token并创建 refresh session。
5. 返回:
- `token`
- `bindingStatus`
- `user`
关键约束:
1. 小程序壳不能把裸 `openid` 直接拼给 H5 做登录。
2. H5 仍只消费本系统 `auth_token`,小程序壳只是把这枚 token 放入既有 hash 回调格式。
3. 小程序请求必须补传 `x-client-type=mini_program``x-client-runtime=wechat_mini_program`,用于 refresh session 记录来源。
## 4. 当前最小实现策略 ## 4. 当前最小实现策略
当前阶段为了先打通 Rust 后端闭环,采用以下最小实现: 当前阶段为了先打通 Rust 后端闭环,采用以下最小实现:
@@ -125,6 +144,7 @@
4. `GET /api/auth/wechat/start` 4. `GET /api/auth/wechat/start`
5. `GET /api/auth/wechat/callback` 5. `GET /api/auth/wechat/callback`
6. `POST /api/auth/wechat/bind-phone` 6. `POST /api/auth/wechat/bind-phone`
7. `POST /api/auth/wechat/miniprogram-login`
## 6. 环境变量 ## 6. 环境变量
@@ -139,11 +159,14 @@
7. `WECHAT_AUTHORIZE_ENDPOINT` 7. `WECHAT_AUTHORIZE_ENDPOINT`
8. `WECHAT_ACCESS_TOKEN_ENDPOINT` 8. `WECHAT_ACCESS_TOKEN_ENDPOINT`
9. `WECHAT_USER_INFO_ENDPOINT` 9. `WECHAT_USER_INFO_ENDPOINT`
10. `WECHAT_STATE_TTL_MINUTES` 10. `WECHAT_JS_CODE_SESSION_ENDPOINT`
11. `WECHAT_MOCK_USER_ID` 11. `WECHAT_MINI_PROGRAM_APP_ID`
12. `WECHAT_MOCK_UNION_ID` 12. `WECHAT_MINI_PROGRAM_APP_SECRET`
13. `WECHAT_MOCK_DISPLAY_NAME` 13. `WECHAT_STATE_TTL_MINUTES`
14. `WECHAT_MOCK_AVATAR_URL` 14. `WECHAT_MOCK_USER_ID`
15. `WECHAT_MOCK_UNION_ID`
16. `WECHAT_MOCK_DISPLAY_NAME`
17. `WECHAT_MOCK_AVATAR_URL`
## 7. 与后续 SpacetimeDB 的衔接要求 ## 7. 与后续 SpacetimeDB 的衔接要求

View File

@@ -100,6 +100,9 @@ real 模式行为固定为:
| `WECHAT_AUTHORIZE_ENDPOINT` | 否 | 默认桌面二维码授权地址 | | `WECHAT_AUTHORIZE_ENDPOINT` | 否 | 默认桌面二维码授权地址 |
| `WECHAT_ACCESS_TOKEN_ENDPOINT` | 否 | 默认 access_token 接口 | | `WECHAT_ACCESS_TOKEN_ENDPOINT` | 否 | 默认 access_token 接口 |
| `WECHAT_USER_INFO_ENDPOINT` | 否 | 默认用户信息接口 | | `WECHAT_USER_INFO_ENDPOINT` | 否 | 默认用户信息接口 |
| `WECHAT_JS_CODE_SESSION_ENDPOINT` | 否 | 默认小程序 `jscode2session` 接口 |
| `WECHAT_MINI_PROGRAM_APP_ID` | 小程序 `real` 模式必填 | 微信小程序 AppID不填时回退 `WECHAT_APP_ID` |
| `WECHAT_MINI_PROGRAM_APP_SECRET` | 小程序 `real` 模式必填 | 微信小程序 AppSecret不填时回退 `WECHAT_APP_SECRET` |
| `WECHAT_STATE_TTL_MINUTES` | 否 | state 有效期,默认 `15` 分钟 | | `WECHAT_STATE_TTL_MINUTES` | 否 | state 有效期,默认 `15` 分钟 |
补充说明: 补充说明:
@@ -225,7 +228,46 @@ https://game.example.com
- `wechatBound = true` - `wechatBound = true`
- `bindingStatus` 已更新为目标状态 - `bindingStatus` 已更新为目标状态
## 8. 账号命中规则 ## 8. 小程序 web-view 登录联调步骤
小程序壳走原生 `wx.login`,不走网页 OAuth callback。联调前需要额外确认
```bash
WECHAT_AUTH_ENABLED=true
WECHAT_AUTH_PROVIDER=real
WECHAT_MINI_PROGRAM_APP_ID="你的微信小程序 AppID"
WECHAT_MINI_PROGRAM_APP_SECRET="你的微信小程序 AppSecret"
```
`miniprogram/config.js` 中确认:
```js
const WEB_VIEW_ENTRY_URL = 'https://你的H5业务域名/';
const API_BASE_URL = 'https://你的服务器域名/';
const MINI_PROGRAM_APP_ID = '你的微信小程序 AppID';
```
联调流程:
1. 微信开发者工具打开项目根目录。
2. 小程序启动后调用 `wx.login`
3. 小程序壳请求:
```http
POST /api/auth/wechat/miniprogram-login
```
4. 后端通过 `jscode2session` 兑换 `openid/unionid`
5. 后端返回系统 `token``bindingStatus``user`
6. 小程序壳打开 H5并在 hash 中附加:
- `auth_provider=wechat`
- `auth_token=...`
- `auth_binding_status=active|pending_bind_phone`
7. H5 消费 hash 后通过 `/api/auth/me` 恢复登录态。
这里不能把裸 `openid` 作为 web-view query 登录凭证;`openid` 只能留在后端身份绑定层H5 只消费本系统 JWT。
## 9. 账号命中规则
当前实现固定按以下顺序命中已有账号: 当前实现固定按以下顺序命中已有账号:
@@ -238,7 +280,7 @@ https://game.example.com
1. 若按 `unionid` 命中了已有微信身份,但本次微信回调带来了新的 `openid`,后端会把新的 `openid -> user_id` 映射补齐 1. 若按 `unionid` 命中了已有微信身份,但本次微信回调带来了新的 `openid`,后端会把新的 `openid -> user_id` 映射补齐
2. 若后续绑定手机号时发现该手机号已经属于正式账号,则会把微信身份并入这个正式账号 2. 若后续绑定手机号时发现该手机号已经属于正式账号,则会把微信身份并入这个正式账号
## 9. 前端验收点 ## 10. 前端验收点
前端联调时至少检查以下行为: 前端联调时至少检查以下行为:

View File

@@ -0,0 +1,187 @@
# 微信小程序 web-view 壳接入记录
日期:`2026-05-03`
## 1. 目标
本次先用微信小程序 `web-view` 承载现有 H5不重写 React/Vite 主前端,也不把 SpacetimeDB SDK 或业务规则搬进小程序端。
当前小程序壳只承担五件事:
1. 提供微信开发者工具可识别的 `miniprogram/` 工程根目录。
2. 在原生小程序壳中调用 `wx.login` 获取小程序 `code`
3. 调用服务器域名下的 `/api/auth/wechat/miniprogram-login`,由 Rust `api-server` 兑换微信身份并签发系统登录态。
4. 若后端返回 `pending_bind_phone`,先在小程序原生层通过 `button open-type="getPhoneNumber"` 取得用户同意后的手机号动态令牌,再调用 `/api/auth/wechat/bind-phone` 完成绑定。
5. 用一个全屏 `web-view` 打开现有 H5 入口,并把系统 `auth_token` 放入 H5 现有登录回调 hash。
重要边界:
1. `openid` 只作为后端微信身份绑定依据,不直接暴露给 H5 当登录凭证。
2. H5 继续消费本系统 JWT也就是 `#auth_provider=wechat&auth_token=...&auth_binding_status=...`
3. 这与 [`WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md`](./WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md) 中“微信只提供三方身份Axum 签发系统 JWT”的边界一致。
## 2. 文件入口
| 文件 | 说明 |
| --- | --- |
| `project.config.json` | 指定 `miniprogramRoot: "miniprogram/"`。 |
| `miniprogram/app.json` | 小程序全局配置,注册 `pages/web-view/index`。 |
| `miniprogram/config.js` | 业务域名入口配置,需要部署时填写。 |
| `miniprogram/pages/web-view/index.*` | 最小 web-view 页面。 |
| `server-rs/crates/api-server/src/wechat_auth.rs` | 新增小程序登录接口 `/api/auth/wechat/miniprogram-login`。 |
| `server-rs/crates/platform-auth/src/lib.rs` | 新增 `jscode2session` 兑换能力。 |
## 3. 需要手工填写的配置
`miniprogram/config.js` 中填写:
```js
const WEB_VIEW_ENTRY_URL = 'https://你的H5业务域名/';
const API_BASE_URL = 'https://你的服务器域名/';
const MINI_PROGRAM_APP_ID = '你的微信小程序 AppID';
const MINI_PROGRAM_ENV = 'develop';
```
约束:
1. 必须是 `https`
2. 不能是 `localhost` 或 IP。
3. `WEB_VIEW_ENTRY_URL` 域名需要在微信小程序后台配置为业务域名。
4. `API_BASE_URL` 域名需要在微信小程序后台配置为 request 合法域名。
5. H5 页面里的 API、图片、音视频、iframe 等外链也要满足微信侧域名与证书要求。
`api-server` 环境变量中填写:
```bash
WECHAT_AUTH_ENABLED=true
WECHAT_AUTH_PROVIDER=real
WECHAT_MINI_PROGRAM_APP_ID="你的微信小程序 AppID"
WECHAT_MINI_PROGRAM_APP_SECRET="你的微信小程序 AppSecret"
WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session"
WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token"
WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber"
```
如果开放平台网页 OAuth 与小程序使用同一个 AppID/Secret也可以继续使用已有
```bash
WECHAT_APP_ID="你的微信 AppID"
WECHAT_APP_SECRET="你的微信 AppSecret"
```
但正式部署建议把小程序配置写到 `WECHAT_MINI_PROGRAM_APP_ID``WECHAT_MINI_PROGRAM_APP_SECRET`,避免和网页 OAuth 配置混淆。
`WEB_VIEW_SOURCE_QUERY` 默认附加:
```text
clientType=mini_program
clientRuntime=wechat_mini_program
```
小程序壳调用登录接口时会补传:
```text
x-client-type=mini_program
x-client-runtime=wechat_mini_program
x-client-platform=ios|android|unknown
x-client-instance-id=<小程序本地持久化随机值>
x-mini-program-app-id=<MINI_PROGRAM_APP_ID>
x-mini-program-env=<MINI_PROGRAM_ENV>
```
这些字段会进入 refresh session 的客户端身份快照URL query 只作为 H5 识别宿主来源的轻量标记,不作为鉴权依据。
## 4. 登录链路
当前登录链路固定为:
1. 小程序页面启动。
2. 调用 `wx.login` 获取一次性 `code`
3. 小程序壳请求:
```http
POST /api/auth/wechat/miniprogram-login
Content-Type: application/json
{
"code": "wx.login code"
}
```
4. `api-server` 调用微信 `jscode2session` 兑换 `openid/unionid`
5. `api-server` 复用现有微信身份逻辑:
- 先按 `unionid` 命中已有身份
- 再按 `openid` 命中已有身份
- 都没有命中时创建 `pending_bind_phone` 的微信壳账号
6. `api-server` 签发系统 access token并写入 refresh session。
7. 如果返回 `bindingStatus=active`,小程序壳打开:
```text
https://你的H5业务域名/#auth_provider=wechat&auth_token=<系统JWT>&auth_binding_status=active
```
8. 如果返回 `bindingStatus=pending_bind_phone`,小程序壳暂不打开 H5而是展示原生 `getPhoneNumber` 按钮。用户点击并同意后,小程序把 `bindgetphonenumber` 事件里的 `detail.code` 作为 `wechatPhoneCode` 传给:
```http
POST /api/auth/wechat/bind-phone
Authorization: Bearer <JWT>
Content-Type: application/json
{
"wechatPhoneCode": "getPhoneNumber code"
}
```
9. `api-server` 通过微信 `stable_token` 获取小程序 `access_token`,再调用 `getuserphonenumber` 换取平台验证后的手机号,并复用现有微信待绑定账号合并逻辑。成功后重新签发 `active` 系统 token。
10. H5 复用 `consumeAuthCallbackResult()` 消费 `auth_token` 并进入现有登录态恢复流程。
补充H5 里的旧短信验证码绑定页继续保留为非小程序环境兜底;小程序原生手机号授权只替代“手动输入手机号 + 短信验证码”这一步,不代表后台静默读取本机号码。
## 5. 微信后台配置
至少需要在小程序后台配置:
1. `业务域名`:承载 H5 的域名。
2. `request 合法域名``API_BASE_URL` 对应的服务器域名。
3. `socket 合法域名`:若后续小程序原生层直连 WebSocket 才需要;当前不启用。
当前仓库的 H5 仍建议通过同域 `/api/*` 访问 Rust `api-server`,避免在小程序和 H5 中分别维护跨域白名单。
## 6. 当前不做的事
本次不做原生小程序页面迁移,原因是当前主前端依赖:
1. React DOM 挂载、浏览器 history 和 `window.location`
2. `localStorage` / `sessionStorage`
3. 浏览器 `fetch``ReadableStream` SSE。
4. DOM、Canvas、Three.js 等浏览器渲染能力。
这些能力不能稳定原样运行在原生小程序宿主中。后续如要原生化,应新建小程序端宿主,复用 `packages/shared` 契约和 `api-server` BFF而不是把 `src/` 整体搬过去。
本次也不做 `openid` query 直登。原因是 `openid` 不是本系统签发的登录凭证,不能表达 token 版本、会话 ID、绑定状态、角色与过期时间也不能被 H5 直接信任。
## 7. 验收口径
可重复自动化 smoke
```bash
npm run check:wechat-miniprogram-auth
```
该命令固定覆盖三段链路:
1. 静态确认 `miniprogram/pages/web-view/index.js` 会请求 `/api/auth/wechat/miniprogram-login`,携带 `mini_program / wechat_mini_program` 客户端来源头,并把 `auth_provider/auth_token/auth_binding_status` 拼入 H5 hash。
2. 运行 `api-server` 定向测试 `wechat_miniprogram_login_returns_system_token_and_marks_session_source`,断言小程序登录返回 `token/bindingStatus/user`、写入 refresh cookie并且 `/api/auth/sessions` 能看到 `clientType=mini_program``clientRuntime=wechat_mini_program``miniProgramAppId`
3. 静态确认小程序壳在 `pending_bind_phone` 时使用 `getPhoneNumber``wechatPhoneCode` 调用 `/api/auth/wechat/bind-phone`,而不是打开 H5 后再要求手输手机号。
4. 运行前端 `authService` 定向测试,断言 `consumeAuthCallbackResult()` 会消费 `#auth_provider=wechat&auth_token=...&auth_binding_status=...`、保存 access token并清理地址栏 hash。
手工联调仍按以下口径确认真实微信与域名配置:
1. 微信开发者工具打开项目根目录后,识别 `miniprogram/` 为小程序源码目录。
2. 未填写 `WEB_VIEW_ENTRY_URL``API_BASE_URL` 时,页面显示配置提示,不出现空白页。
3. 填写已配置业务域名后,小程序先请求 `/api/auth/wechat/miniprogram-login`
4. 后端返回 `token/bindingStatus/user`,并写入 refresh cookie。
5. 若返回 `pending_bind_phone`,先看到小程序原生授权手机号按钮;用户同意后,小程序请求 `/api/auth/wechat/bind-phone` 且请求体包含 `wechatPhoneCode`
6. 绑定成功后首页全屏打开 H5URL hash 中包含 `auth_provider=wechat``auth_token``auth_binding_status=active`
7. H5 内 `consumeAuthCallbackResult()` 消费 hash 后,`/api/auth/me` 能返回当前用户。
8. `/api/auth/sessions` 能看到来源为 `mini_program / wechat_mini_program` 的会话记录。

10
miniprogram/app.js Normal file
View File

@@ -0,0 +1,10 @@
App({
globalData: {
launchOptions: null,
},
onLaunch(options) {
// 中文注释:保留启动参数,后续如果要把分享路径映射到 H5 深链,可以从这里统一读取。
this.globalData.launchOptions = options;
},
});

21
miniprogram/app.json Normal file
View File

@@ -0,0 +1,21 @@
{
"pages": [
"pages/web-view/index"
],
"window": {
"navigationBarTitleText": "百梦",
"navigationBarBackgroundColor": "#0b0f14",
"navigationBarTextStyle": "white",
"backgroundColor": "#0b0f14",
"backgroundTextStyle": "light"
},
"networkTimeout": {
"request": 60000,
"connectSocket": 60000,
"uploadFile": 60000,
"downloadFile": 60000
},
"permission": {},
"style": "v2",
"sitemapLocation": "sitemap.json"
}

5
miniprogram/app.wxss Normal file
View File

@@ -0,0 +1,5 @@
page {
min-height: 100vh;
background: #0b0f14;
color: #f5f7fb;
}

28
miniprogram/config.js Normal file
View File

@@ -0,0 +1,28 @@
// 中文注释:这里填写已经在“小程序后台-开发-开发设置-业务域名”配置过的 H5 入口。
// 示例https://game.example.com/
// 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。
const WEB_VIEW_ENTRY_URL = 'https://dev.genarrative.world/';
// 中文注释:这里填写 Rust api-server 的公网 HTTPS 域名,必须在“小程序后台-开发设置-request 合法域名”中配置。
// 如果 H5 和 API 同域,可保持和 WEB_VIEW_ENTRY_URL 同一个域名;请求路径会固定走 /api/auth/wechat/miniprogram-login。
const API_BASE_URL = 'https://dev.genarrative.world/';
// 中文注释:这里填写微信小程序 AppID用于后端记录会话来源project.config.json 里的 appid 也要保持一致。
const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65';
// 中文注释:按当前上传版本填写 develop / trial / release后端会写入会话来源快照。
const MINI_PROGRAM_ENV = 'develop';
// 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。
const WEB_VIEW_SOURCE_QUERY = {
clientType: 'mini_program',
clientRuntime: 'wechat_mini_program',
};
module.exports = {
API_BASE_URL,
MINI_PROGRAM_APP_ID,
MINI_PROGRAM_ENV,
WEB_VIEW_ENTRY_URL,
WEB_VIEW_SOURCE_QUERY,
};

View File

@@ -0,0 +1,349 @@
const {
API_BASE_URL,
MINI_PROGRAM_APP_ID,
MINI_PROGRAM_ENV,
WEB_VIEW_ENTRY_URL,
WEB_VIEW_SOURCE_QUERY,
} = require('../../config');
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id';
function isConfiguredEntryUrl(value) {
const trimmed = String(value || '').trim();
return /^https:\/\/[^/]+/i.test(trimmed);
}
function trimTrailingSlash(value) {
return String(value || '').trim().replace(/\/+$/u, '');
}
function isConfiguredApiBaseUrl(value) {
return /^https:\/\/[^/]+/i.test(String(value || '').trim());
}
function appendQuery(url, query) {
const pairs = Object.keys(query)
.filter((key) => query[key])
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
);
if (pairs.length === 0) {
return url;
}
return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`;
}
function appendHashParams(url, params) {
const pairs = Object.keys(params)
.filter((key) => params[key])
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
);
if (pairs.length === 0) {
return url;
}
const hashIndex = url.indexOf('#');
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
const separator = rawHash ? '&' : '';
return `${baseUrl}#${rawHash}${separator}${pairs.join('&')}`;
}
function resolveWebViewUrl(authResult) {
const entryUrl = String(WEB_VIEW_ENTRY_URL || '').trim();
if (!isConfiguredEntryUrl(entryUrl)) {
return '';
}
const sourcedUrl = appendQuery(entryUrl, WEB_VIEW_SOURCE_QUERY);
if (!authResult || !authResult.token) {
return sourcedUrl;
}
return appendHashParams(sourcedUrl, {
auth_provider: 'wechat',
auth_token: authResult.token,
auth_binding_status: authResult.bindingStatus,
});
}
function getClientInstanceId() {
const stored = wx.getStorageSync(CLIENT_INSTANCE_STORAGE_KEY);
if (stored) {
return String(stored);
}
const nextId = `wxmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
wx.setStorageSync(CLIENT_INSTANCE_STORAGE_KEY, nextId);
return nextId;
}
function resolveClientPlatform() {
const info = wx.getSystemInfoSync();
const platform = String(info.platform || '').toLowerCase();
if (platform === 'ios') {
return 'ios';
}
if (platform === 'android') {
return 'android';
}
return 'unknown';
}
function wxLogin() {
return new Promise((resolve, reject) => {
wx.login({
success(result) {
if (result.code) {
resolve(result.code);
return;
}
reject(new Error('微信登录未返回 code'));
},
fail(error) {
reject(new Error(error.errMsg || '微信登录失败'));
},
});
});
}
function requestMiniProgramLogin(code) {
return new Promise((resolve, reject) => {
const apiBaseUrl = trimTrailingSlash(API_BASE_URL);
if (!isConfiguredApiBaseUrl(apiBaseUrl)) {
reject(new Error('请先配置 API_BASE_URL'));
return;
}
wx.request({
url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`,
method: 'POST',
data: { code },
header: {
'content-type': 'application/json',
'x-client-type': MINI_PROGRAM_CLIENT_TYPE,
'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME,
'x-client-platform': resolveClientPlatform(),
'x-client-instance-id': getClientInstanceId(),
'x-mini-program-app-id': MINI_PROGRAM_APP_ID,
'x-mini-program-env': MINI_PROGRAM_ENV,
},
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.data);
return;
}
const message =
response.data &&
response.data.error &&
response.data.error.message
? response.data.error.message
: `微信登录失败:${response.statusCode}`;
reject(new Error(message));
},
fail(error) {
reject(new Error(error.errMsg || '微信登录请求失败'));
},
});
});
}
function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
return new Promise((resolve, reject) => {
const apiBaseUrl = trimTrailingSlash(API_BASE_URL);
if (!isConfiguredApiBaseUrl(apiBaseUrl)) {
reject(new Error('请先配置 API_BASE_URL'));
return;
}
wx.request({
url: `${apiBaseUrl}/api/auth/wechat/bind-phone`,
method: 'POST',
data: { wechatPhoneCode },
header: {
authorization: `Bearer ${authToken}`,
'content-type': 'application/json',
'x-client-type': MINI_PROGRAM_CLIENT_TYPE,
'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME,
'x-client-platform': resolveClientPlatform(),
'x-client-instance-id': getClientInstanceId(),
'x-mini-program-app-id': MINI_PROGRAM_APP_ID,
'x-mini-program-env': MINI_PROGRAM_ENV,
},
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.data);
return;
}
const message =
response.data &&
response.data.error &&
response.data.error.message
? response.data.error.message
: `绑定手机号失败:${response.statusCode}`;
reject(new Error(message));
},
fail(error) {
reject(new Error(error.errMsg || '绑定手机号请求失败'));
},
});
});
}
async function resolveAuthResult() {
const code = await wxLogin();
const response = await requestMiniProgramLogin(code);
if (!response || !response.token) {
throw new Error('服务器未返回登录态');
}
return {
token: response.token,
bindingStatus: response.bindingStatus || 'pending_bind_phone',
};
}
Page({
data: {
authResult: null,
bindingPhone: false,
errorMessage: '',
loading: true,
phoneBindingRequired: false,
webViewUrl: '',
},
async onLoad() {
// 中文注释web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。
if (!isConfiguredEntryUrl(WEB_VIEW_ENTRY_URL)) {
this.setData({
errorMessage: '请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。',
loading: false,
webViewUrl: '',
});
return;
}
if (!isConfiguredApiBaseUrl(API_BASE_URL)) {
this.setData({
errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。',
loading: false,
webViewUrl: '',
});
return;
}
try {
const authResult = await resolveAuthResult();
if (authResult.bindingStatus === 'pending_bind_phone') {
this.setData({
authResult,
errorMessage: '',
loading: false,
phoneBindingRequired: true,
webViewUrl: '',
});
return;
}
this.setData({
authResult,
errorMessage: '',
loading: false,
phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(authResult),
});
} catch (error) {
this.setData({
authResult: null,
errorMessage:
error && error.message
? error.message
: '微信登录失败,请稍后重试。',
loading: false,
phoneBindingRequired: false,
webViewUrl: '',
});
}
},
async handleGetPhoneNumber(event) {
if (!this.data.authResult || !this.data.authResult.token) {
this.handleRetryLogin();
return;
}
const detail = event.detail || {};
if (!detail.code) {
this.setData({
errorMessage: detail.errMsg || '需要授权手机号后才能完成绑定。',
});
return;
}
this.setData({
bindingPhone: true,
errorMessage: '',
});
try {
const response = await requestMiniProgramBindPhone(
this.data.authResult.token,
detail.code,
);
if (!response || !response.token) {
throw new Error('服务器未返回绑定后的登录态');
}
const nextAuthResult = {
token: response.token,
bindingStatus: 'active',
};
this.setData({
authResult: nextAuthResult,
bindingPhone: false,
errorMessage: '',
loading: false,
phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(nextAuthResult),
});
} catch (error) {
this.setData({
bindingPhone: false,
errorMessage:
error && error.message
? error.message
: '绑定手机号失败,请稍后重试。',
});
}
},
handleRetryLogin() {
this.setData({
authResult: null,
bindingPhone: false,
errorMessage: '',
loading: true,
phoneBindingRequired: false,
webViewUrl: '',
});
this.onLoad();
},
handleWebViewLoad(event) {
console.info('[web-view] loaded', event.detail);
},
handleWebViewError(event) {
console.error('[web-view] load failed', event.detail);
},
handleWebViewMessage(event) {
// 中文注释H5 如需和小程序壳通信,可通过 wx.miniProgram.postMessage 发送轻量消息。
console.info('[web-view] message', event.detail);
},
});

View File

@@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@@ -0,0 +1,47 @@
<block wx:if="{{webViewUrl}}">
<web-view
src="{{webViewUrl}}"
bindload="handleWebViewLoad"
binderror="handleWebViewError"
bindmessage="handleWebViewMessage"
/>
</block>
<view wx:elif="{{loading}}" class="setup-screen">
<view class="setup-card">
<view class="setup-title">正在登录</view>
</view>
</view>
<view wx:elif="{{phoneBindingRequired}}" class="setup-screen">
<view class="setup-card">
<view class="setup-title">绑定手机号</view>
<view wx:if="{{errorMessage}}" class="setup-text setup-text--danger">
{{errorMessage}}
</view>
<button
class="retry-button"
open-type="getPhoneNumber"
bindgetphonenumber="handleGetPhoneNumber"
loading="{{bindingPhone}}"
disabled="{{bindingPhone}}"
>
{{bindingPhone ? '正在绑定' : '微信授权手机号'}}
</button>
<button
class="ghost-button"
disabled="{{bindingPhone}}"
bindtap="handleRetryLogin"
>
重新登录
</button>
</view>
</view>
<view wx:else class="setup-screen">
<view class="setup-card">
<view class="setup-title">无法进入</view>
<view class="setup-text">{{errorMessage}}</view>
<button class="retry-button" bindtap="handleRetryLogin">重试</button>
</view>
</view>

View File

@@ -0,0 +1,58 @@
.setup-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
background: #0b0f14;
box-sizing: border-box;
}
.setup-card {
width: 100%;
max-width: 560rpx;
padding: 36rpx;
border: 1rpx solid rgba(255, 255, 255, 0.14);
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.06);
box-sizing: border-box;
}
.setup-title {
font-size: 34rpx;
font-weight: 600;
line-height: 1.35;
color: #f5f7fb;
}
.setup-text {
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.55;
color: rgba(245, 247, 251, 0.72);
}
.setup-text--danger {
color: #ffb4a9;
}
.retry-button {
margin-top: 28rpx;
width: 100%;
border-radius: 8rpx;
background: #f5f7fb;
color: #0b0f14;
font-size: 28rpx;
line-height: 2.6;
}
.ghost-button {
margin-top: 18rpx;
width: 100%;
border-radius: 8rpx;
border: 1rpx solid rgba(255, 255, 255, 0.24);
background: transparent;
color: rgba(245, 247, 251, 0.86);
font-size: 26rpx;
line-height: 2.6;
}

8
miniprogram/sitemap.json Normal file
View File

@@ -0,0 +1,8 @@
{
"rules": [
{
"action": "allow",
"page": "*"
}
]
}

View File

@@ -25,6 +25,7 @@
"assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs", "assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs",
"check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs", "check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs",
"check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs", "check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs",
"check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs",
"check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs", "check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs",
"lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
"lint:guardrails": "npm run lint:eslint", "lint:guardrails": "npm run lint:eslint",
@@ -36,6 +37,8 @@
"format:check": "prettier --check .", "format:check": "prettier --check .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs",
"loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js",
"check": "npm run lint && npm run test && npm run build && npm run check:content", "check": "npm run lint && npm run test && npm run build && npm run check:content",
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts", "check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts", "check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",

View File

@@ -114,8 +114,9 @@ export type AuthWechatStartResponse = {
}; };
export type AuthWechatBindPhoneRequest = { export type AuthWechatBindPhoneRequest = {
phone: string; phone?: string;
code: string; code?: string;
wechatPhoneCode?: string;
}; };
export type AuthWechatBindPhoneResponse = { export type AuthWechatBindPhoneResponse = {
@@ -123,6 +124,16 @@ export type AuthWechatBindPhoneResponse = {
user: AuthUser; user: AuthUser;
}; };
export type AuthWechatMiniProgramLoginRequest = {
code: string;
};
export type AuthWechatMiniProgramLoginResponse = {
token: string;
bindingStatus: AuthBindingStatus;
user: AuthUser;
};
export type AuthPhoneChangeRequest = { export type AuthPhoneChangeRequest = {
phone: string; phone: string;
code: string; code: string;

View File

@@ -0,0 +1,53 @@
export type CreationAudioGenerationKind =
| 'background_music'
| 'sound_effect';
export interface CreationAudioAsset {
taskId: string;
provider: string;
assetObjectId?: string | null;
assetKind?: string | null;
audioSrc: string;
prompt?: string | null;
title?: string | null;
updatedAt?: string | null;
}
export interface CreateBackgroundMusicRequest {
prompt: string;
title: string;
tags?: string | null;
model?: string | null;
}
export interface CreateSoundEffectRequest {
prompt: string;
duration?: number | null;
seed?: number | null;
}
export interface AudioGenerationTaskResponse {
kind: CreationAudioGenerationKind;
taskId: string;
provider: string;
status: string;
}
export interface PublishGeneratedAudioAssetRequest {
entityKind: string;
entityId: string;
slot: string;
assetKind: string;
profileId?: string | null;
storagePrefix?: 'puzzle_assets' | 'match3d_assets' | 'custom_world_scenes' | null;
}
export interface GeneratedAudioAssetResponse {
kind: CreationAudioGenerationKind;
taskId: string;
provider: string;
status: string;
assetObjectId?: string | null;
assetKind?: string | null;
audioSrc?: string | null;
}

View File

@@ -0,0 +1,89 @@
export const BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match';
export const BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
export const BABY_OBJECT_MATCH_EDUTAINMENT_TAG = '寓教于乐';
export type BabyObjectMatchTemplateId =
typeof BABY_OBJECT_MATCH_TEMPLATE_ID;
export type BabyObjectMatchAssetProvider =
| 'vector-engine-gpt-image-2'
| 'placeholder';
export type BabyObjectMatchPublicationStatus = 'draft' | 'published';
export type BabyObjectMatchItemAsset = {
itemId: string;
itemName: string;
imageSrc: string;
assetObjectId: string | null;
generationProvider: BabyObjectMatchAssetProvider;
prompt: string;
};
export type BabyObjectMatchDraft = {
draftId: string;
profileId: string;
templateId: BabyObjectMatchTemplateId;
templateName: typeof BABY_OBJECT_MATCH_TEMPLATE_NAME;
workTitle: string;
workDescription: string;
itemNames: [string, string];
itemAssets: [BabyObjectMatchItemAsset, BabyObjectMatchItemAsset];
themeTags: string[];
publicationStatus: BabyObjectMatchPublicationStatus;
createdAt: string;
updatedAt: string;
publishedAt: string | null;
};
export type CreateBabyObjectMatchDraftRequest = {
itemAName: string;
itemBName: string;
};
export type BabyObjectMatchDraftResponse = {
draft: BabyObjectMatchDraft;
};
export type SaveBabyObjectMatchDraftRequest = {
draft: BabyObjectMatchDraft;
};
export type BabyObjectMatchPublishRequest = {
draft: BabyObjectMatchDraft;
};
export type BabyObjectMatchPublishResponse = {
draft: BabyObjectMatchDraft;
publicWorkCode: string;
};
export function normalizeBabyObjectMatchItemName(value: string) {
return value.trim();
}
export function normalizeBabyObjectMatchTags(tags: string[]) {
return [
...new Set([
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
...tags.map((tag) => tag.trim()).filter(Boolean),
]),
];
}
export function hasBabyObjectMatchRequiredTag(tags: string[]) {
return tags.some((tag) => tag === BABY_OBJECT_MATCH_EDUTAINMENT_TAG);
}
export function validateBabyObjectMatchItemNames(
payload: CreateBabyObjectMatchDraftRequest,
) {
const itemAName = normalizeBabyObjectMatchItemName(payload.itemAName);
const itemBName = normalizeBabyObjectMatchItemName(payload.itemBName);
return {
itemAName,
itemBName,
valid: Boolean(itemAName && itemBName),
};
}

View File

@@ -1,4 +1,5 @@
export type * from './creativeAgent'; export type * from './creativeAgent';
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';

View File

@@ -2,6 +2,8 @@
* 抓大鹅 Match3D 作品读写共享契约。 * 抓大鹅 Match3D 作品读写共享契约。
* 首版作品发布必须补齐游戏名称、标签、封面、题材、消除次数和难度。 * 首版作品发布必须补齐游戏名称、标签、封面、题材、消除次数和难度。
*/ */
import type { CreationAudioAsset } from './creationAudio';
export type Match3DWorkPublicationStatus = 'draft' | 'published' | string; export type Match3DWorkPublicationStatus = 'draft' | 'published' | string;
export type Match3DGeneratedItemAssetStatus = export type Match3DGeneratedItemAssetStatus =
@@ -22,10 +24,16 @@ export interface Match3DGeneratedItemAsset {
modelFileName?: string | null; modelFileName?: string | null;
taskUuid?: string | null; taskUuid?: string | null;
subscriptionKey?: string | null; subscriptionKey?: string | null;
backgroundMusic?: CreationAudioAsset | null;
clickSound?: CreationAudioAsset | null;
status: Match3DGeneratedItemAssetStatus; status: Match3DGeneratedItemAssetStatus;
error?: string | null; error?: string | null;
} }
export interface PutMatch3DAudioAssetsRequest {
generatedItemAssets: Match3DGeneratedItemAsset[];
}
export interface PutMatch3DWorkRequest { export interface PutMatch3DWorkRequest {
gameName: string; gameName: string;
themeText?: string; themeText?: string;
@@ -37,6 +45,15 @@ export interface PutMatch3DWorkRequest {
difficulty: number; difficulty: number;
} }
export interface GenerateMatch3DWorkTagsRequest {
gameName: string;
themeText: string;
}
export interface GenerateMatch3DWorkTagsResponse {
tags: string[];
}
export interface Match3DWorkSummary { export interface Match3DWorkSummary {
workId: string; workId: string;
profileId: string; profileId: string;

View File

@@ -1,4 +1,5 @@
import type { JsonObject } from './common'; import type { JsonObject } from './common';
import type { CreationAudioAsset } from './creationAudio';
export type PuzzleAnchorStatus = export type PuzzleAnchorStatus =
| 'missing' | 'missing'
@@ -47,6 +48,7 @@ export interface PuzzleDraftLevel {
levelName: string; levelName: string;
pictureDescription: string; pictureDescription: string;
pictureReference?: string | null; pictureReference?: string | null;
backgroundMusic?: CreationAudioAsset | null;
candidates: PuzzleGeneratedImageCandidate[]; candidates: PuzzleGeneratedImageCandidate[];
selectedCandidateId: string | null; selectedCandidateId: string | null;
coverImageSrc: string | null; coverImageSrc: string | null;

View File

@@ -352,7 +352,6 @@ export type AdminDisableProfileRedeemCodeRequest = {
export type AdminUpsertProfileInviteCodeRequest = { export type AdminUpsertProfileInviteCodeRequest = {
inviteCode: string; inviteCode: string;
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
grantedUserTags?: string[];
startsAt?: string | null; startsAt?: string | null;
expiresAt?: string | null; expiresAt?: string | null;
}; };
@@ -361,7 +360,6 @@ export type ProfileInviteCodeAdminResponse = {
userId: string; userId: string;
inviteCode: string; inviteCode: string;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
grantedUserTags: string[];
startsAt?: string | null; startsAt?: string | null;
expiresAt?: string | null; expiresAt?: string | null;
status: 'pending' | 'active' | 'expired'; status: 'pending' | 'active' | 'expired';

View File

@@ -3,8 +3,10 @@ export * from './contracts/auth';
export type * from './contracts/bigFish'; export type * from './contracts/bigFish';
export * from './contracts/common'; export * from './contracts/common';
export type * from './contracts/creationAgentDocumentInput'; export type * from './contracts/creationAgentDocumentInput';
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/edutainmentBabyObject';
export type * from './contracts/hyper3d'; export type * from './contracts/hyper3d';
export * from './contracts/match3dAgent'; export * from './contracts/match3dAgent';
export * from './contracts/match3dRuntime'; export * from './contracts/match3dRuntime';
@@ -12,8 +14,8 @@ export * from './contracts/match3dWorks';
export * from './contracts/puzzleAgentActions'; export * from './contracts/puzzleAgentActions';
export * from './contracts/puzzleAgentDraft'; export * from './contracts/puzzleAgentDraft';
export * from './contracts/puzzleAgentSession'; export * from './contracts/puzzleAgentSession';
export * from './contracts/puzzleOnboarding';
export type * from './contracts/puzzleCreativeTemplate'; export type * from './contracts/puzzleCreativeTemplate';
export * from './contracts/puzzleOnboarding';
export * from './contracts/puzzleResultPreview'; export * from './contracts/puzzleResultPreview';
export * from './contracts/puzzleRuntimeSession'; export * from './contracts/puzzleRuntimeSession';
export * from './contracts/puzzleWorkSummary'; export * from './contracts/puzzleWorkSummary';

26
project.config.json Normal file
View File

@@ -0,0 +1,26 @@
{
"setting": {
"es6": true,
"postcss": true,
"minified": true,
"uglifyFileName": false,
"enhance": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"useCompilerPlugins": false,
"minifyWXML": true
},
"compileType": "miniprogram",
"miniprogramRoot": "miniprogram/",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wx3da23ea14ca66b65",
"editorSetting": {}
}

View File

@@ -0,0 +1,14 @@
{
"libVersion": "3.15.2",
"projectname": "Genarrative",
"setting": {
"urlCheck": true,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"compileHotReLoad": true
}
}

View File

@@ -0,0 +1,17 @@
[
{
"id": "bark-battle-player-back",
"title": "玩家背对屏幕狗狗",
"prompt": "竖屏手机游戏素材,背对屏幕的可爱小狗,站在下半屏中央,耳朵竖起,身体朝向远处对手,夸张卡通 2D 手游风,轮廓清晰,暖橙色毛发,适合做 sprite透明背景或纯色背景无文字、水印、UI、边框"
},
{
"id": "bark-battle-opponent-front",
"title": "对手面向屏幕狗狗",
"prompt": "竖屏手机游戏素材,面向屏幕的可爱小狗,站在上半屏中央,张嘴准备汪汪叫,夸张卡通 2D 手游风,轮廓清晰,紫蓝色竞技光效,适合做 sprite透明背景或纯色背景无文字、水印、UI、边框"
},
{
"id": "bark-battle-bark-particles",
"title": "汪字粒子声浪",
"prompt": "竖屏手机游戏特效素材画面中心必须是完整清晰的中文汉字“汪”包含左侧三点水偏旁“氵”和右侧“王”字体由金黄色发光粒子组成字形周围向外扩散圆形声浪冲击波与粉色火花深色纯背景便于叠加适合做游戏粒子特效贴图无其他文字、水印、按钮、UI不要只生成“王”字"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -0,0 +1,120 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = process.cwd();
const failures = [];
const smokeSteps = [
{
label: '小程序壳请求与 hash 回跳静态检查',
run: checkMiniProgramShell,
},
{
label: 'api-server 小程序登录与会话来源测试',
run: () =>
runCommand('cargo', [
'test',
'-p',
'api-server',
'wechat_miniprogram_login_returns_system_token_and_marks_session_source',
'--manifest-path',
'server-rs/Cargo.toml',
'--',
'--nocapture',
]),
},
{
label: 'H5 auth hash 消费测试',
run: () =>
runCommand(process.execPath, [
fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)),
'run',
'src/services/authService.test.ts',
'-t',
'consumes auth callback hash and persists the returned access token',
]),
},
];
for (const step of smokeSteps) {
console.log(`[wechat-miniprogram-auth-smoke] ${step.label}`);
step.run();
}
if (failures.length > 0) {
console.error('\n[wechat-miniprogram-auth-smoke] 未通过:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('\n[wechat-miniprogram-auth-smoke] 通过');
function checkMiniProgramShell() {
const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js');
const shellTemplatePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.wxml');
const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts');
ensureNeedles(shellPath, [
'/api/auth/wechat/miniprogram-login',
'/api/auth/wechat/bind-phone',
"'x-client-type': MINI_PROGRAM_CLIENT_TYPE",
"'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME",
'auth_provider',
'auth_token',
'auth_binding_status',
'bindingStatus',
'pending_bind_phone',
'wechatPhoneCode',
]);
ensureNeedles(shellTemplatePath, ['getPhoneNumber', 'bindgetphonenumber']);
// 中文注释:这里锁定 H5 消费回跳 hash 的真实测试输入,避免只检查实现文本。
ensureNeedles(authServiceTestPath, [
'#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone',
'consumeAuthCallbackResult()',
"bindingStatus: 'pending_bind_phone'",
"expect(getStoredAccessToken()).toBe('jwt-callback-token')",
]);
}
function ensureNeedles(relativeOrFullPath, needles) {
if (!existsSync(relativeOrFullPath)) {
failures.push(`缺少文件:${relativeOrFullPath}`);
return;
}
const content = readFileSync(relativeOrFullPath, 'utf8');
for (const needle of needles) {
if (!content.includes(needle)) {
failures.push(`${relativeOrFullPath} 缺少内容:${needle}`);
}
}
}
function runCommand(command, args) {
const result = spawnSync(command, args, {
cwd: repoRoot,
env: process.env,
shell: false,
stdio: 'inherit',
});
if (result.error) {
failures.push(`${command} 启动失败:${result.error.message}`);
return;
}
if (result.signal) {
failures.push(`${command} 被信号终止:${result.signal}`);
return;
}
if ((result.status ?? 0) !== 0) {
failures.push(`${command} ${args.join(' ')} 退出码 ${result.status}`);
}
}

View File

@@ -0,0 +1,145 @@
import { Buffer } from 'node:buffer';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
const promptsPath = path.join(repoRoot, 'public', 'bark-battle-assets', 'bark-battle-image-prompts.json');
const outDir = path.join(repoRoot, 'public', 'bark-battle-assets', 'generated');
const args = new Set(process.argv.slice(2));
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 || 180000), 10),
};
}
function generationUrl(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) => typeof entry === 'string' && entry.trim() && output.push(entry.trim()));
}
collectStringsByKey(nested, targetKey, output);
}
}
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, 300)}`);
return JSON.parse(text);
} finally {
clearTimeout(timer);
}
}
async function downloadUrl(url, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) throw new Error(`download ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
const type = response.headers.get('content-type') || '';
const extension = type.includes('webp') ? 'webp' : type.includes('jpeg') ? 'jpg' : 'png';
return { bytes, extension };
} finally {
clearTimeout(timer);
}
}
const rawTemplates = JSON.parse(readFileSync(promptsPath, 'utf8'));
const onlyIds = process.argv
.slice(2)
.flatMap((arg, index, values) => (arg === '--only' ? String(values[index + 1] || '').split(',') : []))
.map((value) => value.trim())
.filter(Boolean);
const templates = rawTemplates.filter((template) => !onlyIds.length || onlyIds.includes(template.id));
const dryRun = args.has('--dry-run') || !args.has('--live');
const requests = templates.map((template) => ({ id: template.id, title: template.title, body: { model: 'gpt-image-2-all', prompt: template.prompt, n: 1, size: '1024x1024' } }));
if (dryRun) {
console.log(JSON.stringify({ mode: 'dry-run', outDir, count: requests.length, requests }, 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);
}
mkdirSync(outDir, { recursive: true });
const files = [];
for (const request of requests) {
console.log(`Generating ${request.id}...`);
const payload = await fetchJson(generationUrl(env.baseUrl), {
method: 'POST',
headers: { Authorization: `Bearer ${env.apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(request.body),
}, env.timeoutMs);
const urls = [];
const b64 = [];
collectStringsByKey(payload, 'url', urls);
collectStringsByKey(payload, 'image', urls);
collectStringsByKey(payload, 'image_url', urls);
collectStringsByKey(payload, 'b64_json', b64);
let image;
const url = [...new Set(urls)].find((item) => /^https?:\/\//u.test(item));
if (url) {
image = await downloadUrl(url, env.timeoutMs);
} else if (b64[0]) {
const bytes = Buffer.from(b64[0], 'base64');
image = { bytes, extension: inferExtensionFromBytes(bytes) };
} else {
throw new Error(`VectorEngine returned no image for ${request.id}`);
}
const outputPath = path.join(outDir, `${request.id}.${image.extension}`);
writeFileSync(outputPath, image.bytes);
files.push(outputPath);
}
console.log(JSON.stringify({ ok: true, count: files.length, files }, null, 2));

View File

@@ -1,4 +1,5 @@
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import { spawnSync } from 'node:child_process';
import { import {
existsSync, existsSync,
mkdirSync, mkdirSync,
@@ -10,16 +11,13 @@ import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(scriptDir, '..');
const defaultOut = path.join( const assetDir = path.join(repoRoot, 'public', 'child-motion-demo');
repoRoot, const intermediateDir = path.join(repoRoot, 'tmp', 'child-motion-demo-assets');
'public',
'child-motion-demo',
'picture-book-grass-stage.webp',
);
const defaultSize = '1536x1024';
const defaultTimeoutMs = 180000; const defaultTimeoutMs = 180000;
const chromaKeyColor = '#ff00ff';
const layoutReferenceOutput = 'picture-book-stage-layout-v2.png';
const prompt = [ const backgroundPrompt = [
'请生成一张横版儿童动作互动游戏舞台背景图,卡通绘本风格,温暖明亮。', '请生成一张横版儿童动作互动游戏舞台背景图,卡通绘本风格,温暖明亮。',
'画面下半部分必须是开阔柔软的草地地面,适合叠加半透明角色轮廓和地面圆圈指示环。', '画面下半部分必须是开阔柔软的草地地面,适合叠加半透明角色轮廓和地面圆圈指示环。',
'远处有柔和小山坡、树木、天空和浅色云朵,中心和下方前景保持干净开阔。', '远处有柔和小山坡、树木、天空和浅色云朵,中心和下方前景保持干净开阔。',
@@ -28,6 +26,252 @@ const prompt = [
'不要出现人物、动物、文字、按钮、UI、边框、水印、摄像头画面、真实照片质感。', '不要出现人物、动物、文字、按钮、UI、边框、水印、摄像头画面、真实照片质感。',
].join(''); ].join('');
const styleReferenceNote = [
'参考图仅用于统一卡通绘本草地舞台的色彩、笔触、纸张纹理和明亮童趣气质。',
'不要复制参考图构图,不要出现真实照片质感。',
].join('');
const layoutReferencePrompt = [
'请基于参考背景重新设计一张 16:9 儿童动作互动游戏热身关版式参考图,卡通绘本草地风格保持统一。',
'背景品质和明亮草地绘本质感沿用参考图,不要把背景做暗或做成科技风。',
'画面中心到下方中部保持开阔,留给半透明角色轮廓和地面椭圆指示环。',
'底部只放一条自然的前景草坪边缘,占舞台高度约 18% 到 22%,草叶比例真实可爱,不要拉伸成扁平色块。',
'顶部居中放一个小型横向 HUD 软纸条,占舞台宽度约 45% 到 52%,高度约 9% 到 12%,不要做成整屏顶部栏。',
'右下角放一个小型五格状态条,占舞台宽度约 28% 到 34%,高度约 6% 到 8%,不要压住角色脚下区域。',
'开始按钮占位使用小型胶囊按钮和轻盈托盘,整体不要超过舞台宽度 26%。',
'所有 UI 都是无文字、无图标的空白资源占位,边缘带少量草叶、水彩纸张纹理和浅蓝高光。',
'不要出现人物、动物、文字、数字、水印、摄像头画面、真实照片质感。',
].join('');
const chromaKeyNote = [
`背景必须是完全纯色、均匀一致的 ${chromaKeyColor} 品红色,用于后续去背。`,
'背景不能有阴影、渐变、纹理、地面、反光或光照变化。',
`主体中不要使用 ${chromaKeyColor} 或接近品红的颜色。`,
'主体边缘保持清晰,四周留出充足空白。',
'不要出现文字、水印、真实照片质感。',
].join('');
const noStretchNote = [
'资源自身必须按最终用途设计比例绘制,不要画成方形卡片再留大面积空白。',
'网页端会按资源原始比例等比缩放使用,不会把资源横向或纵向强行拉伸。',
'不要出现文字、数字、按钮文案、水印、真实照片质感。',
].join('');
const assetDefinitions = [
{
id: 'background',
output: 'picture-book-grass-stage.png',
size: '1536x1024',
prompt: backgroundPrompt,
transparent: false,
useBackgroundReference: false,
},
{
id: 'layout-reference-v2',
output: layoutReferenceOutput,
outputDirectory: 'intermediate',
size: '2048x1152',
prompt: layoutReferencePrompt,
transparent: false,
useBackgroundReference: true,
},
{
id: 'floor',
output: 'picture-book-foreground-grass-v2.png',
sourceOutput: 'picture-book-foreground-grass-v2-source.png',
size: '2048x768',
transparent: true,
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 2048,
canvasHeight: 640,
fit: 'cover-width',
fillWidth: 1.04,
anchorY: 'bottom',
padding: 18,
},
prompt: [
'请生成儿童动作互动游戏的底部前景草坪资源,不是完整背景。',
'主体是一条横向自然草地边缘,用于覆盖 16:9 舞台最下方约五分之一高度。',
'草坪顶部边缘有松散手绘草叶和少量浅色小花,底部更厚实,中心不要出现硬平台、椭圆地毯或 UI 栏。',
'整体应像绘本背景自然延伸出来的草地前景,比例宽而舒展,草叶不能被压扁或横向拉伸。',
'不要天空、远山、人物、角色、按钮、面板、边框。',
'风格必须和参考背景一致:明亮、温暖、卡通绘本、水彩笔触、轻微纸张纹理。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'ground-ring',
output: 'picture-book-ground-ring-v2.png',
sourceOutput: 'picture-book-ground-ring-v2-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: [
'请生成一个儿童动作互动游戏地面椭圆指示环资产。',
'主体是单个透视椭圆环,直接设计成贴在草地地面上的椭圆,不要依赖网页后期压扁。',
'圆环由柔软草叶、水彩绿色描边和浅色高光组成,中心留空,边缘带轻微绘本手绘不规则感。',
'整体清爽、明亮、儿童绘本风,不要科技感,不要霓虹,不要金属材质。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'character-outline',
output: 'picture-book-character-outline-v2.png',
sourceOutput: 'picture-book-character-outline-v2-source.png',
size: '1024x1536',
transparent: true,
transparencyCleanup: 'character-outline',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1024,
canvasHeight: 1536,
fit: 'contain',
fillWidth: 0.78,
fillHeight: 0.9,
anchorY: 'bottom',
padding: 28,
},
prompt: [
'请生成一个儿童动作互动游戏的半透明角色轮廓指示器资产。',
'主体是正面站立的人形轮廓,儿童友好比例,无五官、无衣服细节、无性别特征,双臂自然微微张开。',
'视觉上像浅蓝绿色水彩发光描边加半透明白色填充,用于表示真实用户的位置剪影。',
'轮廓需要简洁清晰,适合缩放到游戏舞台中使用。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'hud-strip',
output: 'picture-book-hud-strip-v2.png',
sourceOutput: 'picture-book-hud-strip-v2-source.png',
size: '1536x512',
transparent: true,
transparencyCleanup: 'soft-panel',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 2200,
canvasHeight: 420,
fit: 'contain',
fillWidth: 0.96,
fillHeight: 0.92,
anchorY: 'center',
padding: 18,
},
prompt: [
'请生成儿童动作互动游戏顶部 HUD 软纸条资产,不是方形面板。',
'主体是一条细长横向顶部信息条,目标宽高比约 5:1像轻盈软纸丝带不要做成圆形徽章、方形卡片或厚重弹窗。',
'中间为浅米白到淡浅绿色水彩软纸区域,左右边缘可以有少量草叶装饰,但不能扩大成大圆端。',
'边缘有少量草叶、浅蓝高光和绘本纸张纹理,中心必须干净空白,方便网页叠加标题和进度。',
'形状轻盈,适合放在 16:9 舞台顶部居中,占画面宽度约一半,不要做成全宽导航栏或后台系统面板。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'calibration-strip',
output: 'picture-book-calibration-strip-v2.png',
sourceOutput: 'picture-book-calibration-strip-v2-source.png',
size: '1536x512',
transparent: true,
transparencyCleanup: 'soft-panel',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1800,
canvasHeight: 360,
fit: 'contain',
fillWidth: 0.96,
fillHeight: 0.9,
anchorY: 'center',
padding: 16,
},
prompt: [
'请生成儿童动作互动游戏右下角五格状态条资产,不是方形面板。',
'主体是横向小型状态条,内部有五个柔和小胶囊或五个浅色分隔留白区域,但不要写任何文字或数字。',
'整体用于舞台右下角,轻薄、不厚重,不压住角色脚下区域。',
'米白、淡浅绿和浅蓝水彩高光为主,边缘可以有少量草叶和纸张纹理,风格必须和参考背景一致。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'start-panel',
output: 'picture-book-start-panel-v2.png',
sourceOutput: 'picture-book-start-panel-v2-source.png',
size: '1024x512',
transparent: true,
transparencyCleanup: 'soft-panel',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1280,
canvasHeight: 520,
fit: 'contain',
fillWidth: 0.88,
fillHeight: 0.88,
anchorY: 'center',
padding: 18,
},
prompt: [
'请生成儿童动作互动游戏开始按钮背后的轻盈托盘资产,不是完整弹窗。',
'主体是一个小型横向圆角软纸托盘,中心空白,适合只承载一个开始按钮。',
'边缘可以有少量草叶、浅蓝高光和淡绿色纸张纹理,整体要比 HUD 更小、更轻,不要做成大卡片。',
'不要文字、数字、图标或按钮文案。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'ui-button',
output: 'picture-book-ui-button-v2.png',
sourceOutput: 'picture-book-ui-button-v2-source.png',
size: '1024x512',
transparent: true,
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1300,
canvasHeight: 520,
fit: 'contain',
fillWidth: 0.86,
fillHeight: 0.76,
anchorY: 'center',
padding: 18,
},
prompt: [
'请生成一个儿童动作互动游戏主按钮背景资产。',
'主体是横向胶囊形按钮,无文字,绿色草地色为主,带浅蓝天空高光和柔和水彩纸张质感。',
'按钮中心保持干净,适合网页叠加“开始游戏”等文字。',
'整体要圆润、明亮、童趣、绘本感,不要科技感、金属感、真实照片质感。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
];
const args = new Map(); const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) { for (let index = 2; index < process.argv.length; index += 1) {
const raw = process.argv[index]; const raw = process.argv[index];
@@ -36,7 +280,14 @@ for (let index = 2; index < process.argv.length; index += 1) {
} }
const next = process.argv[index + 1]; const next = process.argv[index + 1];
if (next && !next.startsWith('--')) { if (next && !next.startsWith('--')) {
const existing = args.get(raw);
if (Array.isArray(existing)) {
existing.push(next);
} else if (existing) {
args.set(raw, [existing, next]);
} else {
args.set(raw, next); args.set(raw, next);
}
index += 1; index += 1;
} else { } else {
args.set(raw, true); args.set(raw, true);
@@ -138,6 +389,63 @@ function extractBase64Images(payload) {
return values; return values;
} }
function inferExtensionFromBytes(bytes, preferredPath) {
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 path.extname(preferredPath).replace(/^\./u, '') || 'png';
}
function toDataUrl(filePath) {
if (!existsSync(filePath)) {
return null;
}
const bytes = readFileSync(filePath);
const extension = inferExtensionFromBytes(bytes, filePath);
const mime = extension === 'jpg' ? 'image/jpeg' : `image/${extension}`;
return `data:${mime};base64,${bytes.toString('base64')}`;
}
function pushReferenceImage(body, filePath) {
const reference = toDataUrl(filePath);
if (!reference) {
return false;
}
body.image = [...(body.image || []), reference];
return true;
}
function buildRequestBody(asset, size) {
const body = {
model: 'gpt-image-2-all',
prompt: asset.prompt,
n: 1,
size: size || asset.size,
};
if (asset.useBackgroundReference) {
pushReferenceImage(
body,
path.join(assetDir, 'picture-book-grass-stage.png'),
);
}
if (asset.useLayoutReference) {
pushReferenceImage(
body,
path.join(intermediateDir, layoutReferenceOutput),
);
}
return body;
}
async function fetchWithTimeout(url, options, timeoutMs) { async function fetchWithTimeout(url, options, timeoutMs) {
const abortController = new AbortController(); const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs); const timer = setTimeout(() => abortController.abort(), timeoutMs);
@@ -180,43 +488,390 @@ async function downloadImage(url, timeoutMs) {
} }
} }
const size = String(args.get('--size') || defaultSize); function outputPathFor(asset) {
const outPath = path.resolve(String(args.get('--out') || defaultOut)); if (asset.outputDirectory === 'intermediate') {
const requestBody = { return path.join(intermediateDir, asset.output);
model: 'gpt-image-2-all', }
prompt, return path.join(assetDir, asset.output);
n: 1, }
size,
};
if (args.has('--dry-run') || !args.has('--live')) { function sourceOutputPathFor(asset) {
console.log( return path.join(intermediateDir, asset.sourceOutput || asset.output);
JSON.stringify( }
function opaqueSourceOutputPathFor(asset) {
return path.join(
intermediateDir,
`${path.basename(asset.sourceOutput || asset.output, path.extname(asset.sourceOutput || asset.output))}-rgb.png`,
);
}
function normalizeOutputPath(preferredPath, imageBytes) {
const actualExtension = inferExtensionFromBytes(imageBytes, preferredPath);
const outputPath =
path.extname(preferredPath).toLowerCase() === `.${actualExtension}`
? preferredPath
: path.join(
path.dirname(preferredPath),
`${path.basename(preferredPath, path.extname(preferredPath))}.${actualExtension}`,
);
return { actualExtension, outputPath };
}
function resolveCodexHome() {
if (process.env.CODEX_HOME) {
return process.env.CODEX_HOME;
}
if (process.env.USERPROFILE) {
return path.join(process.env.USERPROFILE, '.codex');
}
if (process.env.HOME) {
return path.join(process.env.HOME, '.codex');
}
return null;
}
function findChromaKeyHelper() {
const codexHome = resolveCodexHome();
if (!codexHome) {
return null;
}
const helper = path.join(
codexHome,
'skills',
'.system',
'imagegen',
'scripts',
'remove_chroma_key.py',
);
return existsSync(helper) ? helper : null;
}
function removeChromaKey(sourcePath, finalPath) {
const helper = findChromaKeyHelper();
if (!helper) {
throw new Error(
'Missing Codex imagegen remove_chroma_key.py helper for transparent assets',
);
}
const result = spawnSync(
'python',
[
helper,
'--input',
sourcePath,
'--out',
finalPath,
'--key-color',
chromaKeyColor,
'--auto-key',
'border',
'--soft-matte',
'--transparent-threshold',
'12',
'--opaque-threshold',
'220',
'--despill',
'--force',
],
{ {
mode: 'dry-run', cwd: repoRoot,
outPath, encoding: 'utf8',
body: requestBody,
}, },
null,
2,
),
); );
process.exit(0);
if (result.status !== 0) {
throw new Error(
`remove_chroma_key.py failed: ${(result.stderr || result.stdout).trim()}`,
);
}
} }
const env = resolveEnv(); function removeUiPanelChromaKey(sourcePath, finalPath) {
if (!env.baseUrl || !env.apiKey) { const script = [
console.error( 'from PIL import Image, ImageFilter',
JSON.stringify({ 'import sys',
ok: false, 'source, out = sys.argv[1], sys.argv[2]',
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', 'im = Image.open(source).convert("RGBA")',
hasBaseUrl: Boolean(env.baseUrl), 'px = im.load()',
hasApiKey: Boolean(env.apiKey), 'w, h = im.size',
}), 'corner = im.getpixel((0, 0))',
'key = corner[:3]',
'for y in range(h):',
' for x in range(w):',
' r, g, b, _ = px[x, y]',
' brightness = (r + g + b) / 3',
' dist = ((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) ** 0.5',
' magenta_bias = r + b - 1.85 * g',
' if brightness < 42 or dist < 155 or (r > 185 and b > 150 and g < 190 and magenta_bias > 235):',
' alpha = 0',
' elif dist < 225:',
' alpha = int(max(0, min(255, (dist - 155) / 70 * 255)))',
' else:',
' alpha = 255',
' if alpha > 0 and r > g + 28 and b > g + 20:',
' r = min(r, g + 18)',
' b = min(b, g + 14)',
' px[x, y] = (r, g, b, alpha)',
'alpha = im.getchannel("A").filter(ImageFilter.GaussianBlur(0.45))',
'im.putalpha(alpha)',
'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 UI panel transparency: ${(result.stderr || result.stdout).trim()}`,
); );
process.exit(1); }
} }
function removeCharacterOutlineChromaKey(sourcePath, finalPath) {
const script = [
'from PIL import Image, ImageFilter',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'im = Image.open(source).convert("RGBA")',
'px = im.load()',
'w, h = im.size',
'for y in range(h):',
' for x in range(w):',
' r, g, b, _ = px[x, y]',
' magenta_strength = min(r, b) - g',
' magenta_bg = r > 180 and b > 170 and g < 145 and magenta_strength > 70',
' hot_bg = r > 225 and b > 205 and g < 190 and magenta_strength > 55',
' if magenta_bg or hot_bg:',
' alpha = 0',
' else:',
' alpha = 255',
' if alpha > 0 and r > g + 35 and b > g + 22:',
' r = min(r, g + 24)',
' b = min(b, g + 20)',
' px[x, y] = (r, g, b, alpha)',
'alpha = im.getchannel("A").filter(ImageFilter.GaussianBlur(0.35))',
'im.putalpha(alpha)',
'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 character outline transparency: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function normalizeTransparentAsset(finalPath, layoutNormalization) {
if (!layoutNormalization) {
return;
}
const script = [
'from PIL import Image',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'canvas_w = int(sys.argv[3])',
'canvas_h = int(sys.argv[4])',
'fit = sys.argv[5]',
'fill_w = float(sys.argv[6])',
'fill_h = float(sys.argv[7])',
'anchor_y = sys.argv[8]',
'padding = int(sys.argv[9])',
'im = Image.open(source).convert("RGBA")',
'alpha = im.getchannel("A").point(lambda a: 255 if a > 8 else 0)',
'bbox = alpha.getbbox()',
'if bbox is None:',
' im.save(out)',
' raise SystemExit(0)',
'left, top, right, bottom = bbox',
'left = max(0, left - padding)',
'top = max(0, top - padding)',
'right = min(im.width, right + padding)',
'bottom = min(im.height, bottom + padding)',
'subject = im.crop((left, top, right, bottom))',
'target_w = max(1, int(canvas_w * fill_w))',
'target_h = max(1, int(canvas_h * fill_h))',
'scale_w = target_w / subject.width',
'scale_h = target_h / subject.height',
'scale = max(scale_w, scale_h) if fit == "cover-width" else min(scale_w, scale_h)',
'new_w = max(1, int(subject.width * scale))',
'new_h = max(1, int(subject.height * scale))',
'subject = subject.resize((new_w, new_h), Image.Resampling.LANCZOS)',
'if new_w > canvas_w:',
' crop_left = max(0, (new_w - canvas_w) // 2)',
' subject = subject.crop((crop_left, 0, crop_left + canvas_w, new_h))',
' new_w = canvas_w',
'if new_h > canvas_h:',
' if anchor_y == "bottom":',
' crop_top = new_h - canvas_h',
' elif anchor_y == "top":',
' crop_top = 0',
' else:',
' crop_top = max(0, (new_h - canvas_h) // 2)',
' subject = subject.crop((0, crop_top, new_w, crop_top + canvas_h))',
' new_h = canvas_h',
'canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))',
'x = (canvas_w - new_w) // 2',
'if anchor_y == "bottom":',
' y = canvas_h - new_h',
'elif anchor_y == "top":',
' y = 0',
'else:',
' y = (canvas_h - new_h) // 2',
'canvas.alpha_composite(subject, (x, y))',
'canvas.save(out)',
].join('\n');
const result = spawnSync(
'python',
[
'-c',
script,
finalPath,
finalPath,
String(layoutNormalization.canvasWidth),
String(layoutNormalization.canvasHeight),
layoutNormalization.fit || 'contain',
String(layoutNormalization.fillWidth || 0.92),
String(layoutNormalization.fillHeight || 0.92),
layoutNormalization.anchorY || 'center',
String(layoutNormalization.padding || 0),
],
{
cwd: repoRoot,
encoding: 'utf8',
},
);
if (result.status !== 0) {
throw new Error(
`Failed to normalize transparent asset canvas: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function scrubChromaFringe(finalPath) {
const script = [
'from PIL import Image',
'import sys',
'path = sys.argv[1]',
'im = Image.open(path).convert("RGBA")',
'px = im.load()',
'w, h = im.size',
'for y in range(h):',
' for x in range(w):',
' r, g, b, a = px[x, y]',
' if a == 0:',
' continue',
' magenta_bias = min(r, b) - g',
' is_magenta_edge = r > 135 and b > 135 and magenta_bias > 24 and abs(r - b) < 92',
' if is_magenta_edge and a < 90:',
' px[x, y] = (r, g, b, 0)',
' continue',
' if is_magenta_edge:',
' neutral = max(g, min(248, int((r + b + g) / 3)))',
' r = min(r, neutral + 18)',
' b = min(b, neutral + 16)',
' g = max(g, min(neutral, 230))',
' px[x, y] = (r, g, b, a)',
'im.save(path)',
].join('\n');
const result = spawnSync('python', ['-c', script, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to scrub chroma fringe: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function writeOpaquePng(sourcePath, outputPath) {
const result = spawnSync(
'python',
[
'-c',
[
'from PIL import Image',
'import sys',
'Image.open(sys.argv[1]).convert("RGB").save(sys.argv[2])',
].join('; '),
sourcePath,
outputPath,
],
{
cwd: repoRoot,
encoding: 'utf8',
},
);
if (result.status !== 0) {
throw new Error(
`Failed to normalize transparent source before chroma key removal: ${(result.stderr || result.stdout).trim()}`,
);
}
}
async function generateAsset(asset, env, size, force) {
const finalPath = outputPathFor(asset);
if (!force && existsSync(finalPath)) {
return {
id: asset.id,
ok: true,
skipped: true,
file: finalPath,
};
}
if (args.has('--postprocess-only')) {
if (!asset.transparent) {
return {
id: asset.id,
ok: true,
skipped: true,
file: finalPath,
};
}
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
mkdirSync(intermediateDir, { recursive: true });
const opaqueSourcePath = opaqueSourceOutputPathFor(asset);
writeOpaquePng(sourcePath, opaqueSourcePath);
if (asset.transparencyCleanup === 'soft-panel') {
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
} else if (asset.transparencyCleanup === 'character-outline') {
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
} else {
removeChromaKey(opaqueSourcePath, finalPath);
}
normalizeTransparentAsset(finalPath, asset.layoutNormalization);
scrubChromaFringe(finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
const requestBody = buildRequestBody(asset, size);
const payloadText = await fetchWithTimeout( const payloadText = await fetchWithTimeout(
buildVectorEngineImagesGenerationUrl(env.baseUrl), buildVectorEngineImagesGenerationUrl(env.baseUrl),
{ {
@@ -241,19 +896,137 @@ const imageBytes = urls[0]
: null; : null;
if (!imageBytes) { if (!imageBytes) {
throw new Error('VectorEngine returned no image'); throw new Error(`VectorEngine returned no image for ${asset.id}`);
} }
mkdirSync(path.dirname(outPath), { recursive: true }); mkdirSync(assetDir, { recursive: true });
writeFileSync(outPath, imageBytes); mkdirSync(intermediateDir, { recursive: true });
const preferredPath = asset.transparent
? sourceOutputPathFor(asset)
: finalPath;
const { actualExtension, outputPath } = normalizeOutputPath(
preferredPath,
imageBytes,
);
writeFileSync(outputPath, imageBytes);
if (asset.transparent) {
const opaqueSourcePath = opaqueSourceOutputPathFor(asset);
writeOpaquePng(outputPath, opaqueSourcePath);
if (asset.transparencyCleanup === 'soft-panel') {
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
} else if (asset.transparencyCleanup === 'character-outline') {
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
} else {
removeChromaKey(opaqueSourcePath, finalPath);
}
normalizeTransparentAsset(finalPath, asset.layoutNormalization);
scrubChromaFringe(finalPath);
}
return {
id: asset.id,
ok: true,
file: asset.transparent ? finalPath : outputPath,
sourceFile: asset.transparent ? outputPath : undefined,
size: requestBody.size,
extension: actualExtension,
source: urls[0] ? 'url' : 'b64_json',
usedReferenceImage: Boolean(requestBody.image),
};
}
function normalizeSelection(value) {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function selectAssets() {
const selectedIds = new Set([
...normalizeSelection(args.get('--asset')),
...normalizeSelection(args.get('--only')),
]);
if (selectedIds.size === 0) {
return assetDefinitions;
}
return assetDefinitions.filter((asset) => selectedIds.has(asset.id));
}
function dryRun(selectedAssets, size) {
console.log( console.log(
JSON.stringify( JSON.stringify(
{ {
ok: true, mode: 'dry-run',
file: outPath, assets: selectedAssets.map((asset) => {
size, const body = buildRequestBody(asset, size);
source: urls[0] ? 'url' : 'b64_json', return {
id: asset.id,
outputPath: outputPathFor(asset),
sourceOutputPath: asset.transparent
? sourceOutputPathFor(asset)
: undefined,
transparent: asset.transparent,
body: {
...body,
image: body.image ? ['<local style reference image>'] : undefined,
},
};
}),
},
null,
2,
),
);
}
const selectedAssets = selectAssets();
const unknownAssetRequested =
selectedAssets.length === 0 &&
(args.has('--asset') || args.has('--only'));
if (unknownAssetRequested) {
console.error(
JSON.stringify({
ok: false,
error: 'No matching child motion demo asset id',
availableIds: assetDefinitions.map((asset) => asset.id),
}),
);
process.exit(1);
}
const size = args.has('--size') ? String(args.get('--size')) : undefined;
if (args.has('--dry-run') || !args.has('--live')) {
dryRun(selectedAssets, size);
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 force = Boolean(args.get('--force'));
const results = [];
for (const asset of selectedAssets) {
results.push(await generateAsset(asset, env, size, force));
}
console.log(
JSON.stringify(
{
ok: true,
results,
}, },
null, null,
2, 2,

205
scripts/loadtest/README.md Normal file
View File

@@ -0,0 +1,205 @@
# Genarrative 作品列表 K6 压测
本目录用于对“作品列表/公开广场”读接口做本地压测。数据源来自私有 SpacetimeDB migration但提取脚本只输出作品 profile 白名单表并对用户、作者、作品号、asset id 等标识做稳定映射。
## 文件
- `extract-works-list-data.mjs`:从 migration JSON 提取作品列表压测数据;本地输出也会脱敏路由 ID因此默认用于列表接口压测详情接口需先把同一份脱敏数据导入目标环境。
- `k6-works-list.js`K6 压测脚本。
- `data/spacetime-migration-7.local.json`:本地私有原始数据副本,已被 `.gitignore` 忽略,不要提交。
- `data/works-list.local.json`:本地脱敏压测数据,已被 `.gitignore` 忽略,不要提交。
- `data/works-list.sample.json`:可提交的少量脱敏样例。
## 数据边界
允许导入的表:
- `puzzle_work_profile`
- `custom_world_profile`
- `match3d_work_profile`
- `square_hole_work_profile`
- `big_fish_work_profile`
- `visual_novel_work_profile`
明确不导入:
- 账号/认证:`user_account``auth_identity``refresh_session``auth_store_snapshot`
- 钱包/邀请:`profile_wallet_ledger``profile_redeem_*``profile_invite_*`
- 游玩历史/埋点/存档:`public_work_play_daily_stat``profile_played_world``puzzle_runtime_run``profile_save_archive``runtime_snapshot`
- AI 任务过程:`ai_task``ai_task_stage``ai_text_chunk`
- asset 二进制:`asset_object``asset_entity_binding`
提取脚本会移除 `source_session_id` / `source_agent_session_id` 等会话派生字段;这些字段不属于作品列表卡片压测必要字段。
## 重新提取数据
从仓库根目录执行:
```bash
npm run loadtest:extract-works -- \
--input scripts/loadtest/data/spacetime-migration-7.local.json \
--output scripts/loadtest/data/works-list.local.json \
--sample-output scripts/loadtest/data/works-list.sample.json
```
也可以直接执行:
```bash
node scripts/loadtest/extract-works-list-data.mjs \
--input scripts/loadtest/data/spacetime-migration-7.local.json \
--output scripts/loadtest/data/works-list.local.json \
--sample-output scripts/loadtest/data/works-list.sample.json
```
当前 local 全量提取结果:
- `puzzle_work_profile`: 80
- `custom_world_profile`: 1
- `match3d_work_profile`: 0
- `normalizedWorks`: 81
当前可提交 sample 结果:
- `puzzle_work_profile`: 3
- `custom_world_profile`: 1
- `match3d_work_profile`: 0
- `normalizedWorks`: 4
## 真实接口
已从 `server-rs/crates/api-server/src/app.rs` 确认的读接口:
公开接口,无需 Bearer token
- `GET /api/runtime/puzzle/gallery`
- `GET /api/runtime/puzzle/gallery/{profile_id}`
- `GET /api/runtime/custom-world-gallery`
- `GET /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}`
- `GET /api/runtime/custom-world-gallery/by-code/{code}`
需要 Bearer token 的个人作品列表接口:
- `GET /api/runtime/puzzle/works`
- `GET /api/runtime/puzzle/works/{profile_id}`
- `GET /api/runtime/custom-world/works`
K6 脚本默认只跑公开列表接口;传入 `AUTH_TOKEN` 后会额外跑需要登录态的个人作品列表接口。当前真实列表 handler 未暴露分页/排序 query 参数,因此脚本不追加 `limit/offset`;若后续接口增加分页参数,再在 K6 中补随机分页。
详情接口默认不压测,因为本地数据中的 `profile_id` / `owner_user_id` 已脱敏,直接请求未导入脱敏数据的目标服务会 404。只有在目标环境已导入同一份脱敏数据或改用真实 ID 本地文件时,才设置 `DETAIL_RATIO` 大于 0详情请求不把 404 视为成功。
## 启动服务
按项目约定启动本地 dev 栈:
```bash
npm run dev
```
注意端口可能漂移。以启动日志中的实际 api-server 端口为准,然后传给 K6。
注意K6 的 `open()` 会按 `k6-works-list.js` 所在目录解析相对路径,因此 `WORKS_DATA` 应写成 `data/works-list.local.json`,不要写成 `scripts/loadtest/data/works-list.local.json`
Bash / Git Bash
```bash
BASE_URL=http://127.0.0.1:<actual-api-port> WORKS_DATA=data/works-list.local.json npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
```
PowerShell
```powershell
$env:BASE_URL="http://127.0.0.1:<actual-api-port>"
$env:WORKS_DATA="data/works-list.local.json"
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
```
## Smoke
```bash
BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=smoke \
DETAIL_RATIO=0 \
npm run loadtest:k6:works
```
默认1 VU / 30s。
## Baseline
```bash
BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=baseline \
VUS=10 \
DURATION=3m \
DETAIL_RATIO=0 \
npm run loadtest:k6:works
```
默认阈值:
- `http_req_failed < 1%`
- `http_req_duration p95 < 800ms`
- `http_req_duration p99 < 1500ms`
- `works_list_shape_error_rate < 1%`
## Spike
```bash
BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=spike \
START_RPS=5 \
PEAK_RPS=100 \
HOLD=2m \
DETAIL_RATIO=0 \
npm run loadtest:k6:works
```
默认阈值:
- `http_req_failed < 5%`
- `http_req_duration p95 < 2000ms`
- `works_list_shape_error_rate < 5%`
## 带登录态压测个人作品列表
先通过本地登录或接口获取 access token然后传入
```bash
BASE_URL=http://127.0.0.1:8787 \
AUTH_TOKEN='<access-token>' \
SCENARIO=smoke \
DETAIL_RATIO=0 \
npm run loadtest:k6:works
```
不要把 token 写入仓库文件、README 或 shell history 中可共享的位置。
## 详情接口压测
仅当目标环境存在 `WORKS_DATA` 中的同一批 `profileId/ownerUserId` 时启用:
```bash
BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=smoke \
DETAIL_RATIO=0.35 \
npm run loadtest:k6:works
```
如果详情请求返回 404说明压测数据 ID 未导入目标环境或目标服务数据不一致,应先修正数据源,不要把 404 当成功。
## 排障
- 如果公开 gallery 返回 `creation_entry_disabled` 或 503检查本地 creation entry 配置是否禁用了对应入口。
- 如果个人作品列表返回 401确认 `AUTH_TOKEN` 是当前 api-server 可识别的 access token。
- 如果详情全部 404确认是否已向目标环境导入与 `WORKS_DATA` 一致的数据。
## 验证命令
```bash
npx vitest run scripts/loadtest/extract-works-list-data.test.ts
npx eslint scripts/loadtest/extract-works-list-data.mjs scripts/loadtest/extract-works-list-data.test.ts scripts/loadtest/k6-works-list.js
```

View File

@@ -0,0 +1,214 @@
{
"source": "spacetime-migration-7.local.json",
"generatedAt": "2026-05-11T13:09:51.569Z",
"counts": {
"puzzle_work_profile": 3,
"custom_world_profile": 1,
"match3d_work_profile": 0
},
"tables": {
"puzzle_work_profile": [
{
"profile_id": "profile-001",
"work_id": "work-001",
"owner_user_id": "user-001",
"author_display_name": "author-001",
"cover_asset_id": "asset-001",
"cover_image_src": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
"work_title": "化学家",
"level_name": "文学家",
"summary": "几个文学家正站在山上面对着瀑布侃侃而谈",
"work_description": "一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室",
"levels_json": "[{\"level_id\":\"puzzle-level-1777649242577-7\",\"level_name\":\"文学家\",\"picture_description\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"candidates\":[{\"candidate_id\":\"puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2\",\"image_src\":\"/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png\",\"asset_id\":\"asset-1777649330373133\",\"prompt\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"actual_prompt\":\"请生成一张高清插画。画面主体:几个文学家正站在山上面对着瀑布侃侃而谈。画面…",
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"化学家\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"化学家、拼图、插画;禁止标题字\",\"status\":\"I…",
"theme_tags_json": "[\"化学家\",\"拼图\",\"插画\",\"禁止标题字\"]",
"publication_status": {
"Published": []
},
"play_count": 1,
"like_count": 0,
"remix_count": 1,
"updated_at": {
"__timestamp_micros_since_unix_epoch__": 1777703338322544
},
"created_at": {
"__timestamp_micros_since_unix_epoch__": 1777648804043558
},
"published_at": {
"__timestamp_micros_since_unix_epoch__": 1777649364112270
}
},
{
"profile_id": "profile-002",
"work_id": "work-002",
"owner_user_id": "user-002",
"author_display_name": "author-002",
"work_title": "我不知道",
"level_name": "",
"summary": "你猜我是谁",
"work_description": "你猜我是谁",
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"真不知道\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"我不知道\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"真不知道\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"我不知道、拼图、插画;禁止标题字\",\"status\":\"Inferred\"}}",
"theme_tags_json": "[\"我不知道\"]",
"publication_status": {
"Draft": []
},
"play_count": 0,
"like_count": 0,
"remix_count": 0,
"updated_at": {
"__timestamp_micros_since_unix_epoch__": 1777619351714201
},
"created_at": {
"__timestamp_micros_since_unix_epoch__": 1777619336673245
}
},
{
"profile_id": "profile-003",
"work_id": "work-003",
"owner_user_id": "user-003",
"author_display_name": "author-002",
"work_title": "",
"level_name": "",
"summary": "",
"work_description": "",
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"\",\"status\":\"Missing\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"\",\"status\":\"Missing\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"\",\"status\":\"Missing\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"\",\"status\":\"Missing\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"\",\"status\":\"Missing\"}}",
"theme_tags_json": "[\"拼图\",\"插画\",\"清晰构图\"]",
"publication_status": {
"Draft": []
},
"play_count": 0,
"like_count": 0,
"remix_count": 0,
"updated_at": {
"__timestamp_micros_since_unix_epoch__": 1777622285252380
},
"created_at": {
"__timestamp_micros_since_unix_epoch__": 1777622285252380
}
}
],
"custom_world_profile": [
{
"profile_id": "profile-081",
"owner_user_id": "user-002",
"author_display_name": "author-012",
"author_public_user_code": "author-code-001",
"world_name": "青春飞扬校园",
"summary_text": "在现代校园中,玩家摆脱内卷,追求真实成长",
"subtitle": "反内卷的自由学习之旅",
"profile_payload_json": "{\"anchorContent\":null,\"anchorPack\":null,\"attributeSchema\":{\"generatedFrom\":{\"conflictCore\":\"与传统教育模式的冲突\",\"settingSummary\":\"在现代校园中,玩家摆脱内卷,追求真实成长\",\"tone\":\"积极向上,充满活力与创新\",\"worldName\":\"青春飞扬校园\",\"worldType\":\"CUSTOM\"},\"id\":\"schema:rpg-agent:1e15b44d:v1\",\"schemaVersion\":1,\"slots\":[{\"name\":\"知识储备\",\"slotId\":\"axis_a\"},{\"name\":\"创新思维\",\"slotId\":\"axis_b\"},{\"name\":\"社交能力\",\"slotId\":\"axis_c\"},{\"name\":\"抗压能力\",\"slotId\":\"axis_d\"},{\"name\":\"自我认知\",\"slotId\":\"axis_e\"},{\"name\":\"团队协作\",\"slotId\":\"axis_f\"}],\"worldId\":\"custom:青春飞扬校…",
"publication_status": {
"Draft": []
},
"play_count": 0,
"like_count": 0,
"remix_count": 0,
"updated_at": {
"__timestamp_micros_since_unix_epoch__": 1777532006629209
},
"created_at": {
"__timestamp_micros_since_unix_epoch__": 1777531745887256
}
}
],
"match3d_work_profile": []
},
"profileIds": {
"puzzle": [
"profile-001",
"profile-002",
"profile-003"
],
"customWorld": [
"profile-081"
],
"match3d": [],
"squareHole": [],
"bigFish": [],
"visualNovel": []
},
"workIds": {
"puzzle": [
"work-001",
"work-002",
"work-003"
],
"customWorld": [],
"match3d": [],
"squareHole": [],
"bigFish": [],
"visualNovel": []
},
"normalizedWorks": [
{
"type": "puzzle",
"workId": "work-001",
"profileId": "profile-001",
"ownerUserId": "user-001",
"title": "化学家",
"subtitle": "几个文学家正站在山上面对着瀑布侃侃而谈",
"publicationStatus": {
"Published": []
},
"playCount": 1,
"likeCount": 0,
"remixCount": 1,
"coverImageSrc": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
"updatedAt": {
"__timestamp_micros_since_unix_epoch__": 1777703338322544
}
},
{
"type": "puzzle",
"workId": "work-002",
"profileId": "profile-002",
"ownerUserId": "user-002",
"title": "我不知道",
"subtitle": "你猜我是谁",
"publicationStatus": {
"Draft": []
},
"playCount": 0,
"likeCount": 0,
"remixCount": 0,
"updatedAt": {
"__timestamp_micros_since_unix_epoch__": 1777619351714201
}
},
{
"type": "puzzle",
"workId": "work-003",
"profileId": "profile-003",
"ownerUserId": "user-003",
"title": "",
"subtitle": "",
"publicationStatus": {
"Draft": []
},
"playCount": 0,
"likeCount": 0,
"remixCount": 0,
"updatedAt": {
"__timestamp_micros_since_unix_epoch__": 1777622285252380
}
},
{
"type": "customWorld",
"profileId": "profile-081",
"ownerUserId": "user-002",
"title": "青春飞扬校园",
"subtitle": "反内卷的自由学习之旅",
"publicationStatus": {
"Draft": []
},
"playCount": 0,
"likeCount": 0,
"remixCount": 0,
"updatedAt": {
"__timestamp_micros_since_unix_epoch__": 1777532006629209
}
}
]
}

View File

@@ -0,0 +1,370 @@
#!/usr/bin/env node
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { fileURLToPath } from 'node:url';
const ALLOWED_TABLES = new Set([
'puzzle_work_profile',
'custom_world_profile',
'match3d_work_profile',
'square_hole_work_profile',
'big_fish_work_profile',
'visual_novel_work_profile',
]);
const WORK_TABLE_TYPES = {
puzzle_work_profile: 'puzzle',
custom_world_profile: 'customWorld',
match3d_work_profile: 'match3d',
square_hole_work_profile: 'squareHole',
big_fish_work_profile: 'bigFish',
visual_novel_work_profile: 'visualNovel',
};
const TABLE_OUTPUT_ORDER = [
'puzzle_work_profile',
'custom_world_profile',
'match3d_work_profile',
'square_hole_work_profile',
'big_fish_work_profile',
'visual_novel_work_profile',
];
const WORK_TYPES = ['puzzle', 'customWorld', 'match3d', 'squareHole', 'bigFish', 'visualNovel'];
const SHORT_TEXT_LIMIT = 120;
const LONG_TEXT_LIMIT = 500;
const SENSITIVE_PATTERN = /(token|secret|password|passwd|phone|wallet|credential|authorization|auth[_-]?key|api[_-]?key)/giu;
class StableMapper {
constructor(prefix) {
this.prefix = prefix;
this.values = new Map();
}
map(value) {
if (value === undefined || value === null || value === '') return value;
const key = String(value);
if (!this.values.has(key)) {
this.values.set(
key,
`${this.prefix}-${String(this.values.size + 1).padStart(3, '0')}`,
);
}
return this.values.get(key);
}
}
function createContext() {
return {
user: new StableMapper('user'),
session: new StableMapper('session'),
author: new StableMapper('author'),
authorCode: new StableMapper('author-code'),
publicWorkCode: new StableMapper('public-work-code'),
coverAsset: new StableMapper('asset'),
work: new StableMapper('work'),
profile: new StableMapper('profile'),
};
}
function createWorkTypeBuckets() {
return Object.fromEntries(WORK_TYPES.map((type) => [type, []]));
}
function unwrapSpacetimeOption(value) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value).length === 1
) {
if (Object.prototype.hasOwnProperty.call(value, 'some')) return value.some;
if (Object.prototype.hasOwnProperty.call(value, 'none')) return undefined;
}
return value;
}
function truncateText(value, limit) {
if (value === undefined || value === null) return value;
const text = String(value).replace(/\s+/g, ' ').trim();
if (text.length <= limit) return text;
return `${text.slice(0, limit)}`;
}
function redactSensitiveText(value) {
if (value === undefined || value === null) return value;
return String(value).replace(SENSITIVE_PATTERN, '[redacted]');
}
function sanitizeCoverImageSrc(value) {
const unwrapped = unwrapSpacetimeOption(value);
if (unwrapped === undefined || unwrapped === null || unwrapped === '') return unwrapped;
const text = String(unwrapped);
if (text.startsWith('data:image/')) return '[redacted-data-image]';
let withoutQuery = text.split('?')[0].split('#')[0];
if (withoutQuery.length > 180) withoutQuery = `${withoutQuery.slice(0, 180)}`;
return withoutQuery;
}
function sanitizeLargeJson(value) {
const unwrapped = unwrapSpacetimeOption(value);
if (unwrapped === undefined || unwrapped === null) return unwrapped;
if (typeof unwrapped === 'string') {
return truncateText(redactSensitiveText(unwrapped), LONG_TEXT_LIMIT);
}
try {
return truncateText(redactSensitiveText(JSON.stringify(unwrapped)), LONG_TEXT_LIMIT);
} catch {
return truncateText(redactSensitiveText(String(unwrapped)), LONG_TEXT_LIMIT);
}
}
function firstDefined(row, keys) {
for (const key of keys) {
if (row[key] !== undefined && row[key] !== null) return row[key];
}
return undefined;
}
function sanitizeShortField(row, sanitized, key) {
if (row[key] !== undefined) {
sanitized[key] = truncateText(unwrapSpacetimeOption(row[key]), SHORT_TEXT_LIMIT);
}
}
function sanitizeWorkRow(row, ctx) {
const sanitized = {};
const profileId = unwrapSpacetimeOption(firstDefined(row, ['profile_id', 'profileId']));
const workId = unwrapSpacetimeOption(firstDefined(row, ['work_id', 'workId']));
if (profileId !== undefined) sanitized.profile_id = ctx.profile.map(profileId);
if (workId !== undefined) sanitized.work_id = ctx.work.map(workId);
if (row.owner_user_id !== undefined) {
sanitized.owner_user_id = ctx.user.map(unwrapSpacetimeOption(row.owner_user_id));
}
if (row.user_id !== undefined) sanitized.user_id = ctx.user.map(unwrapSpacetimeOption(row.user_id));
if (row.author_display_name !== undefined) {
sanitized.author_display_name = ctx.author.map(unwrapSpacetimeOption(row.author_display_name));
}
if (row.public_work_code !== undefined) {
sanitized.public_work_code = ctx.publicWorkCode.map(unwrapSpacetimeOption(row.public_work_code));
}
if (row.author_public_user_code !== undefined) {
sanitized.author_public_user_code = ctx.authorCode.map(
unwrapSpacetimeOption(row.author_public_user_code),
);
}
if (row.cover_asset_id !== undefined) {
sanitized.cover_asset_id = ctx.coverAsset.map(unwrapSpacetimeOption(row.cover_asset_id));
}
if (row.cover_image_src !== undefined) sanitized.cover_image_src = sanitizeCoverImageSrc(row.cover_image_src);
for (const key of [
'title',
'work_title',
'level_name',
'world_name',
'summary',
'summary_text',
'description',
'work_description',
'subtitle',
]) {
sanitizeShortField(row, sanitized, key);
}
for (const key of ['levels_json', 'profile_payload_json', 'anchor_pack_json', 'theme_tags_json']) {
if (row[key] !== undefined) sanitized[key] = sanitizeLargeJson(row[key]);
}
const passthroughKeys = [
'publication_status',
'publicationStatus',
'play_count',
'playCount',
'like_count',
'likeCount',
'remix_count',
'remixCount',
'updated_at',
'created_at',
'published_at',
'visibility',
'status',
'category',
'tags',
];
for (const key of passthroughKeys) {
if (row[key] !== undefined) sanitized[key] = unwrapSpacetimeOption(row[key]);
}
return sanitized;
}
function normalizeWork(tableName, row) {
const type = WORK_TABLE_TYPES[tableName];
return {
type,
workId: row.work_id,
profileId: row.profile_id,
ownerUserId: row.owner_user_id,
publicWorkCode: row.public_work_code,
title: row.title ?? row.work_title ?? row.level_name ?? row.world_name,
subtitle: row.subtitle ?? row.summary_text ?? row.summary ?? row.work_description ?? row.description,
publicationStatus: row.publicationStatus ?? row.publication_status ?? row.status,
playCount: row.playCount ?? row.play_count ?? 0,
likeCount: row.likeCount ?? row.like_count ?? 0,
remixCount: row.remixCount ?? row.remix_count ?? 0,
coverImageSrc: row.cover_image_src,
updatedAt: row.updated_at,
};
}
function toRowsByTable(input) {
const tables = Array.isArray(input?.tables) ? input.tables : [];
const result = new Map();
for (const table of tables) {
if (!ALLOWED_TABLES.has(table?.name)) continue;
result.set(table.name, Array.isArray(table.rows) ? table.rows : []);
}
return result;
}
export function extractWorksListData(input, options = {}) {
const ctx = createContext();
const rowsByTable = toRowsByTable(input);
const outputTables = {};
const counts = {};
const profileIds = createWorkTypeBuckets();
const workIds = createWorkTypeBuckets();
const normalizedWorks = [];
for (const tableName of TABLE_OUTPUT_ORDER) {
const sourceRows = rowsByTable.get(tableName);
if (!sourceRows) continue;
const sanitizedRows = sourceRows.map((row) => sanitizeWorkRow(row, ctx));
outputTables[tableName] = sanitizedRows;
counts[tableName] = sanitizedRows.length;
const type = WORK_TABLE_TYPES[tableName];
if (type) {
for (const row of sanitizedRows) {
if (row.profile_id) profileIds[type].push(row.profile_id);
if (row.work_id) workIds[type].push(row.work_id);
normalizedWorks.push(normalizeWork(tableName, row));
}
}
}
return {
source: options.source ?? 'unknown',
generatedAt: options.generatedAt ?? new Date().toISOString(),
counts,
tables: outputTables,
profileIds,
workIds,
normalizedWorks,
};
}
function createSampleOutput(output, maxRowsPerTable = 3) {
const tables = {};
const counts = {};
const allowedWorkIds = new Set();
const allowedProfileIds = new Set();
for (const [tableName, rows] of Object.entries(output.tables)) {
tables[tableName] = rows.slice(0, maxRowsPerTable);
counts[tableName] = tables[tableName].length;
const type = WORK_TABLE_TYPES[tableName];
if (type) {
for (const row of tables[tableName]) {
if (row.work_id) allowedWorkIds.add(row.work_id);
if (row.profile_id) allowedProfileIds.add(row.profile_id);
}
}
}
const profileIds = Object.fromEntries(
Object.entries(output.profileIds).map(([type, ids]) => [
type,
ids.filter((id) => allowedProfileIds.has(id)).slice(0, maxRowsPerTable),
]),
);
const workIds = Object.fromEntries(
Object.entries(output.workIds).map(([type, ids]) => [
type,
ids.filter((id) => allowedWorkIds.has(id)).slice(0, maxRowsPerTable),
]),
);
const normalizedWorks = output.normalizedWorks
.filter((work) => allowedWorkIds.has(work.workId) || allowedProfileIds.has(work.profileId))
.slice(0, maxRowsPerTable * 6);
return {
...output,
counts,
tables,
profileIds,
workIds,
normalizedWorks,
};
}
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--input' || arg === '--output' || arg === '--sample-output') {
const value = argv[index + 1];
if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`);
args[arg.slice(2)] = value;
index += 1;
} else if (arg === '--help' || arg === '-h') {
args.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return args;
}
function usage() {
return 'Usage: node scripts/loadtest/extract-works-list-data.mjs --input <migration.json> --output <works-list.local.json> [--sample-output <works-list.sample.json>]';
}
export async function runCli(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
if (args.help) {
console.log(usage());
return;
}
if (!args.input) throw new Error('Missing required --input. ' + usage());
if (!args.output) throw new Error('Missing required --output. ' + usage());
const raw = await readFile(args.input, 'utf8');
const migration = JSON.parse(raw);
const output = extractWorksListData(migration, { source: basename(args.input) });
await writeFile(args.output, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
if (args['sample-output']) {
const sample = createSampleOutput(output);
await writeFile(args['sample-output'], `${JSON.stringify(sample, null, 2)}\n`, 'utf8');
}
console.log(
`works-list extracted: source=${output.source}, tables=${Object.keys(output.tables).length}, normalizedWorks=${output.normalizedWorks.length}`,
);
for (const [tableName, count] of Object.entries(output.counts)) {
console.log(` ${tableName}: ${count}`);
}
}
const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isDirectRun) {
runCli().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,247 @@
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { describe, expect, it } from 'vitest';
import { extractWorksListData } from './extract-works-list-data.mjs';
const execFileAsync = promisify(execFile);
const scriptPath = fileURLToPath(new URL('./extract-works-list-data.mjs', import.meta.url));
const fixtureMigration = {
schema_version: 7,
tables: [
{
name: 'puzzle_work_profile',
rows: [
{
profile_id: 'profile-real-aaa',
work_id: 'work-real-aaa',
owner_user_id: 'owner-secret-123',
author_display_name: 'Alice Secret',
author_public_user_code: 'author-code-secret',
public_work_code: 'public-code-secret',
title: '超长标题'.repeat(20),
summary: 'summary '.repeat(80),
description: 'description '.repeat(120),
publication_status: 'published',
play_count: 42,
like_count: 7,
cover_asset_id: { some: 'asset-secret-cover' },
cover_image_src: { some: 'https://cdn.example.test/cover.png?token=***&sig=abc' },
levels_json: JSON.stringify({ secret: 'level-token-value', data: 'x'.repeat(2000) }),
theme_tags_json: JSON.stringify(['化学家', '实验室']),
remix_count: 2,
updated_at: '2026-05-01T00:00:00Z',
},
{
profile_id: 'profile-real-bbb',
work_id: 'work-real-bbb',
owner_user_id: 'owner-secret-123',
author_display_name: 'Alice Secret',
publication_status: 'draft',
play_count: 3,
},
],
},
{
name: 'custom_world_profile',
rows: [
{
profile_id: 'world-profile-secret',
work_id: 'world-work-secret',
owner_user_id: 'world-owner-secret',
title: '世界作品',
profile_payload_json: '{"large":"' + 'y'.repeat(2000) + '"}',
},
],
},
{
name: 'public_work_play_daily_stat',
rows: [
{
source_type: 'puzzle',
profile_id: 'profile-real-aaa',
owner_user_id: 'owner-secret-123',
user_id: 'player-secret-456',
source_session_id: 'session-secret-789',
played_day: '2026-05-01',
play_count: 12,
updated_at: '2026-05-02T00:00:00Z',
},
],
},
{
name: 'user_account',
rows: [
{
user_id: 'owner-secret-123',
phone: '+8613800138000',
auth_token: 'auth-token-secret',
wallet_balance: 999,
},
],
},
{
name: 'refresh_session',
rows: [{ token: 'refresh-token-secret', source_session_id: 'session-secret-789' }],
},
{
name: 'profile_wallet_ledger',
rows: [{ wallet_id: 'wallet-secret', amount: 100 }],
},
],
};
async function withTempDir(fn) {
const dir = await mkdtemp(path.join(tmpdir(), 'works-list-test-'));
try {
return await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
describe('extractWorksListData', () => {
it('只保留作品 profile 白名单表,禁用的行为/敏感表不会出现在输出 JSON 字符串中', () => {
const output = extractWorksListData(fixtureMigration, { source: 'fixture.local.json' });
const serialized = JSON.stringify(output);
expect(Object.keys(output.tables).sort()).toEqual([
'custom_world_profile',
'puzzle_work_profile',
]);
expect(serialized).not.toContain('public_work_play_daily_stat');
expect(serialized).not.toContain('user_account');
expect(serialized).not.toContain('refresh_session');
expect(serialized).not.toContain('profile_wallet_ledger');
expect(serialized).not.toContain('+8613800138000');
expect(serialized).not.toContain('auth-token-secret');
expect(serialized).not.toContain('wallet-secret');
});
it('不会输出 owner/user/session/auth/token/phone/wallet 等敏感原值owner 稳定映射', () => {
const output = extractWorksListData(fixtureMigration, { source: 'fixture.local.json' });
const serialized = JSON.stringify(output);
for (const secret of [
'owner-secret-123',
'player-secret-456',
'session-secret-789',
'Alice Secret',
'author-code-secret',
'public-code-secret',
'asset-secret-cover',
'SECRET_TOKEN',
]) {
expect(serialized).not.toContain(secret);
}
expect(output.tables.puzzle_work_profile[0].owner_user_id).toBe('user-001');
expect(output.tables.puzzle_work_profile[1].owner_user_id).toBe('user-001');
expect(output.tables.puzzle_work_profile[0].author_display_name).toBe('author-001');
expect(serialized).not.toContain('level-token-value');
});
it('puzzle 数据生成 profileIds/workIds 和 normalizedWorks并保留列表展示字段', () => {
const output = extractWorksListData(fixtureMigration, { source: 'fixture.local.json' });
expect(output.source).toBe('fixture.local.json');
expect(output.generatedAt).toEqual(expect.any(String));
expect(output.counts.puzzle_work_profile).toBe(2);
expect(output.profileIds.puzzle).toEqual(['profile-001', 'profile-002']);
expect(output.workIds.puzzle).toEqual(['work-001', 'work-002']);
expect(output.normalizedWorks).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'puzzle',
workId: 'work-001',
profileId: 'profile-001',
publicationStatus: 'published',
playCount: 42,
title: expect.any(String),
remixCount: 2,
}),
]),
);
expect(output.tables.puzzle_work_profile[0].cover_image_src).toBe('https://cdn.example.test/cover.png');
expect(output.tables.puzzle_work_profile[0].theme_tags_json).toBe('["化学家","实验室"]');
});
it('data image、URL token 和绝对输入路径不会泄露到输出', async () => {
await withTempDir(async (dir) => {
const input = path.join(dir, 'migration.local.json');
const output = path.join(dir, 'works-list.local.json');
await writeFile(
input,
JSON.stringify({
tables: [
{
name: 'puzzle_work_profile',
rows: [
{
profile_id: 'profile-real',
work_id: 'work-real',
cover_image_src: { some: 'data:image/png;base64,SECRET_IMAGE_BYTES' },
levels_json: JSON.stringify({ token: 'SECRET_TOKEN_VALUE', title: 'safe' }),
},
],
},
],
}),
'utf8',
);
await execFileAsync(process.execPath, [scriptPath, '--input', input, '--output', output]);
const extracted = JSON.parse(await readFile(output, 'utf8'));
const serialized = JSON.stringify(extracted);
expect(extracted.source).toBe('migration.local.json');
expect(serialized).not.toContain(dir);
expect(serialized).not.toContain('SECRET_IMAGE_BYTES');
expect(serialized).not.toContain('SECRET_TOKEN_VALUE');
expect(extracted.tables.puzzle_work_profile[0].cover_image_src).toBe('[redacted-data-image]');
});
});
it('sample-output 只输出少量脱敏样例', async () => {
await withTempDir(async (dir) => {
const input = path.join(dir, 'migration.local.json');
const output = path.join(dir, 'works-list.local.json');
const sampleOutput = path.join(dir, 'works-list.sample.json');
const manyRows = Array.from({ length: 5 }, (_, index) => ({
profile_id: `profile-real-${index}`,
work_id: `work-real-${index}`,
owner_user_id: `owner-secret-${index}`,
title: `作品 ${index}`,
publication_status: 'published',
play_count: index,
}));
await writeFile(
input,
JSON.stringify({ tables: [{ name: 'puzzle_work_profile', rows: manyRows }] }),
'utf8',
);
await execFileAsync(process.execPath, [scriptPath, '--input', input, '--output', output, '--sample-output', sampleOutput]);
const sample = JSON.parse(await readFile(sampleOutput, 'utf8'));
const serialized = JSON.stringify(sample);
expect(sample.tables.puzzle_work_profile).toHaveLength(3);
expect(sample.normalizedWorks).toHaveLength(3);
expect(serialized).not.toContain('owner-secret-0');
expect(serialized).not.toContain('work-real-0');
});
});
it('CLI 参数缺失时退出非 0 并输出清晰错误', async () => {
await expect(execFileAsync(process.execPath, [scriptPath, '--input', 'missing.json'])).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining('--output'),
});
});
});

View File

@@ -0,0 +1,229 @@
/* global __ENV */
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
import http from 'k6/http';
import { Rate, Trend } from 'k6/metrics';
// k6 resolves open() paths relative to this script file, not the shell cwd.
const DEFAULT_WORKS_DATA = 'data/works-list.local.json';
const WORKS_DATA = __ENV.WORKS_DATA || DEFAULT_WORKS_DATA;
const BASE_URL = (__ENV.BASE_URL || 'http://127.0.0.1:8787').replace(/\/+$/u, '');
const AUTH_TOKEN = __ENV.AUTH_TOKEN || '';
const SCENARIO = __ENV.SCENARIO || 'smoke';
const REQUEST_TIMEOUT = __ENV.REQUEST_TIMEOUT || '30s';
const SLEEP_MIN_SECONDS = Number(__ENV.SLEEP_MIN_SECONDS || '0.5');
const SLEEP_MAX_SECONDS = Number(__ENV.SLEEP_MAX_SECONDS || '2');
const DETAIL_RATIO = Number(__ENV.DETAIL_RATIO || '0');
const worksListShapeErrorRate = new Rate('works_list_shape_error_rate');
const worksDetailShapeErrorRate = new Rate('works_detail_shape_error_rate');
const worksListDuration = new Trend('works_list_duration');
const worksDetailDuration = new Trend('works_detail_duration');
const data = new SharedArray('works-list-data', () => [JSON.parse(open(WORKS_DATA))])[0];
const normalizedWorks = Array.isArray(data.normalizedWorks) ? data.normalizedWorks : [];
const scenarioOptions = {
smoke: {
scenarios: {
smoke: {
executor: 'constant-vus',
vus: Number(__ENV.VUS || 1),
duration: __ENV.DURATION || '30s',
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<800'],
works_list_shape_error_rate: ['rate<0.01'],
},
},
baseline: {
scenarios: {
baseline: {
executor: 'constant-vus',
vus: Number(__ENV.VUS || 10),
duration: __ENV.DURATION || '3m',
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<800', 'p(99)<1500'],
works_list_shape_error_rate: ['rate<0.01'],
},
},
spike: {
scenarios: {
spike: {
executor: 'ramping-arrival-rate',
preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50),
maxVUs: Number(__ENV.MAX_VUS || 200),
timeUnit: '1s',
stages: [
{ target: Number(__ENV.START_RPS || 5), duration: __ENV.RAMP_UP || '30s' },
{ target: Number(__ENV.PEAK_RPS || 100), duration: __ENV.HOLD || '2m' },
{ target: Number(__ENV.END_RPS || 5), duration: __ENV.RAMP_DOWN || '30s' },
],
},
},
thresholds: {
http_req_failed: ['rate<0.05'],
http_req_duration: ['p(95)<2000'],
works_list_shape_error_rate: ['rate<0.05'],
},
},
};
export const options = scenarioOptions[SCENARIO] || scenarioOptions.smoke;
const PUBLIC_ENDPOINTS = [
{
name: 'puzzle_gallery_list',
method: 'GET',
path: '/api/runtime/puzzle/gallery',
expectCollectionKeys: ['items', 'works', 'entries'],
},
{
name: 'custom_world_gallery_list',
method: 'GET',
path: '/api/runtime/custom-world-gallery',
expectCollectionKeys: ['entries', 'items', 'works'],
},
];
const AUTH_ENDPOINTS = [
{
name: 'puzzle_works_list',
method: 'GET',
path: '/api/runtime/puzzle/works',
expectCollectionKeys: ['items', 'works'],
},
{
name: 'custom_world_works_list',
method: 'GET',
path: '/api/runtime/custom-world/works',
expectCollectionKeys: ['items', 'entries', 'works'],
},
];
function requestParams(endpointName) {
const headers = { 'x-genarrative-response-envelope': 'v1' };
if (AUTH_TOKEN) headers.Authorization = `Bearer ${AUTH_TOKEN}`;
return {
headers,
timeout: REQUEST_TIMEOUT,
tags: { endpoint: endpointName },
};
}
function buildUrl(path) {
return `${BASE_URL}${path}`;
}
function parseJson(response) {
try {
return response.json();
} catch (_) {
return null;
}
}
function unwrapPayload(json) {
if (!json || typeof json !== 'object') return null;
if (json.data && typeof json.data === 'object') return json.data;
return json;
}
function hasCollection(payload, keys) {
return keys.some((key) => Array.isArray(payload?.[key]));
}
function firstCollection(payload, keys) {
for (const key of keys) {
if (Array.isArray(payload?.[key])) return payload[key];
}
return [];
}
function hasListItemShape(payload, keys) {
const collection = firstCollection(payload, keys);
if (collection.length === 0) return true;
const item = collection[0];
const hasId = Boolean(
item?.profileId || item?.profile_id || item?.workId || item?.work_id || item?.publicWorkCode,
);
const hasTitle = Boolean(
item?.title || item?.workTitle || item?.work_title || item?.levelName || item?.worldName,
);
return hasId && hasTitle;
}
function randomItem(items) {
if (!items.length) return null;
return items[Math.floor(Math.random() * items.length)];
}
function listEndpoints() {
return AUTH_TOKEN ? PUBLIC_ENDPOINTS.concat(AUTH_ENDPOINTS) : PUBLIC_ENDPOINTS;
}
function detailEndpointFor(work) {
if (!work || !work.profileId) return null;
if (work.type === 'puzzle') {
return {
name: 'puzzle_gallery_detail',
path: `/api/runtime/puzzle/gallery/${encodeURIComponent(work.profileId)}`,
expectKeys: ['item', 'work', 'entry'],
};
}
if (work.type === 'customWorld' && work.profileId && work.ownerUserId) {
return {
name: 'custom_world_gallery_detail',
path: `/api/runtime/custom-world-gallery/${encodeURIComponent(work.ownerUserId)}/${encodeURIComponent(work.profileId)}`,
expectKeys: ['entry', 'item', 'work'],
};
}
return null;
}
function performListRequest(endpoint) {
const url = buildUrl(endpoint.path);
const response = http.request(endpoint.method, url, null, requestParams(endpoint.name));
worksListDuration.add(response.timings.duration, { endpoint: endpoint.name });
const json = parseJson(response);
const payload = unwrapPayload(json);
const ok = check(response, {
[`${endpoint.name} status is 200`]: (res) => res.status === 200,
[`${endpoint.name} returns json object`]: () => Boolean(payload),
[`${endpoint.name} has collection`]: () => hasCollection(payload, endpoint.expectCollectionKeys),
[`${endpoint.name} list item shape`]: () => hasListItemShape(payload, endpoint.expectCollectionKeys),
});
worksListShapeErrorRate.add(!ok, { endpoint: endpoint.name });
}
function performDetailRequest() {
const endpoint = detailEndpointFor(randomItem(normalizedWorks));
if (!endpoint) return;
const response = http.get(buildUrl(endpoint.path), requestParams(endpoint.name));
worksDetailDuration.add(response.timings.duration, { endpoint: endpoint.name });
const json = parseJson(response);
const payload = unwrapPayload(json);
const ok = check(response, {
[`${endpoint.name} status is 200`]: (res) => res.status === 200,
[`${endpoint.name} has detail payload`]: () => endpoint.expectKeys.some((key) => payload?.[key]),
});
worksDetailShapeErrorRate.add(!ok, { endpoint: endpoint.name });
}
export default function () {
for (const endpoint of listEndpoints()) {
performListRequest(endpoint);
}
if (normalizedWorks.length && DETAIL_RATIO > 0 && Math.random() < DETAIL_RATIO) {
performDetailRequest();
}
const jitter = SLEEP_MIN_SECONDS + Math.random() * Math.max(0, SLEEP_MAX_SECONDS - SLEEP_MIN_SECONDS);
sleep(jitter);
}

View File

@@ -1,4 +1,7 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
if (crypto.webcrypto) { if (crypto.webcrypto) {
if (typeof crypto.getRandomValues !== 'function') { if (typeof crypto.getRandomValues !== 'function') {
@@ -13,4 +16,7 @@ if (crypto.webcrypto) {
} }
} }
await import('../node_modules/vite/bin/vite.js'); const require = createRequire(import.meta.url);
const vitePackageJsonPath = require.resolve('vite/package.json');
const viteBinPath = join(dirname(vitePackageJsonPath), 'bin', 'vite.js');
await import(pathToFileURL(viteBinPath).href);

1
server-rs/Cargo.lock generated
View File

@@ -3009,7 +3009,6 @@ dependencies = [
name = "shared-contracts" name = "shared-contracts"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"platform-oss",
"serde", "serde",
"serde_json", "serde_json",
] ]

Some files were not shown because too many files have changed in this diff Show More