diff --git a/.env b/.env new file mode 100644 index 00000000..e1ed925f --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +# 微信小程序 web-view 登录配置。 +# 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。 +WECHAT_MINI_PROGRAM_APP_ID="" +WECHAT_MINI_PROGRAM_APP_SECRET="" +WECHAT_JS_CODE_SESSION_ENDPOINT="" diff --git a/.env.example b/.env.example index 74c656dc..482669b7 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,9 @@ WECHAT_REDIRECT_PATH="/" WECHAT_AUTHORIZE_ENDPOINT="https://open.weixin.qq.com/connect/qrconnect" 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_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_MOCK_USER_ID="wx-mock-user" WECHAT_MOCK_UNION_ID="wx-mock-union" diff --git a/.env.local b/.env.local index 7635ae86..34b87a66 100644 --- a/.env.local +++ b/.env.local @@ -56,8 +56,6 @@ LLM_DEBUG_LOG="true" ALIYUN_OSS_BUCKET="xushi-dev" ALIYUN_OSS_REGION="oss-cn-beijing" 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. RUST_SERVER_TARGET="http://127.0.0.1:8082" diff --git a/.gitignore b/.gitignore index 94bcb2eb..8abc3e04 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,11 @@ temp*build*/ /logs .worktrees/ .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 diff --git a/.hermes/plans/2026-05-11_144229-bark-battle-2d-game-bdd-ddd-tdd-plan.md b/.hermes/plans/2026-05-11_144229-bark-battle-2d-game-bdd-ddd-tdd-plan.md new file mode 100644 index 00000000..9bcdfd00 --- /dev/null +++ b/.hermes/plans/2026-05-11_144229-bark-battle-2d-game-bdd-ddd-tdd-plan.md @@ -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 只负责渲染、动画、相机、输入适配。 + +因此本方案采用: + +- Runtime:Phaser 3 +- Language:TypeScript +- Build:Vite +- UI:React/DOM HUD overlay 或项目现有 DOM UI 层 +- Audio input:Web Audio API + MediaDevices.getUserMedia +- Simulation:纯 TS domain/service 层 +- Renderer:Phaser 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 3:Phaser 2D runtime + +- 新建 Phaser Scene。 +- 绘制或占位加载公园背景、左右狗狗、声浪特效。 +- Scene 只消费 snapshot,不写规则。 +- 接入 DOM HUD。 + +### Phase 4:反馈与结算 + +- 加入拟声词、冲击波、狗狗张嘴动画。 +- 加入结算面板。 +- 加入再来一局与返回入口。 + +### Phase 5:Genarrative 集成可选项 + +若要正式接入玩法类型: + +- 补 `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。 diff --git a/.hermes/plans/2026-05-11_170028-bark-battle-three-phase-browser-ai-db-plan.md b/.hermes/plans/2026-05-11_170028-bark-battle-three-phase-browser-ai-db-plan.md new file mode 100644 index 00000000..1866412d --- /dev/null +++ b/.hermes/plans/2026-05-11_170028-bark-battle-three-phase-browser-ai-db-plan.md @@ -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 2:AI 创作入口 + +目标:创作者能从创作中心选择 `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` 分层清晰。 +- 发布为稳定作品 ID,runtime 从后端读取发布态 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.3:TDD 实现叫声检测 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.4:TDD 实现能量条 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.5:TDD 实现单局状态机 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.10:Phase 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.6:Phase 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.3:SpacetimeDB 表、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.4:spacetime-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.5:api-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.7:Phase 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 文档做数据库、发布、成绩和追踪闭环。 + diff --git a/.hermes/plans/2026-05-11_195214-k6-works-list-load-test-plan.md b/.hermes/plans/2026-05-11_195214-k6-works-list-load-test-plan.md new file mode 100644 index 00000000..5599c99f --- /dev/null +++ b/.hermes/plans/2026-05-11_195214-k6-works-list-load-test-plan.md @@ -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": "", + "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": [""], + "customWorld": [""] + } +} +``` + +### 过滤原则 + +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: 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: \ +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? diff --git a/.hermes/plans/2026-05-11_205645-genarrative-disaster-recovery.md b/.hermes/plans/2026-05-11_205645-genarrative-disaster-recovery.md new file mode 100644 index 00000000..99985241 --- /dev/null +++ b/.hermes/plans/2026-05-11_205645-genarrative-disaster-recovery.md @@ -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//` + - `/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---.json` +- 增加 `SERVER_BACKUP_DIRECTORY` 默认建议: + - `/var/backups/genarrative/spacetimedb//` +- 增加备份保留策略: + - 本机保留 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 若在本地 Windows,controller 自身备份和恢复需要单独制定,不应只依赖 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。 diff --git a/.hermes/plans/2026-05-11_205658-security-vulnerability-scan.md b/.hermes/plans/2026-05-11_205658-security-vulnerability-scan.md new file mode 100644 index 00000000..fd17c42e --- /dev/null +++ b/.hermes/plans/2026-05-11_205658-security-vulnerability-scan.md @@ -0,0 +1,403 @@ +# 当前项目安全漏洞检查计划 + +> **For Hermes:** Use subagent-driven-development skill only if the user later asks to execute this plan. 本计划当前仅用于规划,不实施代码修改。 + +**Goal:** 对 Genarrative 当前工作区做一次可复现的安全漏洞基线检查,覆盖依赖漏洞、密钥泄露、常见高风险代码模式、后端 Rust crate 风险和前端/Node 供应链风险,并输出可落地的整改清单。 + +**Architecture:** 采用“只读扫描 → 结果归档 → 人工分级 → 最小修复建议”的方式推进。先不直接升级依赖或改代码,避免安全扫描引入不可控 breaking change;执行阶段只在用户确认后运行扫描命令,并把报告保存到 `docs/audits/` 或 `.hermes/plans/` 附件中。 + +**Tech Stack:** Node/Vite/React/TypeScript、Rust workspace/Axum/SpacetimeDB、npm lockfile、Cargo.lock、Git worktree。 + +--- + +## 当前上下文 / 假设 + +- 当前有效工作区:`C:/proj/Genarrative/.worktrees/hermes-3337436a`。 +- 本次用户以 `/plan` 模式要求“检查一下当前项目的安全漏洞”,因此本轮只制定计划,不执行会产生报告、安装工具、修改依赖、提交或推送的操作。 +- 已确认项目包含: + - 根 `package.json`,脚本包括 `npm run lint`、`npm run test`、`npm run build`、`npm run check:encoding`。 + - 根 `package-lock.json`。 + - `server-rs/Cargo.toml` 和 `server-rs/Cargo.lock`。 + - `apps/admin-web/package.json`、`packages/shared/package.json`。 +- `.hermes/shared-memory/development-workflow.md` 要求开发前读取共享记忆,并以当前代码、`docs/`、`AGENTS.md` 为准。 +- 安全扫描不应把真实密钥写入仓库;发现疑似密钥时只记录文件位置、变量名、脱敏片段和处置建议。 + +## 总体策略 + +1. 先做仓库状态和范围确认,避免扫描其他 worktree 或错误路径。 +2. 优先运行不会修改文件的安全检查:`npm audit --json`、`cargo audit`、密钥扫描、危险代码模式扫描。 +3. 分前端供应链、后端供应链、源码安全、配置/脚本安全四类归档。 +4. 对结果按严重级别分层:Critical / High / Medium / Low / Informational。 +5. 对每个真实问题给出:影响范围、证据、可行修复、验证命令、是否需要业务回归。 +6. 只有在用户确认进入执行/修复阶段后,才做依赖升级、代码修复、文档更新、测试和提交。 + +--- + +## Step-by-step Plan + +### Task 1: 确认扫描工作区和基线状态 + +**Objective:** 确保后续扫描针对当前 worktree,且不会误把既有未提交变更当成安全修复结果。 + +**Files:** +- Read-only: `AGENTS.md` +- Read-only: `.hermes/README.md` +- Read-only: `.hermes/shared-memory/development-workflow.md` +- Read-only: `package.json` +- Read-only: `server-rs/Cargo.toml` + +**Commands:** + +```bash +pwd +git status --short +git branch --show-current +git rev-parse --show-toplevel +``` + +**Expected:** +- `pwd` / `git rev-parse --show-toplevel` 指向 `C:/proj/Genarrative/.worktrees/hermes-3337436a` 对应路径。 +- 分支为当前隔离 worktree 分支。 +- 记录是否已有未提交变更;如存在,扫描报告需标注“基于含未提交变更的工作区”。 + +**Validation:** +- 不修改任何项目文件。 +- 如发现路径不是当前 worktree,停止并重新确认路径。 + +### Task 2: 生成依赖清单和锁文件基线 + +**Objective:** 明确 Node 与 Rust 依赖入口,避免漏扫子包或 admin web。 + +**Files:** +- Read-only: `package.json` +- Read-only: `package-lock.json` +- Read-only: `apps/admin-web/package.json` +- Read-only: `packages/shared/package.json` +- Read-only: `server-rs/Cargo.toml` +- Read-only: `server-rs/Cargo.lock` + +**Commands:** + +```bash +npm --version +node --version +cargo --version +rustc --version +``` + +可选只读清单: + +```bash +npm ls --all --json > /tmp/genarrative-npm-ls.json +cargo metadata --manifest-path server-rs/Cargo.toml --format-version 1 > /tmp/genarrative-cargo-metadata.json +``` + +**Expected:** +- 明确 npm / Node / Rust / Cargo 版本。 +- 若 `npm ls` 因 peer dependency 或历史依赖问题非 0,保留输出并继续 audit。 + +**Validation:** +- `/tmp` 输出不进入 Git。 +- 不运行 `npm install`、`npm update`、`cargo update`。 + +### Task 3: Node 供应链漏洞扫描 + +**Objective:** 检查根 lockfile 覆盖的前端、脚本和 admin web 依赖漏洞。 + +**Files:** +- Read-only: `package-lock.json` +- Read-only: `package.json` + +**Commands:** + +```bash +npm audit --json > /tmp/genarrative-npm-audit.json +npm audit --audit-level=moderate +``` + +**Expected:** +- `npm audit --json` 生成机器可读结果。 +- 第二条命令给出人类可读摘要;如返回非 0,按漏洞严重度记录,不直接执行 `npm audit fix`。 + +**Result fields to extract:** +- package name +- vulnerable versions +- installed version +- severity +- CVE / GHSA +- via chain +- fixAvailable 是否为 major/breaking +- affected direct dependency or transitive dependency + +**Validation:** +- 不执行 `npm audit fix`。 +- 如 npm registry 网络不可用,记录阻塞原因和可重试命令。 + +### Task 4: Rust 供应链漏洞扫描 + +**Objective:** 检查 `server-rs` workspace 的 Cargo 依赖漏洞、弃用 crate 和 yanked crate。 + +**Files:** +- Read-only: `server-rs/Cargo.toml` +- Read-only: `server-rs/Cargo.lock` + +**Commands:** + +优先: + +```bash +cargo audit --json --manifest-path server-rs/Cargo.toml > /tmp/genarrative-cargo-audit.json +cargo audit --manifest-path server-rs/Cargo.toml +``` + +如果本机没有 `cargo audit`: + +```bash +cargo install cargo-audit --locked +cargo audit --manifest-path server-rs/Cargo.toml +``` + +**Execution note:** +- 安装 `cargo-audit` 会改变用户 Cargo 工具目录,不属于纯只读扫描;执行前需用户确认。 +- 如果用户不希望安装工具,则记录“Rust 漏洞扫描未完成”,并给出本地安装或 CI 执行建议。 + +**Result fields to extract:** +- advisory id +- package +- version +- patched versions +- unaffected versions +- severity / CVSS if available +- dependency path +- whether it is runtime reachable in `api-server` / `spacetime-module` + +**Validation:** +- 不运行 `cargo update`。 +- 不改 `Cargo.lock`。 + +### Task 5: 密钥和敏感配置泄露扫描 + +**Objective:** 检查仓库中是否误提交 API key、token、私钥、cookie、`.env` 类文件或个人 Hermes 配置。 + +**Files / paths to scan:** +- Full repo excluding `.git/`, `node_modules/`, `target/`, `dist/`, build artifacts。 +- 特别关注:`.hermes/`、`scripts/`、`server-rs/`、`apps/admin-web/`、`src/`、`docs/`。 + +**Preferred commands:** + +如果有 gitleaks: + +```bash +gitleaks detect --source . --no-git --redact --report-format json --report-path /tmp/genarrative-gitleaks.json +``` + +如果没有 gitleaks,先用只读 grep/ripgrep 兜底: + +```bash +git ls-files -z | xargs -0 grep -nIE "(api[_-]?key|secret|password|passwd|token|private[_-]?key|BEGIN (RSA|OPENSSH|EC|DSA)? ?PRIVATE KEY|AKIA[0-9A-Z]{16}|xox[baprs]-|sk-[A-Za-z0-9_-]{20,})" > /tmp/genarrative-secret-grep.txt || true +``` + +**Execution note:** +- 安装 gitleaks 需要用户确认。 +- grep 结果包含 false positive,必须人工分级,不得直接当作泄露结论。 + +**Validation:** +- 报告中对值做脱敏,只保留前后 3-4 位或完全不记录值。 +- 如果发现 `.env.local` 或真实 token 被跟踪,立即标为 Critical。 + +### Task 6: 常见源码安全模式扫描 + +**Objective:** 快速发现高风险代码模式:命令注入、动态执行、路径穿越、危险反序列化、XSS、日志泄密、宽松 CORS 等。 + +**Files / paths:** +- `src/**/*.{ts,tsx,js,mjs,cjs}` +- `apps/admin-web/**/*.{ts,tsx,js,mjs,cjs}` +- `scripts/**/*.{js,mjs,cjs,ts}` +- `server-rs/crates/**/*.rs` + +**Commands:** + +```bash +# JS/TS 动态执行与 HTML 注入 +rg -n "\beval\(|new Function\(|dangerouslySetInnerHTML|innerHTML\s*=|document\.write\(" src apps scripts packages + +# Node 命令执行风险 +rg -n "exec\(|execSync\(|spawn\(|spawnSync\(|shell:\s*true|child_process" scripts src apps packages + +# Rust 命令、文件路径、unwrap 风险热点 +rg -n "Command::new|std::process|\.unwrap\(|\.expect\(|fs::|File::open|PathBuf|set_header|cors|CorsLayer" server-rs/crates + +# 宽松 CORS / Cookie / Auth 相关热点 +rg -n "allow_origin|Any|cookie|Authorization|Bearer|refresh|access_token|set_cookie|SameSite|Secure|HttpOnly" server-rs/crates src apps scripts +``` + +**Expected:** +- 输出作为“热点清单”,不等同于漏洞。 +- 对 auth/session、文件上传、OSS 签名、外部 LLM/图片服务请求、SpacetimeDB 访问 facade 做人工复核。 + +**Validation:** +- 每个疑似问题必须能说明可利用条件,无法说明则降级为 Informational。 + +### Task 7: Web/API 安全配置人工复核 + +**Objective:** 对项目特有的安全边界做代码审阅,补足工具扫描无法覆盖的业务风险。 + +**Likely files to review:** +- `server-rs/crates/api-server/src/**` +- `server-rs/crates/platform-auth/src/**` +- `server-rs/crates/platform-oss/src/**` +- `server-rs/crates/platform-llm/src/**` +- `server-rs/crates/spacetime-client/src/**` +- `src/services/**` +- `apps/admin-web/src/**` +- `scripts/*deploy*` +- `scripts/*api-server*` +- `.github/workflows/**` if present + +**Checklist:** +- Auth / session:access token 与 refresh cookie 的生命周期、SameSite/Secure/HttpOnly、错误日志是否泄露 token。 +- CORS:开发环境与生产环境是否区分,是否存在生产 `Any`。 +- SSRF / outbound:LLM、图片生成、OSS、任意 URL 下载是否校验协议和大小。 +- Upload / Data URL:大小限制、MIME 校验、base64 解析错误处理。 +- Path traversal:脚本和后端是否拼接用户输入路径。 +- Admin:后台接口是否有权限校验,是否复用普通用户 token。 +- SpacetimeDB:private table / reducer 是否绕过 api-server facade 暴露敏感数据。 +- Logging:日志是否打印 API key、token、cookie、用户私密内容。 + +**Validation:** +- 对每个命中的真实风险,记录具体文件路径和函数名。 +- 对“需要运行环境才能验证”的风险,列出 smoke 或单测建议。 + +### Task 8: 汇总漏洞分级与整改建议 + +**Objective:** 把扫描结果转成团队可执行的安全整改报告。 + +**Deliverable candidates:** +- `docs/audits/SECURITY_VULNERABILITY_SCAN_YYYY-MM-DD.md` +- 或如果用户只要临时报告:`.hermes/plans/assets/security-scan-YYYY-MM-DD.md` + +**Report structure:** + +```markdown +# 安全漏洞扫描报告 YYYY-MM-DD + +## 扫描范围 +## 扫描命令与环境 +## 摘要 +## Critical +## High +## Medium +## Low +## Informational / False Positive +## 依赖升级建议 +## 代码修复建议 +## 需要人工确认的问题 +## 验证命令 +``` + +**Validation:** +- 报告不包含真实密钥。 +- 每条问题都有“证据、影响、建议、验证”。 +- 明确哪些是工具扫描结果,哪些是人工判断。 + +### Task 9: 如用户要求修复,再分批执行最小修复 + +**Objective:** 避免一次性大规模升级导致回归,把修复拆为可验证的小批次。 + +**Suggested order:** +1. Critical secrets:立即移除、轮换密钥、补 `.gitignore`/文档约束(注意项目约束:不要在 `.gitignore` 中添加 `.env.local`)。 +2. Critical/High direct dependencies:优先升级 direct dependency,运行最小测试。 +3. Critical/High transitive dependencies:评估是否由 direct dependency patch/minor 升级带出。 +4. 源码漏洞:按入口编写回归测试,再修复。 +5. Medium/Low:按风险和 breaking change 代价排期。 + +**Required verification after fixes:** + +```bash +npm run check:encoding +npm run lint:eslint +npm run typecheck +npm run test +npm run build +cd server-rs && cargo test --workspace +``` + +后端 API 或 auth 修复涉及运行态时,还需要: + +```bash +npm run api-server +# 另一个终端检查 /healthz 并执行对应 smoke +``` + +**Validation:** +- 修复后重新跑对应 audit / secret scan。 +- 走 `requesting-code-review` 的独立安全复核流程。 + +--- + +## Files likely to change(仅修复阶段) + +本计划阶段不修改以下文件;只有用户确认执行修复时才可能变化: + +- `package.json` +- `package-lock.json` +- `apps/admin-web/package.json` +- `server-rs/Cargo.toml` +- `server-rs/Cargo.lock` +- `server-rs/crates/api-server/src/**` +- `server-rs/crates/platform-auth/src/**` +- `server-rs/crates/platform-oss/src/**` +- `server-rs/crates/platform-llm/src/**` +- `src/services/**` +- `apps/admin-web/src/**` +- `scripts/**` +- `docs/audits/SECURITY_VULNERABILITY_SCAN_YYYY-MM-DD.md` +- `.hermes/shared-memory/pitfalls.md`(仅当发现长期有效、会反复踩的安全排障经验时更新) + +## Tests / Validation + +安全扫描执行阶段: + +```bash +npm audit --json > /tmp/genarrative-npm-audit.json +npm audit --audit-level=moderate +cargo audit --manifest-path server-rs/Cargo.toml +rg -n "\beval\(|new Function\(|dangerouslySetInnerHTML|innerHTML\s*=|document\.write\(" src apps scripts packages +rg -n "exec\(|execSync\(|spawn\(|spawnSync\(|shell:\s*true|child_process" scripts src apps packages +rg -n "Command::new|std::process|\.unwrap\(|\.expect\(|fs::|File::open|PathBuf|set_header|cors|CorsLayer" server-rs/crates +``` + +修复执行阶段: + +```bash +npm run check:encoding +npm run lint:eslint +npm run typecheck +npm run test +npm run build +cd server-rs && cargo test --workspace +``` + +如变更后端运行态、安全中间件、auth/session: + +```bash +npm run api-server +# 检查 /healthz +# 执行相关 auth / API smoke +``` + +## Risks, tradeoffs, and open questions + +- `npm audit fix` 可能升级 major version,破坏 Vite/React/ESLint/Vitest 兼容性;必须先人工审查 `fixAvailable`。 +- `cargo audit` 可能需要安装 `cargo-audit`;安装工具属于用户环境变更,应先确认。 +- 密钥扫描极易产生 false positive;必须人工复核,报告中禁止输出真实密钥。 +- Rust `unwrap/expect` 不是天然漏洞;只有对外部输入、网络、文件、数据库响应等不可信数据造成 panic/DoS 时才升级为真实风险。 +- Web 安全检查需要区分开发环境和生产环境;开发 CORS 放宽不等于生产漏洞,但生产配置必须有明确边界。 +- 如果扫描发现历史提交中曾泄露密钥,删除当前文件不够,必须轮换密钥并考虑历史清理策略。 +- 当前计划未直接访问 CI/Jenkins/生产配置;若用户希望覆盖 CI/CD、镜像、部署主机和运行时端口,需要补充 Jenkins console、部署脚本和生产环境配置的只读访问方式。 + +## Missing artifacts / follow-up checkpoints + +- 尚未获得用户确认是否允许安装 `cargo-audit` / `gitleaks` 等工具。 +- 尚未执行真实扫描,因此当前没有漏洞结论;执行后需要生成正式报告。 +- 如果用户希望“检查当前项目”包含远端仓库历史 secrets、Docker 镜像、Jenkins 凭据和生产运行时配置,需要另行确认访问范围和凭据边界。 diff --git a/.hermes/plans/2026-05-12_0616-remote-works-list-loadtest-diagnosis.md b/.hermes/plans/2026-05-12_0616-remote-works-list-loadtest-diagnosis.md new file mode 100644 index 00000000..32b7f1e2 --- /dev/null +++ b/.hermes/plans/2026-05-12_0616-remote-works-list-loadtest-diagnosis.md @@ -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`: + +- completed:4661 +- status:200 全部 +- slow_request:0 +- latency_ms:min 13 / p50 30 / p90 43 / p95 50 / p99 62 / max 88 + +`/api/runtime/custom-world-gallery`: + +- completed:4659 +- status:200 全部 +- slow_request:0 +- latency_ms:min 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,避免诊断时混淆。 diff --git a/.hermes/plans/frame_003.jpg b/.hermes/plans/frame_003.jpg new file mode 100644 index 00000000..e2adf321 Binary files /dev/null and b/.hermes/plans/frame_003.jpg differ diff --git a/.hermes/plans/frame_010.jpg b/.hermes/plans/frame_010.jpg new file mode 100644 index 00000000..f7680827 Binary files /dev/null and b/.hermes/plans/frame_010.jpg differ diff --git a/.hermes/plans/frame_020.jpg b/.hermes/plans/frame_020.jpg new file mode 100644 index 00000000..9ce68639 Binary files /dev/null and b/.hermes/plans/frame_020.jpg differ diff --git a/.hermes/plans/frame_035.jpg b/.hermes/plans/frame_035.jpg new file mode 100644 index 00000000..03442627 Binary files /dev/null and b/.hermes/plans/frame_035.jpg differ diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index e722c8be..e4985c17 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -35,7 +35,7 @@ ## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化 - 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。 -- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 +- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段在首图和背景音乐后自动生成首关 UI 背景,失败只记录 warning 并允许结果页重试;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 - 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。 - 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。 @@ -56,6 +56,14 @@ - 验证方式:执行 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、触碰文件 ESLint、`npm run check:encoding`。 - 关联文档:`docs/prd/PROFILE_LEGAL_INFO_AND_AUTH_AGREEMENT_PRD_2026-05-12.md`。 +## 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 字段。 @@ -65,12 +73,20 @@ - 验证方式:执行拼图/抓大鹅结果页定向测试、`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 视觉资产统一为绘本草地舞台 -- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要预留 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、地面指示环和用户轮廓使用同一套 image-2 资源口径。 +- 决策:热身舞台及后续儿童动作 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 资产生成流程。 -- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 应能写出默认背景文件。 +- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 或 `--live --only ` 应能写出对应 PNG,并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only ` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 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`。 ## 2026-05-10 方洞挑战从创作页入口和作品架隐藏 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 01327790..cbd9c0b1 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -43,6 +43,14 @@ - 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。 - 关联:`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 配置、会话或密钥复制进仓库。 @@ -59,14 +67,31 @@ - 验证:运行 `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`。 -## 儿童动作 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 草地绘本兜底。 -- 处理:在本地私密环境补齐 `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` 生成默认背景图。 -- 验证:生成后确认 `public/child-motion-demo/picture-book-grass-stage.webp` 存在,重新打开 `/child-motion-demo` 可看到真实绘本草地背景;`npm run check:encoding` 仍通过。 +- 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮。 +- 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名和手部轨迹,或在 `correct` / `wrong` 反馈阶段继续累计手部路径,会把抓握、反馈期间残留移动或未知侧别手部误算成下一次选篮。 +- 处理:宝贝识物选篮只使用明确 `leftHand` / `rightHand` 的连续横向轨迹阈值;侧别为 `unknown` 的手部轨迹不参与选篮;礼物盒打开和反馈阶段清空轨迹,不在非 `active` 阶段累计路径。礼物盒激活仍使用 `open_palm -> grab` 抓握序列。 +- 补充:当前本地 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`。 +## 儿童动作 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 `,不重新请求 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 图片配置 - 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。 diff --git a/AGENTS.md b/AGENTS.md index 5cb4c83b..6970172b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,20 @@ - 仓库 `.hermes/` 只保存可进入 Git 的团队共享内容;禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。 - 若 `.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`. + ## 项目约束 - 代码需要有完善的中文注释 - 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。 diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md new file mode 100644 index 00000000..817a5a85 --- /dev/null +++ b/deploy/nginx/README.md @@ -0,0 +1,34 @@ +# Genarrative Nginx compression policy + +本配置片段由 `scripts/jenkins-server-provision.sh` 在安装 Nginx 站点配置时展开。 + +## gzip + +- `deploy/nginx/genarrative.conf` 与 `deploy/nginx/genarrative-dev-http.conf` 默认开启 gzip。 +- 覆盖 `application/json`,用于降低 `/api/runtime/*/gallery` 这类 JSON 列表接口的公网带宽占用。 +- 当前推荐等级为 `gzip_comp_level 5`,兼顾 2C/2G 服务器 CPU 与压缩收益。 + +## Brotli + +- Brotli 只在目标服务器 Nginx 接受 brotli 指令时开启。 +- Provision 脚本通过临时配置执行 `nginx -t` 做能力探测;探测配置会先 `include /etc/nginx/modules-enabled/*.conf`,避免 Ubuntu 动态模块已安装但测试配置未加载模块导致误判。可用时把模板中的 `# __GENARRATIVE_BROTLI_DIRECTIVES__` 替换为 brotli 指令,不可用时保留注释说明。 +- 不要直接在静态模板里无条件写 `brotli on;`,否则没有 brotli 模块的服务器会 `nginx -t` 失败并回滚。 +- 不要用 `nginx -V | grep brotli` 判断 brotli 是否可用;Ubuntu apt 安装的 brotli 是动态模块,可能只出现在 `nginx -T` 的 `load_module` 配置里。 + +## 验证 + +```bash +curl -sSI -H 'Accept-Encoding: gzip' \ + http:///api/runtime/puzzle/gallery \ + | grep -iE 'content-encoding|vary|content-type|content-length' + +curl -sSI -H 'Accept-Encoding: br' \ + http:///api/runtime/puzzle/gallery \ + | grep -iE 'content-encoding|vary|content-type|content-length' +``` + +预期: + +- gzip 可用时返回 `Content-Encoding: gzip`。 +- br 可用时返回 `Content-Encoding: br`。 +- 响应头应包含 `Vary: Accept-Encoding`。 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index 4e566377..824a8f5a 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -5,6 +5,23 @@ server { listen 80; server_name genarrative.example.com; + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/javascript + application/javascript + application/json + application/xml + application/xml+rss + image/svg+xml; + + # __GENARRATIVE_BROTLI_DIRECTIVES__ + root /srv/genarrative/web; index index.html; diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 3db856ed..06a3bf86 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -16,6 +16,23 @@ server { listen 443 ssl http2; server_name genarrative.example.com; + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/javascript + application/javascript + application/json + application/xml + application/xml+rss + image/svg+xml; + + # __GENARRATIVE_BROTLI_DIRECTIVES__ + ssl_certificate /etc/letsencrypt/live/genarrative.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/genarrative.example.com/privkey.pem; diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 00000000..a42a7ace --- /dev/null +++ b/docs/agents/domain.md @@ -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. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 00000000..f4b84310 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -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 ` +- 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. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 00000000..71d37f37 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -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. diff --git a/docs/audits/SECURITY_VULNERABILITY_SCAN_2026-05-11.md b/docs/audits/SECURITY_VULNERABILITY_SCAN_2026-05-11.md new file mode 100644 index 00000000..11e7f2f0 --- /dev/null +++ b/docs/audits/SECURITY_VULNERABILITY_SCAN_2026-05-11.md @@ -0,0 +1,377 @@ +# 安全漏洞扫描报告 2026-05-11 + +## 扫描范围 + +- 工作区:`C:/proj/Genarrative/.worktrees/hermes-3337436a` +- 分支:`hermes/hermes-3337436a` +- Git 基线:扫描时存在一个未跟踪计划文件 `.hermes/plans/2026-05-11_205658-security-vulnerability-scan.md`。 +- 扫描对象:根 Node/Vite/React 依赖、`server-rs` Rust workspace 依赖入口、仓库已跟踪文件中的敏感配置、JS/TS/Rust 源码安全热点。 + +## 扫描命令与工具状态 + +已执行: + +```bash +pwd +git branch --show-current +git rev-parse --show-toplevel +git status --short +node --version +npm --version +cargo --version +rustc --version +rg --version +npm audit --json +npm audit --audit-level=moderate +git ls-files -z | xargs -0 grep -nIE "(api[_-]?key|secret|password|passwd|token|private[_-]?key|BEGIN (RSA|OPENSSH|EC|DSA)? ?PRIVATE KEY|AKIA[0-9A-Z]{16}|xox[baprs]-|sk-[A-Za-z0-9_-]{20,})" +rg -n "\beval\(|new Function\(|dangerouslySetInnerHTML|innerHTML\s*=|document\.write\(" src apps scripts packages +rg -n "exec\(|execSync\(|spawn\(|spawnSync\(|shell:\s*true|child_process" scripts src apps packages +rg -n "Command::new|std::process|\.unwrap\(|\.expect\(|fs::|File::open|PathBuf|set_header|cors|CorsLayer" server-rs/crates +rg -n "allow_origin|Any|cookie|Authorization|Bearer|refresh|access_token|set_cookie|SameSite|Secure|HttpOnly" server-rs/crates src apps scripts +``` + +工具版本: + +- Node:`v22.22.2` +- npm:`10.9.7` +- Cargo:`cargo 1.95.0 (f2d3ce0bd 2026-03-21)` +- Rustc:`rustc 1.95.0 (59807616e 2026-04-14)` +- ripgrep:`15.1.0` +- `gitleaks`:未安装,本次未执行。 +- `cargo-audit`:未安装,本次未执行;未擅自安装到用户环境。 + +原始扫描输出保存于: + +- `.hermes/plans/assets/security-scan-2026-05-11/npm-audit.json` +- `.hermes/plans/assets/security-scan-2026-05-11/npm-audit.txt` +- `.hermes/plans/assets/security-scan-2026-05-11/secret-grep.txt` +- `.hermes/plans/assets/security-scan-2026-05-11/js-xss-dynamic.txt` +- `.hermes/plans/assets/security-scan-2026-05-11/node-command-exec.txt` +- `.hermes/plans/assets/security-scan-2026-05-11/rust-hotspots.txt` +- `.hermes/plans/assets/security-scan-2026-05-11/auth-cors-hotspots.txt` + +注意:`secret-grep.txt` 可能包含敏感片段,提交前应删除或改为脱敏摘要,不建议直接进入 Git。 + +## 摘要 + +| 等级 | 数量 | 说明 | +| --- | ---: | --- | +| Critical | 1 | `.env.local` 被 Git 跟踪且含多项非空真实密钥/凭据形态配置。 | +| High | 2 | npm 依赖存在 8 个 high advisory 聚合项;Vite dev server 任意文件读取类漏洞需要优先升级。另有 TypeScript ESLint 链路 ReDoS 风险。 | +| Medium | 2 | esbuild dev server 请求读取、PostCSS CSS stringify XSS。 | +| Low | 3 | jsdom/http-proxy-agent/@tootallnate/once 低危链路。 | +| Unknown | 1 | Rust 依赖漏洞未完成扫描,因为本机未安装 `cargo-audit`。 | +| Informational | 多项 | 源码热点扫描命中大量测试/脚本/unwrap/expect,需要按入口人工复核。 | + +## Critical + +### C-1:仓库跟踪了 `.env.local`,且包含多项非空真实密钥形态配置 + +**证据:** + +`git ls-files --error-unmatch .env.local` 显示 `.env.local` 已被 Git 跟踪。扫描确认该文件包含多项非空密钥/凭据形态变量,包括但不限于: + +- `LLM_API_KEY` +- `ARK_API_KEY` +- `ARK_CHARACTER_VIDEO_API_KEY` +- `DASHSCOPE_API_KEY` +- `VOLCENGINE_ACCESS_KEY_ID` +- `VOLCENGINE_SECRET_ACCESS_KEY` +- `ALIYUN_SMS_ACCESS_KEY_ID` +- `ALIYUN_SMS_ACCESS_KEY_SECRET` +- `GENARRATIVE_LLM_API_KEY` +- `ALIYUN_OSS_ACCESS_KEY_ID` +- `ALIYUN_OSS_ACCESS_KEY_SECRET` +- `GENARRATIVE_ADMIN_PASSWORD` + +报告中不记录具体值。 + +**影响:** + +如果该文件已进入远端仓库或被团队成员拉取,相关外部服务密钥、OSS/SMS/LLM/后台密码均应视为已泄露。即使后续从当前工作树删除,也不能撤销历史泄露风险。 + +**建议修复:** + +1. 立即轮换 `.env.local` 中出现过的所有真实密钥、访问密钥、后台密码和 token。 +2. 从 Git 跟踪中移除 `.env.local`,但不要删除本地私有文件: + + ```bash + git rm --cached .env.local + ``` + +3. 按项目约束,不要在 `.gitignore` 中新增 `.env.local`;如果仓库已有其他机制管理本地私密 env,应遵循既有约定。若没有,应先补一份安全说明文档,而不是提交真实 `.env.local`。 +4. 将必要的占位示例保留在 `.env.example` 或 `deploy/env/api-server.env.example`,确保示例值不是可用密钥。 +5. 如该文件已推送到远端历史,评估是否需要历史清理;无论是否清历史,密钥轮换都是必须步骤。 + +**验证:** + +```bash +git ls-files --error-unmatch .env.local +# 预期:返回非 0,表示不再跟踪 + +git diff --cached -- .env.local +# 预期:只显示从索引移除,不输出真实值到公开报告 +``` + +## High + +### H-1:Vite 依赖存在高危 dev server 任意文件读取/路径遍历类 advisory + +**证据:** + +`npm audit` 显示: + +- package:`vite` +- direct dependency:是 +- installed vulnerable range:`<=6.4.1` +- severity:`high` +- 相关 advisory 包括: + - `GHSA-p9ff-h696-f583`:Vite dev server WebSocket 任意文件读取,高危。 + - `GHSA-4w7w-66w2-5vf9`:optimized deps `.map` 处理路径遍历,中危。 + - 多个 `server.fs` / public directory 相关低中危问题。 +- `fixAvailable=true`。 + +**影响:** + +主要影响开发服务器和预览环境。如果开发机、测试机或内网联调环境将 Vite dev server 暴露给不可信网络,攻击者可能读取工作区文件或旁路 `server.fs` 限制。 + +**建议修复:** + +1. 优先将 `vite` 升级到 npm audit 推荐的安全版本范围。 +2. 升级后执行: + + ```bash + npm run check:encoding + npm run lint:eslint + npm run typecheck + npm run test + npm run build + ``` + +3. 检查 `scripts/vite-cli.mjs`、`scripts/dev-web-rust.mjs`、Vite 配置中的 dev server host 暴露范围,开发环境避免绑定 `0.0.0.0` 或暴露到公网。 + +### H-2:`@typescript-eslint/*` 链路经 `minimatch` 存在 ReDoS 高危 advisory + +**证据:** + +`npm audit` 显示: + +- direct packages: + - `@typescript-eslint/eslint-plugin`,当前范围 `6.16.0 - 7.5.0`,high。 + - `@typescript-eslint/parser`,当前范围 `6.16.0 - 7.5.0`,high。 +- transitive packages: + - `@typescript-eslint/type-utils` + - `@typescript-eslint/typescript-estree` + - `@typescript-eslint/utils` + - `minimatch` +- `minimatch` advisory: + - `GHSA-3ppc-4f35-3m26` + - `GHSA-7r86-cg39-jmmj` + - `GHSA-23c5-xmqv-rm74` +- npm 建议升级到 `@typescript-eslint/* 8.59.3`,属于 SemVer major。 + +**影响:** + +主要影响 lint/构建工具链。如果 CI 或开发命令处理不可信 glob pattern,可能造成 ReDoS。生产运行时直接影响较低,但 CI 可用性和供应链安全仍应修复。 + +**建议修复:** + +1. 单独开依赖升级分支,将 `@typescript-eslint/eslint-plugin` 与 `@typescript-eslint/parser` 升级到兼容 ESLint 8/TypeScript 5.8 的安全版本。 +2. 因为是 major 升级,先阅读迁移说明并运行 ESLint 全量检查。 +3. 验证: + + ```bash + npm run lint:eslint + npm run typecheck + npm run test + ``` + +### H-3:`picomatch` ReDoS / glob matching 高危 advisory + +**证据:** + +`npm audit` 显示: + +- package:`picomatch` +- severity:`high` +- vulnerable range:`4.0.0 - 4.0.3` +- advisory: + - `GHSA-c2c7-rcm5-vvqj`:extglob quantifiers ReDoS,高危。 + - `GHSA-3v7f-55p6-f55p`:POSIX character classes method injection,中危。 +- `fixAvailable=true`。 + +**影响:** + +主要影响依赖 picomatch 的构建、测试、文件匹配工具链。生产直接影响取决于是否在服务端运行时用它处理用户输入 glob;当前未在扫描摘要中发现明显业务入口直接使用。 + +**建议修复:** + +通过升级引入它的 direct dependency 来消除,不建议手工改 lockfile。 + +## Medium + +### M-1:`esbuild <=0.24.2` dev server 允许任意网站请求并读取响应 + +**证据:** + +`npm audit` 显示: + +- package:`esbuild` +- severity:`moderate` +- advisory:`GHSA-67mh-4wv8-2f99` +- vulnerable range:`<=0.24.2` +- `fixAvailable=true`。 + +**影响:** + +主要影响开发服务器场景。若本地开发服务暴露到不可信网络,风险上升。 + +**建议修复:** + +随 Vite / 构建链路升级一并修复,升级后跑前端检查与构建。 + +### M-2:`postcss <8.5.10` CSS stringify XSS advisory + +**证据:** + +`npm audit` 显示: + +- package:`postcss` +- severity:`moderate` +- advisory:`GHSA-qx2v-qp2m-jg93` +- vulnerable range:`<8.5.10` +- `fixAvailable=true`。 + +**影响:** + +如果系统把不可信 CSS 内容 stringify 后注入页面,可能触发 XSS。当前项目是否存在这类业务入口需人工复核;从依赖角度建议升级。 + +**建议修复:** + +升级 Tailwind/Vite/PostCSS 链路带出的安全版本,并执行前端构建验证。 + +## Low + +### L-1:`jsdom` 链路低危 advisory + +**证据:** + +`npm audit` 显示: + +- `jsdom` direct dependency,severity low。 +- transitive:`http-proxy-agent`、`@tootallnate/once`。 +- npm 建议升级到 `jsdom 29.1.1`,SemVer major。 + +**影响:** + +通常影响测试环境。若测试工具处理不可信 URL/代理输入,风险上升。 + +**建议修复:** + +不要和 Vite/TypeScript ESLint 大升级混在一个提交里。单独升级 jsdom 后运行: + +```bash +npm run test +``` + +## Unknown / 未完成项 + +### U-1:Rust 依赖漏洞未完成扫描 + +**原因:** + +本机没有 `cargo-audit`,本次没有擅自安装用户级 Cargo 工具。 + +**建议:** + +如确认允许安装: + +```bash +cargo install cargo-audit --locked +cargo audit --manifest-path server-rs/Cargo.toml +``` + +或在 CI/具备工具的环境执行并回填结果。 + +## Informational / 源码热点 + +### I-1:JS/TS XSS / 动态执行热点 + +扫描命中 1 行: + +- `src/routing/RouteImageReadyGate.test.ts` 中测试代码使用 `root.innerHTML`。 + +初步判断为测试环境构造 DOM,不是生产漏洞。若后续发现生产代码使用 `dangerouslySetInnerHTML` 或直接 `innerHTML = userInput`,应升级为 High。 + +### I-2:Node 脚本命令执行热点 + +扫描命中 21 行,主要集中在: + +- `scripts/spacetime-migration-common.mjs` +- `scripts/run-bash-script.mjs` +- `scripts/generate-spacetime-bindings.mjs` +- `scripts/dev-web-rust.mjs` + +初步判断为项目脚本启动 `spacetime`、bash、Vite 等工具的正常行为。后续人工复核重点: + +- 是否使用固定命令和参数数组,而不是拼接 shell 字符串。 +- 是否把用户输入直接作为命令或 shell 参数。 +- 是否设置 `shell: true`。 + +### I-3:Rust unwrap/expect、文件路径、CORS/Auth 热点较多 + +扫描命中: + +- `rust-hotspots.txt`:1348 行。 +- `auth-cors-hotspots.txt`:1157 行。 + +这些是热点,不等于漏洞。建议后续按模块分批人工复核: + +1. `server-rs/crates/api-server/src/admin.rs` +2. `server-rs/crates/api-server/src/app.rs` +3. `server-rs/crates/platform-auth/src/**` +4. `server-rs/crates/platform-oss/src/**` +5. `server-rs/crates/platform-llm/src/**` +6. `server-rs/crates/spacetime-client/src/**` + +重点看:生产 CORS、Cookie 安全属性、token 日志、路径拼接、外部 URL 下载、Data URL 大小限制、OSS 签名边界。 + +## 推荐修复顺序 + +1. 立即处理 C-1:轮换 `.env.local` 里所有真实密钥,并从 Git 索引移除 `.env.local`。 +2. 升级 `vite` 相关依赖,优先消除 dev server 任意文件读取/路径遍历 advisory。 +3. 升级 `@typescript-eslint/*`,消除 minimatch 链路 ReDoS;因 major 升级,单独提交。 +4. 升级 `postcss` / `esbuild` / `picomatch` 的来源依赖。 +5. 单独评估 `jsdom` major 升级。 +6. 用户确认后安装或使用 CI 执行 `cargo audit`,补齐 Rust 依赖漏洞结论。 +7. 对 `auth-cors-hotspots.txt` 和 `rust-hotspots.txt` 做模块级人工审计。 + +## 修复后的验证命令 + +```bash +npm run check:encoding +npm run lint:eslint +npm run typecheck +npm run test +npm run build +``` + +如修改后端安全、Auth、Cookie、CORS 或 API: + +```bash +cd server-rs && cargo test --workspace +npm run api-server +# 检查 /healthz,并执行相关 API/auth smoke +``` + +如补齐 Rust audit: + +```bash +cargo audit --manifest-path server-rs/Cargo.toml +``` + +## 备注 + +- 本报告没有输出任何真实密钥值。 +- `.hermes/plans/assets/security-scan-2026-05-11/secret-grep.txt` 可能包含敏感内容,仅用于本地排查;提交前应删除或替换为脱敏报告。 +- 由于 `gitleaks` 未安装,本次密钥扫描只是 grep 兜底,不等价于完整 secrets audit。 diff --git a/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md b/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md index 68ee8cbb..c9a1e668 100644 --- a/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md +++ b/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md @@ -108,3 +108,42 @@ output/imagegen/baimeng-expo-rollup/baimeng-rollup-final-cn-preview.png 2. 若需要放二维码,应放在底部独立留白区,不遮挡产品心智和关键技术段。 3. 若展会现场观众偏投资人或B端合作方,可以把“产品心智”段压缩,放大“关键技术”与平台愿景。 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 +``` diff --git a/docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md b/docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md index cd2befcb..21ec5651 100644 --- a/docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md +++ b/docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md @@ -48,7 +48,7 @@ 1. 发现页隐藏“寓教于乐”标签; 2. 隐藏“寓教于乐”标签下内容; 3. 该内容线内容不进入推荐、今日、分类、排行和搜索结果; -4. 该内容线内容完全不可见,公开作品搜索、作品号搜索直达、公开详情深链、浏览历史入口等平台公开入口都不能打开该内容。 +4. 该内容线内容完全不可见,公开作品搜索、作品号搜索直达、公开详情深链、浏览历史入口、创作入口和创作页作品架等平台入口都不能打开或展示该内容。 ## 4. 内容识别规则 @@ -114,4 +114,23 @@ no 4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 已复用同一过滤 helper,避免推荐运行态自动启动寓教于乐作品,并在公开详情、作品号直达和公开详情深链等公开入口保留不可见保护。 5. 浏览历史入口会优先按当前公开作品集合匹配作品标签;匹配到“寓教于乐”作品且开关关闭时不再展示历史入口。 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 默认详情、推荐运行态或错误的改造链路。 diff --git a/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md b/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md new file mode 100644 index 00000000..3e99b9f5 --- /dev/null +++ b/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md @@ -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 次后出现“再来一次”和“下一关”按钮。 diff --git a/docs/prd/BARK_BATTLE_BDD_2026-05-11.md b/docs/prd/BARK_BATTLE_BDD_2026-05-11.md new file mode 100644 index 00000000..bc3fd29c --- /dev/null +++ b/docs/prd/BARK_BATTLE_BDD_2026-05-11.md @@ -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 生成,还是复用项目现有素材管线? diff --git a/docs/prd/README.md b/docs/prd/README.md index 80aba891..0784a5e0 100644 --- a/docs/prd/README.md +++ b/docs/prd/README.md @@ -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:TXT 玩法平台化接入](./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 创作、结果页、资产、试玩、发布到后端权威配置与前端高频运行表现的完整闭环。 diff --git a/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md b/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md new file mode 100644 index 00000000..20ab9ddb --- /dev/null +++ b/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md @@ -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 表目录更新。 diff --git a/docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md b/docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md new file mode 100644 index 00000000..aed7a90a --- /dev/null +++ b/docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md @@ -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 HUD;Canvas 保持负责 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 + 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 + ├─
+ └─ +``` + +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,正式成绩、排行榜、发布和奖励后续再交给后端链路。 diff --git a/docs/technical/BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md b/docs/technical/BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md new file mode 100644 index 00000000..69ed4771 --- /dev/null +++ b/docs/technical/BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md @@ -0,0 +1,1055 @@ +# bark-battle 后端 DDD 技术方案(2026-05-11) + +## 1. 背景、范围与非目标 + +### 1.1 背景 + +`bark-battle` / “汪汪声浪大作战”是一个浏览器 2D 声控狗叫对战玩法。玩家通过麦克风发出狗叫声,浏览器 runtime 根据音量峰值、有效叫声次数与节奏推动顶部红蓝能量条;每局默认 30 秒;结束后按能量条偏向判定胜负或平局。 + +现有前端方案 `docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md` 已覆盖 Phaser / TypeScript / Vite / Web Audio / DOM HUD 的 runtime 落地方式,并明确不覆盖后端表结构、成绩持久化、作品发布、广场接入与实时多人协议。因此需要单独补充后端 DDD 技术方案,避免前端 runtime 在接入平台作品、正式游玩埋点、成绩、排行榜和发布闭环时承接不属于表现层的业务真相。 + +本方案遵循当前 Genarrative 后端路线:`server-rs` + Axum + SpacetimeDB。DDD 边界保持为: + +- `module-*`:纯领域规则与 use case 约束。 +- `spacetime-module`:SpacetimeDB 表、reducer、migration。 +- `spacetime-client`:SpacetimeDB 绑定调用 facade。 +- `api-server`:HTTP / SSE / BFF 门面与权限校验入口。 +- `shared-contracts`:前后端共享 DTO、请求、响应、错误码与 schema 约束。 +- `platform-*`:作品、发布、广场、埋点等平台能力的领域边界。 + +### 1.2 本文范围 + +本文覆盖后端方案,不实现代码: + +1. bark-battle 玩法接入级别建议。 +2. 后端 DDD 分层与职责边界。 +3. shared contracts 草案。 +4. SpacetimeDB 数据模型草案。 +5. HTTP API / facade 草案。 +6. SpacetimeDB migration 与绑定生成策略。 +7. 安全、隐私与反作弊约束。 +8. TDD / 验收顺序与可执行命令。 +9. 与现有前端方案和 BDD 文档的关系。 + +### 1.3 非目标 + +MVP 明确不做: + +- 不保存原始麦克风音频、音频片段、可还原语音内容的 waveform 或频谱明细。 +- 不做实时多人在线对战;首版只支持本地 runtime + 后端记录派生结果。 +- 不做复杂 AI 声纹识别、狗叫语义识别、身份声纹比对或真人/动物声纹分类。 +- 不由前端直接写正式成绩、排行榜或作品发布状态。 +- 不把 Phaser / Web Audio / DOM HUD 逻辑迁入后端。 +- 不在本方案中实现代码、建表或生成绑定。 + +## 2. 玩法接入级别建议 + +### 2.1 推荐首版闭环 + +建议先支持“本地 runtime + 可发布配置化作品 + 单局结果记录 / 可选排行榜”的闭环: + +1. 创作者创建 bark-battle 草稿,配置标题、描述、狗狗主题、背景、难度、单局时长、音量阈值、AI 对手参数和排行榜开关。 +2. 发布为稳定作品 ID,`playTypeId = "bark-battle"`。 +3. 玩家从作品页或广场进入 runtime,前端获取发布态 runtime config。 +4. 玩家授权麦克风后在本地完成 30 秒声控对战。 +5. 前端提交单局 finish 请求,只上传派生指标,例如峰值、有效叫声次数、节奏命中、最终能量、客户端结果摘要等。 +6. 后端校验 work、config version、run token、时长、分数范围和权限后,生成服务端认可的 run result / score summary。 +7. 若作品开启排行榜,则写入可投影的 leaderboard 记录。 +8. 正式作品级游玩埋点统一写 `work_play_start`,其中 `scope_kind=work`,`scope_id=稳定作品 ID`,metadata 包含 `playType`、`workId`、`sourceRoute`、`userId`。 + +### 2.2 后续增强路径 + +后续再考虑多人实时: + +- Phase 2:排行榜、挑战分享、个人历史成绩、作品统计面板。 +- Phase 3:异步影子对手 / ghost replay,但仍不保存原始音频,只保存低维派生曲线或聚合指标。 +- Phase 4:实时多人对战协议,需要独立同步模型、房间服务、延迟补偿、断线恢复与更严格反作弊;不应混入 MVP。 + +## 3. DDD 分层设计 + +### 3.1 总体分层 + +```text +frontend/runtime + -> api-server HTTP BFF + -> shared-contracts DTO + -> spacetime-client facade + -> generated SpacetimeDB bindings + -> spacetime-module reducers / tables / migration.rs + -> module-bark-battle pure domain + -> platform-work / platform-publish / platform-tracking / platform-leaderboard +``` + +### 3.2 `module-bark-battle` 职责 + +建议新增 `server-rs/crates/module-bark-battle/`,只放纯领域规则,不依赖 Axum、SpacetimeDB SDK、HTTP、数据库绑定或前端类型。 + +职责: + +- 校验 bark-battle 配置合法性。 +- 定义配置版本兼容规则。 +- 计算提交结果的派生分数区间与胜负判定是否自洽。 +- 计算 `ScoreSummary`、排行榜排序分数、统计指标。 +- 定义反作弊基础规则:时长范围、有效叫声次数上限、峰值范围、能量范围、提交窗口、run 状态机。 + +不职责: + +- 不访问数据库。 +- 不处理 HTTP 请求。 +- 不生成 SpacetimeDB 表。 +- 不处理麦克风音频采样。 + +### 3.3 `shared-contracts` 职责 + +建议新增 `server-rs/crates/shared-contracts/src/bark_battle.rs`,并在前端共享契约生成流程中对齐同名 DTO。 + +职责: + +- 定义草稿、发布态 runtime config、work summary、run start、run finish、run result、score summary、leaderboard DTO。 +- 定义 request / response / error code。 +- 保持字段命名、枚举值、时间单位和数值范围稳定。 + +约束: + +- DTO 可以表达业务数据,但不承载领域算法。 +- 前端不得手写与后端不一致的正式契约;如果存在 TypeScript mirror,需要由契约生成或测试保证一致。 + +### 3.4 `spacetime-module` 职责 + +建议在现有 SpacetimeDB 模块边界内新增 bark-battle 表与 reducer,或按当前仓库约定新增独立模块文件。职责: + +- 定义表:作品配置、发布版本、runtime run、score/stat、leaderboard entry。 +- 定义 reducer / procedure:保存草稿、发布版本、开始 run、结束 run、写排行榜、查询投影所需索引。 +- 维护 migration。 + +必须明确: + +- 所有表结构变更进入 `migration.rs`。 +- SpacetimeDB 绑定通过既有生成命令生成。 +- 不手改生成物,不手改 generated bindings。 + +### 3.5 `spacetime-client` 职责 + +职责: + +- 封装 generated bindings,向 `api-server` 提供稳定 facade。 +- 隐藏 reducer 名称、表订阅细节和绑定类型差异。 +- 将 SpacetimeDB 错误转换为后端内部错误模型。 + +不职责: + +- 不放业务规则主逻辑。 +- 不绕过 `module-bark-battle` 做胜负和分数裁决。 + +### 3.6 `api-server` 职责 + +Axum HTTP / BFF 门面职责: + +- 鉴权、用户上下文、work 权限、发布态读取权限。 +- 解析请求、调用 domain 校验、调用 spacetime-client facade。 +- 将内部错误映射成 HTTP status + shared error code。 +- 负责正式作品级游玩埋点入口,统一写 `work_play_start`。 +- 为前端 runtime 提供一次性 start token / run id,避免匿名 finish 直接刷榜。 + +### 3.7 `platform-*` 职责 + +- `platform-work`:稳定作品 ID、作品所有权、作品摘要、作品状态。 +- `platform-publish`:草稿到发布态版本、config version、发布可见性。 +- `platform-tracking`:统一埋点,尤其 `work_play_start`。 +- `platform-leaderboard`:若已有通用排行榜能力,bark-battle 只提供 score projection,不重复建设平台级排名系统。 + +### 3.8 `frontend/runtime` 职责边界 + +前端只做: + +- Phaser / DOM HUD 表现。 +- Web Audio 采样、环境噪声校准和本地即时反馈。 +- 本地临时 UI 状态:权限、倒计时、动画、结算展示。 +- 调用 start / finish / leaderboard API。 + +前端不得承接正式业务真相: + +- 不直接决定正式排行榜结果。 +- 不直接写作品发布状态。 +- 不绕过后端写成绩。 +- 不上传原始麦克风音频。 + +## 4. shared contracts 设计草案 + +以下为字段草案,具体 Rust / TypeScript 命名按仓库契约规范落地。 + +### 4.1 BarkBattleDraft + +```text +BarkBattleDraft { + draftId: string + ownerUserId: string + playTypeId: "bark-battle" + title: string + description?: string + theme: BarkBattleTheme + runtimeConfig: BarkBattleRuntimeConfigDraft + leaderboardEnabled: boolean + visibility: "private" | "unlisted" | "public" + createdAt: string + updatedAt: string +} +``` + +### 4.2 BarkBattleRuntimeConfig + +```text +BarkBattleRuntimeConfig { + workId: string + configVersion: number + playTypeId: "bark-battle" + durationMs: number // MVP 默认 30000 + energyMin: number // 默认 -100 + energyMax: number // 默认 100 + winEnergyThreshold: number // 可选:低于阈值可平局 + barkThreshold: number // 归一化 0..1 + peakWeight: number + barkCountWeight: number + rhythmWeight: number + opponent: { + difficulty: "easy" | "normal" | "hard" + basePower: number + variance: number + } + theme: { + playerDogSkin: string + opponentDogSkin: string + stageId: string + soundPackId?: string + } + leaderboardEnabled: boolean + publishedAt: string +} +``` + +### 4.3 WorkSummary + +```text +BarkBattleWorkSummary { + workId: string + playTypeId: "bark-battle" + title: string + description?: string + coverAssetId?: string + authorUserId: string + authorDisplayName?: string + configVersion: number + leaderboardEnabled: boolean + totalPlayCount: number + publishedAt: string + updatedAt: string +} +``` + +### 4.4 RunStart + +```text +BarkBattleRunStartRequest { + workId: string + configVersion: number + sourceRoute?: string + clientRuntimeVersion?: string +} + +BarkBattleRunStartResponse { + runId: string + runToken: string + workId: string + configVersion: number + serverStartedAt: string + expiresAt: string + runtimeConfig: BarkBattleRuntimeConfig +} +``` + +### 4.5 RunFinish + +```text +BarkBattleRunFinishRequest { + runId: string + runToken: string + workId: string + configVersion: number + clientStartedAt?: string + clientFinishedAt?: string + elapsedMs: number + finalEnergy: number + clientWinner: "player" | "opponent" | "draw" + metrics: BarkBattleDerivedMetrics + clientRuntimeVersion?: string +} + +BarkBattleDerivedMetrics { + peakVolumeMax: number // 0..1 + peakVolumeAvg: number // 0..1 + validBarkCount: number + rhythmHitCount: number + longestCombo: number + sampleWindowCount?: number + calibrationNoiseFloor?: number // 0..1 +} +``` + +### 4.6 RunResult + +```text +BarkBattleRunResult { + runId: string + workId: string + userId?: string + configVersion: number + accepted: boolean + serverWinner: "player" | "opponent" | "draw" + finalEnergy: number + score: number + scoreSummary: BarkBattleScoreSummary + leaderboardEntry?: BarkBattleLeaderboardEntry + antiCheatFlags: string[] + finishedAt: string +} +``` + +### 4.7 ScoreSummary + +```text +BarkBattleScoreSummary { + score: number + grade: "S" | "A" | "B" | "C" | "D" + finalEnergy: number + winMargin: number + validBarkCount: number + peakVolumeMax: number + rhythmHitCount: number + longestCombo: number + elapsedMs: number +} +``` + +### 4.8 Leaderboard 可选类型 + +```text +BarkBattleLeaderboardQuery { + workId: string + configVersion?: number + period: "all" | "daily" | "weekly" + limit: number + cursor?: string +} + +BarkBattleLeaderboardEntry { + rank?: number + runId: string + workId: string + userId?: string + displayName?: string + score: number + scoreSummary: BarkBattleScoreSummary + createdAt: string +} + +BarkBattleLeaderboardResponse { + workId: string + entries: BarkBattleLeaderboardEntry[] + viewerBest?: BarkBattleLeaderboardEntry + nextCursor?: string +} +``` + +## 5. 数据模型草案 + +### 5.1 作品配置表 + +建议表:`bark_battle_work_config`。 + +字段草案: + +- `work_id`:稳定作品 ID,关联平台作品。 +- `draft_id`:草稿 ID,可选。 +- `owner_user_id`:创作者。 +- `play_type_id`:固定 `bark-battle`。 +- `config_version`:发布配置版本。 +- `title`、`description`、`cover_asset_id`。 +- `runtime_config_json`:发布态 runtime 配置 JSON;字段需由 shared contracts 校验。 +- `leaderboard_enabled`。 +- `status`:`draft` / `published` / `archived`。 +- `created_at`、`updated_at`、`published_at`。 + +约束: + +- 同一 `work_id + config_version` 不可变;新发布生成新版本。 +- runtime 请求只读发布态配置。 + +### 5.2 runtime run 表 + +建议表:`bark_battle_runtime_run`。 + +字段草案: + +- `run_id`。 +- `run_token_hash`:保存 token hash,不保存明文 token。 +- `work_id`。 +- `config_version`。 +- `user_id`:匿名时可空或使用匿名会话 ID。 +- `source_route`。 +- `status`:`started` / `finished` / `rejected` / `expired`。 +- `server_started_at`、`server_finished_at`、`expires_at`。 +- `client_elapsed_ms`。 +- `final_energy`。 +- `client_winner`、`server_winner`。 +- `anti_cheat_flags_json`。 +- `client_runtime_version`。 + +### 5.3 score / stat 表 + +建议表:`bark_battle_score_record`。 + +字段草案: + +- `score_id`。 +- `run_id`。 +- `work_id`。 +- `config_version`。 +- `user_id`。 +- `score`。 +- `grade`。 +- `final_energy`。 +- `valid_bark_count`。 +- `peak_volume_max`。 +- `peak_volume_avg`。 +- `rhythm_hit_count`。 +- `longest_combo`。 +- `elapsed_ms`。 +- `metrics_json`:只保存派生聚合指标。 +- `created_at`。 + +明确禁止: + +- 不保存原始麦克风音频。 +- 不保存可还原语音的 PCM、Opus、MP3、WAV、base64 音频、逐帧 waveform。 +- 不保存高精度声纹向量。 + +### 5.4 leaderboard 表 + +若平台已有通用排行榜,优先复用平台 leaderboard 投影;否则可新增 `bark_battle_leaderboard_entry`: + +- `entry_id`。 +- `work_id`。 +- `config_version`。 +- `run_id`。 +- `user_id`。 +- `score`。 +- `tie_breaker_energy`。 +- `tie_breaker_elapsed_ms`。 +- `created_at`。 + +排序建议: + +1. `score` 降序。 +2. `final_energy` / `winMargin` 降序。 +3. `elapsed_ms` 更接近配置时长者优先,避免异常短局刷分。 +4. `created_at` 升序或按平台既有规则。 + +## 6. API 草案 + +路径仅为草案,落地时按 `api-server` 当前路由命名规范调整。 + +### 6.1 创建 / 保存草稿 + +```text +POST /api/bark-battle/drafts +PUT /api/bark-battle/drafts/{draftId} +GET /api/bark-battle/drafts/{draftId} +``` + +职责: + +- 仅创作者可创建和保存。 +- 校验 `playTypeId = bark-battle`。 +- 调用 `module-bark-battle` 校验 runtime config 范围。 + +### 6.2 发布 / 获取作品 + +```text +POST /api/bark-battle/drafts/{draftId}/publish +GET /api/works/{workId}/bark-battle +GET /api/bark-battle/works/{workId}/runtime-config +``` + +职责: + +- 发布生成稳定 `workId` 和递增 `configVersion`。 +- 获取作品只返回发布态配置与展示摘要。 +- 未发布或无权限作品返回明确错误。 + +### 6.3 start runtime + +```text +POST /api/bark-battle/runs/start +``` + +请求:`BarkBattleRunStartRequest`。响应:`BarkBattleRunStartResponse`。 + +职责: + +- 校验作品存在、发布态、可游玩。 +- 校验 config version,必要时返回最新版本。 +- 创建 `run_id` 与一次性 `run_token`。 +- 写正式作品级游玩埋点:`work_play_start`。 + +埋点要求: + +```text +event_key: work_play_start +scope_kind: work +scope_id: <稳定作品 ID> +metadata: { + playType: "bark-battle", + workId: "", + sourceRoute: "", + userId: "" +} +``` + +### 6.4 finish runtime + +```text +POST /api/bark-battle/runs/{runId}/finish +``` + +请求:`BarkBattleRunFinishRequest`。响应:`BarkBattleRunResult`。 + +职责: + +- 校验 run token。 +- 校验 run 仍处于 `started` 且未过期。 +- 校验 `work_id + config_version` 与 start 时一致。 +- 校验时长、finalEnergy、metrics 范围。 +- 使用 `module-bark-battle` 生成服务端认可的 `serverWinner`、`score`、`ScoreSummary`、`antiCheatFlags`。 +- 写 `bark_battle_runtime_run` finish 状态与 `bark_battle_score_record`。 +- 如开启 leaderboard 且结果 accepted,写排行榜。 + +### 6.5 作品级游玩埋点 + +`start runtime` 内部必须触发统一埋点;不建议前端单独调用一个 bark-battle 专用埋点 API。若平台已有通用 tracking API,则 api-server 内部调用 platform tracking facade: + +```text +track_event( + event_key = "work_play_start", + scope_kind = "work", + scope_id = workId, + metadata = { playType, workId, sourceRoute, userId } +) +``` + +### 6.6 可选排行榜 + +```text +GET /api/bark-battle/works/{workId}/leaderboard?period=all&limit=50&cursor=... +GET /api/bark-battle/works/{workId}/leaderboard/me +``` + +职责: + +- 只读已接受成绩。 +- 支持分页。 +- 支持匿名用户时隐藏或弱化身份展示。 +- 若作品关闭排行榜,返回空投影或明确 disabled 状态。 + +## 7. SpacetimeDB 与 migration 策略 + +### 7.1 表 / reducer / procedure 边界 + +SpacetimeDB 侧只承载持久化、索引、reducer 原子写入和可查询投影,不承载 HTTP 鉴权或前端表现。 + +建议 reducer / procedure: + +- `bark_battle_save_draft_config`:保存草稿配置。 +- `bark_battle_publish_work_config`:发布配置版本。 +- `bark_battle_start_run`:创建 runtime run。 +- `bark_battle_finish_run`:结束 run 并写 score。 +- `bark_battle_upsert_leaderboard_entry`:写排行榜投影。 +- `bark_battle_get_work_runtime_config`:读取发布态配置。 +- `bark_battle_get_leaderboard`:读取排行榜投影。 + +如当前架构要求 reducer 仅由 `spacetime-client` 调用,则 api-server 不直接操作 SpacetimeDB SDK。 + +### 7.2 migration.rs + +所有表结构变更必须进入 `migration.rs`: + +- 新增 bark-battle 表时写显式 migration。 +- 新增索引、唯一约束或版本字段时写 migration。 +- 从草稿 JSON 拆字段时写数据迁移说明。 +- 不允许只改 Rust struct 而不补 migration。 + +### 7.3 绑定生成 + +涉及 SpacetimeDB schema / reducer 变更后: + +1. 运行仓库既有 SpacetimeDB 绑定生成命令。 +2. 检查 generated bindings 变化。 +3. `spacetime-client` 只引用生成物,不手改生成物。 +4. shared contracts 与 generated bindings 的差异通过 facade 消化,不让前端直接依赖数据库绑定。 + +明确要求:不手改生成物,不手改 generated bindings,不用临时复制粘贴类型绕过生成流程。 + +### 7.4 api-server facade + +`api-server` 通过 `spacetime-client` facade 调用 SpacetimeDB: + +```text +BarkBattleService + -> BarkBattleDomainPolicy + -> BarkBattleSpacetimeClient + -> generated bindings +``` + +这样可以保证: + +- HTTP 层易测试。 +- domain 纯函数可独立测试。 +- SpacetimeDB 绑定变更不会扩散到 route handler。 + +## 8. 安全、隐私与反作弊 + +### 8.1 隐私 + +- 不上传原始音频。 +- 不保存原始音频。 +- 不保存可还原用户声音的高精度采样曲线。 +- 只保存派生指标:峰值、均值、有效叫声次数、节奏命中、最终能量、分数、耗时。 +- 前端权限文案必须说明麦克风只用于本地玩法输入,MVP 不上传原始声音。 + +### 8.2 不信任前端胜负 + +后端不能直接信任: + +- `clientWinner`。 +- `score`。 +- `finalEnergy`。 +- `elapsedMs`。 +- `validBarkCount`。 + +后端必须校验并重算服务端认可结果。MVP 因不上传音频,无法完全证明声音真实性,但仍需做边界反作弊。 + +### 8.3 校验规则 + +必须校验: + +- run token 是否匹配且未使用。 +- run 是否未过期。 +- `work_id + config_version` 是否与 start 时一致。 +- 用户是否有权限游玩该 work。 +- 提交时长是否接近配置时长,例如 30 秒局允许少量网络 / 页面调度误差。 +- `finalEnergy` 是否在配置范围。 +- `peakVolumeMax`、`peakVolumeAvg` 是否在 `0..1`。 +- `validBarkCount`、`rhythmHitCount`、`longestCombo` 是否在物理合理上限。 +- `clientStartedAt` / `clientFinishedAt` 与服务端时间窗口是否合理。 +- 同一用户 / 匿名会话的频率限制。 + +### 8.4 反作弊处理 + +建议结果状态: + +- `accepted`:写 score,可进入排行榜。 +- `accepted_with_flags`:写 score,但标记异常,默认不入榜或降低可信度。 +- `rejected`:不入榜,只记录 run 失败原因。 + +常见 flags: + +- `elapsed_too_short` +- `elapsed_too_long` +- `metric_out_of_range` +- `config_version_mismatch` +- `token_invalid` +- `duplicate_finish` +- `rate_limited` +- `impossible_bark_count` + +## 9. BDD 行为场景、TDD 落地顺序与验收命令 + +本任务只写方案,不执行代码实现。后续落地必须先用 BDD 锁定可观察行为,再按 TDD 做 RED-GREEN-REFACTOR。没有先失败的测试,不进入生产代码实现。 + +### 9.1 BDD 场景清单 + +以下场景用于约束后端行为,场景标题应映射到后续 Rust 测试名、API 测试名或 smoke 用例名。 + +#### 功能: bark-battle 发布态作品运行配置 + +```gherkin +功能: bark-battle 发布态作品运行配置 + 为了让玩家进入稳定的声控狗叫对战作品 + 作为已发布作品的玩家 + 我希望后端只返回发布态且版本一致的运行配置 + + 背景: + 假如平台中存在 playTypeId 为 "bark-battle" 的作品配置 + + 场景: 玩家请求已发布作品的 runtime config + 假如作品处于 published 状态 + 而且请求携带的 configVersion 与当前发布版本一致 + 当玩家请求 bark-battle runtime config + 那么后端应返回 BarkBattleRuntimeConfig + 而且响应中的 workId 应为稳定作品 ID + 而且响应中的 playTypeId 应为 "bark-battle" + 而且响应不应包含草稿态配置或创作者私有字段 + + 场景: 玩家请求未发布作品的 runtime config + 假如作品处于 draft 状态 + 当玩家请求 bark-battle runtime config + 那么后端应返回不可游玩的错误 + 而且不应创建 runtime run + + 场景: 玩家携带过期配置版本进入 runtime + 假如作品已发布 configVersion 为 3 的版本 + 当玩家使用 configVersion 为 2 的请求开始 runtime + 那么后端应拒绝开始本局或返回最新版本提示 + 而且不应写入正式成绩 +``` + +#### 功能: bark-battle runtime start 与统一游玩埋点 + +```gherkin +功能: bark-battle runtime start 与统一游玩埋点 + 为了统计正式作品游玩行为 + 作为数据分析人员 + 我希望玩家开始 bark-battle 正式游玩时写入统一 work_play_start 事件 + + 场景: 玩家成功开始已发布作品运行态 + 假如存在一个已发布的 bark-battle 作品 + 而且玩家有权限游玩该作品 + 当玩家调用 start runtime + 那么后端应创建状态为 started 的 run + 而且应返回 runId、一次性 runToken 与 runtimeConfig + 而且应写入 work_play_start 事件 + 而且事件 scope_kind 应为 work + 而且事件 scope_id 应为稳定作品 ID + 而且 metadata 应包含 playType、workId、sourceRoute 和 userId + + 场景: 无权限玩家尝试开始运行态 + 假如作品不可被当前玩家访问 + 当玩家调用 start runtime + 那么后端应返回权限错误 + 而且不应创建 run + 而且不应写入 work_play_start 事件 +``` + +#### 功能: bark-battle runtime finish 派生成绩提交 + +```gherkin +功能: bark-battle runtime finish 派生成绩提交 + 为了保护用户隐私并保证成绩可信 + 作为后端服务 + 我只接受派生指标并重新裁决正式结果 + + 场景: 玩家完成一局并提交合法派生指标 + 假如玩家已经成功 start runtime + 而且 run 处于 started 状态 + 而且提交的 runToken 与 start 返回一致 + 当玩家提交 elapsedMs、finalEnergy 和 BarkBattleDerivedMetrics + 那么后端应校验派生指标范围 + 而且应重新计算 serverWinner、score 和 ScoreSummary + 而且应把 run 状态改为 finished + 而且响应中的 accepted 应为 true + 而且请求与持久化记录中不应包含原始麦克风音频 + + 场景: 玩家重复提交同一个 run 的 finish + 假如某个 run 已经处于 finished 状态 + 当玩家再次提交 finish + 那么后端应拒绝重复提交 + 而且不应重复写入成绩 + 而且不应重复写入排行榜 + + 场景: 玩家提交不合理的时长 + 假如作品配置 durationMs 为 30000 + 当玩家提交 elapsedMs 明显短于配置时长 + 那么后端应返回 rejected 或 accepted_with_flags + 而且 antiCheatFlags 应包含 elapsed_too_short + 而且该结果默认不应进入排行榜 + + 场景: 玩家提交超出范围的声音指标 + 假如玩家已经成功 start runtime + 当玩家提交 peakVolumeMax 大于 1 或 validBarkCount 超过物理合理上限 + 那么后端应拒绝或打 flag + 而且 antiCheatFlags 应包含 metric_out_of_range 或 impossible_bark_count +``` + +#### 功能: bark-battle 排行榜投影 + +```gherkin +功能: bark-battle 排行榜投影 + 为了让玩家比较同一作品内的有效成绩 + 作为玩家 + 我希望排行榜只展示被后端接受的成绩 + + 场景: 作品开启排行榜且成绩被接受 + 假如作品 leaderboardEnabled 为 true + 而且玩家提交的 finish 结果 accepted 为 true + 当后端完成成绩写入 + 那么后端应写入或更新该作品的排行榜投影 + 而且排行榜排序应优先按 score 降序 + + 场景: 作品关闭排行榜 + 假如作品 leaderboardEnabled 为 false + 当玩家完成一局合法成绩 + 那么后端应记录 run 与 score + 而且不应写入排行榜 entry + + 场景: 带反作弊 flag 的成绩不进入默认榜单 + 假如玩家提交的结果为 accepted_with_flags + 当后端处理排行榜投影 + 那么默认排行榜不应展示该成绩 + 而且该 run 的反作弊标记应可供后台审计 +``` + +#### 功能: bark-battle 隐私边界 + +```gherkin +功能: bark-battle 隐私边界 + 为了保护玩家声音隐私 + 作为平台 + 我不能上传或保存原始麦克风音频 + + 场景: finish 请求包含原始音频字段 + 假如前端误传 audio、audioBase64、waveform 或 pcmSamples 字段 + 当后端解析 finish 请求 + 那么后端应拒绝请求或忽略并记录非法字段 + 而且持久化记录中不应出现可还原声音的数据 + + 场景: 正常 finish 只提交派生聚合指标 + 假如玩家完成一局 bark-battle + 当玩家提交 finish 请求 + 那么请求体应只包含峰值、均值、有效叫声次数、节奏命中、最终能量和耗时等派生指标 + 而且后端只保存这些派生指标与裁决结果 +``` + +### 9.2 BDD 到测试映射 + +| BDD 场景 | 测试层级 | 建议目标文件 | RED 期望 | +| --- | --- | --- | --- | +| 玩家请求已发布作品的 runtime config | contract + API | `server-rs/crates/shared-contracts/src/bark_battle.rs`、`server-rs/crates/api-server/src/bark_battle.rs` | 新 DTO / route 不存在导致编译或断言失败 | +| 玩家请求未发布作品的 runtime config | domain + API | `server-rs/crates/module-bark-battle/src/domain.rs`、`server-rs/crates/api-server/src/bark_battle.rs` | 未发布状态未被拒绝 | +| 玩家携带过期配置版本进入 runtime | domain | `server-rs/crates/module-bark-battle/src/domain.rs` | configVersion mismatch 未返回错误 | +| 玩家成功开始已发布作品运行态 | API + integration | `server-rs/crates/api-server/src/bark_battle.rs` | start 未返回 runToken 或未写 tracking draft | +| 无权限玩家尝试开始运行态 | API | `server-rs/crates/api-server/src/bark_battle.rs` | 无权限仍创建 run 或写埋点 | +| 玩家完成一局并提交合法派生指标 | domain + facade | `server-rs/crates/module-bark-battle/src/domain.rs`、`server-rs/crates/spacetime-client/src/bark_battle.rs` | score / serverWinner 未由后端计算 | +| 玩家重复提交同一个 run 的 finish | SpacetimeDB reducer / facade | `server-rs/crates/spacetime-module/src/...`、`server-rs/crates/spacetime-client/src/bark_battle.rs` | duplicate finish 被接受 | +| 玩家提交不合理的时长 | domain | `server-rs/crates/module-bark-battle/src/domain.rs` | 未产生 elapsed_too_short flag | +| 玩家提交超出范围的声音指标 | domain | `server-rs/crates/module-bark-battle/src/domain.rs` | 未产生 metric_out_of_range / impossible_bark_count | +| 作品开启排行榜且成绩被接受 | domain + projection | `server-rs/crates/module-bark-battle/src/domain.rs`、SpacetimeDB reducer 测试 | accepted 成绩未生成 leaderboard projection | +| 作品关闭排行榜 | domain + projection | `server-rs/crates/module-bark-battle/src/domain.rs`、SpacetimeDB reducer 测试 | leaderboardEnabled=false 仍写榜 | +| 带反作弊 flag 的成绩不进入默认榜单 | domain | `server-rs/crates/module-bark-battle/src/domain.rs` | flagged 成绩仍进入默认榜 | +| finish 请求包含原始音频字段 | contract + API | `server-rs/crates/shared-contracts/src/bark_battle.rs`、API 反序列化测试 | DTO 接受或持久化原始音频字段 | +| 正常 finish 只提交派生聚合指标 | contract | `server-rs/crates/shared-contracts/src/bark_battle.rs` | DTO 缺字段或包含隐私风险字段 | + +### 9.3 TDD RED-GREEN-REFACTOR 切片 + +每个切片必须先写失败测试并运行到预期失败,再写最小实现。 + +#### Slice 1: shared contracts 固定请求/响应形状 + +RED: + +```bash +cd server-rs +cargo test -p shared-contracts bark_battle_contract_uses_camel_case --no-default-features +``` + +先新增测试,断言: + +- `BarkBattleRunStartRequest` 序列化为 `workId`、`configVersion`、`sourceRoute`。 +- `BarkBattleRunFinishRequest` 只包含 `runId`、`runToken`、`workId`、`configVersion`、`elapsedMs`、`finalEnergy`、`clientWinner`、`metrics`。 +- `metrics` 只包含派生聚合字段,不包含 `audio`、`audioBase64`、`waveform`、`pcmSamples`。 + +GREEN:新增 `shared-contracts/src/bark_battle.rs` 与 `lib.rs` 导出,让测试通过。 + +REFACTOR:统一枚举命名、字段注释和默认值策略。 + +#### Slice 2: domain 校验 runtime config 与 finish metrics + +RED: + +```bash +cd server-rs +cargo test -p module-bark-battle validates_runtime_config_and_finish_metrics --no-default-features +``` + +先新增测试,断言: + +- `playTypeId` 必须为 `bark-battle`。 +- `durationMs`、`energyMin`、`energyMax`、`barkThreshold` 必须在合法范围。 +- `peakVolumeMax`、`peakVolumeAvg` 必须在 `0..1`。 +- 过短时长产生 `elapsed_too_short`。 +- 不可能叫声次数产生 `impossible_bark_count`。 + +GREEN:新增 `module-bark-battle` 纯领域类型与校验函数。 + +REFACTOR:把常量收敛为领域常量,避免 magic number 分散。 + +#### Slice 3: domain 重新裁决成绩与排行榜资格 + +RED: + +```bash +cd server-rs +cargo test -p module-bark-battle scores_finish_result_and_filters_leaderboard --no-default-features +``` + +先新增测试,断言: + +- 后端根据 `finalEnergy` 重算 `serverWinner`,不直接信任 `clientWinner`。 +- 合法胜利生成 `accepted=true` 与稳定 `ScoreSummary`。 +- 带 flag 的结果默认 `leaderboardEligible=false`。 +- `leaderboardEnabled=false` 时不生成榜单投影。 + +GREEN:补 `score_finish_result(...)`、`is_leaderboard_eligible(...)` 等纯函数。 + +REFACTOR:拆分 score、winner、anti-cheat、leaderboard 四组小函数。 + +#### Slice 4: SpacetimeDB 表、reducer 与 migration + +RED: + +```bash +cd server-rs +cargo check -p spacetime-module --no-default-features +cargo test -p spacetime-client bark_battle_facade_maps_run_records --no-default-features +``` + +先新增 facade / mapper 测试或编译断言,预期因绑定、表或 reducer 缺失失败。 + +GREEN:新增表、reducer/procedure、`migration.rs`、生成绑定、`spacetime-client` facade。 + +REFACTOR:确保 api-server 不直接依赖 generated bindings,mapper 命名与现有玩法一致。 + +#### Slice 5: api-server start / finish BFF + +RED: + +```bash +cd server-rs +cargo test -p api-server bark_battle_start_records_work_play_start --no-default-features +cargo test -p api-server bark_battle_finish_rejects_duplicate_or_invalid_metrics --no-default-features +``` + +先新增 API 测试,断言: + +- start 成功返回 `runId`、`runToken`、`runtimeConfig`。 +- start 成功后主动补写 `work_play_start`,不只依赖 route-level 兜底。 +- finish 重复提交被拒绝。 +- finish 不接受非法 metrics。 + +GREEN:新增 Axum route、handler、错误映射、tracking 调用。 + +REFACTOR:handler 只做编排,把规则留在 domain,把持久化留在 facade。 + +#### Slice 6: 前端 contract 对齐 + +RED: + +```bash +npm run test -- bark-battle +npm run typecheck +``` + +先新增前端 contract / client 测试,预期因 TS 类型或 client 缺失失败。 + +GREEN:补前端共享 contract mirror、runtime client 调用 start / finish。 + +REFACTOR:删除重复类型,保持后端 DTO 为事实源。 + +### 9.4 后端验收命令 + +按切片逐步运行,不要等全部实现后一次性补测: + +```bash +cd server-rs +cargo test -p shared-contracts bark_battle --no-default-features +cargo test -p module-bark-battle --no-default-features +cargo check -p spacetime-module --no-default-features +cargo check -p spacetime-client --no-default-features +cargo test -p api-server bark_battle --no-default-features +``` + +如涉及 API smoke: + +```bash +npm run api-server +# 另开终端执行对应 bark-battle start / finish smoke 或项目既有 API 测试脚本 +``` + +每次表结构变更后必须同步: + +1. 更新 `migration.rs`。 +2. 重新生成 SpacetimeDB bindings。 +3. 检查 generated bindings 变更只来自生成命令。 +4. 运行 `cargo check -p spacetime-module -p spacetime-client --no-default-features`。 + +### 9.5 前端 contract 对齐验收 + +前端只在后端 contract 稳定后接入: + +```bash +npm run typecheck +npm test -- bark-battle +npm run build +``` + +验收: + +- runtime start / finish 请求字段与 shared contracts 一致。 +- 前端 result panel 展示后端 `RunResult`。 +- 前端本地结算只作为即时反馈,正式结果以后端返回为准。 + +### 9.6 手工验收清单 + +- 可以创建并保存 bark-battle 草稿。 +- 可以发布成稳定作品 ID,`playTypeId = bark-battle`。 +- runtime start 返回 config、runId、runToken。 +- start 写入 `work_play_start`,scope 与 metadata 符合要求。 +- finish 不上传音频,只上传派生指标。 +- finish 返回服务端认可的 result。 +- 异常时长、重复提交、config version mismatch 会被拒绝或打 flag。 +- 排行榜关闭时不写榜;开启时只写 accepted 结果。 + +## 10. 与现有前端方案和 BDD 文档的关系 + +### 10.1 依赖文档 + +- 前端 runtime 方案:`docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md`。 +- BDD / DDD / TDD 总计划:`.hermes/plans/2026-05-11_144229-bark-battle-2d-game-bdd-ddd-tdd-plan.md`。 +- 当前后端实现基线:`docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`。 +- SpacetimeDB 表结构变更约束:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`。 + +### 10.2 与前端方案的对齐点 + +- 前端方案负责 Phaser / Web Audio / DOM HUD;本文负责作品、成绩、排行榜、发布和埋点。 +- 前端 `BarkBattleSnapshot` 可用于本地即时表现,但正式 `RunResult` 以后端返回为准。 +- 前端不上传原始音频,只上传 `BarkBattleDerivedMetrics`。 +- 前端本地 config 应来自后端发布态 `BarkBattleRuntimeConfig`,不能在生产游玩中使用未发布临时配置。 +- 前端 result panel 应能展示后端返回的 score、grade、antiCheatFlags 与 leaderboard entry。 + +### 10.3 与 BDD 的对齐点 + +后续 BDD 场景应覆盖: + +- 玩家从作品页进入 bark-battle runtime。 +- 玩家授权麦克风后开始 30 秒对战。 +- 玩家完成单局后看到后端确认结果。 +- 未授权麦克风时可以看到降级说明,但不写正式成绩。 +- 作品关闭排行榜时不展示排名入口。 +- 作品开启排行榜时展示当前作品排名。 +- 重复 finish / 过期 run / 配置版本不一致时返回可解释错误。 + +### 10.4 后端落地顺序建议 + +1. 先只做 contract + domain,固定 `playTypeId = bark-battle` 与配置 schema。 +2. 再做草稿 / 发布态作品配置读写。 +3. 再做 start / finish run 与 `work_play_start` 埋点。 +4. 最后做排行榜投影。 +5. 实时多人协议另起方案,不与 MVP 混做。 diff --git a/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md index e45de8b5..68aaeb01 100644 --- a/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md +++ b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md @@ -47,7 +47,7 @@ 用户完成热身关所有步骤后,进入关卡选择。 -当前后续游戏仍在设计中。热身结束后可先展示“开始游戏”按钮作为关卡选择占位,用户点击后进入下一关占位界面。 +热身结束后展示“开始游戏”按钮,用户点击后进入宝贝识物首关本地 Demo。该入口只用于热身关后的本地体验验证;正式平台体验仍必须通过“宝贝识物”创作模板发布后,在寓教于乐板块进入。 ### 3.3 固定流程顺序 @@ -642,7 +642,7 @@ 1. `src/ChildMotionDemoApp.tsx` 挂载独立 Demo 应用壳。 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 适配层替换或并行接入。 5. `src/routing/appRoutes.tsx` 新增 `/child-motion-demo` 独立路由,并复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时不允许通过该直达路径进入 Demo。 @@ -669,19 +669,27 @@ 当前未接入但已保留边界: 1. 正式语音播报接口暂不接入,当前先展示热身文案。 -2. 正式 gpt-image-2 视觉资源暂不接入,当前使用 CSS 占位表达相同位置和状态。 -3. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和下一关按钮占位。 +2. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和宝贝识物首关本地 Demo 衔接。 ## 16. 当前视觉资产与生图口径补充 儿童动作 Demo 的视觉口径已经统一收敛到绘本风格草地舞台: 1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。 -2. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格重做,未生成真实背景图时由 CSS 兜底。 -3. 真实背景图的默认输出路径固定为 `public/child-motion-demo/picture-book-grass-stage.webp`。 -4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2-all` 调用 VectorEngine `POST /v1/images/generations`。 -5. 当前本机工作区未检测到 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`,因此暂时只能完成 dry-run 或代码层接入,不能直接产出真实 image-2 资产。 -6. 若后续补齐 VectorEngine 私密配置,再运行 live 生成即可把真实绘本背景写入上述固定路径,页面会自动读取。 +2. 该卡通绘本草地风格是儿童动作 Demo 后续场景、物品、UI 资源的全局风格要求;新增资源不得切回暗色科技风、真实照片风或后台面板风。 +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`,透明资源先生成品红底源图,再在本地移除色键,源图写入 `tmp/child-motion-demo-assets/`。 +5. 当前已生成并接入以下正式 Demo 资源: + - `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 ` 小批量生成;仅调整透明去背、裁切、画布归一或品红边缘时,可用 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only ` 复用 `tmp/child-motion-demo-assets/` 中的源图,不额外请求 image-2;不得把 `VECTOR_ENGINE_API_KEY`、源图或中间预览图提交到仓库。 已执行的定向验证命令: diff --git a/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md b/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md index b65e0164..72833e8a 100644 --- a/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md +++ b/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md @@ -76,3 +76,16 @@ 3. 只有用户显式修改或重置密码后,才允许密码登录。 后续迁移到 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 +``` diff --git a/docs/technical/README.md b/docs/technical/README.md index 3876420a..39f070fb 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,8 +4,12 @@ ## 文档列表 +- [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` 空状态白屏。 - [PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md](./PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md):冻结移动端推荐页隐藏顶部品牌栏、扩大推荐卡片可用高度,以及只在底部作品信息区承接切换作品手势的布局口径。 +- [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 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。 - [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 差异;同时冻结 `shared-contracts` 不得反向依赖 `platform-*`,避免 SpacetimeDB 模块发布时拉入 `wasm-bindgen`。 diff --git a/docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md b/docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md index 36fbee52..9e1d1685 100644 --- a/docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md +++ b/docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md @@ -92,6 +92,25 @@ 2. 若是并入已有手机号正式账号,则返回目标正式账号快照,当前实现会保持其账号主登录方式,例如 `loginMethod = phone`。 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. 当前最小实现策略 当前阶段为了先打通 Rust 后端闭环,采用以下最小实现: @@ -125,6 +144,7 @@ 4. `GET /api/auth/wechat/start` 5. `GET /api/auth/wechat/callback` 6. `POST /api/auth/wechat/bind-phone` +7. `POST /api/auth/wechat/miniprogram-login` ## 6. 环境变量 @@ -139,11 +159,14 @@ 7. `WECHAT_AUTHORIZE_ENDPOINT` 8. `WECHAT_ACCESS_TOKEN_ENDPOINT` 9. `WECHAT_USER_INFO_ENDPOINT` -10. `WECHAT_STATE_TTL_MINUTES` -11. `WECHAT_MOCK_USER_ID` -12. `WECHAT_MOCK_UNION_ID` -13. `WECHAT_MOCK_DISPLAY_NAME` -14. `WECHAT_MOCK_AVATAR_URL` +10. `WECHAT_JS_CODE_SESSION_ENDPOINT` +11. `WECHAT_MINI_PROGRAM_APP_ID` +12. `WECHAT_MINI_PROGRAM_APP_SECRET` +13. `WECHAT_STATE_TTL_MINUTES` +14. `WECHAT_MOCK_USER_ID` +15. `WECHAT_MOCK_UNION_ID` +16. `WECHAT_MOCK_DISPLAY_NAME` +17. `WECHAT_MOCK_AVATAR_URL` ## 7. 与后续 SpacetimeDB 的衔接要求 diff --git a/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md b/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md index 053b67e5..6dd75c74 100644 --- a/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md +++ b/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md @@ -100,6 +100,9 @@ real 模式行为固定为: | `WECHAT_AUTHORIZE_ENDPOINT` | 否 | 默认桌面二维码授权地址 | | `WECHAT_ACCESS_TOKEN_ENDPOINT` | 否 | 默认 access_token 接口 | | `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` 分钟 | 补充说明: @@ -225,7 +228,46 @@ https://game.example.com - `wechatBound = true` - `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` 映射补齐 2. 若后续绑定手机号时发现该手机号已经属于正式账号,则会把微信身份并入这个正式账号 -## 9. 前端验收点 +## 10. 前端验收点 前端联调时至少检查以下行为: diff --git a/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md b/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md new file mode 100644 index 00000000..3584ec12 --- /dev/null +++ b/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md @@ -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= +x-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. 绑定成功后首页全屏打开 H5,URL 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` 的会话记录。 diff --git a/miniprogram/app.js b/miniprogram/app.js new file mode 100644 index 00000000..c060f609 --- /dev/null +++ b/miniprogram/app.js @@ -0,0 +1,10 @@ +App({ + globalData: { + launchOptions: null, + }, + + onLaunch(options) { + // 中文注释:保留启动参数,后续如果要把分享路径映射到 H5 深链,可以从这里统一读取。 + this.globalData.launchOptions = options; + }, +}); diff --git a/miniprogram/app.json b/miniprogram/app.json new file mode 100644 index 00000000..b83a1148 --- /dev/null +++ b/miniprogram/app.json @@ -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" +} diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss new file mode 100644 index 00000000..8cc4f9c8 --- /dev/null +++ b/miniprogram/app.wxss @@ -0,0 +1,5 @@ +page { + min-height: 100vh; + background: #0b0f14; + color: #f5f7fb; +} diff --git a/miniprogram/config.js b/miniprogram/config.js new file mode 100644 index 00000000..20ddf987 --- /dev/null +++ b/miniprogram/config.js @@ -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, +}; diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js new file mode 100644 index 00000000..40065664 --- /dev/null +++ b/miniprogram/pages/web-view/index.js @@ -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); + }, +}); diff --git a/miniprogram/pages/web-view/index.json b/miniprogram/pages/web-view/index.json new file mode 100644 index 00000000..8835af06 --- /dev/null +++ b/miniprogram/pages/web-view/index.json @@ -0,0 +1,3 @@ +{ + "usingComponents": {} +} \ No newline at end of file diff --git a/miniprogram/pages/web-view/index.wxml b/miniprogram/pages/web-view/index.wxml new file mode 100644 index 00000000..5d830465 --- /dev/null +++ b/miniprogram/pages/web-view/index.wxml @@ -0,0 +1,47 @@ + + + + + + + 正在登录 + + + + + + 绑定手机号 + + {{errorMessage}} + + + + + + + + + 无法进入 + {{errorMessage}} + + + diff --git a/miniprogram/pages/web-view/index.wxss b/miniprogram/pages/web-view/index.wxss new file mode 100644 index 00000000..5877f417 --- /dev/null +++ b/miniprogram/pages/web-view/index.wxss @@ -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; +} diff --git a/miniprogram/sitemap.json b/miniprogram/sitemap.json new file mode 100644 index 00000000..1de189d2 --- /dev/null +++ b/miniprogram/sitemap.json @@ -0,0 +1,8 @@ +{ + "rules": [ + { + "action": "allow", + "page": "*" + } + ] +} diff --git a/package.json b/package.json index 9542ad7a..2a848379 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "assets:match3d-style-references": "node scripts/generate-match3d-style-references.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:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.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:guardrails": "npm run lint:eslint", @@ -37,6 +38,8 @@ "format:check": "prettier --check .", "test": "vitest run", "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:data": "node scripts/run-tsx.cjs scripts/validate-content.ts", "check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts", diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 4ceb9b66..2fffab28 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -114,8 +114,9 @@ export type AuthWechatStartResponse = { }; export type AuthWechatBindPhoneRequest = { - phone: string; - code: string; + phone?: string; + code?: string; + wechatPhoneCode?: string; }; export type AuthWechatBindPhoneResponse = { @@ -123,6 +124,16 @@ export type AuthWechatBindPhoneResponse = { user: AuthUser; }; +export type AuthWechatMiniProgramLoginRequest = { + code: string; +}; + +export type AuthWechatMiniProgramLoginResponse = { + token: string; + bindingStatus: AuthBindingStatus; + user: AuthUser; +}; + export type AuthPhoneChangeRequest = { phone: string; code: string; diff --git a/packages/shared/src/contracts/edutainmentBabyObject.ts b/packages/shared/src/contracts/edutainmentBabyObject.ts new file mode 100644 index 00000000..83b0bf1b --- /dev/null +++ b/packages/shared/src/contracts/edutainmentBabyObject.ts @@ -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), + }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 523ae03d..e98aacce 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,6 +6,7 @@ export type * from './contracts/creationAgentDocumentInput'; export type * from './contracts/creationAudio'; export type * from './contracts/creativeAgent'; export type * from './contracts/customWorldAgent'; +export * from './contracts/edutainmentBabyObject'; export type * from './contracts/hyper3d'; export * from './contracts/match3dAgent'; export * from './contracts/match3dRuntime'; @@ -13,8 +14,8 @@ export * from './contracts/match3dWorks'; export * from './contracts/puzzleAgentActions'; export * from './contracts/puzzleAgentDraft'; export * from './contracts/puzzleAgentSession'; -export * from './contracts/puzzleOnboarding'; export type * from './contracts/puzzleCreativeTemplate'; +export * from './contracts/puzzleOnboarding'; export * from './contracts/puzzleResultPreview'; export * from './contracts/puzzleRuntimeSession'; export * from './contracts/puzzleWorkSummary'; diff --git a/project.config.json b/project.config.json new file mode 100644 index 00000000..f526e96e --- /dev/null +++ b/project.config.json @@ -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": {} +} diff --git a/project.private.config.json b/project.private.config.json new file mode 100644 index 00000000..220cf2f7 --- /dev/null +++ b/project.private.config.json @@ -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 + } +} \ No newline at end of file diff --git a/public/bark-battle-assets/bark-battle-image-prompts.json b/public/bark-battle-assets/bark-battle-image-prompts.json new file mode 100644 index 00000000..46f6ca98 --- /dev/null +++ b/public/bark-battle-assets/bark-battle-image-prompts.json @@ -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,不要只生成“王”字" + } +] diff --git a/public/bark-battle-assets/generated/bark-battle-bark-particles.png b/public/bark-battle-assets/generated/bark-battle-bark-particles.png new file mode 100644 index 00000000..0754fde5 Binary files /dev/null and b/public/bark-battle-assets/generated/bark-battle-bark-particles.png differ diff --git a/public/bark-battle-assets/generated/bark-battle-opponent-front.png b/public/bark-battle-assets/generated/bark-battle-opponent-front.png new file mode 100644 index 00000000..75cfd0b5 Binary files /dev/null and b/public/bark-battle-assets/generated/bark-battle-opponent-front.png differ diff --git a/public/bark-battle-assets/generated/bark-battle-player-back.png b/public/bark-battle-assets/generated/bark-battle-player-back.png new file mode 100644 index 00000000..83407cf9 Binary files /dev/null and b/public/bark-battle-assets/generated/bark-battle-player-back.png differ diff --git a/public/child-motion-demo/picture-book-calibration-strip-v2.png b/public/child-motion-demo/picture-book-calibration-strip-v2.png new file mode 100644 index 00000000..ddb4803d Binary files /dev/null and b/public/child-motion-demo/picture-book-calibration-strip-v2.png differ diff --git a/public/child-motion-demo/picture-book-character-outline-v2.png b/public/child-motion-demo/picture-book-character-outline-v2.png new file mode 100644 index 00000000..88a367ce Binary files /dev/null and b/public/child-motion-demo/picture-book-character-outline-v2.png differ diff --git a/public/child-motion-demo/picture-book-foreground-grass-v2.png b/public/child-motion-demo/picture-book-foreground-grass-v2.png new file mode 100644 index 00000000..6f553a13 Binary files /dev/null and b/public/child-motion-demo/picture-book-foreground-grass-v2.png differ diff --git a/public/child-motion-demo/picture-book-grass-stage.png b/public/child-motion-demo/picture-book-grass-stage.png new file mode 100644 index 00000000..84101166 Binary files /dev/null and b/public/child-motion-demo/picture-book-grass-stage.png differ diff --git a/public/child-motion-demo/picture-book-ground-ring-v2.png b/public/child-motion-demo/picture-book-ground-ring-v2.png new file mode 100644 index 00000000..b0ee6573 Binary files /dev/null and b/public/child-motion-demo/picture-book-ground-ring-v2.png differ diff --git a/public/child-motion-demo/picture-book-hud-strip-v2.png b/public/child-motion-demo/picture-book-hud-strip-v2.png new file mode 100644 index 00000000..b50a96d3 Binary files /dev/null and b/public/child-motion-demo/picture-book-hud-strip-v2.png differ diff --git a/public/child-motion-demo/picture-book-start-panel-v2.png b/public/child-motion-demo/picture-book-start-panel-v2.png new file mode 100644 index 00000000..a169bee5 Binary files /dev/null and b/public/child-motion-demo/picture-book-start-panel-v2.png differ diff --git a/public/child-motion-demo/picture-book-ui-button-v2.png b/public/child-motion-demo/picture-book-ui-button-v2.png new file mode 100644 index 00000000..d32d8daa Binary files /dev/null and b/public/child-motion-demo/picture-book-ui-button-v2.png differ diff --git a/scripts/check-wechat-miniprogram-auth-smoke.mjs b/scripts/check-wechat-miniprogram-auth-smoke.mjs new file mode 100644 index 00000000..373e0327 --- /dev/null +++ b/scripts/check-wechat-miniprogram-auth-smoke.mjs @@ -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}`); + } +} diff --git a/scripts/generate-bark-battle-assets.mjs b/scripts/generate-bark-battle-assets.mjs new file mode 100644 index 00000000..0fc94075 --- /dev/null +++ b/scripts/generate-bark-battle-assets.mjs @@ -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)); diff --git a/scripts/generate-child-motion-demo-assets.mjs b/scripts/generate-child-motion-demo-assets.mjs index 4d783f3a..15ebda6c 100644 --- a/scripts/generate-child-motion-demo-assets.mjs +++ b/scripts/generate-child-motion-demo-assets.mjs @@ -1,4 +1,5 @@ import { Buffer } from 'node:buffer'; +import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, @@ -10,16 +11,13 @@ import { fileURLToPath } from 'node:url'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); -const defaultOut = path.join( - repoRoot, - 'public', - 'child-motion-demo', - 'picture-book-grass-stage.webp', -); -const defaultSize = '1536x1024'; +const assetDir = path.join(repoRoot, 'public', 'child-motion-demo'); +const intermediateDir = path.join(repoRoot, 'tmp', 'child-motion-demo-assets'); 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、边框、水印、摄像头画面、真实照片质感。', ].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(); for (let index = 2; index < process.argv.length; index += 1) { 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]; if (next && !next.startsWith('--')) { - args.set(raw, next); + 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); + } index += 1; } else { args.set(raw, true); @@ -138,6 +389,63 @@ function extractBase64Images(payload) { 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) { const abortController = new AbortController(); const timer = setTimeout(() => abortController.abort(), timeoutMs); @@ -180,27 +488,518 @@ async function downloadImage(url, timeoutMs) { } } -const size = String(args.get('--size') || defaultSize); -const outPath = path.resolve(String(args.get('--out') || defaultOut)); -const requestBody = { - model: 'gpt-image-2-all', - prompt, - n: 1, - size, -}; +function outputPathFor(asset) { + if (asset.outputDirectory === 'intermediate') { + return path.join(intermediateDir, asset.output); + } + return path.join(assetDir, asset.output); +} -if (args.has('--dry-run') || !args.has('--live')) { +function sourceOutputPathFor(asset) { + return path.join(intermediateDir, asset.sourceOutput || asset.output); +} + +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', + ], + { + cwd: repoRoot, + encoding: 'utf8', + }, + ); + + if (result.status !== 0) { + throw new Error( + `remove_chroma_key.py failed: ${(result.stderr || result.stdout).trim()}`, + ); + } +} + +function removeUiPanelChromaKey(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', + '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()}`, + ); + } +} + +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( + buildVectorEngineImagesGenerationUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + env.timeoutMs, + ); + + const payload = JSON.parse(payloadText); + const urls = extractImageUrls(payload); + const base64Images = extractBase64Images(payload); + const imageBytes = urls[0] + ? await downloadImage(urls[0], env.timeoutMs) + : base64Images[0] + ? Buffer.from(base64Images[0], 'base64') + : null; + + if (!imageBytes) { + throw new Error(`VectorEngine returned no image for ${asset.id}`); + } + + mkdirSync(assetDir, { recursive: true }); + 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( JSON.stringify( { mode: 'dry-run', - outPath, - body: requestBody, + assets: selectedAssets.map((asset) => { + const body = buildRequestBody(asset, size); + return { + id: asset.id, + outputPath: outputPathFor(asset), + sourceOutputPath: asset.transparent + ? sourceOutputPathFor(asset) + : undefined, + transparent: asset.transparent, + body: { + ...body, + image: body.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); } @@ -217,43 +1016,17 @@ if (!env.baseUrl || !env.apiKey) { process.exit(1); } -const payloadText = await fetchWithTimeout( - buildVectorEngineImagesGenerationUrl(env.baseUrl), - { - method: 'POST', - headers: { - Authorization: `Bearer ${env.apiKey}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }, - env.timeoutMs, -); - -const payload = JSON.parse(payloadText); -const urls = extractImageUrls(payload); -const base64Images = extractBase64Images(payload); -const imageBytes = urls[0] - ? await downloadImage(urls[0], env.timeoutMs) - : base64Images[0] - ? Buffer.from(base64Images[0], 'base64') - : null; - -if (!imageBytes) { - throw new Error('VectorEngine returned no image'); +const force = Boolean(args.get('--force')); +const results = []; +for (const asset of selectedAssets) { + results.push(await generateAsset(asset, env, size, force)); } -mkdirSync(path.dirname(outPath), { recursive: true }); -writeFileSync(outPath, imageBytes); - console.log( JSON.stringify( { ok: true, - file: outPath, - size, - source: urls[0] ? 'url' : 'b64_json', + results, }, null, 2, diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index fd8f6df1..8551a738 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -295,12 +295,62 @@ ensure_spacetime_owner_client_token() { echo "[server-provision] 已同步 SpacetimeDB CLI 登录态;后续首次 publish 将使用同一 client identity。" } +render_nginx_brotli_directives() { + if ! command -v nginx >/dev/null 2>&1; then + echo " # Brotli 未启用:目标服务器未找到 nginx 命令。" + return + fi + + local brotli_snippet + brotli_snippet="$(mktemp)" + cat >"${brotli_snippet}" <<'EOF' +include /etc/nginx/modules-enabled/*.conf; +events {} +http { + brotli on; + brotli_comp_level 4; + brotli_min_length 1024; + brotli_types application/json; +} +EOF + if nginx -t -c "${brotli_snippet}" >/dev/null 2>&1; then + cat <<'EOF' + brotli on; + brotli_comp_level 4; + brotli_min_length 1024; + brotli_types + text/plain + text/css + text/javascript + application/javascript + application/json + application/xml + application/xml+rss + image/svg+xml; +EOF + else + echo " # Brotli 未启用:nginx -t 不接受 brotli 指令。" + fi + rm -f "${brotli_snippet}" +} + +render_nginx_template() { + local template="$1" + local rendered_brotli + rendered_brotli="$(render_nginx_brotli_directives)" + sed \ + -e "s/genarrative.example.com/${SERVER_NAME}/g" \ + -e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/r /dev/stdin" \ + -e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/d" \ + "${template}" <<<"${rendered_brotli}" +} + render_nginx_https_config() { - sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative.conf + render_nginx_template deploy/nginx/genarrative.conf } render_nginx_development_http_config() { - sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative-dev-http.conf + render_nginx_template deploy/nginx/genarrative-dev-http.conf } render_api_env_example() { diff --git a/scripts/loadtest/README.md b/scripts/loadtest/README.md new file mode 100644 index 00000000..0b406675 --- /dev/null +++ b/scripts/loadtest/README.md @@ -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: 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:" +$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='' \ +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 +``` diff --git a/scripts/loadtest/data/works-list.sample.json b/scripts/loadtest/data/works-list.sample.json new file mode 100644 index 00000000..250157bd --- /dev/null +++ b/scripts/loadtest/data/works-list.sample.json @@ -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 + } + } + ] +} diff --git a/scripts/loadtest/extract-works-list-data.mjs b/scripts/loadtest/extract-works-list-data.mjs new file mode 100644 index 00000000..b7738e07 --- /dev/null +++ b/scripts/loadtest/extract-works-list-data.mjs @@ -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 --output [--sample-output ]'; +} + +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; + }); +} diff --git a/scripts/loadtest/extract-works-list-data.test.ts b/scripts/loadtest/extract-works-list-data.test.ts new file mode 100644 index 00000000..28b6f1c5 --- /dev/null +++ b/scripts/loadtest/extract-works-list-data.test.ts @@ -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'), + }); + }); +}); diff --git a/scripts/loadtest/k6-works-list.js b/scripts/loadtest/k6-works-list.js new file mode 100644 index 00000000..45e51a82 --- /dev/null +++ b/scripts/loadtest/k6-works-list.js @@ -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); +} diff --git a/scripts/vite-cli.mjs b/scripts/vite-cli.mjs index 5ef8dafa..2b37e8a0 100644 --- a/scripts/vite-cli.mjs +++ b/scripts/vite-cli.mjs @@ -1,4 +1,7 @@ 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 (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); diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index fd61b824..6df6090f 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -176,7 +176,9 @@ use crate::{ get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection, stream_volcengine_tts_sse, }, - wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login}, + wechat_auth::{ + bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login, + }, }; const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; @@ -346,6 +348,10 @@ pub fn build_router(state: AppState) -> Router { .route("/api/auth/phone/login", post(phone_login)) .route("/api/auth/wechat/start", get(start_wechat_login)) .route("/api/auth/wechat/callback", get(handle_wechat_callback)) + .route( + "/api/auth/wechat/miniprogram-login", + post(login_wechat_mini_program), + ) .route( "/api/auth/wechat/bind-phone", post(bind_wechat_phone).route_layer(middleware::from_fn_with_state( @@ -3728,6 +3734,210 @@ mod tests { ); } + #[tokio::test] + async fn wechat_miniprogram_login_returns_system_token_and_marks_session_source() { + let config = AppConfig { + wechat_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/wechat/miniprogram-login") + .header("content-type", "application/json") + .header("x-client-type", "mini_program") + .header("x-client-runtime", "wechat_mini_program") + .header("x-client-platform", "ios") + .header("x-client-instance-id", "mini-instance-001") + .header("x-mini-program-app-id", "wx-mini-test") + .header("x-mini-program-env", "develop") + .body(Body::from( + serde_json::json!({ + "code": "wx-mini-code-001" + }) + .to_string(), + )) + .expect("mini program login request should build"), + ) + .await + .expect("mini program login request should succeed"); + + assert_eq!(login_response.status(), StatusCode::OK); + let refresh_cookie = login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("refresh cookie should exist") + .to_string(); + let login_body = login_response + .into_body() + .collect() + .await + .expect("mini program login body should collect") + .to_bytes(); + let login_payload: Value = + serde_json::from_slice(&login_body).expect("mini program login payload should be json"); + let token = login_payload["token"] + .as_str() + .expect("system token should exist") + .to_string(); + + assert_eq!( + login_payload["bindingStatus"], + Value::String("pending_bind_phone".to_string()) + ); + assert_eq!( + login_payload["user"]["loginMethod"], + Value::String("wechat".to_string()) + ); + assert!(refresh_cookie.contains("genarrative_refresh_session=")); + + let sessions_response = app + .oneshot( + Request::builder() + .uri("/api/auth/sessions") + .header("authorization", format!("Bearer {token}")) + .header("cookie", refresh_cookie) + .body(Body::empty()) + .expect("sessions request should build"), + ) + .await + .expect("sessions request should succeed"); + + assert_eq!(sessions_response.status(), StatusCode::OK); + let sessions_body = sessions_response + .into_body() + .collect() + .await + .expect("sessions body should collect") + .to_bytes(); + let sessions_payload: Value = + serde_json::from_slice(&sessions_body).expect("sessions payload should be json"); + assert_eq!( + sessions_payload["sessions"][0]["clientType"], + Value::String("mini_program".to_string()) + ); + assert_eq!( + sessions_payload["sessions"][0]["clientRuntime"], + Value::String("wechat_mini_program".to_string()) + ); + assert_eq!( + sessions_payload["sessions"][0]["miniProgramAppId"], + Value::String("wx-mini-test".to_string()) + ); + } + + #[tokio::test] + async fn wechat_miniprogram_bind_phone_code_activates_pending_user() { + let config = AppConfig { + wechat_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/wechat/miniprogram-login") + .header("content-type", "application/json") + .header("x-client-type", "mini_program") + .header("x-client-runtime", "wechat_mini_program") + .header("x-client-platform", "ios") + .header("x-client-instance-id", "mini-bind-instance-001") + .header("x-mini-program-app-id", "wx-mini-test") + .header("x-mini-program-env", "develop") + .body(Body::from( + serde_json::json!({ + "code": "wx-mini-code-bind-001" + }) + .to_string(), + )) + .expect("mini program login request should build"), + ) + .await + .expect("mini program login request should succeed"); + + assert_eq!(login_response.status(), StatusCode::OK); + let login_body = login_response + .into_body() + .collect() + .await + .expect("mini program login body should collect") + .to_bytes(); + let login_payload: Value = + serde_json::from_slice(&login_body).expect("mini program login payload should be json"); + let token = login_payload["token"] + .as_str() + .expect("system token should exist") + .to_string(); + assert_eq!( + login_payload["bindingStatus"], + Value::String("pending_bind_phone".to_string()) + ); + + let bind_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/wechat/bind-phone") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-client-type", "mini_program") + .header("x-client-runtime", "wechat_mini_program") + .header("x-client-platform", "ios") + .header("x-client-instance-id", "mini-bind-instance-001") + .header("x-mini-program-app-id", "wx-mini-test") + .header("x-mini-program-env", "develop") + .body(Body::from( + serde_json::json!({ + "wechatPhoneCode": "13800138000" + }) + .to_string(), + )) + .expect("bind request should build"), + ) + .await + .expect("bind request should succeed"); + + assert_eq!(bind_response.status(), StatusCode::OK); + assert!( + bind_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("genarrative_refresh_session=")) + ); + let bind_body = bind_response + .into_body() + .collect() + .await + .expect("bind body should collect") + .to_bytes(); + let bind_payload: Value = + serde_json::from_slice(&bind_body).expect("bind payload should be json"); + + assert_eq!( + bind_payload["user"]["bindingStatus"], + Value::String("active".to_string()) + ); + assert_eq!(bind_payload["user"]["wechatBound"], Value::Bool(true)); + assert_eq!( + bind_payload["user"]["phoneNumberMasked"], + Value::String("138****8000".to_string()) + ); + assert!( + bind_payload["token"] + .as_str() + .is_some_and(|value| !value.is_empty()) + ); + } + #[tokio::test] async fn wechat_bind_phone_merges_into_existing_phone_user() { let config = AppConfig { @@ -4083,6 +4293,108 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn password_reset_allows_login_with_new_password_only() { + let config = AppConfig { + sms_auth_enabled: true, + ..AppConfig::default() + }; + let state = AppState::new(config).expect("state should build"); + seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await; + let app = build_router(state); + + let send_code_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13800138026", + "scene": "reset_password" + }) + .to_string(), + )) + .expect("reset code request should build"), + ) + .await + .expect("reset code request should succeed"); + assert_eq!(send_code_response.status(), StatusCode::OK); + + let reset_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/password/reset") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13800138026", + "code": "123456", + "newPassword": "secret456" + }) + .to_string(), + )) + .expect("reset password request should build"), + ) + .await + .expect("reset password request should succeed"); + assert_eq!(reset_response.status(), StatusCode::OK); + assert!( + reset_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("genarrative_refresh_session=")) + ); + + let old_password_response = + password_login_request(app.clone(), "13800138026", TEST_PASSWORD).await; + assert_eq!(old_password_response.status(), StatusCode::UNAUTHORIZED); + + let new_password_response = password_login_request(app, "13800138026", "secret456").await; + assert_eq!(new_password_response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn password_change_allows_login_with_new_password_only() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_password_change"); + let app = build_router(state); + + let change_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/password/change") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "currentPassword": TEST_PASSWORD, + "newPassword": "secret456" + }) + .to_string(), + )) + .expect("change password request should build"), + ) + .await + .expect("change password request should succeed"); + assert_eq!(change_response.status(), StatusCode::OK); + + let old_password_response = + password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await; + assert_eq!(old_password_response.status(), StatusCode::UNAUTHORIZED); + + let new_password_response = password_login_request(app, "13800138027", "secret456").await; + assert_eq!(new_password_response.status(), StatusCode::OK); + } + #[tokio::test] async fn password_entry_rejects_email_or_username_identifier() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index d8e42168..6f6a2d47 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -56,11 +56,16 @@ pub struct AppConfig { pub wechat_auth_provider: String, pub wechat_app_id: Option, pub wechat_app_secret: Option, + pub wechat_mini_program_app_id: Option, + pub wechat_mini_program_app_secret: Option, pub wechat_callback_path: String, pub wechat_redirect_path: String, pub wechat_authorize_endpoint: String, pub wechat_access_token_endpoint: String, pub wechat_user_info_endpoint: String, + pub wechat_js_code_session_endpoint: String, + pub wechat_stable_access_token_endpoint: String, + pub wechat_phone_number_endpoint: String, pub wechat_state_ttl_minutes: u32, pub wechat_mock_user_id: String, pub wechat_mock_union_id: Option, @@ -165,12 +170,20 @@ impl Default for AppConfig { wechat_auth_provider: "mock".to_string(), wechat_app_id: None, wechat_app_secret: None, + wechat_mini_program_app_id: None, + wechat_mini_program_app_secret: None, wechat_callback_path: "/api/auth/wechat/callback".to_string(), wechat_redirect_path: "/".to_string(), wechat_authorize_endpoint: "https://open.weixin.qq.com/connect/qrconnect".to_string(), wechat_access_token_endpoint: "https://api.weixin.qq.com/sns/oauth2/access_token" .to_string(), wechat_user_info_endpoint: "https://api.weixin.qq.com/sns/userinfo".to_string(), + wechat_js_code_session_endpoint: "https://api.weixin.qq.com/sns/jscode2session" + .to_string(), + wechat_stable_access_token_endpoint: "https://api.weixin.qq.com/cgi-bin/stable_token" + .to_string(), + wechat_phone_number_endpoint: + "https://api.weixin.qq.com/wxa/business/getuserphonenumber".to_string(), wechat_state_ttl_minutes: 15, wechat_mock_user_id: "wx-mock-user".to_string(), wechat_mock_union_id: Some("wx-mock-union".to_string()), @@ -389,6 +402,10 @@ impl AppConfig { } config.wechat_app_id = read_first_non_empty_env(&["WECHAT_APP_ID"]); config.wechat_app_secret = read_first_non_empty_env(&["WECHAT_APP_SECRET"]); + config.wechat_mini_program_app_id = + read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_APP_ID", "WECHAT_APP_ID"]); + config.wechat_mini_program_app_secret = + read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_APP_SECRET", "WECHAT_APP_SECRET"]); if let Some(wechat_callback_path) = read_first_non_empty_env(&["WECHAT_CALLBACK_PATH"]) { config.wechat_callback_path = wechat_callback_path; } @@ -410,6 +427,21 @@ impl AppConfig { { config.wechat_user_info_endpoint = wechat_user_info_endpoint; } + if let Some(wechat_js_code_session_endpoint) = + read_first_non_empty_env(&["WECHAT_JS_CODE_SESSION_ENDPOINT"]) + { + config.wechat_js_code_session_endpoint = wechat_js_code_session_endpoint; + } + if let Some(wechat_stable_access_token_endpoint) = + read_first_non_empty_env(&["WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT"]) + { + config.wechat_stable_access_token_endpoint = wechat_stable_access_token_endpoint; + } + if let Some(wechat_phone_number_endpoint) = + read_first_non_empty_env(&["WECHAT_PHONE_NUMBER_ENDPOINT"]) + { + config.wechat_phone_number_endpoint = wechat_phone_number_endpoint; + } if let Some(wechat_state_ttl_minutes) = read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"]) { diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index 560e211a..635a517d 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -40,6 +40,13 @@ pub async fn change_password( }) .await .map_err(map_password_management_error)?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; Ok(json_success_body( Some(&request_context), @@ -87,6 +94,13 @@ pub async fn reset_password( module_auth::AuthLoginMethod::Password, ) .await; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 1dde66e5..c075070e 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -57,8 +57,8 @@ use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, - PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, + PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, @@ -2189,7 +2189,9 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft ui_background_prompt: level.ui_background_prompt, ui_background_image_src: level.ui_background_image_src, ui_background_image_object_key: level.ui_background_image_object_key, - background_music: level.background_music.map(map_puzzle_audio_asset_record_response), + background_music: level + .background_music + .map(map_puzzle_audio_asset_record_response), candidates: level .candidates .into_iter() @@ -2506,7 +2508,9 @@ fn map_puzzle_runtime_level_response( theme_tags: level.theme_tags, cover_image_src: level.cover_image_src, ui_background_image_src: level.ui_background_image_src, - background_music: level.background_music.map(map_puzzle_audio_asset_record_response), + background_music: level + .background_music + .map(map_puzzle_audio_asset_record_response), board: map_puzzle_board_response(level.board), status: level.status, started_at_ms: level.started_at_ms, @@ -2800,7 +2804,9 @@ fn parse_puzzle_level_records_from_module_json( ui_background_prompt: level.ui_background_prompt, ui_background_image_src: level.ui_background_image_src, ui_background_image_object_key: level.ui_background_image_object_key, - background_music: level.background_music.map(map_puzzle_audio_asset_domain_record), + background_music: level + .background_music + .map(map_puzzle_audio_asset_domain_record), candidates: level .candidates .into_iter() @@ -3413,6 +3419,24 @@ fn attach_puzzle_level_background_music( }); } +fn attach_puzzle_level_ui_background( + levels: &mut [PuzzleDraftLevelRecord], + level_id: &str, + prompt: String, + generated: GeneratedPuzzleUiBackgroundResponse, +) { + let Some(index) = levels + .iter() + .position(|level| level.level_id == level_id) + .or_else(|| (!levels.is_empty()).then_some(0)) + else { + return; + }; + levels[index].ui_background_prompt = Some(prompt); + levels[index].ui_background_image_src = Some(generated.image_src); + levels[index].ui_background_image_object_key = Some(generated.object_key); +} + async fn try_generate_puzzle_background_music( state: &AppState, owner_user_id: &str, @@ -3456,6 +3480,37 @@ async fn try_generate_puzzle_background_music( } } +async fn try_generate_puzzle_initial_ui_background( + state: &AppState, + owner_user_id: &str, + session_id: &str, + draft: &PuzzleResultDraftRecord, + target_level: &PuzzleDraftLevelRecord, +) -> Option<(String, GeneratedPuzzleUiBackgroundResponse)> { + let prompt = normalize_puzzle_ui_background_prompt("", draft, target_level); + match generate_puzzle_ui_background_image( + state, + owner_user_id, + session_id, + target_level.level_name.as_str(), + prompt.as_str(), + ) + .await + { + Ok(generated) => Some((prompt, generated)), + Err(error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id, + level_id = %target_level.level_id, + error = %error, + "拼图草稿 UI 背景图自动生成失败,保留草稿并允许结果页重试" + ); + None + } + } +} + async fn compile_puzzle_draft_with_initial_cover( state: &AppState, session_id: String, @@ -3540,9 +3595,24 @@ async fn compile_puzzle_draft_with_initial_cover( music, ); } - let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module( - &updated_levels, - )?); + if let Some((ui_prompt, ui_background)) = try_generate_puzzle_initial_ui_background( + state, + owner_user_id.as_str(), + compiled_session.session_id.as_str(), + &draft, + &target_level, + ) + .await + { + attach_puzzle_level_ui_background( + &mut updated_levels, + target_level.level_id.as_str(), + ui_prompt, + ui_background, + ); + } + let levels_json_with_generated_name = + Some(serialize_puzzle_level_records_for_module(&updated_levels)?); let candidates_json = serde_json::to_string( &candidates .iter() @@ -3674,7 +3744,7 @@ async fn compile_puzzle_draft_with_uploaded_cover( &target_level.picture_description, &draft.summary, ); - // 中文注释:关闭 AI 重绘时不请求 VectorEngine,也不进入光点扣费流程;上传图直接成为首关正式图候选。 + // 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine;上传图直接成为首关正式图候选。 let candidate_id = format!( "{}-candidate-{}", compiled_session.session_id, @@ -3714,9 +3784,24 @@ async fn compile_puzzle_draft_with_uploaded_cover( music, ); } - let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module( - &updated_levels, - )?); + if let Some((ui_prompt, ui_background)) = try_generate_puzzle_initial_ui_background( + state, + owner_user_id.as_str(), + compiled_session.session_id.as_str(), + &draft, + &target_level, + ) + .await + { + attach_puzzle_level_ui_background( + &mut updated_levels, + target_level.level_id.as_str(), + ui_prompt, + ui_background, + ); + } + let levels_json_with_generated_name = + Some(serialize_puzzle_level_records_for_module(&updated_levels)?); let persisted_upload = persist_puzzle_generated_asset( state, owner_user_id.as_str(), @@ -4728,12 +4813,12 @@ async fn load_puzzle_ui_background_reference_data_url() -> Result PuzzleAnchorPackRecord { let item = PuzzleAnchorItemRecord { key: "visualSubject".to_string(), diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 7750a5da..60ada394 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1,8 +1,9 @@ use std::{ collections::HashMap, error::Error, - fmt, + fmt, fs, sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, }; use module_ai::{AiTaskService, InMemoryAiTaskStore}; @@ -369,18 +370,18 @@ impl AppState { pool_size: config.spacetime_pool_size, procedure_timeout: config.spacetime_procedure_timeout, }); + let mut candidates = Vec::new(); + match spacetime_client .export_auth_store_snapshot_from_tables() .await { Ok(snapshot) => { - if let Some(snapshot_json) = snapshot.snapshot_json { - if !snapshot_json.trim().is_empty() { - let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) - .map_err(AppStateInitError::AuthStore)?; - info!("已从 SpacetimeDB 表恢复认证快照"); - return Self::new_with_auth_store(config, auth_store); - } + if let Some(candidate) = auth_store_candidate_from_snapshot_record( + snapshot, + AuthStoreRestoreSource::SpacetimeTables, + )? { + candidates.push(candidate); } } Err(error) => { @@ -390,13 +391,11 @@ impl AppState { match spacetime_client.get_auth_store_snapshot().await { Ok(snapshot) => { - if let Some(snapshot_json) = snapshot.snapshot_json { - if !snapshot_json.trim().is_empty() { - let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) - .map_err(AppStateInitError::AuthStore)?; - info!("已从 SpacetimeDB 快照记录恢复认证快照"); - return Self::new_with_auth_store(config, auth_store); - } + if let Some(candidate) = auth_store_candidate_from_snapshot_record( + snapshot, + AuthStoreRestoreSource::SpacetimeSnapshot, + )? { + candidates.push(candidate); } } Err(error) => { @@ -404,6 +403,30 @@ impl AppState { } } + if let Some(candidate) = auth_store_candidate_from_local_file(&config)? { + candidates.push(candidate); + } + + if let Some(candidate) = select_auth_store_restore_candidate(candidates) { + let source = candidate.source; + let should_sync_to_spacetime = source == AuthStoreRestoreSource::LocalFile; + let state = Self::new_with_auth_store(config, candidate.auth_store)?; + info!( + source = source.as_str(), + updated_at_micros = candidate.updated_at_micros, + "已恢复认证快照" + ); + if should_sync_to_spacetime { + if let Err(error) = state.sync_auth_store_snapshot_to_spacetime().await { + warn!( + error = %error, + "本地认证快照回写 SpacetimeDB 失败,当前启动继续" + ); + } + } + return Ok(state); + } + Self::new(config) } @@ -695,6 +718,95 @@ impl AppState { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AuthStoreRestoreSource { + SpacetimeTables, + SpacetimeSnapshot, + LocalFile, +} + +impl AuthStoreRestoreSource { + fn as_str(self) -> &'static str { + match self { + Self::SpacetimeTables => "spacetime_tables", + Self::SpacetimeSnapshot => "spacetime_snapshot", + Self::LocalFile => "local_file", + } + } +} + +#[derive(Debug)] +struct AuthStoreRestoreCandidate { + source: AuthStoreRestoreSource, + updated_at_micros: Option, + auth_store: InMemoryAuthStore, +} + +fn auth_store_candidate_from_snapshot_record( + snapshot: spacetime_client::AuthStoreSnapshotRecord, + source: AuthStoreRestoreSource, +) -> Result, AppStateInitError> { + let Some(snapshot_json) = snapshot + .snapshot_json + .filter(|value| !value.trim().is_empty()) + else { + return Ok(None); + }; + let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) + .map_err(AppStateInitError::AuthStore)?; + + Ok(Some(AuthStoreRestoreCandidate { + source, + updated_at_micros: snapshot.updated_at_micros, + auth_store, + })) +} + +fn auth_store_candidate_from_local_file( + config: &AppConfig, +) -> Result, AppStateInitError> { + if !config.auth_store_path.is_file() { + return Ok(None); + } + + let updated_at_micros = fs::metadata(&config.auth_store_path) + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(system_time_to_unix_micros); + let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone()) + .map_err(AppStateInitError::AuthStore)?; + + Ok(Some(AuthStoreRestoreCandidate { + source: AuthStoreRestoreSource::LocalFile, + updated_at_micros, + auth_store, + })) +} + +fn system_time_to_unix_micros(system_time: SystemTime) -> Option { + let duration = system_time.duration_since(UNIX_EPOCH).ok()?; + i64::try_from(duration.as_micros()).ok() +} + +fn select_auth_store_restore_candidate( + candidates: Vec, +) -> Option { + candidates.into_iter().max_by_key(|candidate| { + ( + candidate.updated_at_micros.unwrap_or(i64::MIN), + auth_store_restore_source_priority(candidate.source), + ) + }) +} + +fn auth_store_restore_source_priority(source: AuthStoreRestoreSource) -> u8 { + match source { + AuthStoreRestoreSource::SpacetimeSnapshot => 3, + AuthStoreRestoreSource::SpacetimeTables => 2, + AuthStoreRestoreSource::LocalFile => 1, + } +} + impl fmt::Display for AppStateInitError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index 2584e504..d7381253 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -5,11 +5,13 @@ use axum::{ response::{IntoResponse, Redirect, Response}, }; use module_auth::{ - AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError, + AuthLoginMethod, BindWechatPhoneInput, BindWechatVerifiedPhoneInput, + CreateWechatAuthStateInput, WechatAuthError, }; use platform_auth::WechatAuthScene; use shared_contracts::auth::{ - WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery, + WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, + WechatMiniProgramLoginRequest, WechatMiniProgramLoginResponse, WechatStartQuery, WechatStartResponse, }; use time::OffsetDateTime; @@ -190,18 +192,55 @@ pub async fn bind_wechat_phone( if !state.config.wechat_auth_enabled { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); } - let result = state - .phone_auth_service() - .bind_wechat_phone( - BindWechatPhoneInput { + let result = if let Some(wechat_phone_code) = payload + .wechat_phone_code + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let phone_profile = state + .wechat_provider() + .resolve_mini_program_phone_number(Some(wechat_phone_code)) + .await + .map_err(map_wechat_provider_error)?; + state + .phone_auth_service() + .bind_wechat_verified_phone(BindWechatVerifiedPhoneInput { user_id: authenticated.claims().user_id().to_string(), - phone_number: payload.phone, - verify_code: payload.code, - }, - OffsetDateTime::now_utc(), - ) - .await - .map_err(map_wechat_bind_phone_error)?; + phone_number: phone_profile.phone_number, + }) + .await + .map_err(map_wechat_bind_phone_error)? + } else { + let phone = payload + .phone + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少需要绑定的手机号") + })?; + let code = payload + .code + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少短信验证码") + })?; + state + .phone_auth_service() + .bind_wechat_phone( + BindWechatPhoneInput { + user_id: authenticated.claims().user_id().to_string(), + phone_number: phone.to_string(), + verify_code: code.to_string(), + }, + OffsetDateTime::now_utc(), + ) + .await + .map_err(map_wechat_bind_phone_error)? + }; if result.activated_new_user { crate::registration_reward::grant_new_user_registration_wallet_reward( &state, @@ -250,6 +289,68 @@ pub async fn bind_wechat_phone( )) } +pub async fn login_wechat_mini_program( + State(state): State, + Extension(request_context): Extension, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + if !state.config.wechat_auth_enabled { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); + } + let code = payload.code.trim(); + if code.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少微信授权 code") + ); + } + + let profile = state + .wechat_provider() + .resolve_mini_program_login_profile(Some(code)) + .await + .map_err(map_wechat_provider_error)?; + let result = state + .wechat_auth_service() + .resolve_login(module_auth::ResolveWechatLoginInput { + profile: map_wechat_profile_to_domain(profile), + }) + .await + .map_err(map_wechat_auth_error)?; + let session_client = resolve_session_client_context(&headers); + let signed_session = create_auth_session( + &state, + &result.user, + &session_client, + AuthLoginMethod::Wechat, + )?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; + + let mut response_headers = HeaderMap::new(); + attach_set_cookie_header( + &mut response_headers, + build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?, + ); + + Ok(( + response_headers, + json_success_body( + Some(&request_context), + WechatMiniProgramLoginResponse { + token: signed_session.access_token, + binding_status: result.user.binding_status.as_str().to_string(), + user: map_auth_user_payload(result.user), + }, + ), + )) +} + fn resolve_wechat_scene(user_agent: Option<&str>) -> Result { let user_agent = user_agent.unwrap_or_default(); let is_wechat = user_agent.contains("MicroMessenger"); diff --git a/server-rs/crates/api-server/src/wechat_provider.rs b/server-rs/crates/api-server/src/wechat_provider.rs index da4448ef..60722cb8 100644 --- a/server-rs/crates/api-server/src/wechat_provider.rs +++ b/server-rs/crates/api-server/src/wechat_provider.rs @@ -1,6 +1,8 @@ use platform_auth::{ DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT, - DEFAULT_WECHAT_USER_INFO_ENDPOINT, WechatAuthConfig, WechatProvider, + DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT, + DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_USER_INFO_ENDPOINT, + WechatAuthConfig, WechatProvider, }; use crate::config::AppConfig; @@ -11,6 +13,8 @@ pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider { config.wechat_auth_provider.clone(), config.wechat_app_id.clone(), config.wechat_app_secret.clone(), + config.wechat_mini_program_app_id.clone(), + config.wechat_mini_program_app_secret.clone(), normalize_wechat_endpoint( &config.wechat_authorize_endpoint, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT, @@ -23,6 +27,18 @@ pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider { &config.wechat_user_info_endpoint, DEFAULT_WECHAT_USER_INFO_ENDPOINT, ), + normalize_wechat_endpoint( + &config.wechat_js_code_session_endpoint, + DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, + ), + normalize_wechat_endpoint( + &config.wechat_stable_access_token_endpoint, + DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, + ), + normalize_wechat_endpoint( + &config.wechat_phone_number_endpoint, + DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT, + ), config.wechat_mock_user_id.clone(), config.wechat_mock_union_id.clone(), config.wechat_mock_display_name.clone(), diff --git a/server-rs/crates/module-auth/src/commands.rs b/server-rs/crates/module-auth/src/commands.rs index 35302bd3..da48cffb 100644 --- a/server-rs/crates/module-auth/src/commands.rs +++ b/server-rs/crates/module-auth/src/commands.rs @@ -67,6 +67,12 @@ pub struct BindWechatPhoneInput { pub verify_code: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BindWechatVerifiedPhoneInput { + pub user_id: String, + pub phone_number: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CreateRefreshSessionInput { pub user_id: String, diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 78bbd7ef..6b1ac1e4 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -627,6 +627,33 @@ impl PhoneAuthService { activated_new_user, }) } + + pub async fn bind_wechat_verified_phone( + &self, + input: BindWechatVerifiedPhoneInput, + ) -> Result { + let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + let current_user = self + .store + .find_by_user_id(&input.user_id) + .map_err(map_password_error_to_phone_error)? + .ok_or(PhoneAuthError::UserNotFound)?; + if current_user.user.binding_status != AuthBindingStatus::PendingBindPhone { + return Err(PhoneAuthError::UserStateMismatch); + } + if !current_user.user.wechat_bound { + return Err(PhoneAuthError::UserStateMismatch); + } + + let (merged_user, activated_new_user) = self + .store + .bind_wechat_phone_to_user(&input.user_id, normalized_phone)?; + + Ok(BindWechatPhoneResult { + user: merged_user, + activated_new_user, + }) + } } impl WechatAuthStateService { diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index bccf1127..1d7be11b 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -40,6 +40,12 @@ pub const DEFAULT_WECHAT_IN_APP_AUTHORIZE_ENDPOINT: &str = pub const DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT: &str = "https://api.weixin.qq.com/sns/oauth2/access_token"; pub const DEFAULT_WECHAT_USER_INFO_ENDPOINT: &str = "https://api.weixin.qq.com/sns/userinfo"; +pub const DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT: &str = + "https://api.weixin.qq.com/sns/jscode2session"; +pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str = + "https://api.weixin.qq.com/cgi-bin/stable_token"; +pub const DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT: &str = + "https://api.weixin.qq.com/wxa/business/getuserphonenumber"; type HmacSha256 = Hmac; @@ -176,9 +182,14 @@ pub struct WechatAuthConfig { pub provider: String, pub app_id: Option, pub app_secret: Option, + pub mini_program_app_id: Option, + pub mini_program_app_secret: Option, pub authorize_endpoint: String, pub access_token_endpoint: String, pub user_info_endpoint: String, + pub js_code_session_endpoint: String, + pub stable_access_token_endpoint: String, + pub phone_number_endpoint: String, pub mock_user_id: String, pub mock_union_id: Option, pub mock_display_name: String, @@ -211,11 +222,23 @@ pub struct MockWechatProvider { #[derive(Clone, Debug)] pub struct RealWechatProvider { client: Client, - app_id: String, - app_secret: String, + app_id: Option, + app_secret: Option, + mini_program_app_id: Option, + mini_program_app_secret: Option, authorize_endpoint: String, access_token_endpoint: String, user_info_endpoint: String, + js_code_session_endpoint: String, + stable_access_token_endpoint: String, + phone_number_endpoint: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatPhoneNumberProfile { + pub phone_number: String, + pub pure_phone_number: Option, + pub country_code: Option, } #[derive(Clone, Debug)] @@ -309,6 +332,39 @@ struct WechatUserInfoResponse { errmsg: Option, } +#[derive(Debug, Deserialize)] +struct WechatJsCodeSessionResponse { + openid: Option, + unionid: Option, + errcode: Option, + errmsg: Option, +} + +#[derive(Debug, Deserialize)] +struct WechatStableAccessTokenResponse { + access_token: Option, + errcode: Option, + errmsg: Option, +} + +#[derive(Debug, Deserialize)] +struct WechatPhoneNumberResponse { + errcode: Option, + errmsg: Option, + #[serde(default)] + phone_info: Option, +} + +#[derive(Debug, Deserialize)] +struct WechatPhoneNumberInfo { + #[serde(default)] + phone_number: Option, + #[serde(default)] + pure_phone_number: Option, + #[serde(default)] + country_code: Option, +} + #[derive(Debug, Deserialize)] struct AliyunSendSmsVerifyCodeResponse { // 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。 @@ -626,9 +682,14 @@ impl WechatAuthConfig { provider: String, app_id: Option, app_secret: Option, + mini_program_app_id: Option, + mini_program_app_secret: Option, authorize_endpoint: String, access_token_endpoint: String, user_info_endpoint: String, + js_code_session_endpoint: String, + stable_access_token_endpoint: String, + phone_number_endpoint: String, mock_user_id: String, mock_union_id: Option, mock_display_name: String, @@ -639,9 +700,14 @@ impl WechatAuthConfig { provider, app_id, app_secret, + mini_program_app_id, + mini_program_app_secret, authorize_endpoint, access_token_endpoint, user_info_endpoint, + js_code_session_endpoint, + stable_access_token_endpoint, + phone_number_endpoint, mock_user_id, mock_union_id, mock_display_name, @@ -665,20 +731,38 @@ impl WechatProvider { }); } - let Some(app_id) = config.app_id else { - return Self::Disabled; - }; - let Some(app_secret) = config.app_secret else { + let has_web_oauth_config = config + .app_id + .as_ref() + .is_some_and(|value| !value.is_empty()) + && config + .app_secret + .as_ref() + .is_some_and(|value| !value.is_empty()); + let has_mini_program_config = config + .mini_program_app_id + .as_ref() + .is_some_and(|value| !value.is_empty()) + && config + .mini_program_app_secret + .as_ref() + .is_some_and(|value| !value.is_empty()); + if !has_web_oauth_config && !has_mini_program_config { return Self::Disabled; }; Self::Real(RealWechatProvider { client: Client::new(), - app_id, - app_secret, + app_id: config.app_id, + app_secret: config.app_secret, + mini_program_app_id: config.mini_program_app_id, + mini_program_app_secret: config.mini_program_app_secret, authorize_endpoint: config.authorize_endpoint, access_token_endpoint: config.access_token_endpoint, user_info_endpoint: config.user_info_endpoint, + js_code_session_endpoint: config.js_code_session_endpoint, + stable_access_token_endpoint: config.stable_access_token_endpoint, + phone_number_endpoint: config.phone_number_endpoint, }) } @@ -706,6 +790,39 @@ impl WechatProvider { Self::Real(provider) => provider.resolve_callback_profile(code).await, } } + + pub async fn resolve_mini_program_login_profile( + &self, + code: Option<&str>, + ) -> Result { + match self { + Self::Disabled => Err(WechatProviderError::Disabled), + Self::Mock(provider) => Ok(provider.resolve_callback_profile(code)), + Self::Real(provider) => provider.resolve_mini_program_login_profile(code).await, + } + } + + pub async fn resolve_mini_program_phone_number( + &self, + code: Option<&str>, + ) -> Result { + match self { + Self::Disabled => Err(WechatProviderError::Disabled), + Self::Mock(_) => { + let phone_number = code + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("13800138000") + .to_string(); + Ok(WechatPhoneNumberProfile { + phone_number: phone_number.clone(), + pure_phone_number: Some(phone_number), + country_code: Some("86".to_string()), + }) + } + Self::Real(provider) => provider.resolve_mini_program_phone_number(code).await, + } + } } impl MockWechatProvider { @@ -738,8 +855,11 @@ impl RealWechatProvider { let mut url = Url::parse(endpoint).map_err(|error| { WechatProviderError::InvalidConfig(format!("微信授权地址非法:{error}")) })?; + let app_id = self.app_id.as_ref().ok_or_else(|| { + WechatProviderError::InvalidConfig("微信开放平台 AppID 未配置".to_string()) + })?; url.query_pairs_mut() - .append_pair("appid", &self.app_id) + .append_pair("appid", app_id) .append_pair("redirect_uri", callback_url) .append_pair("response_type", "code") .append_pair( @@ -762,13 +882,19 @@ impl RealWechatProvider { .filter(|value| !value.is_empty()) .ok_or(WechatProviderError::MissingCode)?; + let app_id = self.app_id.as_ref().ok_or_else(|| { + WechatProviderError::InvalidConfig("微信开放平台 AppID 未配置".to_string()) + })?; + let app_secret = self.app_secret.as_ref().ok_or_else(|| { + WechatProviderError::InvalidConfig("微信开放平台 AppSecret 未配置".to_string()) + })?; let mut access_token_url = Url::parse(&self.access_token_endpoint).map_err(|error| { WechatProviderError::InvalidConfig(format!("微信 access_token 地址非法:{error}")) })?; access_token_url .query_pairs_mut() - .append_pair("appid", &self.app_id) - .append_pair("secret", &self.app_secret) + .append_pair("appid", app_id) + .append_pair("secret", app_secret) .append_pair("code", code) .append_pair("grant_type", "authorization_code"); @@ -854,6 +980,219 @@ impl RealWechatProvider { avatar_url: user_info_payload.headimgurl, }) } + + async fn resolve_mini_program_login_profile( + &self, + code: Option<&str>, + ) -> Result { + let code = code + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(WechatProviderError::MissingCode)?; + let app_id = self + .mini_program_app_id + .as_ref() + .or(self.app_id.as_ref()) + .ok_or_else(|| { + WechatProviderError::InvalidConfig("微信小程序 AppID 未配置".to_string()) + })?; + let app_secret = self + .mini_program_app_secret + .as_ref() + .or(self.app_secret.as_ref()) + .ok_or_else(|| { + WechatProviderError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()) + })?; + + let mut js_code_session_url = + Url::parse(&self.js_code_session_endpoint).map_err(|error| { + WechatProviderError::InvalidConfig(format!("微信 jscode2session 地址非法:{error}")) + })?; + js_code_session_url + .query_pairs_mut() + .append_pair("appid", app_id) + .append_pair("secret", app_secret) + .append_pair("js_code", code) + .append_pair("grant_type", "authorization_code"); + + let payload = self + .client + .get(js_code_session_url.as_str()) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信小程序 jscode2session 请求失败"); + WechatProviderError::RequestFailed( + "微信小程序登录失败:jscode2session 请求失败".to_string(), + ) + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信小程序 jscode2session 响应解析失败"); + WechatProviderError::DeserializeFailed( + "微信小程序登录失败:jscode2session 响应非法".to_string(), + ) + })?; + + if let Some(errcode) = payload.errcode.filter(|value| *value != 0) { + return Err(WechatProviderError::Upstream(format!( + "微信小程序登录失败:{}", + payload + .errmsg + .unwrap_or_else(|| format!("jscode2session 返回错误 {errcode}")) + ))); + } + + let provider_uid = payload + .openid + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + WechatProviderError::MissingProfile("微信小程序登录失败:缺少 openid".to_string()) + })?; + + Ok(WechatIdentityProfile { + provider_uid, + provider_union_id: payload.unionid, + display_name: None, + avatar_url: None, + }) + } + + async fn resolve_mini_program_phone_number( + &self, + code: Option<&str>, + ) -> Result { + let code = code + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(WechatProviderError::MissingCode)?; + let app_id = self + .mini_program_app_id + .as_ref() + .or(self.app_id.as_ref()) + .ok_or_else(|| { + WechatProviderError::InvalidConfig("微信小程序 AppID 未配置".to_string()) + })?; + let app_secret = self + .mini_program_app_secret + .as_ref() + .or(self.app_secret.as_ref()) + .ok_or_else(|| { + WechatProviderError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()) + })?; + + let access_token = self + .request_mini_program_access_token(app_id, app_secret) + .await?; + let mut phone_number_url = Url::parse(&self.phone_number_endpoint).map_err(|error| { + WechatProviderError::InvalidConfig(format!("微信手机号接口地址非法:{error}")) + })?; + phone_number_url + .query_pairs_mut() + .append_pair("access_token", &access_token); + + let payload = self + .client + .post(phone_number_url.as_str()) + .json(&serde_json::json!({ "code": code })) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信小程序手机号请求失败"); + WechatProviderError::RequestFailed("微信手机号授权失败:手机号请求失败".to_string()) + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信小程序手机号响应解析失败"); + WechatProviderError::DeserializeFailed( + "微信手机号授权失败:手机号响应非法".to_string(), + ) + })?; + + if let Some(errcode) = payload.errcode.filter(|value| *value != 0) { + return Err(WechatProviderError::Upstream(format!( + "微信手机号授权失败:{}", + payload + .errmsg + .unwrap_or_else(|| format!("getuserphonenumber 返回错误 {errcode}")) + ))); + } + + let phone_info = payload.phone_info.ok_or_else(|| { + WechatProviderError::MissingProfile("微信手机号授权失败:缺少手机号信息".to_string()) + })?; + let phone_number = phone_info + .pure_phone_number + .clone() + .or(phone_info.phone_number.clone()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + WechatProviderError::MissingProfile("微信手机号授权失败:缺少手机号".to_string()) + })?; + + Ok(WechatPhoneNumberProfile { + phone_number, + pure_phone_number: phone_info.pure_phone_number, + country_code: phone_info.country_code, + }) + } + + async fn request_mini_program_access_token( + &self, + app_id: &str, + app_secret: &str, + ) -> Result { + let url = Url::parse(&self.stable_access_token_endpoint).map_err(|error| { + WechatProviderError::InvalidConfig(format!("微信 stable_token 地址非法:{error}")) + })?; + let payload = self + .client + .post(url.as_str()) + .json(&serde_json::json!({ + "grant_type": "client_credential", + "appid": app_id, + "secret": app_secret, + "force_refresh": false + })) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信小程序 stable_token 请求失败"); + WechatProviderError::RequestFailed( + "微信手机号授权失败:access_token 请求失败".to_string(), + ) + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信小程序 stable_token 响应解析失败"); + WechatProviderError::DeserializeFailed( + "微信手机号授权失败:access_token 响应非法".to_string(), + ) + })?; + + if let Some(errcode) = payload.errcode.filter(|value| *value != 0) { + return Err(WechatProviderError::Upstream(format!( + "微信手机号授权失败:{}", + payload + .errmsg + .unwrap_or_else(|| format!("stable_token 返回错误 {errcode}")) + ))); + } + + payload + .access_token + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + WechatProviderError::Upstream( + payload + .errmsg + .unwrap_or_else(|| "微信手机号授权失败:缺少 access_token".to_string()), + ) + }) + } } fn build_mock_wechat_authorization_url( @@ -1777,9 +2116,14 @@ mod tests { "mock".to_string(), None, None, + None, + None, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(), DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(), DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(), + DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT.to_string(), + DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT.to_string(), + DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT.to_string(), "wx-user-001".to_string(), Some("wx-union-001".to_string()), "微信测试用户".to_string(), @@ -1805,9 +2149,14 @@ mod tests { "mock".to_string(), None, None, + None, + None, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(), DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(), DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(), + DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT.to_string(), + DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT.to_string(), + DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT.to_string(), "wx-user-001".to_string(), Some("wx-union-001".to_string()), "微信测试用户".to_string(), diff --git a/server-rs/crates/shared-contracts/README.md b/server-rs/crates/shared-contracts/README.md index ce9bfcb0..753f99c9 100644 --- a/server-rs/crates/shared-contracts/README.md +++ b/server-rs/crates/shared-contracts/README.md @@ -32,6 +32,7 @@ 7. `auth/wechat/start` 8. `auth/wechat/callback` 9. `auth/wechat/bind-phone` +10. `auth/wechat/miniprogram-login` 当前阶段继续补齐的 Stage3 公开请求 DTO: diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 3bd58ccf..038133a4 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -211,8 +211,12 @@ pub struct WechatCallbackQuery { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WechatBindPhoneRequest { - pub phone: String, - pub code: String, + #[serde(default)] + pub phone: Option, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub wechat_phone_code: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -222,6 +226,20 @@ pub struct WechatBindPhoneResponse { pub user: AuthUserPayload, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WechatMiniProgramLoginRequest { + pub code: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WechatMiniProgramLoginResponse { + pub token: String, + pub binding_status: String, + pub user: AuthUserPayload, +} + pub fn build_available_login_methods( sms_auth_enabled: bool, password_auth_enabled: bool, @@ -318,4 +336,23 @@ mod tests { }) ); } + + #[test] + fn wechat_bind_phone_request_accepts_mini_program_phone_code() { + let payload = serde_json::to_value(WechatBindPhoneRequest { + phone: None, + code: None, + wechat_phone_code: Some("wx-phone-code-001".to_string()), + }) + .expect("payload should serialize"); + + assert_eq!( + payload, + json!({ + "phone": null, + "code": null, + "wechatPhoneCode": "wx-phone-code-001" + }) + ); + } } diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs index 48c7300c..8f9cf75a 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -1,5 +1,5 @@ use crate::*; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; #[spacetimedb::table( accessor = custom_world_profile, @@ -1549,11 +1549,26 @@ fn list_custom_world_gallery_snapshots( ) -> Result, String> { sync_missing_custom_world_gallery_entries(ctx)?; - let mut entries = ctx + let entries = ctx .db .custom_world_gallery_entry() .iter() - .map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row)) + .collect::>(); + let profile_ids = entries + .iter() + .map(|row| row.profile_id.clone()) + .collect::>(); + let recent_play_counts = count_recent_public_work_plays_for_profiles( + ctx, + "custom-world", + &profile_ids, + ctx.timestamp.to_micros_since_unix_epoch(), + ); + let mut entries = entries + .iter() + .map(|row| { + build_custom_world_gallery_entry_snapshot_with_recent_counts(row, &recent_play_counts) + }) .collect::>(); entries.sort_by(|left, right| { @@ -5078,6 +5093,19 @@ fn build_custom_world_draft_card_snapshot( fn build_custom_world_gallery_entry_snapshot( ctx: &ReducerContext, row: &CustomWorldGalleryEntry, +) -> CustomWorldGalleryEntrySnapshot { + let recent_play_counts = count_recent_public_work_plays_for_profiles( + ctx, + "custom-world", + &[row.profile_id.clone()], + ctx.timestamp.to_micros_since_unix_epoch(), + ); + build_custom_world_gallery_entry_snapshot_with_recent_counts(row, &recent_play_counts) +} + +fn build_custom_world_gallery_entry_snapshot_with_recent_counts( + row: &CustomWorldGalleryEntry, + recent_play_counts: &HashMap, ) -> CustomWorldGalleryEntrySnapshot { CustomWorldGalleryEntrySnapshot { profile_id: row.profile_id.clone(), @@ -5095,12 +5123,10 @@ fn build_custom_world_gallery_entry_snapshot( play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, - recent_play_count_7d: count_recent_public_work_plays( - ctx, - "custom-world", - &row.profile_id, - ctx.timestamp.to_micros_since_unix_epoch(), - ), + recent_play_count_7d: recent_play_counts + .get(&row.profile_id) + .copied() + .unwrap_or(0), published_at_micros: row.published_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 5e3dfa0c..d0333118 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1,8 +1,9 @@ use crate::runtime::{ ProfilePlayedWorkUpsertInput, ProfileSaveArchiveUpsertInput, PublicWorkLikeRecordInput, PublicWorkPlayRecordInput, add_profile_observed_play_time, count_recent_public_work_plays, - grant_profile_wallet_points, record_public_work_like, record_public_work_play, - upsert_profile_played_work, upsert_profile_save_archive, + count_recent_public_work_plays_for_profiles, grant_profile_wallet_points, + record_public_work_like, record_public_work_play, upsert_profile_played_work, + upsert_profile_save_archive, }; use module_puzzle::{ PUZZLE_MAX_TAG_COUNT, PUZZLE_NEXT_LEVEL_MODE_NONE, PUZZLE_NEXT_LEVEL_MODE_SAME_WORK, @@ -1480,12 +1481,21 @@ fn delete_puzzle_work_tx( fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result, String> { let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); - let mut items = ctx + let rows = ctx .db .puzzle_work_profile() .iter() .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) - .map(|row| build_puzzle_work_profile_from_row_with_recent_count(ctx, &row, now_micros)) + .collect::>(); + let profile_ids = rows + .iter() + .map(|row| row.profile_id.clone()) + .collect::>(); + let recent_play_counts = + count_recent_public_work_plays_for_profiles(ctx, "puzzle", &profile_ids, now_micros); + let mut items = rows + .iter() + .map(|row| build_puzzle_work_profile_from_row_with_recent_counts(row, &recent_play_counts)) .collect::, _>>()?; items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); Ok(items) @@ -2356,6 +2366,18 @@ fn build_puzzle_work_profile_from_row_with_recent_count( Ok(profile) } +fn build_puzzle_work_profile_from_row_with_recent_counts( + row: &PuzzleWorkProfileRow, + recent_play_counts: &std::collections::HashMap, +) -> Result { + let mut profile = build_puzzle_work_profile_from_row_without_recent_count(row)?; + profile.recent_play_count_7d = recent_play_counts + .get(&row.profile_id) + .copied() + .unwrap_or(0); + Ok(profile) +} + fn build_puzzle_work_profile_from_row_without_recent_count( row: &PuzzleWorkProfileRow, ) -> Result { diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 81b55dcf..e0fcfcd5 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -1,4 +1,5 @@ use crate::*; +use std::collections::{HashMap, HashSet}; const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; @@ -1298,25 +1299,94 @@ pub(crate) fn count_recent_public_work_plays( profile_id: &str, now_micros: i64, ) -> u32 { + count_recent_public_work_plays_for_profiles( + ctx, + source_type, + &[profile_id.to_string()], + now_micros, + ) + .remove(profile_id.trim()) + .unwrap_or(0) +} + +pub(crate) fn count_recent_public_work_plays_for_profiles( + ctx: &ReducerContext, + source_type: &str, + profile_ids: &[String], + now_micros: i64, +) -> HashMap { let source_type = source_type.trim(); - let profile_id = profile_id.trim(); - if source_type.is_empty() || profile_id.is_empty() { - return 0; + if source_type.is_empty() || profile_ids.is_empty() { + return HashMap::new(); } let current_day = public_work_play_day_from_micros(now_micros); let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); - - ctx.db - .public_work_play_daily_stat() + let requested_profile_ids = profile_ids .iter() - .filter(|row| { - row.source_type == source_type - && row.profile_id == profile_id - && row.played_day >= first_day - && row.played_day <= current_day - }) - .fold(0u32, |total, row| total.saturating_add(row.play_count)) + .map(|profile_id| profile_id.trim()) + .filter(|profile_id| !profile_id.is_empty()) + .collect::>(); + let mut counts = HashMap::new(); + + for profile_id in requested_profile_ids { + let mut total = 0u32; + for played_day in first_day..=current_day { + let day_total = ctx + .db + .public_work_play_daily_stat() + .by_public_work_play_daily_stat_work_day() + .filter((source_type, profile_id, played_day)) + .fold(0u32, |sum, row| sum.saturating_add(row.play_count)); + total = total.saturating_add(day_total); + } + if total > 0 { + counts.insert(profile_id.to_string(), total); + } + } + + counts +} + +#[cfg(test)] +fn build_recent_public_work_play_counts( + rows: impl IntoIterator, + source_type: &str, + profile_ids: &[String], + now_micros: i64, +) -> HashMap { + let source_type = source_type.trim(); + if source_type.is_empty() || profile_ids.is_empty() { + return HashMap::new(); + } + + let requested_profile_ids = profile_ids + .iter() + .map(|profile_id| profile_id.trim()) + .filter(|profile_id| !profile_id.is_empty()) + .collect::>(); + if requested_profile_ids.is_empty() { + return HashMap::new(); + } + + let current_day = public_work_play_day_from_micros(now_micros); + let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); + let mut counts = HashMap::new(); + + for row in rows { + if row.source_type != source_type + || !requested_profile_ids.contains(row.profile_id.as_str()) + || row.played_day < first_day + || row.played_day > current_day + { + continue; + } + + let entry = counts.entry(row.profile_id.clone()).or_insert(0u32); + *entry = entry.saturating_add(row.play_count); + } + + counts } fn public_work_play_day_from_micros(value: i64) -> i64 { @@ -1335,6 +1405,75 @@ fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str) format!("{source_type}:{profile_id}:{user_id}") } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recent_public_work_play_counts_group_requested_profiles_in_window() { + let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10; + let updated_at = Timestamp::from_micros_since_unix_epoch(now_micros); + let rows = vec![ + PublicWorkPlayDailyStat { + stat_id: "puzzle:profile-a:10".to_string(), + source_type: "puzzle".to_string(), + owner_user_id: "user-a".to_string(), + profile_id: "profile-a".to_string(), + played_day: 10, + play_count: 3, + updated_at, + }, + PublicWorkPlayDailyStat { + stat_id: "puzzle:profile-a:4".to_string(), + source_type: "puzzle".to_string(), + owner_user_id: "user-a".to_string(), + profile_id: "profile-a".to_string(), + played_day: 4, + play_count: 5, + updated_at, + }, + PublicWorkPlayDailyStat { + stat_id: "puzzle:profile-a:3".to_string(), + source_type: "puzzle".to_string(), + owner_user_id: "user-a".to_string(), + profile_id: "profile-a".to_string(), + played_day: 3, + play_count: 99, + updated_at, + }, + PublicWorkPlayDailyStat { + stat_id: "custom-world:profile-a:10".to_string(), + source_type: "custom-world".to_string(), + owner_user_id: "user-a".to_string(), + profile_id: "profile-a".to_string(), + played_day: 10, + play_count: 7, + updated_at, + }, + PublicWorkPlayDailyStat { + stat_id: "puzzle:profile-b:9".to_string(), + source_type: "puzzle".to_string(), + owner_user_id: "user-b".to_string(), + profile_id: "profile-b".to_string(), + played_day: 9, + play_count: 11, + updated_at, + }, + ]; + + let counts = build_recent_public_work_play_counts( + rows, + "puzzle", + &["profile-a".to_string(), "profile-b".to_string()], + now_micros, + ); + + assert_eq!(counts.get("profile-a"), Some(&8)); + assert_eq!(counts.get("profile-b"), Some(&11)); + assert_eq!(counts.get("profile-c"), None); + } +} + fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) { if ctx .db diff --git a/src/BarkBattlePlaygroundApp.tsx b/src/BarkBattlePlaygroundApp.tsx new file mode 100644 index 00000000..9651e129 --- /dev/null +++ b/src/BarkBattlePlaygroundApp.tsx @@ -0,0 +1,5 @@ +import { BarkBattleRuntimeShell } from './games/bark-battle/ui/BarkBattleRuntimeShell'; + +export default function BarkBattlePlaygroundApp() { + return ; +} diff --git a/src/Match3DPlaygroundApp.tsx b/src/Match3DPlaygroundApp.tsx index ba37e6a8..f446f865 100644 --- a/src/Match3DPlaygroundApp.tsx +++ b/src/Match3DPlaygroundApp.tsx @@ -6,51 +6,64 @@ import type { } from '../packages/shared/src/contracts/match3dRuntime'; import { Match3DRuntimeShell } from './components/match3d-runtime'; import { - confirmLocalMatch3DClick, - resolveLocalMatch3DTimer, + createLocalMatch3DRuntimeAdapter, + type Match3DRuntimeAdapter, startLocalMatch3DRun, } from './services/match3d-runtime'; -function buildInitialRun() { +type LocalMatch3DRuntimeSession = { + adapter: Match3DRuntimeAdapter; + initialRun: Match3DRunSnapshot; +}; + +function resolveClearCountParam() { const params = new URLSearchParams(window.location.search); const clearCountParam = params.get('clearCount') ?? params.get('count'); const clearCount = clearCountParam === null ? 12 : Number.parseInt(clearCountParam, 10); - return startLocalMatch3DRun( - Number.isFinite(clearCount) && clearCount > 0 ? clearCount : 12, - ); + return Number.isFinite(clearCount) && clearCount > 0 ? clearCount : 12; +} + +function buildInitialRuntimeSession(): LocalMatch3DRuntimeSession { + const initialRun = startLocalMatch3DRun(resolveClearCountParam()); + return { + adapter: createLocalMatch3DRuntimeAdapter({ initialRun }), + initialRun, + }; } export default function Match3DPlaygroundApp() { - const [run, setRun] = useState(buildInitialRun); - const authorityRunRef = useRef(run); + const runtimeSessionRef = useRef(buildInitialRuntimeSession()); + const [run, setRun] = useState( + runtimeSessionRef.current.initialRun, + ); const syncRun = useCallback((nextRun: Match3DRunSnapshot) => { setRun(nextRun); }, []); const handleClickItem = useCallback(async (payload: Match3DClickItemRequest) => { - const result = await confirmLocalMatch3DClick(authorityRunRef.current, payload); - authorityRunRef.current = result.run; + const runId = payload.runId ?? runtimeSessionRef.current.initialRun.runId; + const result = await runtimeSessionRef.current.adapter.clickItem(runId, payload); setRun(result.run); return result; }, []); const handleRestart = useCallback(() => { - const nextRun = buildInitialRun(); - authorityRunRef.current = nextRun; - setRun(nextRun); - }, []); + void runtimeSessionRef.current.adapter.restartRun(run.runId).then(({ run }) => { + setRun(run); + }); + }, [run.runId]); const handleExit = useCallback(() => { window.location.assign('/'); }, []); const handleTimeExpired = useCallback(() => { - const nextRun = resolveLocalMatch3DTimer(authorityRunRef.current); - authorityRunRef.current = nextRun; - setRun(nextRun); - }, []); + void runtimeSessionRef.current.adapter.finishTimeUp(run.runId).then(({ run }) => { + setRun(run); + }); + }, [run.runId]); return ( ({ }), })); +vi.mock('../ResolvedAssetImage', () => ({ + ResolvedAssetImage: ({ + src, + alt, + className, + }: { + src?: string | null; + alt?: string; + className?: string; + }) => (src ? {alt} : null), +})); + beforeEach(() => { resetChildMotionWarmupRuntimeSession(); vi.restoreAllMocks(); @@ -71,6 +83,18 @@ test('re-entering within the same runtime session opens the start button', () => expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy(); }); +test('start button opens the baby object match level', () => { + markChildMotionWarmupCompletedInRuntime(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: '开始游戏' })); + + expect(screen.getByTestId('baby-object-match-runtime')).toBeTruthy(); + expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy(); + expect(screen.queryByText('下一关正在设计中')).toBeNull(); +}); + test('developer keyboard input moves the avatar and triggers jump state', () => { render(); diff --git a/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx b/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx index 031b5367..f7e15b9a 100644 --- a/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx +++ b/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx @@ -4,12 +4,19 @@ import type { } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import { + BABY_OBJECT_MATCH_EDUTAINMENT_TAG, + BABY_OBJECT_MATCH_TEMPLATE_ID, + BABY_OBJECT_MATCH_TEMPLATE_NAME, +} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { MocapConnectionStatus, MocapHandInput, MocapInputCommand, } from '../../services/useMocapInput'; import { useMocapInput } from '../../services/useMocapInput'; +import { BabyObjectMatchRuntimeShell } from '../edutainment-runtime/BabyObjectMatchRuntimeShell'; import { applyChildMotionWarmupCompletion, CHILD_MOTION_CENTER_X, @@ -33,6 +40,41 @@ type CameraAccessState = 'idle' | 'requesting' | 'ready' | 'blocked'; type MotionSourceState = 'connecting' | 'ready' | 'waiting' | 'offline'; type WarmupMocapGestureIntent = 'greeting' | 'left-hand' | 'right-hand' | 'jump'; +const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = { + draftId: 'child-motion-demo-baby-object-draft', + profileId: 'child-motion-demo-baby-object-profile', + templateId: BABY_OBJECT_MATCH_TEMPLATE_ID, + templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME, + workTitle: '宝贝识物', + workDescription: '苹果和香蕉识物分类', + itemNames: ['苹果', '香蕉'], + itemAssets: [ + { + itemId: 'child-motion-demo-baby-object-apple', + itemName: '苹果', + imageSrc: + 'data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 512 512%22%3E%3Crect width=%22512%22 height=%22512%22 rx=%2296%22 fill=%22%23fff1d6%22/%3E%3Ccircle cx=%22256%22 cy=%22266%22 r=%22122%22 fill=%22%23ef5b5b%22/%3E%3Cpath d=%22M250 148c20-50 58-66 102-54-18 45-52 70-102 54Z%22 fill=%22%2351a45f%22/%3E%3Cpath d=%22M256 150c-8-34 2-62 28-84%22 stroke=%22%23734822%22 stroke-width=%2218%22 stroke-linecap=%22round%22 fill=%22none%22/%3E%3Ccircle cx=%22216%22 cy=%22226%22 r=%2218%22 fill=%22%23fff%22 opacity=%22.65%22/%3E%3C/svg%3E', + assetObjectId: null, + generationProvider: 'placeholder', + prompt: '苹果', + }, + { + itemId: 'child-motion-demo-baby-object-banana', + itemName: '香蕉', + imageSrc: + 'data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 512 512%22%3E%3Crect width=%22512%22 height=%22512%22 rx=%2296%22 fill=%22%23e9f7ff%22/%3E%3Cpath d=%22M142 302c128 74 228 38 278-122 14 144-84 244-226 220-52-9-84-38-52-98Z%22 fill=%22%23ffd75d%22/%3E%3Cpath d=%22M406 180c6-20 18-34 38-44%22 stroke=%22%238b5b22%22 stroke-width=%2218%22 stroke-linecap=%22round%22/%3E%3Cpath d=%22M158 310c70 40 152 42 218-38%22 stroke=%22%23fff2a7%22 stroke-width=%2220%22 stroke-linecap=%22round%22 fill=%22none%22 opacity=%22.72%22/%3E%3C/svg%3E', + assetObjectId: null, + generationProvider: 'placeholder', + prompt: '香蕉', + }, + ], + themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'], + publicationStatus: 'published', + createdAt: '2026-05-11T00:00:00.000Z', + updatedAt: '2026-05-11T00:00:00.000Z', + publishedAt: '2026-05-11T00:00:00.000Z', +}; + const WARMUP_MOCAP_WAVE_MIN_POINTS = 3; const WARMUP_MOCAP_WAVE_MIN_X_RANGE = 0.055; @@ -246,7 +288,6 @@ function getStepIndex(stepId: ChildMotionWarmupStepId) { 'jump_once', 'warmup_finish', 'level_select', - 'play_placeholder', ]; return Math.max(0, order.indexOf(stepId)); } @@ -377,6 +418,7 @@ export function ChildMotionWarmupDemo() { const [stepId, setStepId] = useState(() => hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive', ); + const [isBabyObjectRuntimeOpen, setIsBabyObjectRuntimeOpen] = useState(false); const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X); const [calibration, setCalibration] = useState( createEmptyChildMotionCalibration, @@ -778,16 +820,24 @@ export function ChildMotionWarmupDemo() { } }; - const handleStartPlaceholderLevel = () => { - setStepId('play_placeholder'); - }; - - const handleReturnToStart = () => { - setStepId('level_select'); + const handleStartBabyObjectLevel = () => { + setIsBabyObjectRuntimeOpen(true); }; const lineText = useMemo(() => step.spokenLines.join(','), [step.spokenLines]); + if (isBabyObjectRuntimeOpen) { + return ( + { + setIsBabyObjectRuntimeOpen(false); + setStepId('level_select'); + }} + /> + ); + } + return (
@@ -846,21 +896,12 @@ export function ChildMotionWarmupDemo() { {step.kind === 'levelSelect' ? (
-
) : null} - {step.kind === 'placeholder' ? ( -
- 下一关正在设计中 - -
- ) : null} -
diff --git a/src/components/child-motion-demo/childMotionWarmupModel.test.ts b/src/components/child-motion-demo/childMotionWarmupModel.test.ts index 16c04b07..c1156283 100644 --- a/src/components/child-motion-demo/childMotionWarmupModel.test.ts +++ b/src/components/child-motion-demo/childMotionWarmupModel.test.ts @@ -25,14 +25,11 @@ describe('childMotionWarmupModel', () => { 'jump_once', 'warmup_finish', 'level_select', - 'play_placeholder', ]); expect(resolveNextChildMotionWarmupStep('center_arrive')).toBe( 'wave_greeting', ); - expect(resolveNextChildMotionWarmupStep('level_select')).toBe( - 'play_placeholder', - ); + expect(resolveNextChildMotionWarmupStep('level_select')).toBe('level_select'); }); it('checks position completion against the active green ring target', () => { diff --git a/src/components/child-motion-demo/childMotionWarmupModel.ts b/src/components/child-motion-demo/childMotionWarmupModel.ts index f009fe7a..6af8c961 100644 --- a/src/components/child-motion-demo/childMotionWarmupModel.ts +++ b/src/components/child-motion-demo/childMotionWarmupModel.ts @@ -10,8 +10,7 @@ export type ChildMotionWarmupStepId = | 'wave_right_hand' | 'jump_once' | 'warmup_finish' - | 'level_select' - | 'play_placeholder'; + | 'level_select'; export type ChildMotionWarmupTarget = 'center' | 'left' | 'right'; @@ -20,8 +19,7 @@ export type ChildMotionWarmupStepKind = | 'gesture' | 'narration' | 'finish' - | 'levelSelect' - | 'placeholder'; + | 'levelSelect'; export type ChildMotionWarmupStep = { id: ChildMotionWarmupStepId; @@ -151,12 +149,6 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [ title: '准备开始', spokenLines: ['现在开始我们的游戏吧'], }, - { - id: 'play_placeholder', - kind: 'placeholder', - title: '下一关', - spokenLines: ['游戏关卡正在准备中'], - }, ]; const STEP_BY_ID = new Map( diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 21872731..3774d610 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; @@ -65,6 +66,8 @@ type CustomWorldCreationHubProps = { onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null; onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null; claimingPuzzleProfileId?: string | null; + babyObjectMatchItems?: BabyObjectMatchDraft[]; + onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null; visualNovelItems?: VisualNovelWorkSummary[]; onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null; onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null; @@ -167,6 +170,8 @@ export function CustomWorldCreationHub({ onDeletePuzzle = null, onClaimPuzzlePointIncentive = null, claimingPuzzleProfileId = null, + babyObjectMatchItems = [], + onOpenBabyObjectMatchDetail = null, visualNovelItems = [], onOpenVisualNovelDetail = null, onDeleteVisualNovel = null, @@ -189,6 +194,7 @@ export function CustomWorldCreationHub({ match3dItems, squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [], puzzleItems, + babyObjectMatchItems, visualNovelItems, canDeleteRpg: Boolean(onDeletePublished), canDeleteBigFish: Boolean(onDeleteBigFish), @@ -197,11 +203,27 @@ export function CustomWorldCreationHub({ isSquareHoleCreationVisible && Boolean(onDeleteSquareHole), canDeletePuzzle: Boolean(onDeletePuzzle), canDeleteVisualNovel: Boolean(onDeleteVisualNovel), + onOpenRpgDraft: onOpenDraft, + onEnterRpgPublished: onEnterPublished, + onDeleteRpg: onDeletePublished ?? undefined, + onOpenBigFishDetail, + onDeleteBigFish: onDeleteBigFish ?? undefined, + onOpenMatch3DDetail, + onDeleteMatch3D: onDeleteMatch3D ?? undefined, + onOpenSquareHoleDetail, + onDeleteSquareHole: onDeleteSquareHole ?? undefined, + onOpenPuzzleDetail, + onDeletePuzzle: onDeletePuzzle ?? undefined, + onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined, + onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined, + onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined, + onDeleteVisualNovel: onDeleteVisualNovel ?? undefined, getItemState: getWorkState, }), [ bigFishItems, isSquareHoleCreationVisible, + babyObjectMatchItems, items, match3dItems, onDeleteBigFish, @@ -210,6 +232,15 @@ export function CustomWorldCreationHub({ onDeletePublished, onDeletePuzzle, onDeleteVisualNovel, + onClaimPuzzlePointIncentive, + onOpenBigFishDetail, + onOpenDraft, + onOpenMatch3DDetail, + onOpenBabyObjectMatchDetail, + onOpenPuzzleDetail, + onOpenSquareHoleDetail, + onOpenVisualNovelDetail, + onEnterPublished, getWorkState, puzzleItems, rpgLibraryEntries, @@ -238,12 +269,13 @@ export function CustomWorldCreationHub({ ); function handleOpenShelfItem(item: CreationWorkShelfItem) { - onOpenShelfItem?.(item); - switch (item.source.kind) { case 'puzzle': onOpenPuzzleDetail?.(item.source.item); return; + case 'baby-object-match': + onOpenBabyObjectMatchDetail?.(item.source.item); + return; case 'visual-novel': onOpenVisualNovelDetail?.(item.source.item); return; @@ -273,55 +305,11 @@ export function CustomWorldCreationHub({ return null; } - switch (item.source.kind) { - case 'puzzle': { - const sourceItem = item.source.item; - return () => { - onDeletePuzzle?.(sourceItem); - }; - } - case 'visual-novel': { - const sourceItem = item.source.item; - return () => { - onDeleteVisualNovel?.(sourceItem); - }; - } - case 'big-fish': { - const sourceItem = item.source.item; - return () => { - onDeleteBigFish?.(sourceItem); - }; - } - case 'match3d': { - const sourceItem = item.source.item; - return () => { - onDeleteMatch3D?.(sourceItem); - }; - } - case 'square-hole': { - const sourceItem = item.source.item; - return () => { - onDeleteSquareHole?.(sourceItem); - }; - } - case 'rpg': { - const sourceItem = item.source.item; - return () => { - onDeletePublished?.(sourceItem); - }; - } - } + return item.actions.delete ?? null; } function buildPointIncentiveAction(item: CreationWorkShelfItem) { - if (item.source.kind !== 'puzzle' || !onClaimPuzzlePointIncentive) { - return null; - } - - const sourceItem = item.source.item; - return () => { - onClaimPuzzlePointIncentive(sourceItem); - }; + return item.actions.claimPointIncentive ?? null; } const showStartCard = mode !== 'works-only'; @@ -390,7 +378,10 @@ export function CustomWorldCreationHub({ previousMetricValues={ metricSnapshot[buildWorkMetricCacheItemKey(item)] } - onOpen={() => handleOpenShelfItem(item)} + onOpen={() => { + onOpenShelfItem?.(item); + item.actions.open(); + }} onDelete={buildDeleteAction(item)} deleteBusy={deletingWorkId === item.id} onClaimPointIncentive={buildPointIncentiveAction(item)} diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index b9cd428d..57036046 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -1,5 +1,6 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { buildCreationWorkShelfItems } from './creationWorkShelf'; test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => { @@ -45,3 +46,98 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code', expect(items[1]?.status).toBe('draft'); expect(items[1]?.publicWorkCode).toBeNull(); }); + +test('buildCreationWorkShelfItems attaches open and delete actions through shelf adapters', () => { + const onOpenPuzzleDetail = vi.fn(); + const onDeletePuzzle = vi.fn(); + const puzzleWork = { + workId: 'puzzle:work-action', + profileId: 'puzzle-profile-action', + ownerUserId: 'user-1', + authorDisplayName: '测试作者', + levelName: '动作拼图', + summary: '验证作品架动作 Adapter。', + themeTags: [], + coverImageSrc: null, + publicationStatus: 'draft' as const, + updatedAt: '2026-05-08T00:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + }; + + const [item] = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [puzzleWork], + onOpenPuzzleDetail, + onDeletePuzzle, + }); + + item?.actions.open(); + item?.actions.delete?.(); + + expect(onOpenPuzzleDetail).toHaveBeenCalledWith(puzzleWork); + expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork); +}); + +test('buildCreationWorkShelfItems maps baby object match local drafts', () => { + const baseDraft: BabyObjectMatchDraft = { + draftId: 'baby-object-draft-1', + profileId: 'baby-object-profile-12345678', + templateId: 'baby-object-match', + templateName: '宝贝识物', + workTitle: '宝贝识物', + workDescription: '苹果和香蕉识物分类', + itemNames: ['苹果', '香蕉'], + itemAssets: [ + { + itemId: 'baby-object-item-1', + itemName: '苹果', + imageSrc: '/apple.png', + assetObjectId: null, + generationProvider: 'placeholder', + prompt: '苹果', + }, + { + itemId: 'baby-object-item-2', + itemName: '香蕉', + imageSrc: '/banana.png', + assetObjectId: null, + generationProvider: 'placeholder', + prompt: '香蕉', + }, + ], + themeTags: ['寓教于乐'], + publicationStatus: 'draft', + createdAt: '2026-05-11T00:00:00.000Z', + updatedAt: '2026-05-11T00:00:00.000Z', + publishedAt: null, + }; + + const items = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [], + babyObjectMatchItems: [ + baseDraft, + { + ...baseDraft, + draftId: 'baby-object-draft-2', + profileId: 'baby-object-profile-87654321', + publicationStatus: 'published', + publishedAt: '2026-05-11T01:00:00.000Z', + updatedAt: '2026-05-11T01:00:00.000Z', + }, + ], + }); + + expect(items[0]?.kind).toBe('baby-object-match'); + expect(items[0]?.status).toBe('published'); + expect(items[0]?.publicWorkCode).toBe('BO-87654321'); + expect(items[0]?.sharePath).toContain('/works/detail?work=BO-87654321'); + expect(items[1]?.status).toBe('draft'); + expect(items[1]?.publicWorkCode).toBeNull(); +}); diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 095999c9..4465da4d 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -1,5 +1,6 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; @@ -7,6 +8,7 @@ import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contrac import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { + buildBabyObjectMatchPublicWorkCode, buildBigFishPublicWorkCode, buildMatch3DPublicWorkCode, buildPuzzlePublicWorkCode, @@ -21,6 +23,7 @@ export type CreationWorkShelfKind = | 'match3d' | 'square-hole' | 'puzzle' + | 'baby-object-match' | 'visual-novel'; export type CreationWorkShelfStatus = 'draft' | 'published'; @@ -77,8 +80,18 @@ export type CreationWorkShelfSource = | { kind: 'visual-novel'; item: VisualNovelWorkSummary; + } + | { + kind: 'baby-object-match'; + item: BabyObjectMatchDraft; }; +export type CreationWorkShelfActions = { + open: () => void; + delete?: () => void; + claimPointIncentive?: () => void; +}; + export type CreationWorkShelfItem = { id: string; kind: CreationWorkShelfKind; @@ -99,6 +112,7 @@ export type CreationWorkShelfItem = { badges: CreationWorkShelfBadge[]; metrics: CreationWorkShelfMetric[]; pointIncentive?: CreationWorkShelfPointIncentive; + actions: CreationWorkShelfActions; source: CreationWorkShelfSource; }; @@ -109,6 +123,7 @@ export function buildCreationWorkShelfItems(params: { match3dItems?: Match3DWorkSummary[]; squareHoleItems?: SquareHoleWorkSummary[]; puzzleItems: PuzzleWorkSummary[]; + babyObjectMatchItems?: BabyObjectMatchDraft[]; visualNovelItems?: VisualNovelWorkSummary[]; canDeleteRpg?: boolean; canDeleteBigFish?: boolean; @@ -116,6 +131,21 @@ export function buildCreationWorkShelfItems(params: { canDeleteSquareHole?: boolean; canDeletePuzzle?: boolean; canDeleteVisualNovel?: boolean; + onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void; + onEnterRpgPublished?: (profileId: string) => void; + onDeleteRpg?: (item: CustomWorldWorkSummary) => void; + onOpenBigFishDetail?: (item: BigFishWorkSummary) => void; + onDeleteBigFish?: (item: BigFishWorkSummary) => void; + onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void; + onDeleteMatch3D?: (item: Match3DWorkSummary) => void; + onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void; + onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void; + onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; + onDeletePuzzle?: (item: PuzzleWorkSummary) => void; + onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void; + onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void; + onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void; + onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void; getItemState?: ( item: CreationWorkShelfItem, ) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null; @@ -127,6 +157,7 @@ export function buildCreationWorkShelfItems(params: { match3dItems = [], squareHoleItems = [], puzzleItems, + babyObjectMatchItems = [], visualNovelItems = [], canDeleteRpg = false, canDeleteBigFish = false, @@ -134,27 +165,67 @@ export function buildCreationWorkShelfItems(params: { canDeleteSquareHole = false, canDeletePuzzle = false, canDeleteVisualNovel = false, + onOpenRpgDraft, + onEnterRpgPublished, + onDeleteRpg, + onOpenBigFishDetail, + onDeleteBigFish, + onOpenMatch3DDetail, + onDeleteMatch3D, + onOpenSquareHoleDetail, + onDeleteSquareHole, + onOpenPuzzleDetail, + onDeletePuzzle, + onClaimPuzzlePointIncentive, + onOpenBabyObjectMatchDetail, + onOpenVisualNovelDetail, + onDeleteVisualNovel, getItemState, } = params; return [ ...rpgItems.map((item) => - mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries), + mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, { + onOpenDraft: onOpenRpgDraft, + onEnterPublished: onEnterRpgPublished, + onDelete: onDeleteRpg, + }), ), ...bigFishItems.map((item) => - mapBigFishWorkToShelfItem(item, canDeleteBigFish), + mapBigFishWorkToShelfItem(item, canDeleteBigFish, { + onOpen: onOpenBigFishDetail, + onDelete: onDeleteBigFish, + }), ), ...match3dItems.map((item) => - mapMatch3DWorkToShelfItem(item, canDeleteMatch3D), + mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, { + onOpen: onOpenMatch3DDetail, + onDelete: onDeleteMatch3D, + }), ), ...squareHoleItems.map((item) => - mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole), + mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, { + onOpen: onOpenSquareHoleDetail, + onDelete: onDeleteSquareHole, + }), ), ...puzzleItems.map((item) => - mapPuzzleWorkToShelfItem(item, canDeletePuzzle), + mapPuzzleWorkToShelfItem(item, canDeletePuzzle, { + onOpen: onOpenPuzzleDetail, + onDelete: onDeletePuzzle, + onClaimPointIncentive: onClaimPuzzlePointIncentive, + }), + ), + ...babyObjectMatchItems.map((item) => + mapBabyObjectMatchDraftToShelfItem(item, { + onOpen: onOpenBabyObjectMatchDetail, + }), ), ...visualNovelItems.map((item) => - mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel), + mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, { + onOpen: onOpenVisualNovelDetail, + onDelete: onDeleteVisualNovel, + }), ), ] .map((item) => { @@ -173,10 +244,26 @@ export function buildCreationWorkShelfItems(params: { ); } +type RpgWorkShelfAdapter = { + onOpenDraft?: (item: CustomWorldWorkSummary) => void; + onEnterPublished?: (profileId: string) => void; + onDelete?: (item: CustomWorldWorkSummary) => void; +}; + +type WorkShelfAdapter = { + onOpen?: (item: TItem) => void; + onDelete?: (item: TItem) => void; +}; + +type PuzzleWorkShelfAdapter = WorkShelfAdapter & { + onClaimPointIncentive?: (item: PuzzleWorkSummary) => void; +}; + function mapRpgWorkToShelfItem( item: CustomWorldWorkSummary, canDelete: boolean, libraryEntries: CustomWorldLibraryEntry[], + adapter: RpgWorkShelfAdapter, ): CreationWorkShelfItem { const isDraft = item.status === 'draft'; const libraryEntry = item.profileId @@ -217,6 +304,7 @@ function mapRpgWorkToShelfItem( : '查看详情', canDelete, canShare: item.status === 'published' && Boolean(publicWorkCode), + actions: buildRpgWorkShelfActions(item, adapter), badges, metrics: isDraft ? [] : metrics, source: { kind: 'rpg', item }, @@ -226,6 +314,7 @@ function mapRpgWorkToShelfItem( function mapBigFishWorkToShelfItem( item: BigFishWorkSummary, canDelete: boolean, + adapter: WorkShelfAdapter, ): CreationWorkShelfItem { const isPublished = item.status === 'published'; const publicWorkCode = isPublished @@ -250,6 +339,7 @@ function mapBigFishWorkToShelfItem( openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情', canDelete, canShare: isPublished && Boolean(publicWorkCode), + actions: buildWorkShelfActions(item, adapter), badges: [ buildStatusBadge(item.status), { id: 'type', label: '大鱼', tone: 'neutral' }, @@ -268,6 +358,7 @@ function mapBigFishWorkToShelfItem( function mapMatch3DWorkToShelfItem( item: Match3DWorkSummary, canDelete: boolean, + adapter: WorkShelfAdapter, ): CreationWorkShelfItem { const status = item.publicationStatus === 'published' ? 'published' : 'draft'; const publicWorkCode = @@ -291,6 +382,7 @@ function mapMatch3DWorkToShelfItem( openActionLabel: status === 'published' ? '查看详情' : '继续创作', canDelete, canShare: status === 'published' && Boolean(publicWorkCode), + actions: buildWorkShelfActions(item, adapter), badges: [ buildStatusBadge(status), { id: 'type', label: '抓鹅', tone: 'neutral' }, @@ -310,6 +402,7 @@ function mapMatch3DWorkToShelfItem( function mapPuzzleWorkToShelfItem( item: PuzzleWorkSummary, canDelete: boolean, + adapter: PuzzleWorkShelfAdapter, ): CreationWorkShelfItem { const status = item.publicationStatus; const publicWorkCode = @@ -337,6 +430,7 @@ function mapPuzzleWorkToShelfItem( status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作', canDelete, canShare: status === 'published' && Boolean(publicWorkCode), + actions: buildPuzzleWorkShelfActions(item, adapter), badges: [ buildStatusBadge(status), { id: 'type', label: '拼图', tone: 'neutral' }, @@ -368,9 +462,59 @@ function mapPuzzleWorkToShelfItem( }; } +function mapBabyObjectMatchDraftToShelfItem( + item: BabyObjectMatchDraft, + adapter: WorkShelfAdapter, +): CreationWorkShelfItem { + const status = item.publicationStatus === 'published' ? 'published' : 'draft'; + const publicWorkCode = + status === 'published' + ? buildBabyObjectMatchPublicWorkCode(item.profileId) + : null; + const coverImageSrc = + item.itemAssets.find((asset) => asset.imageSrc.trim())?.imageSrc ?? null; + + return { + id: item.profileId, + kind: 'baby-object-match', + status, + title: item.workTitle.trim() || item.templateName, + summary: + item.workDescription.trim() || + `${item.itemNames[0]}和${item.itemNames[1]}识物分类`, + updatedAt: item.updatedAt, + coverImageSrc, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + publicWorkCode, + sharePath: + publicWorkCode && status === 'published' + ? buildPublicWorkStagePath('work-detail', publicWorkCode) + : null, + openActionLabel: status === 'published' ? '查看详情' : '继续创作', + canDelete: false, + canShare: status === 'published' && Boolean(publicWorkCode), + badges: [ + buildStatusBadge(status), + { id: 'type', label: '宝贝识物', tone: 'neutral' }, + ], + metrics: + status === 'published' + ? buildPublishedMetrics({ + playCount: 0, + remixCount: 0, + likeCount: 0, + }) + : [], + actions: buildWorkShelfActions(item, adapter), + source: { kind: 'baby-object-match', item }, + }; +} + function mapVisualNovelWorkToShelfItem( item: VisualNovelWorkSummary, canDelete: boolean, + adapter: WorkShelfAdapter, ): CreationWorkShelfItem { const status = item.publishStatus === 'published' ? 'published' : 'draft'; const publicWorkCode = @@ -411,6 +555,7 @@ function mapVisualNovelWorkToShelfItem( likeCount: 0, }) : [], + actions: buildWorkShelfActions(item, adapter), source: { kind: 'visual-novel', item }, }; } @@ -418,6 +563,7 @@ function mapVisualNovelWorkToShelfItem( function mapSquareHoleWorkToShelfItem( item: SquareHoleWorkSummary, canDelete: boolean, + adapter: WorkShelfAdapter, ): CreationWorkShelfItem { const status = item.publicationStatus === 'published' ? 'published' : 'draft'; const publicWorkCode = @@ -455,10 +601,64 @@ function mapSquareHoleWorkToShelfItem( likeCount: 0, }) : [], + actions: buildWorkShelfActions(item, adapter), source: { kind: 'square-hole', item }, }; } +function buildWorkShelfActions( + item: TItem, + adapter: WorkShelfAdapter, +): CreationWorkShelfActions { + return { + open: () => { + adapter.onOpen?.(item); + }, + delete: adapter.onDelete + ? () => { + adapter.onDelete?.(item); + } + : undefined, + }; +} + +function buildPuzzleWorkShelfActions( + item: PuzzleWorkSummary, + adapter: PuzzleWorkShelfAdapter, +): CreationWorkShelfActions { + return { + ...buildWorkShelfActions(item, adapter), + claimPointIncentive: adapter.onClaimPointIncentive + ? () => { + adapter.onClaimPointIncentive?.(item); + } + : undefined, + }; +} + +function buildRpgWorkShelfActions( + item: CustomWorldWorkSummary, + adapter: RpgWorkShelfAdapter, +): CreationWorkShelfActions { + return { + open: () => { + if (item.status === 'draft') { + adapter.onOpenDraft?.(item); + return; + } + + if (item.profileId) { + adapter.onEnterPublished?.(item.profileId); + } + }, + delete: adapter.onDelete + ? () => { + adapter.onDelete?.(item); + } + : undefined, + }; +} + function buildPublishedMetrics(params: { playCount?: number | null; remixCount?: number | null; diff --git a/src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx b/src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx new file mode 100644 index 00000000..14cca450 --- /dev/null +++ b/src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx @@ -0,0 +1,47 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, test, vi } from 'vitest'; + +import { BabyObjectMatchWorkspace } from './BabyObjectMatchWorkspace'; + +test('baby object match workspace requires two item names before submit', async () => { + const user = userEvent.setup(); + const onCreateDraft = vi.fn(); + + render( + {}} onCreateDraft={onCreateDraft} />, + ); + + const submitButton = screen.getByRole('button', { + name: /生成宝贝识物草稿/u, + }); + expect(submitButton).toHaveProperty('disabled', true); + + await user.type(screen.getByLabelText('物品 A'), '苹果'); + expect(submitButton).toHaveProperty('disabled', true); + + await user.type(screen.getByLabelText('物品 B'), '香蕉'); + expect(submitButton).toHaveProperty('disabled', false); + + await user.click(submitButton); + + expect(onCreateDraft).toHaveBeenCalledWith({ + itemAName: '苹果', + itemBName: '香蕉', + }); +}); + +test('baby object match workspace calls back when return button is clicked', async () => { + const user = userEvent.setup(); + const onBack = vi.fn(); + + render( + {}} />, + ); + + await user.click(screen.getByRole('button', { name: '返回' })); + + expect(onBack).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/edutainment-creation/BabyObjectMatchWorkspace.tsx b/src/components/edutainment-creation/BabyObjectMatchWorkspace.tsx new file mode 100644 index 00000000..6025b812 --- /dev/null +++ b/src/components/edutainment-creation/BabyObjectMatchWorkspace.tsx @@ -0,0 +1,187 @@ +import { ArrowLeft, Gift, Loader2, WandSparkles } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import type { CreateBabyObjectMatchDraftRequest } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import { validateBabyObjectMatchItemNames } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; + +type BabyObjectMatchWorkspaceProps = { + isBusy?: boolean; + error?: string | null; + initialPayload?: CreateBabyObjectMatchDraftRequest | null; + onBack: () => void; + onCreateDraft: (payload: CreateBabyObjectMatchDraftRequest) => void; + showBackButton?: boolean; + title?: string | null; +}; + +type BabyObjectMatchFormState = { + itemAName: string; + itemBName: string; +}; + +function resolveInitialFormState( + initialPayload: CreateBabyObjectMatchDraftRequest | null | undefined, +): BabyObjectMatchFormState { + return { + itemAName: initialPayload?.itemAName ?? '', + itemBName: initialPayload?.itemBName ?? '', + }; +} + +export function BabyObjectMatchWorkspace({ + isBusy = false, + error = null, + initialPayload = null, + onBack, + onCreateDraft, + showBackButton = true, + title = null, +}: BabyObjectMatchWorkspaceProps) { + const [formState, setFormState] = useState(() => + resolveInitialFormState(initialPayload), + ); + const appliedInitialKeyRef = useRef(null); + + useEffect(() => { + const nextInitialKey = JSON.stringify(initialPayload ?? null); + if (appliedInitialKeyRef.current === nextInitialKey) { + return; + } + + appliedInitialKeyRef.current = nextInitialKey; + setFormState(resolveInitialFormState(initialPayload)); + }, [initialPayload]); + + const validation = useMemo( + () => validateBabyObjectMatchItemNames(formState), + [formState], + ); + const canSubmit = validation.valid && !isBusy; + + const submitForm = () => { + if (!canSubmit) { + return; + } + + onCreateDraft({ + itemAName: validation.itemAName, + itemBName: validation.itemBName, + }); + }; + + return ( +
+ {showBackButton ? ( +
+ +
+ ) : null} + +
+ {title ? ( +
+
+

+ {title} +

+ + BETA + +
+
+ ) : null} + +
+
+
+ + +
+ +
+
+
+
+
+ +
+
+ 宝贝识物 +
+
+
+
+ +
+ {error ? ( +
+ {error} +
+ ) : null} +
+
+
+ +
+ +
+
+ ); +} + +export default BabyObjectMatchWorkspace; diff --git a/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx b/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx new file mode 100644 index 00000000..c287e669 --- /dev/null +++ b/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx @@ -0,0 +1,105 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, test, vi } from 'vitest'; + +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import { + BABY_OBJECT_MATCH_EDUTAINMENT_TAG, + BABY_OBJECT_MATCH_TEMPLATE_ID, + BABY_OBJECT_MATCH_TEMPLATE_NAME, +} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import { BabyObjectMatchResultView } from './BabyObjectMatchResultView'; + +vi.mock('../ResolvedAssetImage', () => ({ + ResolvedAssetImage: ({ + src, + alt, + className, + }: { + src?: string | null; + alt?: string; + className?: string; + }) => (src ? {alt} : null), +})); + +function createDraft(overrides: Partial = {}) { + const draft: BabyObjectMatchDraft = { + draftId: 'baby-object-draft-1', + profileId: 'baby-object-profile-1', + templateId: BABY_OBJECT_MATCH_TEMPLATE_ID, + templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME, + workTitle: '宝贝识物', + workDescription: '苹果和香蕉识物分类', + itemNames: ['苹果', '香蕉'], + itemAssets: [ + { + itemId: 'baby-object-item-1', + itemName: '苹果', + imageSrc: 'data:image/svg+xml;utf8,a', + assetObjectId: null, + generationProvider: 'placeholder', + prompt: '苹果', + }, + { + itemId: 'baby-object-item-2', + itemName: '香蕉', + imageSrc: 'data:image/svg+xml;utf8,b', + assetObjectId: null, + generationProvider: 'placeholder', + prompt: '香蕉', + }, + ], + themeTags: ['宝贝识物'], + publicationStatus: 'draft', + createdAt: '2026-05-11T00:00:00.000Z', + updatedAt: '2026-05-11T00:00:00.000Z', + publishedAt: null, + ...overrides, + }; + + return draft; +} + +test('baby object result publishes with exact edutainment tag', async () => { + const user = userEvent.setup(); + const onPublish = vi.fn(); + + render( + {}} + onPublish={onPublish} + />, + ); + + await user.click(screen.getByRole('button', { name: '发布' })); + + expect(onPublish).toHaveBeenCalledTimes(1); + expect(onPublish.mock.calls[0]?.[0].themeTags[0]).toBe( + BABY_OBJECT_MATCH_EDUTAINMENT_TAG, + ); + expect(onPublish.mock.calls[0]?.[0].themeTags).toContain('宝贝识物'); +}); + +test('baby object result exposes save and test run actions', async () => { + const user = userEvent.setup(); + const onSaveDraft = vi.fn(); + const onStartTestRun = vi.fn(); + + render( + {}} + onSaveDraft={onSaveDraft} + onStartTestRun={onStartTestRun} + />, + ); + + await user.click(screen.getByRole('button', { name: '保存草稿' })); + await user.click(screen.getByRole('button', { name: '试玩' })); + + expect(onSaveDraft).toHaveBeenCalledTimes(1); + expect(onStartTestRun).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/edutainment-result/BabyObjectMatchResultView.tsx b/src/components/edutainment-result/BabyObjectMatchResultView.tsx new file mode 100644 index 00000000..20f7ca24 --- /dev/null +++ b/src/components/edutainment-result/BabyObjectMatchResultView.tsx @@ -0,0 +1,166 @@ +import { ArrowLeft, CheckCircle2, Loader2, Play, Save, Tag } from 'lucide-react'; +import { useMemo } from 'react'; + +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import { + BABY_OBJECT_MATCH_EDUTAINMENT_TAG, + hasBabyObjectMatchRequiredTag, + normalizeBabyObjectMatchTags, +} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import { ResolvedAssetImage } from '../ResolvedAssetImage'; + +type BabyObjectMatchResultViewProps = { + draft: BabyObjectMatchDraft; + isBusy?: boolean; + error?: string | null; + onBack: () => void; + onSaveDraft?: (draft: BabyObjectMatchDraft) => void; + onPublish?: (draft: BabyObjectMatchDraft) => void; + onStartTestRun?: (draft: BabyObjectMatchDraft) => void; +}; + +function normalizeDraftForAction(draft: BabyObjectMatchDraft) { + return { + ...draft, + themeTags: normalizeBabyObjectMatchTags(draft.themeTags), + updatedAt: new Date().toISOString(), + }; +} + +export function BabyObjectMatchResultView({ + draft, + isBusy = false, + error = null, + onBack, + onSaveDraft, + onPublish, + onStartTestRun, +}: BabyObjectMatchResultViewProps) { + const normalizedDraft = useMemo(() => normalizeDraftForAction(draft), [draft]); + const publishReady = + normalizedDraft.itemNames.every((itemName) => itemName.trim()) && + normalizedDraft.itemAssets.every((asset) => asset.imageSrc.trim()) && + hasBabyObjectMatchRequiredTag(normalizedDraft.themeTags); + const isPublished = normalizedDraft.publicationStatus === 'published'; + + return ( +
+
+
+ +
+ + {isPublished ? '已发布' : '草稿'} + +
+
+ +
+
+
+
+ 模板 +
+

+ {normalizedDraft.workTitle} +

+
+ {normalizedDraft.themeTags.map((tag) => ( + + + {tag} + + ))} +
+
+ +
+ {normalizedDraft.itemAssets.map((asset) => ( +
+
+ + {asset.generationProvider === 'placeholder' ? ( + + 占位图 + + ) : null} +
+
+
+ {asset.itemName} +
+
+
+ ))} +
+
+ + {error ? ( +
+ {error} +
+ ) : null} +
+ +
+ + + +
+
+
+ ); +} + +export default BabyObjectMatchResultView; diff --git a/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx b/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx new file mode 100644 index 00000000..d8f08643 --- /dev/null +++ b/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx @@ -0,0 +1,692 @@ +/* @vitest-environment jsdom */ + +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; + +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import { + BABY_OBJECT_MATCH_EDUTAINMENT_TAG, + BABY_OBJECT_MATCH_TEMPLATE_ID, + BABY_OBJECT_MATCH_TEMPLATE_NAME, +} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { UseMocapInputResult } from '../../services/useMocapInput'; +import { BabyObjectMatchRuntimeShell } from './BabyObjectMatchRuntimeShell'; + +vi.mock('../ResolvedAssetImage', () => ({ + ResolvedAssetImage: ({ + src, + alt, + className, + }: { + src?: string | null; + alt?: string; + className?: string; + }) => (src ? {alt} : null), +})); + +vi.mock('../../services/useMocapInput', () => ({ + useMocapInput: () => ({ + status: 'idle', + latestCommand: null, + rawPacketPreview: null, + error: null, + }), +})); + +function createDraft(): BabyObjectMatchDraft { + return { + draftId: 'baby-object-draft-1', + profileId: 'baby-object-profile-1', + templateId: BABY_OBJECT_MATCH_TEMPLATE_ID, + templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME, + workTitle: '宝贝识物', + workDescription: '苹果和香蕉识物分类', + itemNames: ['苹果', '香蕉'], + itemAssets: [ + { + itemId: 'baby-object-item-1', + itemName: '苹果', + imageSrc: 'data:image/svg+xml;utf8,apple', + assetObjectId: null, + generationProvider: 'placeholder', + prompt: '苹果', + }, + { + itemId: 'baby-object-item-2', + itemName: '香蕉', + imageSrc: 'data:image/svg+xml;utf8,banana', + assetObjectId: null, + generationProvider: 'placeholder', + prompt: '香蕉', + }, + ], + themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'], + publicationStatus: 'published', + createdAt: '2026-05-11T00:00:00.000Z', + updatedAt: '2026-05-11T00:00:00.000Z', + publishedAt: '2026-05-11T00:00:00.000Z', + }; +} + +function createMocapInput( + overrides: Partial = {}, +): UseMocapInputResult { + return { + status: 'connected', + latestCommand: null, + rawPacketPreview: null, + error: null, + ...overrides, + }; +} + +function createRandomSequence(values: number[]) { + let index = 0; + return () => { + const value = values[index] ?? values[values.length - 1] ?? 0; + index += 1; + return value; + }; +} + +function dispatchPointerEvent( + target: HTMLElement, + type: string, + options: { + pointerId: number; + button?: number; + clientX: number; + clientY: number; + }, +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.assign(event, options); + target.dispatchEvent(event); +} + +function dragHand(stage: HTMLElement, button: 0 | 2) { + Object.defineProperty(stage, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 320, + bottom: 240, + width: 320, + height: 240, + toJSON: () => ({}), + }), + }); + + act(() => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: button + 1, + button, + clientX: 20, + clientY: 140, + }); + }); + act(() => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: button + 1, + button, + clientX: 120, + clientY: 140, + }); + }); + act(() => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: button + 1, + button, + clientX: 120, + clientY: 140, + }); + }); +} + +test('opens the gift box with F and shows the next item', () => { + render( + , + ); + + expect( + within(screen.getByTestId('baby-object-current-item')).queryByAltText( + '苹果', + ), + ).toBeNull(); + + fireEvent.keyDown(window, { key: 'f', code: 'KeyF' }); + + expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy(); + expect( + within(screen.getByTestId('baby-object-current-item')).getByAltText( + '苹果', + ), + ).toBeTruthy(); +}); + +test('keeps left and right baskets fixed while only the gift item is random', () => { + render( + , + ); + + fireEvent.keyDown(window, { key: 'f', code: 'KeyF' }); + + expect( + within(screen.getByTestId('baby-object-current-item')).getByAltText( + '香蕉', + ), + ).toBeTruthy(); + expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy(); + expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy(); +}); + +test('mocap open palm followed by grab opens the gift box', () => { + const { rerender } = render( + , + ); + + rerender( + , + ); + + expect( + within(screen.getByTestId('baby-object-current-item')).queryByAltText( + '苹果', + ), + ).toBeNull(); + + rerender( + , + ); + + expect( + within(screen.getByTestId('baby-object-current-item')).getByAltText( + '苹果', + ), + ).toBeTruthy(); +}); + +test('mocap camera-right hand movement sends the player left hand item into the left basket', () => { + vi.useFakeTimers(); + const random = createRandomSequence([0, 0]); + const { rerender } = render( + , + ); + + rerender( + , + ); + + rerender( + , + ); + + rerender( + , + ); + + rerender( + , + ); + + expect(screen.queryByText('真棒')).toBeNull(); + + rerender( + , + ); + + expect(screen.getByText('真棒')).toBeTruthy(); + expect(screen.getByLabelText('成功次数').textContent).toBe('1/20'); + vi.useRealTimers(); +}); + +test('mocap camera-left hand movement sends the player right hand item into the right basket', () => { + vi.useFakeTimers(); + const random = createRandomSequence([0, 0]); + const { rerender } = render( + , + ); + + rerender( + , + ); + + rerender( + , + ); + + rerender( + , + ); + + rerender( + , + ); + + expect(screen.queryByText('再想一想吧')).toBeNull(); + + rerender( + , + ); + + expect(screen.getByText('再想一想吧')).toBeTruthy(); + expect(screen.getByLabelText('成功次数').textContent).toBe('0/20'); + vi.useRealTimers(); +}); + +test('mocap action names do not select a basket without horizontal hand movement', () => { + const random = createRandomSequence([0, 0]); + const { rerender } = render( + , + ); + + rerender( + , + ); + + rerender( + , + ); + + expect(screen.queryByText('真棒')).toBeNull(); + expect(screen.queryByText('再想一想吧')).toBeNull(); + expect( + within(screen.getByTestId('baby-object-current-item')).getByAltText( + '苹果', + ), + ).toBeTruthy(); +}); + +test('mocap unknown hand horizontal movement does not select a basket', () => { + const random = createRandomSequence([0, 0]); + const { rerender } = render( + , + ); + + rerender( + , + ); + + for (let index = 0; index < 4; index += 1) { + const x = [0.22, 0.24, 0.22, 0.31][index] ?? 0.22; + rerender( + , + ); + } + + expect(screen.queryByText('真棒')).toBeNull(); + expect(screen.queryByText('再想一想吧')).toBeNull(); + expect( + within(screen.getByTestId('baby-object-current-item')).getByAltText( + '苹果', + ), + ).toBeTruthy(); +}); + +test('left hand horizontal drag sends a correct item into the left basket', () => { + vi.useFakeTimers(); + const { container } = render( + , + ); + const stage = container.querySelector('.baby-object-runtime__stage'); + if (!(stage instanceof HTMLElement)) { + throw new Error('Missing baby object runtime stage'); + } + + fireEvent.keyDown(window, { key: 'f', code: 'KeyF' }); + dragHand(stage, 0); + + expect(screen.getByText('真棒')).toBeTruthy(); + expect(screen.getByLabelText('成功次数').textContent).toBe('1/20'); + + act(() => { + vi.advanceTimersByTime(800); + }); + + expect(screen.queryByText('真棒')).toBeNull(); + expect( + within(screen.getByTestId('baby-object-current-item')).queryByAltText( + '苹果', + ), + ).toBeNull(); + vi.useRealTimers(); +}); + +test('wrong basket keeps the item active after feedback', () => { + vi.useFakeTimers(); + const { container } = render( + , + ); + const stage = container.querySelector('.baby-object-runtime__stage'); + if (!(stage instanceof HTMLElement)) { + throw new Error('Missing baby object runtime stage'); + } + + fireEvent.keyDown(window, { key: 'f', code: 'KeyF' }); + dragHand(stage, 2); + + expect(screen.getByText('再想一想吧')).toBeTruthy(); + expect(screen.getByLabelText('成功次数').textContent).toBe('0/20'); + + act(() => { + vi.advanceTimersByTime(800); + }); + + expect(screen.queryByText('再想一想吧')).toBeNull(); + expect( + within(screen.getByTestId('baby-object-current-item')).getByAltText( + '苹果', + ), + ).toBeTruthy(); + vi.useRealTimers(); +}); + +test('twenty correct placements completes the level', () => { + vi.useFakeTimers(); + const randomValues = Array.from({ length: 40 }, () => 0); + const { container } = render( + , + ); + const stage = container.querySelector('.baby-object-runtime__stage'); + if (!(stage instanceof HTMLElement)) { + throw new Error('Missing baby object runtime stage'); + } + + for (let index = 0; index < 20; index += 1) { + fireEvent.keyDown(window, { key: 'f', code: 'KeyF' }); + dragHand(stage, 0); + act(() => { + vi.advanceTimersByTime(800); + }); + } + + expect(screen.getAllByText('恭喜你!小朋友!').length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: '再来一次' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '下一关' })).toBeTruthy(); + vi.useRealTimers(); +}); diff --git a/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx b/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx new file mode 100644 index 00000000..679fed6f --- /dev/null +++ b/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx @@ -0,0 +1,583 @@ +import { + ArrowLeft, + Gift, + PartyPopper, + RotateCcw, + SkipForward, +} from 'lucide-react'; +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import type { + BabyObjectMatchDraft, + BabyObjectMatchItemAsset, +} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { + MocapHandInput, + MocapInputCommand, + UseMocapInputResult, +} from '../../services/useMocapInput'; +import { useMocapInput } from '../../services/useMocapInput'; +import { ResolvedAssetImage } from '../ResolvedAssetImage'; + +const BABY_OBJECT_MATCH_SUCCESS_TARGET = 20; +const BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS = 760; +const BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE = 0.05; +const BABY_OBJECT_MATCH_HAND_PATH_LIMIT = 16; + +type BabyObjectMatchRuntimeShellProps = { + draft: BabyObjectMatchDraft; + embedded?: boolean; + enableMocapInput?: boolean; + mocapInput?: UseMocapInputResult | null; + random?: BabyObjectMatchRandom; + onBack?: () => void; + onNextLevel?: () => void; +}; + +type BasketSide = 'left' | 'right'; +type RuntimePhase = 'waiting' | 'active' | 'correct' | 'wrong' | 'complete'; + +type RuntimeRound = { + item: BabyObjectMatchItemAsset; + baskets: Record; +}; + +type DragState = { + side: BasketSide; + startX: number; + lastX: number; +}; + +type RuntimeHandPoint = { + x: number; + y: number; +}; + +type RuntimeMocapHandPaths = { + left: RuntimeHandPoint[]; + right: RuntimeHandPoint[]; +}; + +type BabyObjectMatchRandom = () => number; + +const OPEN_PALM_ACTIONS = [ + 'open_palm', + 'open_palm_up', + 'open', + 'palm', + 'hand_open', +]; + +const GRAB_ACTIONS = [ + 'grab', + 'grabbing', + 'close', + 'fist', + 'closed_fist', + 'closed', +]; + +function pickRandomIndex(length: number, random: BabyObjectMatchRandom) { + if (length <= 1) { + return 0; + } + + return Math.min(length - 1, Math.floor(random() * length)); +} + +function buildRuntimeRound( + draft: BabyObjectMatchDraft, + random: BabyObjectMatchRandom, +): RuntimeRound { + const items = draft.itemAssets; + const item = items[pickRandomIndex(items.length, random)] ?? items[0]!; + + return { + item, + baskets: { + left: items[0]!, + right: items[1]!, + }, + }; +} + +function isHorizontalDrag(dragState: DragState) { + return ( + Math.abs(dragState.lastX - dragState.startX) >= + BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE + ); +} + +function hasMocapAction(command: MocapInputCommand, actions: string[]) { + return command.actions.some((action) => actions.includes(action)); +} + +function mocapHandToRuntimePoint( + hand: MocapHandInput | null | undefined, +): RuntimeHandPoint | null { + if (!hand) { + return null; + } + + return { x: hand.x, y: hand.y }; +} + +function appendRuntimeHandPoint( + points: RuntimeHandPoint[], + point: RuntimeHandPoint, +) { + return [...points, point].slice(-BABY_OBJECT_MATCH_HAND_PATH_LIMIT); +} + +function hasRuntimeHorizontalMovePath(points: RuntimeHandPoint[]) { + if (points.length < 3) { + return false; + } + + const xValues = points.map((point) => point.x); + return ( + Math.max(...xValues) - Math.min(...xValues) >= + BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE + ); +} + +function resolveMocapHandPaths( + command: MocapInputCommand, + currentPaths: RuntimeMocapHandPaths, +) { + // 本地 mocap 当前按摄像头视角输出 handedness,这里换回用户身体视角再选篮。 + const leftPoint = mocapHandToRuntimePoint(command.rightHand); + const rightPoint = mocapHandToRuntimePoint(command.leftHand); + + return { + left: leftPoint + ? appendRuntimeHandPoint(currentPaths.left, leftPoint) + : currentPaths.left, + right: rightPoint + ? appendRuntimeHandPoint(currentPaths.right, rightPoint) + : currentPaths.right, + } satisfies RuntimeMocapHandPaths; +} + +function hasOpenPalmMocapHand(command: MocapInputCommand) { + return ( + hasMocapAction(command, OPEN_PALM_ACTIONS) || + Boolean(command.hands?.some((hand) => hand.state === 'open_palm')) || + command.leftHand?.state === 'open_palm' || + command.rightHand?.state === 'open_palm' || + command.primaryHand?.state === 'open_palm' + ); +} + +function hasGrabMocapHand(command: MocapInputCommand) { + return ( + hasMocapAction(command, GRAB_ACTIONS) || + Boolean(command.hands?.some((hand) => hand.state === 'grab')) || + command.leftHand?.state === 'grab' || + command.rightHand?.state === 'grab' || + command.primaryHand?.state === 'grab' + ); +} + +function resolveMocapHorizontalMoveSide( + paths: RuntimeMocapHandPaths, +): BasketSide | null { + if (hasRuntimeHorizontalMovePath(paths.left)) { + return 'left'; + } + + if (hasRuntimeHorizontalMovePath(paths.right)) { + return 'right'; + } + + return null; +} + +function buildMocapPacketKey( + command: MocapInputCommand, + rawPacketPreview: UseMocapInputResult['rawPacketPreview'], +) { + return rawPacketPreview?.receivedAtMs !== undefined + ? `${rawPacketPreview.receivedAtMs}:${rawPacketPreview.text}` + : JSON.stringify(command); +} + +export function BabyObjectMatchRuntimeShell({ + draft, + embedded = false, + enableMocapInput = true, + mocapInput = null, + random, + onBack, + onNextLevel, +}: BabyObjectMatchRuntimeShellProps) { + const randomRef = useRef(random ?? (() => Math.random())); + const feedbackTimerRef = useRef(null); + const dragStateRef = useRef(null); + const handledMocapPacketKeyRef = useRef(null); + const hasOpenPalmBeforeGrabRef = useRef(false); + const mocapHandPathsRef = useRef({ + left: [], + right: [], + }); + const [phase, setPhase] = useState('waiting'); + const [successCount, setSuccessCount] = useState(0); + const [round, setRound] = useState(null); + const [feedbackText, setFeedbackText] = useState(null); + const [lastTargetSide, setLastTargetSide] = useState(null); + const liveMocapInput = useMocapInput({ + enabled: enableMocapInput && !mocapInput, + }); + const resolvedMocapInput = mocapInput ?? liveMocapInput; + + const progressText = `${successCount}/${BABY_OBJECT_MATCH_SUCCESS_TARGET}`; + const isComplete = phase === 'complete'; + const currentItem = round?.item ?? null; + + useEffect(() => { + randomRef.current = random ?? (() => Math.random()); + }, [random]); + + const clearFeedbackTimer = useCallback(() => { + if (feedbackTimerRef.current !== null) { + window.clearTimeout(feedbackTimerRef.current); + feedbackTimerRef.current = null; + } + }, []); + + const openGiftBox = useCallback(() => { + if (phase !== 'waiting') { + return; + } + + clearFeedbackTimer(); + setFeedbackText(null); + setLastTargetSide(null); + setRound(buildRuntimeRound(draft, randomRef.current)); + setPhase('active'); + }, [clearFeedbackTimer, draft, phase]); + + const resetRuntime = useCallback(() => { + clearFeedbackTimer(); + dragStateRef.current = null; + handledMocapPacketKeyRef.current = null; + hasOpenPalmBeforeGrabRef.current = false; + mocapHandPathsRef.current = { left: [], right: [] }; + setSuccessCount(0); + setRound(null); + setFeedbackText(null); + setLastTargetSide(null); + setPhase('waiting'); + }, [clearFeedbackTimer]); + + const finishFeedback = useCallback( + (nextSuccessCount: number, wasCorrect: boolean) => { + clearFeedbackTimer(); + feedbackTimerRef.current = window.setTimeout(() => { + feedbackTimerRef.current = null; + if (wasCorrect) { + if (nextSuccessCount >= BABY_OBJECT_MATCH_SUCCESS_TARGET) { + setFeedbackText('恭喜你!小朋友!'); + setRound(null); + setPhase('complete'); + return; + } + + setRound(null); + setFeedbackText(null); + setLastTargetSide(null); + setPhase('waiting'); + return; + } + + setFeedbackText(null); + setLastTargetSide(null); + mocapHandPathsRef.current = { left: [], right: [] }; + setPhase('active'); + }, BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS); + }, + [clearFeedbackTimer], + ); + + const sendItemToBasket = useCallback( + (side: BasketSide) => { + if (phase !== 'active' || !round) { + return; + } + + const isCorrect = round.baskets[side].itemId === round.item.itemId; + setLastTargetSide(side); + if (isCorrect) { + const nextSuccessCount = successCount + 1; + setSuccessCount(nextSuccessCount); + setFeedbackText('真棒'); + setPhase('correct'); + finishFeedback(nextSuccessCount, true); + return; + } + + setFeedbackText('再想一想吧'); + setPhase('wrong'); + finishFeedback(successCount, false); + }, + [finishFeedback, phase, round, successCount], + ); + + useEffect(() => clearFeedbackTimer, [clearFeedbackTimer]); + + useEffect(() => { + if (phase === 'waiting') { + mocapHandPathsRef.current = { left: [], right: [] }; + return; + } + hasOpenPalmBeforeGrabRef.current = false; + }, [phase]); + + useEffect(() => { + const command = resolvedMocapInput.latestCommand; + if (!command || isComplete) { + return; + } + + const packetKey = buildMocapPacketKey( + command, + resolvedMocapInput.rawPacketPreview, + ); + if (handledMocapPacketKeyRef.current === packetKey) { + return; + } + handledMocapPacketKeyRef.current = packetKey; + + if (phase === 'waiting') { + if (hasGrabMocapHand(command) && hasOpenPalmBeforeGrabRef.current) { + hasOpenPalmBeforeGrabRef.current = false; + mocapHandPathsRef.current = { left: [], right: [] }; + openGiftBox(); + return; + } + if (hasOpenPalmMocapHand(command)) { + hasOpenPalmBeforeGrabRef.current = true; + } + return; + } + + if (phase !== 'active') { + mocapHandPathsRef.current = { left: [], right: [] }; + return; + } + + const nextPaths = resolveMocapHandPaths( + command, + mocapHandPathsRef.current, + ); + mocapHandPathsRef.current = nextPaths; + + const targetSide = resolveMocapHorizontalMoveSide(nextPaths); + if (targetSide) { + sendItemToBasket(targetSide); + mocapHandPathsRef.current = { left: [], right: [] }; + } + }, [ + isComplete, + openGiftBox, + phase, + resolvedMocapInput.latestCommand, + resolvedMocapInput.rawPacketPreview, + sendItemToBasket, + ]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key.toLowerCase() !== 'f') { + return; + } + + event.preventDefault(); + openGiftBox(); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [openGiftBox]); + + const getPointerUnitX = ( + event: ReactPointerEvent, + element: HTMLElement, + ) => { + const rect = element.getBoundingClientRect(); + const width = rect.width || 1; + return Math.max(0, Math.min(1, (event.clientX - rect.left) / width)); + }; + + const handlePointerDown = (event: ReactPointerEvent) => { + if (event.button !== 0 && event.button !== 2) { + return; + } + + const side: BasketSide = event.button === 2 ? 'right' : 'left'; + const pointerX = getPointerUnitX(event, event.currentTarget); + dragStateRef.current = { + side, + startX: pointerX, + lastX: pointerX, + }; + event.preventDefault(); + if (typeof event.currentTarget.setPointerCapture === 'function') { + event.currentTarget.setPointerCapture(event.pointerId); + } + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + if (!dragStateRef.current) { + return; + } + + dragStateRef.current = { + ...dragStateRef.current, + lastX: getPointerUnitX(event, event.currentTarget), + }; + }; + + const handlePointerUp = (event: ReactPointerEvent) => { + const dragState = dragStateRef.current; + dragStateRef.current = null; + if ( + typeof event.currentTarget.hasPointerCapture === 'function' && + event.currentTarget.hasPointerCapture(event.pointerId) + ) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + if (!dragState || !isHorizontalDrag(dragState)) { + return; + } + + sendItemToBasket(dragState.side); + }; + + return ( +
+
event.preventDefault()} + > + {onBack ? ( + + ) : null} + +
+ 将物品放入对应的篮子里 +
+ +
+ {progressText} +
+ +
+ +
+ +
+ {currentItem ? ( + <> + + + {currentItem.itemName} + + + ) : null} +
+ + {feedbackText ? ( +
+ {feedbackText} +
+ ) : null} + + {isComplete ? ( +
+ +
恭喜你!小朋友!
+
+ + +
+
+ ) : null} + +
+ {(['left', 'right'] as const).map((side) => { + const basketItem = round?.baskets[side] ?? draft.itemAssets[side === 'left' ? 0 : 1]; + + return ( +
+
+ +
+
+
+ ); + })} +
+
+
+ ); +} + +export default BabyObjectMatchRuntimeShell; diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index 56312543..aac5a6cc 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -21,6 +21,7 @@ export interface PlatformEntryCreationTypeModalProps { onSelectPuzzle: () => void; onSelectCreativeAgent: () => void; onSelectVisualNovel: () => void; + onSelectBabyObjectMatch: () => void; } function CreationTypeCard(props: { @@ -101,6 +102,7 @@ export function PlatformEntryCreationTypeModal({ onSelectPuzzle, onSelectCreativeAgent, onSelectVisualNovel, + onSelectBabyObjectMatch, }: PlatformEntryCreationTypeModalProps) { if (!isOpen) { return null; @@ -147,6 +149,9 @@ export function PlatformEntryCreationTypeModal({ if (item.id === 'visual-novel') { onSelectVisualNovel(); } + if (item.id === 'baby-object-match') { + onSelectBabyObjectMatch(); + } }} /> ))} diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 1026713b..58ac8c4e 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -28,6 +28,10 @@ import type { StreamCreativeAgentMessageRequest, } from '../../../packages/shared/src/contracts/creativeAgent'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary'; +import type { + BabyObjectMatchDraft, + CreateBabyObjectMatchDraftRequest, +} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { CreateMatch3DSessionRequest, ExecuteMatch3DActionRequest, @@ -100,10 +104,7 @@ import { buildPublicWorkStagePath, pushAppHistoryPath, } from '../../routing/appPageRoutes'; -import { - resolveRuntimeNotFoundRecoveryAction, - resolveWorkNotFoundRecoveryAction, -} from '../../routing/runtimeNotFoundRecovery'; +import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery'; import { ApiClientError, BACKGROUND_AUTH_REQUEST_OPTIONS, @@ -147,14 +148,14 @@ import { readCustomWorldAgentUiState, shouldRestoreCustomWorldAgentUiState, } from '../../services/customWorldAgentUiState'; -import { match3dCreationClient } from '../../services/match3d-creation'; import { - clickMatch3DItem, - finishMatch3DTimeUp, - restartMatch3DRun, - startMatch3DRun, - stopMatch3DRun, -} from '../../services/match3d-runtime'; + createBabyObjectMatchDraft, + listLocalBabyObjectMatchDrafts, + publishBabyObjectMatchWork, + saveBabyObjectMatchDraft, +} from '../../services/edutainment-baby-object'; +import { match3dCreationClient } from '../../services/match3d-creation'; +import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { deleteMatch3DWork, getMatch3DWorkDetail, @@ -163,6 +164,7 @@ import { } from '../../services/match3d-works'; import { preloadMatch3DGeneratedModelAssets } from '../../services/match3dGeneratedModelCache'; import { + buildBabyObjectMatchGenerationAnchorEntries, buildBigFishGenerationAnchorEntries, buildMatch3DGenerationAnchorEntries, buildMiniGameDraftGenerationProgress, @@ -173,11 +175,13 @@ import { } from '../../services/miniGameDraftGenerationProgress'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { + buildBabyObjectMatchPublicWorkCode, buildBigFishPublicWorkCode, buildMatch3DPublicWorkCode, buildPuzzlePublicWorkCode, buildSquareHolePublicWorkCode, buildVisualNovelPublicWorkCode, + isSameBabyObjectMatchPublicWorkCode, isSameBigFishPublicWorkCode, isSameMatch3DPublicWorkCode, isSamePuzzlePublicWorkCode, @@ -281,10 +285,12 @@ import { resolveCreativeAgentTargetSelectionStage } from '../creative-agent/crea import type { CreationWorkShelfItem } from '../custom-world-home/creationWorkShelf'; import { isBigFishGalleryEntry, + isEdutainmentGalleryEntry, isMatch3DGalleryEntry, isPuzzleGalleryEntry, isSquareHoleGalleryEntry, isVisualNovelGalleryEntry, + mapBabyObjectMatchDraftToPlatformGalleryCard, mapBigFishWorkToPlatformGalleryCard, mapMatch3DWorkToPlatformGalleryCard, mapPuzzleWorkToPlatformGalleryCard, @@ -376,6 +382,7 @@ type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform'; type BigFishRuntimeSessionSource = 'draft' | 'work' | null; type RecommendRuntimeKind = | 'big-fish' + | 'edutainment' | 'match3d' | 'puzzle' | 'square-hole' @@ -390,7 +397,12 @@ type VisualNovelRuntimeReturnStage = | 'visual-novel-gallery-detail' | 'work-detail' | 'platform'; +type BabyObjectMatchRuntimeReturnStage = + | 'baby-object-match-result' + | 'work-detail' + | 'platform'; type VisualNovelEntryGenerationPhase = 'generating' | 'ready' | 'failed'; +type BabyObjectMatchGenerationPhase = 'generating' | 'ready' | 'failed'; type PuzzleSaveArchiveState = { runtimeKind?: unknown; @@ -450,7 +462,9 @@ function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) { ? 'square-hole' : isVisualNovelGalleryEntry(entry) ? 'visual-novel' - : 'rpg'; + : isEdutainmentGalleryEntry(entry) + ? `edutainment:${entry.templateId}` + : 'rpg'; return `${kind}:${entry.ownerUserId}:${entry.profileId}`; } @@ -477,6 +491,10 @@ function getPlatformRecommendRuntimeKind( return 'visual-novel'; } + if (isEdutainmentGalleryEntry(entry)) { + return 'edutainment'; + } + return 'rpg'; } @@ -1062,24 +1080,6 @@ function isMissingPuzzleWorkError(error: unknown) { ); } -function maybeAlertRuntimeNotFoundAndReturnHome() { - if (typeof window === 'undefined') { - return false; - } - - const recoveryAction = resolveRuntimeNotFoundRecoveryAction( - window.location.pathname, - ); - if (!recoveryAction) { - return false; - } - - // 中文注释:直接 runtime 深链找不到作品时,弹窗确认后立刻回首页,避免保留空白运行态。 - window.alert('作品不存在或已下架,将返回首页。'); - pushAppHistoryPath(recoveryAction.nextPath); - return true; -} - function maybeAlertWorkNotFoundAndReturnHome() { if (typeof window === 'undefined') { return false; @@ -1508,6 +1508,11 @@ function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) { item.id, item.source.item.profileId, ]); + case 'baby-object-match': + return collectDraftNoticeKeys('baby-object-match', [ + item.id, + item.source.item.profileId, + ]); } } @@ -1906,6 +1911,33 @@ const VisualNovelAgentWorkspace = lazy(async () => { }; }); +const BabyObjectMatchWorkspace = lazy(async () => { + const module = await import( + '../edutainment-creation/BabyObjectMatchWorkspace' + ); + return { + default: module.BabyObjectMatchWorkspace, + }; +}); + +const BabyObjectMatchResultView = lazy(async () => { + const module = await import( + '../edutainment-result/BabyObjectMatchResultView' + ); + return { + default: module.BabyObjectMatchResultView, + }; +}); + +const BabyObjectMatchRuntimeShell = lazy(async () => { + const module = await import( + '../edutainment-runtime/BabyObjectMatchRuntimeShell' + ); + return { + default: module.BabyObjectMatchRuntimeShell, + }; +}); + const VisualNovelResultView = lazy(async () => { const module = await import('../visual-novel-result/VisualNovelResultView'); return { @@ -2176,6 +2208,25 @@ export function PlatformEntryFlowShellImpl({ ] = useState(null); const [visualNovelGenerationPhase, setVisualNovelGenerationPhase] = useState('generating'); + const [babyObjectMatchDraft, setBabyObjectMatchDraft] = + useState(null); + const [babyObjectMatchDrafts, setBabyObjectMatchDrafts] = useState< + BabyObjectMatchDraft[] + >([]); + const [babyObjectMatchFormPayload, setBabyObjectMatchFormPayload] = + useState(null); + const [babyObjectMatchGenerationState, setBabyObjectMatchGenerationState] = + useState(null); + const [babyObjectMatchGenerationPhase, setBabyObjectMatchGenerationPhase] = + useState('generating'); + const [ + babyObjectMatchRuntimeReturnStage, + setBabyObjectMatchRuntimeReturnStage, + ] = useState('baby-object-match-result'); + const [babyObjectMatchError, setBabyObjectMatchError] = useState< + string | null + >(null); + const [isBabyObjectMatchBusy, setIsBabyObjectMatchBusy] = useState(false); const [isVisualNovelLoadingLibrary, setIsVisualNovelLoadingLibrary] = useState(false); const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] = @@ -2217,6 +2268,10 @@ export function PlatformEntryFlowShellImpl({ creationEntryTypes, 'square-hole', ); + const isBabyObjectMatchVisible = isPlatformCreationTypeVisible( + creationEntryTypes, + 'baby-object-match', + ); const [profilePlayStats, setProfilePlayStats] = useState(null); const [profilePlayStatsError, setProfilePlayStatsError] = useState< @@ -2642,6 +2697,10 @@ export function PlatformEntryFlowShellImpl({ [markDraftReady, platformBootstrap], ); + const refreshBabyObjectMatchShelf = useCallback(() => { + setBabyObjectMatchDrafts(listLocalBabyObjectMatchDrafts()); + }, []); + const sessionController = useRpgCreationSessionController({ userId: authUi?.user?.id, openLoginModal: authUi?.openLoginModal, @@ -2843,6 +2902,11 @@ export function PlatformEntryFlowShellImpl({ const bigFishPublicEntries = isBigFishCreationVisible ? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard) : []; + const babyObjectMatchPublicEntries = isBabyObjectMatchVisible + ? babyObjectMatchDrafts + .filter((draft) => draft.publicationStatus === 'published') + .map(mapBabyObjectMatchDraftToPlatformGalleryCard) + : []; const match3dPublicEntries = match3dGalleryEntries.map( mapMatch3DWorkToPlatformGalleryCard, ); @@ -2863,10 +2927,13 @@ export function PlatformEntryFlowShellImpl({ ...puzzlePublicEntries, ...squareHolePublicEntries, ...visualNovelPublicEntries, + ...babyObjectMatchPublicEntries, ], ).slice(0, 6); }, [ + babyObjectMatchDrafts, isBigFishCreationVisible, + isBabyObjectMatchVisible, bigFishGalleryEntries, match3dGalleryEntries, platformBootstrap.publishedGalleryEntries, @@ -2890,9 +2957,16 @@ export function PlatformEntryFlowShellImpl({ ...visualNovelGalleryEntries.map( mapVisualNovelWorkToPlatformGalleryCard, ), + ...(isBabyObjectMatchVisible + ? babyObjectMatchDrafts + .filter((draft) => draft.publicationStatus === 'published') + .map(mapBabyObjectMatchDraftToPlatformGalleryCard) + : []), ], ), [ + babyObjectMatchDrafts, + isBabyObjectMatchVisible, isBigFishCreationVisible, bigFishGalleryEntries, match3dGalleryEntries, @@ -3071,7 +3145,9 @@ export function PlatformEntryFlowShellImpl({ ? puzzleGenerationState : selectionStage === 'match3d-generating' ? match3dGenerationState - : null; + : selectionStage === 'baby-object-match-generating' + ? babyObjectMatchGenerationState + : null; const shouldTickProgress = selectionStage === 'visual-novel-generating' ? visualNovelGenerationStartedAtMs != null && @@ -3092,6 +3168,7 @@ export function PlatformEntryFlowShellImpl({ return () => window.clearInterval(timerId); }, [ + babyObjectMatchGenerationState, match3dGenerationState, puzzleGenerationState, selectionStage, @@ -3388,6 +3465,10 @@ export function PlatformEntryFlowShellImpl({ }, }); + const match3dRuntimeAdapter = useMemo( + () => createServerMatch3DRuntimeAdapter(), + [], + ); const match3dFlow = usePlatformCreationAgentFlowController< Match3DAgentSessionSnapshot, CreateMatch3DSessionRequest, @@ -3475,7 +3556,9 @@ export function PlatformEntryFlowShellImpl({ runtimeProfile.generatedItemAssets, { expireSeconds: 300 }, ); - const { run } = await startMatch3DRun(runtimeProfile.profileId); + const { run } = await match3dRuntimeAdapter.startRun( + runtimeProfile.profileId, + ); setMatch3DRuntimeProfile(runtimeProfile); setMatch3DRun(run); setMatch3DProfile(runtimeProfile); @@ -4356,6 +4439,57 @@ export function PlatformEntryFlowShellImpl({ ], ); + const createBabyObjectMatchDraftFromForm = useCallback( + async (payload: CreateBabyObjectMatchDraftRequest) => { + setBabyObjectMatchFormPayload(payload); + setBabyObjectMatchDraft(null); + setBabyObjectMatchError(null); + setIsBabyObjectMatchBusy(true); + setBabyObjectMatchGenerationPhase('generating'); + setBabyObjectMatchGenerationState( + createMiniGameDraftGenerationState('baby-object-match'), + ); + setSelectionStage('baby-object-match-generating'); + + try { + const response = await createBabyObjectMatchDraft(payload); + setBabyObjectMatchDraft(response.draft); + refreshBabyObjectMatchShelf(); + setBabyObjectMatchGenerationPhase('ready'); + setBabyObjectMatchGenerationState((current) => + current + ? { + ...current, + phase: 'ready', + completedAssetCount: response.draft.itemAssets.length, + totalAssetCount: response.draft.itemAssets.length, + } + : current, + ); + setSelectionStage('baby-object-match-result'); + } catch (error) { + const errorMessage = resolvePuzzleErrorMessage( + error, + '生成宝贝识物草稿失败。', + ); + setBabyObjectMatchGenerationPhase('failed'); + setBabyObjectMatchError(errorMessage); + setBabyObjectMatchGenerationState((current) => + current + ? { + ...current, + phase: 'failed', + error: errorMessage, + } + : current, + ); + } finally { + setIsBabyObjectMatchBusy(false); + } + }, + [refreshBabyObjectMatchShelf, resolvePuzzleErrorMessage, setSelectionStage], + ); + const savePuzzleFormDraft = useCallback( async (payload: CreatePuzzleAgentSessionRequest) => { const session = puzzleFlow.session; @@ -4465,6 +4599,14 @@ export function PlatformEntryFlowShellImpl({ setVisualNovelGenerationStartedAtMs(null); setVisualNovelGenerationPhase('generating'); setVisualNovelError(null); + setBabyObjectMatchDraft(null); + setBabyObjectMatchDrafts([]); + setBabyObjectMatchFormPayload(null); + setBabyObjectMatchGenerationState(null); + setBabyObjectMatchGenerationPhase('generating'); + setBabyObjectMatchRuntimeReturnStage('baby-object-match-result'); + setBabyObjectMatchError(null); + setIsBabyObjectMatchBusy(false); setDeletingCreationWorkId(null); setClaimingPuzzlePointIncentiveProfileId(null); setPublishSharePayload(null); @@ -4505,6 +4647,7 @@ export function PlatformEntryFlowShellImpl({ setRpgCustomWorldError, setRpgGeneratedCustomWorldProfile, setSelectionStage, + setBabyObjectMatchError, setSquareHoleError, setSquareHoleSession, setStreamingMatch3DReplyText, @@ -4568,15 +4711,28 @@ export function PlatformEntryFlowShellImpl({ setVisualNovelError(null); return; } + + if (type === 'baby-object-match') { + if (!isBabyObjectMatchVisible) { + sessionController.setCreationTypeError(EDUTAINMENT_HIDDEN_MESSAGE); + return; + } + + enterCreateTab(); + setShowCreationTypeModal(false); + setActiveCreationFormType('baby-object-match'); + setBabyObjectMatchError(null); + return; + } }, [ openBigFishAgentWorkspace, enterCreateTab, + isBabyObjectMatchVisible, openSquareHoleAgentWorkspace, prepareCreationLaunch, runProtectedAction, sessionController, - setActiveCreationFormType, setMatch3DError, setPuzzleCreationError, setPuzzleError, @@ -4630,6 +4786,136 @@ export function PlatformEntryFlowShellImpl({ visualNovelFlow.leaveFlow(); }, [visualNovelFlow]); + const leaveBabyObjectMatchFlow = useCallback(() => { + setBabyObjectMatchDraft(null); + setBabyObjectMatchFormPayload(null); + setBabyObjectMatchGenerationState(null); + setBabyObjectMatchGenerationPhase('generating'); + setBabyObjectMatchError(null); + enterCreateTab(); + setSelectionStage('platform'); + }, [enterCreateTab, setSelectionStage]); + + const saveBabyObjectMatchResultDraft = useCallback( + async (draft: BabyObjectMatchDraft) => { + setBabyObjectMatchError(null); + setIsBabyObjectMatchBusy(true); + try { + const response = await saveBabyObjectMatchDraft({ draft }); + setBabyObjectMatchDraft(response.draft); + refreshBabyObjectMatchShelf(); + } catch (error) { + setBabyObjectMatchError( + resolvePuzzleErrorMessage(error, '保存宝贝识物草稿失败。'), + ); + } finally { + setIsBabyObjectMatchBusy(false); + } + }, + [refreshBabyObjectMatchShelf, resolvePuzzleErrorMessage], + ); + + const publishBabyObjectMatchResultDraft = useCallback( + async (draft: BabyObjectMatchDraft) => { + setBabyObjectMatchError(null); + setIsBabyObjectMatchBusy(true); + try { + const response = await publishBabyObjectMatchWork({ draft }); + setBabyObjectMatchDraft(response.draft); + refreshBabyObjectMatchShelf(); + openPublishShareModal({ + title: response.draft.workTitle, + publicWorkCode: + response.publicWorkCode || + buildBabyObjectMatchPublicWorkCode(response.draft.profileId), + stage: 'work-detail', + }); + } catch (error) { + setBabyObjectMatchError( + resolvePuzzleErrorMessage(error, '发布宝贝识物作品失败。'), + ); + } finally { + setIsBabyObjectMatchBusy(false); + } + }, + [ + openPublishShareModal, + refreshBabyObjectMatchShelf, + resolvePuzzleErrorMessage, + ], + ); + + const startBabyObjectMatchRuntimeFromDraft = useCallback( + ( + draft: BabyObjectMatchDraft, + returnStage: BabyObjectMatchRuntimeReturnStage = 'baby-object-match-result', + options: { embedded?: boolean } = {}, + ) => { + setBabyObjectMatchDraft(draft); + setBabyObjectMatchFormPayload({ + itemAName: draft.itemNames[0], + itemBName: draft.itemNames[1], + }); + setBabyObjectMatchRuntimeReturnStage(returnStage); + setBabyObjectMatchError(null); + if (!options.embedded) { + setSelectionStage('baby-object-match-runtime'); + const publicWorkCode = + draft.publicationStatus === 'published' + ? buildBabyObjectMatchPublicWorkCode(draft.profileId) + : null; + if (publicWorkCode) { + pushAppHistoryPath( + buildPublicWorkStagePath( + 'baby-object-match-runtime', + publicWorkCode, + ), + ); + } + } + return true; + }, + [setSelectionStage], + ); + + const resolveBabyObjectMatchRuntimeDraft = useCallback( + (entry: PlatformPublicGalleryCard) => { + if (!isEdutainmentGalleryEntry(entry)) { + return null; + } + + return ( + babyObjectMatchDrafts.find( + (draft) => draft.profileId === entry.profileId, + ) ?? + listLocalBabyObjectMatchDrafts().find( + (draft) => draft.profileId === entry.profileId, + ) ?? + null + ); + }, + [babyObjectMatchDrafts], + ); + + const startBabyObjectMatchRuntimeFromEntry = useCallback( + ( + entry: PlatformPublicGalleryCard, + returnStage: BabyObjectMatchRuntimeReturnStage = 'work-detail', + options: { embedded?: boolean } = {}, + ) => { + const draft = resolveBabyObjectMatchRuntimeDraft(entry); + if (!draft) { + setPublicWorkDetailError( + '当前宝贝识物作品缺少本地草稿,暂时无法进入玩法。', + ); + return false; + } + + return startBabyObjectMatchRuntimeFromDraft(draft, returnStage, options); + }, + [resolveBabyObjectMatchRuntimeDraft, startBabyObjectMatchRuntimeFromDraft], + ); + const saveVisualNovelDraft = useCallback( async (draft: VisualNovelResultDraft) => { const currentSession = visualNovelSession; @@ -5124,6 +5410,19 @@ export function PlatformEntryFlowShellImpl({ visualNovelFormDraftPayload, ]); + const retryBabyObjectMatchDraftGeneration = useCallback(() => { + if (!babyObjectMatchFormPayload) { + setSelectionStage('baby-object-match-workspace'); + return; + } + + void createBabyObjectMatchDraftFromForm(babyObjectMatchFormPayload); + }, [ + babyObjectMatchFormPayload, + createBabyObjectMatchDraftFromForm, + setSelectionStage, + ]); + const executePuzzleWorkspaceAction = useCallback( (payload: PuzzleAgentActionRequest) => { if ( @@ -5313,6 +5612,28 @@ export function PlatformEntryFlowShellImpl({ visualNovelWork, ]); + useEffect(() => { + if ( + selectionStage === 'baby-object-match-result' && + !babyObjectMatchDraft + ) { + setSelectionStage( + babyObjectMatchFormPayload ? 'baby-object-match-workspace' : 'platform', + ); + } + if ( + selectionStage === 'baby-object-match-runtime' && + !babyObjectMatchDraft + ) { + setSelectionStage('platform'); + } + }, [ + babyObjectMatchDraft, + babyObjectMatchFormPayload, + selectionStage, + setSelectionStage, + ]); + const startBigFishRun = useCallback(async () => { if (!bigFishSession) { return; @@ -5512,10 +5833,10 @@ export function PlatformEntryFlowShellImpl({ ? { itemTypeCountOverride: options.itemTypeCountOverride } : {}), }; - const { run } = - Object.keys(runtimeOptions).length > 0 - ? await startMatch3DRun(runtimeProfile.profileId, runtimeOptions) - : await startMatch3DRun(runtimeProfile.profileId); + const { run } = await match3dRuntimeAdapter.startRun( + runtimeProfile.profileId, + runtimeOptions, + ); // 中文注释:运行态必须锁定本次启动时的完整 profile,避免首次切屏渲染读到旧草稿 state。 setMatch3DRuntimeProfile(runtimeProfile); setMatch3DRun(run); @@ -5553,6 +5874,7 @@ export function PlatformEntryFlowShellImpl({ [ isMatch3DBusy, match3dFlow, + match3dRuntimeAdapter, resolveMatch3DErrorMessage, setMatch3DError, setSelectionStage, @@ -6865,6 +7187,12 @@ export function PlatformEntryFlowShellImpl({ return; } + if (isEdutainmentGalleryEntry(entry)) { + setPublicWorkDetailError('宝贝识物点赞将在后续版本开放。'); + setIsPublicWorkDetailBusy(false); + return; + } + if (isSquareHoleGalleryEntry(entry)) { setPublicWorkDetailError('方洞挑战点赞将在后续版本开放。'); setIsPublicWorkDetailBusy(false); @@ -7197,6 +7525,11 @@ export function PlatformEntryFlowShellImpl({ return; } + if (isEdutainmentGalleryEntry(entry)) { + openPublicWorkDetail(entry); + return; + } + void openRpgPublicWorkDetail(entry); }, [ @@ -7578,6 +7911,22 @@ export function PlatformEntryFlowShellImpl({ ], ); + const openBabyObjectMatchDraft = useCallback( + (draft: BabyObjectMatchDraft) => { + setBabyObjectMatchDraft(draft); + setBabyObjectMatchFormPayload({ + itemAName: draft.itemNames[0], + itemBName: draft.itemNames[1], + }); + setBabyObjectMatchGenerationState(null); + setBabyObjectMatchGenerationPhase('ready'); + setBabyObjectMatchError(null); + enterCreateTab(); + setSelectionStage('baby-object-match-result'); + }, + [enterCreateTab, setSelectionStage], + ); + const startBigFishRunFromWork = useCallback( async ( item: BigFishWorkSummary, @@ -7722,6 +8071,15 @@ export function PlatformEntryFlowShellImpl({ return; } + if (isEdutainmentGalleryEntry(selectedPublicWorkDetail)) { + setPublicWorkDetailError(null); + startBabyObjectMatchRuntimeFromEntry( + selectedPublicWorkDetail, + 'work-detail', + ); + return; + } + const launchEntry = selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId ? selectedDetailEntry @@ -7763,6 +8121,7 @@ export function PlatformEntryFlowShellImpl({ startPuzzleRunFromProfile, startMatch3DRunFromProfile, startSquareHoleRunFromProfile, + startBabyObjectMatchRuntimeFromEntry, startVisualNovelRunFromProfile, ]); @@ -7845,6 +8204,10 @@ export function PlatformEntryFlowShellImpl({ 'platform', { embedded: true }, ); + } else if (isEdutainmentGalleryEntry(entry)) { + started = startBabyObjectMatchRuntimeFromEntry(entry, 'platform', { + embedded: true, + }); } else { started = true; } @@ -7885,6 +8248,7 @@ export function PlatformEntryFlowShellImpl({ startMatch3DRunFromProfile, startPuzzleRunFromProfile, startSquareHoleRunFromProfile, + startBabyObjectMatchRuntimeFromEntry, startVisualNovelRunFromProfile, ], ); @@ -7994,7 +8358,8 @@ export function PlatformEntryFlowShellImpl({ match3dFlow.setIsBusy(true); setMatch3DError(null); - void restartMatch3DRun(match3dRun.runId) + void match3dRuntimeAdapter + .restartRun(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); }) @@ -8013,14 +8378,15 @@ export function PlatformEntryFlowShellImpl({ if (!runId) { return Promise.reject(new Error('抓大鹅运行态缺少 runId。')); } - return clickMatch3DItem(runId, payload); + return match3dRuntimeAdapter.clickItem(runId, payload); }} onTimeExpired={() => { if (!match3dRun?.runId) { return; } - void finishMatch3DTimeUp(match3dRun.runId) + void match3dRuntimeAdapter + .finishTimeUp(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); }) @@ -8163,6 +8529,18 @@ export function PlatformEntryFlowShellImpl({ ); } + if (activeRecommendRuntimeKind === 'edutainment' && babyObjectMatchDraft) { + return ( + { + setActiveRecommendRuntimeKind(null); + }} + /> + ); + } + return (
{ const nextEntry = response.entry; @@ -8441,6 +8827,17 @@ export function PlatformEntryFlowShellImpl({ return; } + if (isEdutainmentGalleryEntry(entry)) { + const matchedDraft = resolveBabyObjectMatchRuntimeDraft(entry); + if (!matchedDraft) { + setPublicWorkDetailError('这份宝贝识物缺少可编辑草稿。'); + return; + } + + openBabyObjectMatchDraft(matchedDraft); + return; + } + const editEntry = selectedDetailEntry?.profileId === entry.profileId ? selectedDetailEntry @@ -8461,6 +8858,8 @@ export function PlatformEntryFlowShellImpl({ openPuzzleDraft, openSquareHoleDraft, openVisualNovelDraft, + openBabyObjectMatchDraft, + resolveBabyObjectMatchRuntimeDraft, runProtectedAction, selectedDetailEntry, selectedPuzzleDetail, @@ -8500,12 +8899,14 @@ export function PlatformEntryFlowShellImpl({ normalizedKeyword, ); const shouldSearchBigFishFirst = upperKeyword.startsWith('BF'); + const shouldSearchBabyObjectFirst = upperKeyword.startsWith('BO'); const shouldSearchMatch3DFirst = upperKeyword.startsWith('M3'); const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ'); const shouldSearchSquareHoleFirst = upperKeyword.startsWith('SH'); const shouldSearchVisualNovelFirst = upperKeyword.startsWith('VN'); const shouldSearchWorkFirst = !shouldSearchUserIdFirst && + !shouldSearchBabyObjectFirst && !shouldSearchBigFishFirst && !shouldSearchMatch3DFirst && !shouldSearchPuzzleFirst && @@ -8517,6 +8918,7 @@ export function PlatformEntryFlowShellImpl({ upperKeyword.startsWith('SY') || (!shouldSearchWorkFirst && !shouldSearchBigFishFirst && + !shouldSearchBabyObjectFirst && !shouldSearchMatch3DFirst && !shouldSearchPuzzleFirst && !shouldSearchSquareHoleFirst && @@ -8653,6 +9055,31 @@ export function PlatformEntryFlowShellImpl({ mapVisualNovelWorkToPublicWorkDetail(matchedEntry), ); }; + const tryOpenBabyObjectMatchGalleryEntry = () => { + const entries = listLocalBabyObjectMatchDrafts().filter( + (draft) => draft.publicationStatus === 'published', + ); + const matchedDraft = entries.find((draft) => { + const detailEntry = + mapBabyObjectMatchDraftToPlatformGalleryCard(draft); + return ( + canExposePublicWork(detailEntry) && + isSameBabyObjectMatchPublicWorkCode( + normalizedKeyword, + draft.profileId, + ) + ); + }); + + if (!matchedDraft) { + throw new Error('未找到宝贝识物作品。'); + } + + const detailEntry = + mapBabyObjectMatchDraftToPlatformGalleryCard(matchedDraft); + setBabyObjectMatchDraft(matchedDraft); + openPublicWorkDetail(detailEntry); + }; try { if (shouldSearchUserIdFirst) { @@ -8671,6 +9098,11 @@ export function PlatformEntryFlowShellImpl({ return; } + if (shouldSearchBabyObjectFirst) { + tryOpenBabyObjectMatchGalleryEntry(); + return; + } + if (shouldSearchMatch3DFirst) { await tryOpenMatch3DGalleryEntry(); return; @@ -8752,6 +9184,8 @@ export function PlatformEntryFlowShellImpl({ squareHoleGalleryEntries, selectionStage, setPlatformTab, + setPuzzleError, + setSelectionStage, visualNovelGalleryEntries, ], ); @@ -8950,11 +9384,13 @@ export function PlatformEntryFlowShellImpl({ void refreshSquareHoleShelf(); } void refreshVisualNovelShelf(); + refreshBabyObjectMatchShelf(); } }, [ isSquareHoleCreationVisible, platformBootstrap.canReadProtectedData, platformBootstrap.platformTab, + refreshBabyObjectMatchShelf, refreshMatch3DShelf, refreshPuzzleShelf, refreshSquareHoleShelf, @@ -8994,7 +9430,8 @@ export function PlatformEntryFlowShellImpl({ isMatch3DLoadingLibrary || (isSquareHoleCreationVisible && isSquareHoleLoadingLibrary) || isPuzzleLoadingLibrary || - isVisualNovelLoadingLibrary + isVisualNovelLoadingLibrary || + isBabyObjectMatchBusy } error={ platformBootstrap.isLoadingPlatform || @@ -9002,7 +9439,8 @@ export function PlatformEntryFlowShellImpl({ isMatch3DLoadingLibrary || (isSquareHoleCreationVisible && isSquareHoleLoadingLibrary) || isPuzzleLoadingLibrary || - isVisualNovelLoadingLibrary + isVisualNovelLoadingLibrary || + isBabyObjectMatchBusy ? null : (platformBootstrap.platformError ?? sessionController.agentWorkspaceRestoreError ?? @@ -9011,7 +9449,8 @@ export function PlatformEntryFlowShellImpl({ (isSquareHoleCreationVisible ? squareHoleError : null) ?? puzzleShelfError ?? puzzleError ?? - visualNovelError) + visualNovelError ?? + babyObjectMatchError) } onRetry={() => { platformBootstrap.setPlatformError(null); @@ -9032,6 +9471,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleCreationError(null); setPuzzleError(null); setVisualNovelError(null); + setBabyObjectMatchError(null); void platformBootstrap.refreshCustomWorldWorks().catch((error) => { platformBootstrap.setPlatformError( resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'), @@ -9046,6 +9486,7 @@ export function PlatformEntryFlowShellImpl({ } void refreshPuzzleShelf(); void refreshVisualNovelShelf(); + refreshBabyObjectMatchShelf(); }} createError={ creationEntryConfigError ?? @@ -9055,7 +9496,8 @@ export function PlatformEntryFlowShellImpl({ (isSquareHoleCreationVisible ? squareHoleError : null) ?? puzzleCreationError ?? puzzleError ?? - visualNovelError + visualNovelError ?? + babyObjectMatchError } createBusy={ !creationEntryConfig || @@ -9067,7 +9509,8 @@ export function PlatformEntryFlowShellImpl({ (isSquareHoleCreationVisible && isSquareHoleBusy) || isPuzzleBusy || isVisualNovelBusy || - isVisualNovelStreamingReply + isVisualNovelStreamingReply || + isBabyObjectMatchBusy } entryConfig={creationEntryConfig} creationTypes={creationEntryTypes} @@ -9155,6 +9598,14 @@ export function PlatformEntryFlowShellImpl({ handleClaimPuzzlePointIncentive(item); }} claimingPuzzleProfileId={claimingPuzzlePointIncentiveProfileId} + babyObjectMatchItems={ + isBabyObjectMatchVisible ? babyObjectMatchDrafts : [] + } + onOpenBabyObjectMatchDetail={(item) => { + runProtectedAction(() => { + openBabyObjectMatchDraft(item); + }); + }} visualNovelItems={visualNovelShelfItems} onOpenVisualNovelDetail={(item) => { runProtectedAction(() => { @@ -9189,7 +9640,8 @@ export function PlatformEntryFlowShellImpl({ isSquareHoleBusy || isPuzzleBusy || isVisualNovelBusy || - isVisualNovelStreamingReply; + isVisualNovelStreamingReply || + isBabyObjectMatchBusy; return (
@@ -1521,7 +1524,9 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) { ? 'square-hole' : isVisualNovelGalleryEntry(entry) ? 'visual-novel' - : 'rpg'; + : isEdutainmentGalleryEntry(entry) + ? `edutainment:${entry.templateId}` + : 'rpg'; return `${kind}:${entry.ownerUserId}:${entry.profileId}`; } @@ -1633,7 +1638,9 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) { ? '方洞' : isVisualNovelGalleryEntry(entry) ? '视觉' - : describePlatformThemeLabel(entry.themeMode); + : isEdutainmentGalleryEntry(entry) + ? entry.templateName + : describePlatformThemeLabel(entry.themeMode); return formatPlatformWorkDisplayTag(kind); } diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts index f97677bb..1f606880 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts @@ -1,13 +1,18 @@ import { expect, test } from 'vitest'; import { - buildPuzzleWorkCoverSlides, buildPlatformWorldDisplayTags, + buildPuzzleWorkCoverSlides, + EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, + EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, formatPlatformWorkDisplayName, formatPlatformWorkDisplayTags, formatPlatformWorldTime, + isEdutainmentGalleryEntry, isVisualNovelGalleryEntry, + mapBabyObjectMatchDraftToPlatformGalleryCard, mapVisualNovelWorkToPlatformGalleryCard, + type PlatformEdutainmentGalleryCard, resolvePlatformPublicWorkCode, } from './rpgEntryWorldPresentation'; @@ -132,3 +137,73 @@ test('maps visual novel work to platform gallery card with VN public code', () = expect(resolvePlatformPublicWorkCode(card)).toBe('VN-12345678'); expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']); }); + +test('keeps baby object match public card code and template label intact', () => { + const card: PlatformEdutainmentGalleryCard = { + sourceType: 'edutainment', + templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, + templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, + workId: 'baby-object-match-work-1', + profileId: 'baby-object-match-profile-1', + sourceSessionId: 'baby-object-match-session-1', + publicWorkCode: 'EDU-BABY01', + ownerUserId: 'user-1', + authorDisplayName: '百梦主', + worldName: '宝贝识物水果篮', + subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, + summaryText: '将物品放入对应的篮子里。', + coverImageSrc: null, + themeTags: ['寓教于乐'], + playCount: 3, + remixCount: 0, + likeCount: 1, + recentPlayCount7d: 3, + visibility: 'published', + publishedAt: '2026-05-11T10:00:00.000Z', + updatedAt: '2026-05-11T10:00:00.000Z', + }; + + expect(isEdutainmentGalleryEntry(card)).toBe(true); + expect(resolvePlatformPublicWorkCode(card)).toBe('EDU-BABY01'); + expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['寓教于乐']); +}); + +test('maps baby object match draft to edutainment public card', () => { + const card = mapBabyObjectMatchDraftToPlatformGalleryCard({ + draftId: 'baby-object-draft-1', + profileId: 'baby-object-profile-12345678', + templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, + templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, + workTitle: '宝贝识物水果篮', + workDescription: '苹果和香蕉识物分类', + itemNames: ['苹果', '香蕉'], + itemAssets: [ + { + itemId: 'baby-object-item-1', + itemName: '苹果', + imageSrc: '/apple.png', + assetObjectId: null, + generationProvider: 'placeholder', + prompt: '苹果', + }, + { + itemId: 'baby-object-item-2', + itemName: '香蕉', + imageSrc: '/banana.png', + assetObjectId: null, + generationProvider: 'placeholder', + prompt: '香蕉', + }, + ], + themeTags: ['寓教于乐', '宝贝识物'], + publicationStatus: 'published', + createdAt: '2026-05-11T10:00:00.000Z', + updatedAt: '2026-05-11T12:00:00.000Z', + publishedAt: '2026-05-11T12:00:00.000Z', + }); + + expect(isEdutainmentGalleryEntry(card)).toBe(true); + expect(card.publicWorkCode).toBe('BO-12345678'); + expect(card.coverImageSrc).toBe('/apple.png'); + expect(card.themeTags[0]).toBe('寓教于乐'); +}); diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index 3dd98456..7b0bf83a 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -1,4 +1,6 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { Match3DGeneratedItemAsset, Match3DWorkSummary, @@ -18,6 +20,7 @@ import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contra import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; import { + buildBabyObjectMatchPublicWorkCode, buildBigFishPublicWorkCode, buildMatch3DPublicWorkCode, buildPuzzlePublicWorkCode, @@ -28,6 +31,8 @@ import type { CustomWorldProfile } from '../../types'; export const PLATFORM_WORK_NAME_DISPLAY_LIMIT = 8; export const PLATFORM_WORK_TAG_DISPLAY_LIMIT = 4; +export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match'; +export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物'; export type PlatformWorldCardLike = | CustomWorldGalleryCard @@ -36,7 +41,8 @@ export type PlatformWorldCardLike = | PlatformMatch3DGalleryCard | PlatformSquareHoleGalleryCard | PlatformPuzzleGalleryCard - | PlatformVisualNovelGalleryCard; + | PlatformVisualNovelGalleryCard + | PlatformEdutainmentGalleryCard; export type PlatformPuzzleGalleryCard = { sourceType: 'puzzle'; @@ -164,13 +170,38 @@ export type PlatformVisualNovelGalleryCard = { updatedAt: string; }; +export type PlatformEdutainmentGalleryCard = { + sourceType: 'edutainment'; + templateId: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID; + templateName: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME; + workId: string; + profileId: string; + sourceSessionId?: string | null; + publicWorkCode: string; + ownerUserId: string; + authorDisplayName: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeTags: string[]; + playCount?: number; + remixCount?: number; + likeCount?: number; + recentPlayCount7d?: number; + visibility: 'published'; + publishedAt: string | null; + updatedAt: string; +}; + export type PlatformPublicGalleryCard = | CustomWorldGalleryCard | PlatformBigFishGalleryCard | PlatformMatch3DGalleryCard | PlatformSquareHoleGalleryCard | PlatformPuzzleGalleryCard - | PlatformVisualNovelGalleryCard; + | PlatformVisualNovelGalleryCard + | PlatformEdutainmentGalleryCard; export function isLibraryWorldEntry( entry: PlatformWorldCardLike, @@ -208,6 +239,12 @@ export function isVisualNovelGalleryEntry( return 'sourceType' in entry && entry.sourceType === 'visual-novel'; } +export function isEdutainmentGalleryEntry( + entry: PlatformWorldCardLike, +): entry is PlatformEdutainmentGalleryCard { + return 'sourceType' in entry && entry.sourceType === 'edutainment'; +} + export function mapPuzzleWorkToPlatformGalleryCard( work: PuzzleWorkSummary, ): PlatformPuzzleGalleryCard { @@ -286,8 +323,7 @@ export function mapSquareHoleWorkToPlatformGalleryCard( holeOptions: work.holeOptions, shapeCount: work.shapeCount, difficulty: work.difficulty, - themeTags: - work.tags.length > 0 ? work.tags : [work.themeText, '方洞挑战'], + themeTags: work.tags.length > 0 ? work.tags : [work.themeText, '方洞挑战'], playCount: work.playCount ?? 0, remixCount: 0, likeCount: 0, @@ -349,6 +385,40 @@ export function mapVisualNovelWorkToPlatformGalleryCard( }; } +export function mapBabyObjectMatchDraftToPlatformGalleryCard( + draft: BabyObjectMatchDraft, +): PlatformEdutainmentGalleryCard { + return { + sourceType: 'edutainment', + templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, + templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, + workId: draft.profileId, + profileId: draft.profileId, + sourceSessionId: draft.draftId, + publicWorkCode: buildBabyObjectMatchPublicWorkCode(draft.profileId), + ownerUserId: 'current-user', + authorDisplayName: '百梦主', + worldName: draft.workTitle.trim() || draft.templateName, + subtitle: draft.templateName, + summaryText: + draft.workDescription.trim() || + `${draft.itemNames[0]}和${draft.itemNames[1]}识物分类`, + coverImageSrc: + draft.itemAssets.find((asset) => asset.imageSrc.trim())?.imageSrc ?? null, + themeTags: + draft.themeTags.length > 0 + ? draft.themeTags + : [BABY_OBJECT_MATCH_EDUTAINMENT_TAG], + playCount: 0, + remixCount: 0, + likeCount: 0, + recentPlayCount7d: 0, + visibility: 'published', + publishedAt: draft.publishedAt, + updatedAt: draft.updatedAt, + }; +} + export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) { return { playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0, @@ -488,9 +558,7 @@ export function formatPlatformWorkDisplayTags( ) { return [ ...new Set( - tags - .map((tag) => formatPlatformWorkDisplayTag(tag)) - .filter(Boolean), + tags.map((tag) => formatPlatformWorkDisplayTag(tag)).filter(Boolean), ), ].slice(0, limit); } @@ -512,13 +580,13 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) { } if (isMatch3DGalleryEntry(entry)) { - return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['抓大鹅']; + return entry.themeTags.length > 0 + ? entry.themeTags.slice(0, 3) + : ['抓大鹅']; } if (isSquareHoleGalleryEntry(entry)) { - return entry.themeTags.length > 0 - ? entry.themeTags.slice(0, 3) - : ['方洞']; + return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['方洞']; } if (isVisualNovelGalleryEntry(entry)) { @@ -527,6 +595,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) { : ['视觉小说']; } + if (isEdutainmentGalleryEntry(entry)) { + return entry.themeTags.length > 0 + ? entry.themeTags.slice(0, 3) + : [entry.templateName]; + } + if (!isLibraryWorldEntry(entry)) { return [ describePlatformThemeLabel(entry.themeMode), @@ -613,6 +687,10 @@ export function resolvePlatformPublicWorkCode( return entry.publicWorkCode; } + if (isEdutainmentGalleryEntry(entry)) { + return entry.publicWorkCode; + } + return entry.publicWorkCode; } diff --git a/src/games/bark-battle/application/BarkBattleConfig.ts b/src/games/bark-battle/application/BarkBattleConfig.ts new file mode 100644 index 00000000..1c017896 --- /dev/null +++ b/src/games/bark-battle/application/BarkBattleConfig.ts @@ -0,0 +1,25 @@ +export type BarkBattleConfig = { + roundDurationMs: number; + countdownMs: number; + drawThreshold: number; + barkThreshold: number; + minBarkGapMs: number; + minBarkDurationMs: number; + maxBarkDurationMs: number; + balanceFactor: number; + calibrationMaxWaitMs: number; + opponentBasePower: number; +}; + +export const DEFAULT_BARK_BATTLE_CONFIG: BarkBattleConfig = { + roundDurationMs: 30_000, + countdownMs: 3_000, + drawThreshold: 12, + barkThreshold: 0.5, + minBarkGapMs: 300, + minBarkDurationMs: 90, + maxBarkDurationMs: 900, + balanceFactor: 32, + calibrationMaxWaitMs: 4_000, + opponentBasePower: 0.22, +}; diff --git a/src/games/bark-battle/application/BarkBattleController.ts b/src/games/bark-battle/application/BarkBattleController.ts new file mode 100644 index 00000000..7b0ab61f --- /dev/null +++ b/src/games/bark-battle/application/BarkBattleController.ts @@ -0,0 +1,92 @@ +import { type BarkBattleSession, createBarkBattleSession } from '../domain/BarkBattleSession'; +import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes'; +import { BarkDetector } from '../domain/BarkDetector'; +import type { BarkBattleConfig } from './BarkBattleConfig'; + +export class BarkBattleController { + private session: BarkBattleSession; + private detector: BarkDetector; + private sampleClockMs = 0; + + constructor(private config: BarkBattleConfig) { + this.session = createBarkBattleSession(config); + this.detector = this.createDetector(); + } + + getSnapshot() { + return this.session.snapshot; + } + + getSampleClockMs() { + return this.sampleClockMs; + } + + updateConfig(config: BarkBattleConfig) { + this.config = config; + this.restart(); + } + + finishNow() { + if (this.session.snapshot.phase !== 'playing') { + this.session = this.session.startMockRound(); + } + if (this.session.snapshot.phase === 'countdown') { + this.session = this.session.tick(this.session.snapshot.countdownMs); + } + this.session = this.session.tick(this.session.snapshot.remainingMs + 1); + } + + startWithMockInput() { + this.session = createBarkBattleSession(this.config).startMockRound(); + this.detector = this.createDetector(); + this.sampleClockMs = 0; + } + + forcePlayerBark(volume = 0.9) { + if (this.session.snapshot.phase !== 'playing') { + this.session = this.session.startMockRound(); + } + if (this.session.snapshot.phase === 'countdown') { + this.session = this.session.tick(this.session.snapshot.countdownMs); + } + this.session = this.session.applyPlayerBark({ + side: 'player', + atMs: this.sampleClockMs, + peakVolume: volume, + durationMs: this.config.minBarkDurationMs, + }); + } + + submitInputSample(volume: number, atMs = this.sampleClockMs) { + const events = this.detector.acceptSample({ atMs, volume }); + for (const event of events) { + this.session = this.session.applyPlayerBark(event); + } + } + + submitMockSample(volume: number) { + this.submitInputSample(volume); + } + + tick(deltaMs: number) { + this.sampleClockMs += deltaMs; + this.session = this.session.tick(deltaMs); + } + + restart() { + this.session = createBarkBattleSession(this.config); + this.detector = this.createDetector(); + this.sampleClockMs = 0; + } + + failMicrophone(reason: MicrophoneFailureReason) { + this.session = this.session.failMicrophone(reason); + } + + private createDetector() { + return new BarkDetector({ + threshold: this.config.barkThreshold, + minBarkGapMs: this.config.minBarkGapMs, + }); + } +} diff --git a/src/games/bark-battle/application/__tests__/BarkBattleController.test.ts b/src/games/bark-battle/application/__tests__/BarkBattleController.test.ts new file mode 100644 index 00000000..a82c7b86 --- /dev/null +++ b/src/games/bark-battle/application/__tests__/BarkBattleController.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_BARK_BATTLE_CONFIG } from '../BarkBattleConfig'; +import { BarkBattleController } from '../BarkBattleController'; + +describe('BarkBattleController', () => { + it('mock 模式可跑通完整一局并生成结算', () => { + const controller = new BarkBattleController({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1200, countdownMs: 300 }); + + controller.startWithMockInput(); + controller.tick(300); + expect(controller.getSnapshot().phase).toBe('playing'); + + controller.submitMockSample(0.92); + controller.tick(160); + controller.submitMockSample(0.12); + + expect(controller.getSnapshot().player.barkCount).toBe(1); + expect(controller.getSnapshot().energy).toBeGreaterThan(0); + + controller.tick(1200); + expect(controller.getSnapshot().phase).toBe('finished'); + expect(controller.getSnapshot().result?.winner).toBe('player'); + }); + + it('麦克风失败时进入 unavailable 且不会进入 playing', () => { + const controller = new BarkBattleController(DEFAULT_BARK_BATTLE_CONFIG); + + controller.failMicrophone('permission-denied'); + controller.tick(5000); + + expect(controller.getSnapshot()).toMatchObject({ + phase: 'unavailable', + uiState: 'microphone-unavailable', + errorReason: 'permission-denied', + statusMessageKey: 'microphone-permission-denied', + }); + }); + + it('restart 会重置上一局计数、能量和结果', () => { + const controller = new BarkBattleController({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1, countdownMs: 0 }); + + controller.startWithMockInput(); + controller.tick(1); + controller.submitMockSample(1); + controller.tick(120); + controller.submitMockSample(0.1); + controller.tick(2); + expect(controller.getSnapshot().result).not.toBeNull(); + + controller.restart(); + expect(controller.getSnapshot().phase).toBe('permission'); + expect(controller.getSnapshot().player.barkCount).toBe(0); + expect(controller.getSnapshot().energy).toBe(0); + expect(controller.getSnapshot().result).toBeNull(); + }); + + it('真实输入采样可使用高精度采样时间戳连续触发 100ms 级别叫声', () => { + const controller = new BarkBattleController({ + ...DEFAULT_BARK_BATTLE_CONFIG, + countdownMs: 0, + barkThreshold: 0.5, + minBarkDurationMs: 40, + minBarkGapMs: 100, + }); + + controller.startWithMockInput(); + controller.submitInputSample(0.82, 0); + controller.submitInputSample(0.1, 60); + controller.submitInputSample(0.9, 120); + controller.submitInputSample(0.1, 180); + + expect(controller.getSnapshot().player.barkCount).toBe(2); + }); +}); diff --git a/src/games/bark-battle/domain/BarkBattleScoring.ts b/src/games/bark-battle/domain/BarkBattleScoring.ts new file mode 100644 index 00000000..e356e304 --- /dev/null +++ b/src/games/bark-battle/domain/BarkBattleScoring.ts @@ -0,0 +1,30 @@ +import type { BarkBattleResult, BarkBattleWinner } from './BarkBattleTypes'; + +export function decideBarkBattleWinner( + energy: number, + drawThreshold: number, +): BarkBattleWinner { + if (energy > drawThreshold) { + return 'player'; + } + if (energy < -drawThreshold) { + return 'opponent'; + } + return 'draw'; +} + +export function buildBarkBattleResult(input: { + energy: number; + drawThreshold: number; + playerBarkCount: number; + opponentBarkCount: number; +}): BarkBattleResult { + const winner = decideBarkBattleWinner(input.energy, input.drawThreshold); + return { + winner, + playerBarkCount: input.playerBarkCount, + opponentBarkCount: input.opponentBarkCount, + finalEnergy: input.energy, + score: Math.max(0, Math.round(input.energy + input.playerBarkCount * 120)), + }; +} diff --git a/src/games/bark-battle/domain/BarkBattleSession.ts b/src/games/bark-battle/domain/BarkBattleSession.ts new file mode 100644 index 00000000..232037a2 --- /dev/null +++ b/src/games/bark-battle/domain/BarkBattleSession.ts @@ -0,0 +1,154 @@ +import type { BarkBattleConfig } from '../application/BarkBattleConfig'; +import { buildBarkBattleResult } from './BarkBattleScoring'; +import type { BarkBattleEvent, BarkBattleSnapshot } from './BarkBattleTypes'; +import { advanceEnergy, clampEnergy } from './EnergyTugOfWar'; +import { computeOpponentPower } from './OpponentStrategy'; + +export class BarkBattleSession { + constructor( + private readonly config: BarkBattleConfig, + readonly snapshot: BarkBattleSnapshot, + ) {} + + startMockRound() { + return new BarkBattleSession(this.config, { + ...this.snapshot, + phase: this.config.countdownMs > 0 ? 'countdown' : 'playing', + uiState: this.config.countdownMs > 0 ? 'ready-countdown' : 'playing', + countdownMs: this.config.countdownMs, + remainingMs: this.config.roundDurationMs, + lastEvents: [], + }); + } + + tick(deltaMs: number) { + if (this.snapshot.phase === 'finished' || this.snapshot.phase === 'unavailable') { + return this.withEvents([]); + } + + if (this.snapshot.phase === 'countdown') { + const countdownMs = Math.max(0, this.snapshot.countdownMs - deltaMs); + return new BarkBattleSession(this.config, { + ...this.snapshot, + phase: countdownMs <= 0 ? 'playing' : 'countdown', + uiState: countdownMs <= 0 ? 'playing' : 'ready-countdown', + countdownMs, + remainingMs: this.config.roundDurationMs, + lastEvents: [], + }); + } + + if (this.snapshot.phase !== 'playing') { + return this.withEvents([]); + } + + const elapsedMs = this.snapshot.elapsedMs + deltaMs; + const remainingMs = Math.max(0, this.snapshot.remainingMs - deltaMs); + const opponentPower = computeOpponentPower(this.config, elapsedMs); + const energy = advanceEnergy({ + energy: this.snapshot.energy, + playerPower: this.snapshot.player.power, + opponentPower, + deltaMs, + balanceFactor: this.config.balanceFactor, + }); + const nextSnapshot: BarkBattleSnapshot = { + ...this.snapshot, + elapsedMs, + remainingMs, + energy, + opponent: { + ...this.snapshot.opponent, + power: opponentPower, + }, + player: { + ...this.snapshot.player, + power: Math.max(0, this.snapshot.player.power * 0.78), + }, + lastEvents: [], + }; + + if (remainingMs > 0) { + return new BarkBattleSession(this.config, nextSnapshot); + } + + const result = buildBarkBattleResult({ + energy, + drawThreshold: this.config.drawThreshold, + playerBarkCount: nextSnapshot.player.barkCount, + opponentBarkCount: nextSnapshot.opponent.barkCount, + }); + return new BarkBattleSession(this.config, { + ...nextSnapshot, + phase: 'finished', + uiState: 'finished', + winner: result.winner, + result, + }); + } + + applyPlayerBark(event: BarkBattleEvent) { + if (this.snapshot.phase !== 'playing') { + return this.withEvents([]); + } + + const playerPower = Math.min(1, Math.max(this.snapshot.player.power, event.peakVolume)); + return new BarkBattleSession(this.config, { + ...this.snapshot, + energy: clampEnergy(this.snapshot.energy + event.peakVolume * 12), + player: { + barkCount: this.snapshot.player.barkCount + 1, + power: playerPower, + }, + lastEvents: [event], + }); + } + + failMicrophone(reason: BarkBattleSnapshot['errorReason']) { + return new BarkBattleSession(this.config, { + ...this.snapshot, + phase: 'unavailable', + uiState: 'microphone-unavailable', + errorReason: reason, + statusMessageKey: reason ? MICROPHONE_STATUS_KEYS[reason] : null, + lastEvents: [], + }); + } + + private withEvents(lastEvents: BarkBattleEvent[]) { + return new BarkBattleSession(this.config, { + ...this.snapshot, + lastEvents, + }); + } +} + +const MICROPHONE_STATUS_KEYS = { + unsupported: 'microphone-unsupported', + 'permission-denied': 'microphone-permission-denied', + 'non-secure-context': 'microphone-non-secure-context', + 'not-found': 'microphone-not-found', + 'not-readable': 'microphone-not-readable', + 'audio-context-blocked': 'microphone-audio-context-blocked', + 'calibration-timeout': 'microphone-calibration-timeout', + 'calibration-sample-unreadable': 'microphone-calibration-sample-unreadable', + unknown: 'microphone-unknown-error', +} as const; + +export function createBarkBattleSession(config: BarkBattleConfig) { + return new BarkBattleSession(config, { + phase: 'permission', + uiState: 'permission-ready', + errorReason: null, + statusMessageKey: null, + elapsedMs: 0, + remainingMs: config.roundDurationMs, + countdownMs: config.countdownMs, + energy: 0, + player: { barkCount: 0, power: 0 }, + opponent: { barkCount: 0, power: config.opponentBasePower }, + winner: null, + result: null, + lastEvents: [], + }); +} diff --git a/src/games/bark-battle/domain/BarkBattleTypes.ts b/src/games/bark-battle/domain/BarkBattleTypes.ts new file mode 100644 index 00000000..2bb663c7 --- /dev/null +++ b/src/games/bark-battle/domain/BarkBattleTypes.ts @@ -0,0 +1,84 @@ +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'; + +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'; + +export type BarkAudioSample = { + atMs: number; + volume: number; +}; + +export type BarkBattleEvent = { + side: BarkBattleSide; + atMs: number; + peakVolume: number; + durationMs: number; +}; + +export type BarkBattleParticipantState = { + barkCount: number; + power: number; +}; + +export type BarkBattleResult = { + winner: BarkBattleWinner; + playerBarkCount: number; + opponentBarkCount: number; + finalEnergy: number; + score: number; +}; + +export type BarkBattleSnapshot = { + phase: BarkBattlePhase; + uiState: BarkBattleUiState; + errorReason: MicrophoneFailureReason | null; + statusMessageKey: BarkBattleStatusMessageKey | null; + elapsedMs: number; + remainingMs: number; + countdownMs: number; + energy: number; + player: BarkBattleParticipantState; + opponent: BarkBattleParticipantState; + winner: BarkBattleWinner; + result: BarkBattleResult | null; + lastEvents: BarkBattleEvent[]; +}; diff --git a/src/games/bark-battle/domain/BarkDetector.ts b/src/games/bark-battle/domain/BarkDetector.ts new file mode 100644 index 00000000..48568bf7 --- /dev/null +++ b/src/games/bark-battle/domain/BarkDetector.ts @@ -0,0 +1,41 @@ +import type { BarkAudioSample, BarkBattleEvent } from './BarkBattleTypes'; + +export type BarkDetectorConfig = { + threshold: number; + minBarkGapMs: number; +}; + +export class BarkDetector { + private lastAcceptedAtMs = Number.NEGATIVE_INFINITY; + + constructor(private readonly config: BarkDetectorConfig) {} + + acceptSample(sample: BarkAudioSample): BarkBattleEvent[] { + const volume = clamp01(sample.volume); + if (volume < this.config.threshold) { + return []; + } + + const accepted = sample.atMs - this.lastAcceptedAtMs >= this.config.minBarkGapMs; + if (!accepted) { + return []; + } + + this.lastAcceptedAtMs = sample.atMs; + return [ + { + side: 'player', + atMs: sample.atMs, + peakVolume: volume, + durationMs: 0, + }, + ]; + } +} + +function clamp01(value: number) { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(1, Math.max(0, value)); +} diff --git a/src/games/bark-battle/domain/EnergyTugOfWar.ts b/src/games/bark-battle/domain/EnergyTugOfWar.ts new file mode 100644 index 00000000..cfc0d06b --- /dev/null +++ b/src/games/bark-battle/domain/EnergyTugOfWar.ts @@ -0,0 +1,20 @@ +export type AdvanceEnergyInput = { + energy: number; + playerPower: number; + opponentPower: number; + deltaMs: number; + balanceFactor: number; +}; + +export function advanceEnergy(input: AdvanceEnergyInput) { + const deltaSeconds = Math.max(0, input.deltaMs) / 1000; + const powerDelta = input.playerPower - input.opponentPower; + return clampEnergy(input.energy + powerDelta * input.balanceFactor * deltaSeconds); +} + +export function clampEnergy(value: number) { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(100, Math.max(-100, value)); +} diff --git a/src/games/bark-battle/domain/OpponentStrategy.ts b/src/games/bark-battle/domain/OpponentStrategy.ts new file mode 100644 index 00000000..591542e3 --- /dev/null +++ b/src/games/bark-battle/domain/OpponentStrategy.ts @@ -0,0 +1,6 @@ +import type { BarkBattleConfig } from '../application/BarkBattleConfig'; + +export function computeOpponentPower(config: BarkBattleConfig, elapsedMs: number) { + const pulse = 0.05 * Math.sin(elapsedMs / 480); + return Math.min(1, Math.max(0, config.opponentBasePower + pulse)); +} diff --git a/src/games/bark-battle/domain/__tests__/BarkBattleSession.test.ts b/src/games/bark-battle/domain/__tests__/BarkBattleSession.test.ts new file mode 100644 index 00000000..3984a9ad --- /dev/null +++ b/src/games/bark-battle/domain/__tests__/BarkBattleSession.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig'; +import { decideBarkBattleWinner } from '../BarkBattleScoring'; +import { createBarkBattleSession } from '../BarkBattleSession'; + +describe('BarkBattleSession', () => { + it('能从校准完成进入倒计时、playing 并在归零后结算', () => { + let session = createBarkBattleSession({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1000, countdownMs: 600 }); + + expect(session.snapshot.phase).toBe('permission'); + session = session.startMockRound(); + expect(session.snapshot.phase).toBe('countdown'); + + session = session.tick(600); + expect(session.snapshot.phase).toBe('playing'); + expect(session.snapshot.remainingMs).toBe(1000); + + session = session.tick(400); + expect(session.snapshot.remainingMs).toBe(600); + + session = session.applyPlayerBark({ atMs: 700, peakVolume: 0.9, durationMs: 140, side: 'player' }); + expect(session.snapshot.player.barkCount).toBe(1); + expect(session.snapshot.energy).toBeGreaterThan(0); + + session = session.tick(600); + expect(session.snapshot.phase).toBe('finished'); + expect(session.snapshot.result?.winner).toBe('player'); + }); + + it('finished 后输入不再改变本局叫声计数和能量', () => { + let session = createBarkBattleSession({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1, countdownMs: 0 }).startMockRound().tick(1); + session = session.tick(1); + const before = session.snapshot; + + session = session.applyPlayerBark({ atMs: 200, peakVolume: 1, durationMs: 120, side: 'player' }); + + expect(session.snapshot.player.barkCount).toBe(before.player.barkCount); + expect(session.snapshot.energy).toBe(before.energy); + }); +}); + +describe('decideBarkBattleWinner', () => { + it('按 drawThreshold 判定玩家胜、对手胜和平局', () => { + expect(decideBarkBattleWinner(16, 12)).toBe('player'); + expect(decideBarkBattleWinner(-16, 12)).toBe('opponent'); + expect(decideBarkBattleWinner(8, 12)).toBe('draw'); + }); +}); diff --git a/src/games/bark-battle/domain/__tests__/BarkDetector.test.ts b/src/games/bark-battle/domain/__tests__/BarkDetector.test.ts new file mode 100644 index 00000000..20edf368 --- /dev/null +++ b/src/games/bark-battle/domain/__tests__/BarkDetector.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig'; +import { BarkDetector } from '../BarkDetector'; + +describe('BarkDetector', () => { + it('每个监测点只检测瞬时响度,超过阈值立即触发', () => { + const detector = new BarkDetector({ + threshold: 0.45, + minBarkGapMs: DEFAULT_BARK_BATTLE_CONFIG.minBarkGapMs, + }); + + expect(detector.acceptSample({ atMs: 0, volume: 0.2 })).toEqual([]); + const events = detector.acceptSample({ atMs: 40, volume: 0.72 }); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ side: 'player', atMs: 40, peakVolume: 0.72, durationMs: 0 }); + }); + + it('持续噪音按冷却间隔触发,不需要等待响度回落', () => { + const detector = new BarkDetector({ + threshold: 0.4, + minBarkGapMs: 250, + }); + + const allEvents = [ + ...detector.acceptSample({ atMs: 0, volume: 0.7 }), + ...detector.acceptSample({ atMs: 100, volume: 0.72 }), + ...detector.acceptSample({ atMs: 200, volume: 0.73 }), + ...detector.acceptSample({ atMs: 300, volume: 0.75 }), + ...detector.acceptSample({ atMs: 500, volume: 0.2 }), + ...detector.acceptSample({ atMs: 560, volume: 0.76 }), + ]; + + expect(allEvents.map((event) => event.atMs)).toEqual([0, 300, 560]); + }); + + it('低于阈值的背景噪音和冷却内峰值不计数,最短持续时长不再参与判断', () => { + const detector = new BarkDetector({ + threshold: 0.5, + minBarkGapMs: 300, + }); + + expect(detector.acceptSample({ atMs: 0, volume: 0.48 })).toEqual([]); + expect(detector.acceptSample({ atMs: 20, volume: 0.9 })).toHaveLength(1); + expect(detector.acceptSample({ atMs: 60, volume: 0.95 })).toEqual([]); + + expect(detector.acceptSample({ atMs: 320, volume: 0.88 })).toHaveLength(1); + expect(detector.acceptSample({ atMs: 420, volume: 0.2 })).toEqual([]); + }); + + it('支持 100ms 级别间隔的快速连续有效叫声', () => { + const detector = new BarkDetector({ + threshold: 0.5, + minBarkGapMs: 100, + }); + + const allEvents = [ + ...detector.acceptSample({ atMs: 0, volume: 0.86 }), + ...detector.acceptSample({ atMs: 60, volume: 0.9 }), + ...detector.acceptSample({ atMs: 120, volume: 0.91 }), + ...detector.acceptSample({ atMs: 180, volume: 0.92 }), + ...detector.acceptSample({ atMs: 240, volume: 0.93 }), + ]; + + expect(allEvents).toHaveLength(3); + expect(allEvents.map((event) => event.atMs)).toEqual([0, 120, 240]); + }); + + it('非有限音量会归零,超过 1 的音量会夹到 1', () => { + const detector = new BarkDetector({ + threshold: 0.5, + minBarkGapMs: 100, + }); + + expect(detector.acceptSample({ atMs: 0, volume: Number.NaN })).toEqual([]); + expect(detector.acceptSample({ atMs: 120, volume: Number.POSITIVE_INFINITY })).toEqual([]); + const events = detector.acceptSample({ atMs: 240, volume: 2 }); + + expect(events).toHaveLength(1); + expect(events[0]?.peakVolume).toBe(1); + }); +}); diff --git a/src/games/bark-battle/domain/__tests__/EnergyTugOfWar.test.ts b/src/games/bark-battle/domain/__tests__/EnergyTugOfWar.test.ts new file mode 100644 index 00000000..5965f877 --- /dev/null +++ b/src/games/bark-battle/domain/__tests__/EnergyTugOfWar.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { advanceEnergy } from '../EnergyTugOfWar'; + +describe('advanceEnergy', () => { + it('玩家推动力高于对手时能量增加', () => { + expect(advanceEnergy({ energy: 0, playerPower: 0.8, opponentPower: 0.2, deltaMs: 1000, balanceFactor: 40 })).toBeGreaterThan(0); + }); + + it('对手推动力高于玩家时能量减少', () => { + expect(advanceEnergy({ energy: 0, playerPower: 0.1, opponentPower: 0.7, deltaMs: 1000, balanceFactor: 40 })).toBeLessThan(0); + }); + + it('能量被限制在 -100 到 100 且双方相等时保持稳定', () => { + expect(advanceEnergy({ energy: 98, playerPower: 1, opponentPower: 0, deltaMs: 2000, balanceFactor: 40 })).toBe(100); + expect(advanceEnergy({ energy: -98, playerPower: 0, opponentPower: 1, deltaMs: 2000, balanceFactor: 40 })).toBe(-100); + expect(advanceEnergy({ energy: 12, playerPower: 0.5, opponentPower: 0.5, deltaMs: 1000, balanceFactor: 40 })).toBeCloseTo(12); + }); +}); diff --git a/src/games/bark-battle/infrastructure/BrowserMicrophoneInput.ts b/src/games/bark-battle/infrastructure/BrowserMicrophoneInput.ts new file mode 100644 index 00000000..b4a545a5 --- /dev/null +++ b/src/games/bark-battle/infrastructure/BrowserMicrophoneInput.ts @@ -0,0 +1,85 @@ +import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes'; + +export function mapGetUserMediaError(error: unknown): MicrophoneFailureReason { + const name = error && typeof error === 'object' && 'name' in error ? String((error as { name?: unknown }).name) : ''; + if (name === 'NotAllowedError' || name === 'SecurityError') return 'permission-denied'; + if (name === 'NotFoundError' || name === 'DevicesNotFoundError') return 'not-found'; + if (name === 'NotReadableError' || name === 'TrackStartError') return 'not-readable'; + return 'unknown'; +} + +export function isMicrophoneApiSupported(windowLike: { isSecureContext?: boolean; navigator?: Navigator | { mediaDevices?: { getUserMedia?: unknown } } }) { + if (windowLike.isSecureContext === false) { + return { ok: false as const, reason: 'non-secure-context' as const }; + } + const getUserMedia = windowLike.navigator?.mediaDevices?.getUserMedia; + if (typeof getUserMedia !== 'function') { + return { ok: false as const, reason: 'unsupported' as const }; + } + return { ok: true as const, reason: null }; +} + +export function stopMediaStreamTracks(stream: MediaStream) { + stream.getTracks().forEach((track) => track.stop()); +} + +export type BrowserMicrophoneSampler = { + stop: () => void; +}; + +export type BrowserMicrophoneVolumeHandler = (volume: number, atMs: number) => void; + +export async function startBrowserMicrophoneSampler(onVolume: BrowserMicrophoneVolumeHandler): Promise { + const supported = isMicrophoneApiSupported(window); + if (!supported.ok) { + throw Object.assign(new Error(supported.reason), { reason: supported.reason }); + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const AudioContextCtor = window.AudioContext || window.webkitAudioContext; + if (!AudioContextCtor) { + stopMediaStreamTracks(stream); + throw Object.assign(new Error('audio-context-blocked'), { reason: 'audio-context-blocked' }); + } + const audioContext = new AudioContextCtor(); + if (audioContext.state === 'suspended') { + await audioContext.resume(); + } + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 512; + const source = audioContext.createMediaStreamSource(stream); + source.connect(analyser); + const data = new Uint8Array(analyser.fftSize); + const sampleStartedAtMs = window.performance.now(); + let rafId = 0; + const sample = () => { + analyser.getByteTimeDomainData(data); + let sum = 0; + for (const value of data) { + const centered = (value - 128) / 128; + sum += centered * centered; + } + const volume = Math.min(1, Math.sqrt(sum / data.length) * 3.5); + onVolume(volume, window.performance.now() - sampleStartedAtMs); + rafId = window.requestAnimationFrame(sample); + }; + sample(); + return { + stop: () => { + window.cancelAnimationFrame(rafId); + source.disconnect(); + void audioContext.close(); + stopMediaStreamTracks(stream); + }, + }; + } catch (error) { + const reason = error && typeof error === 'object' && 'reason' in error ? (error as { reason: MicrophoneFailureReason }).reason : mapGetUserMediaError(error); + throw Object.assign(new Error(reason), { reason }); + } +} + +declare global { + interface Window { + webkitAudioContext?: typeof AudioContext; + } +} diff --git a/src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts b/src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts new file mode 100644 index 00000000..0c12c5d8 --- /dev/null +++ b/src/games/bark-battle/infrastructure/__tests__/BrowserMicrophoneInput.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { isMicrophoneApiSupported, mapGetUserMediaError, stopMediaStreamTracks } from '../BrowserMicrophoneInput'; + +describe('BrowserMicrophoneInput', () => { + it('区分非安全上下文和不支持 getUserMedia', () => { + expect(isMicrophoneApiSupported({ isSecureContext: false })).toEqual({ ok: false, reason: 'non-secure-context' }); + expect(isMicrophoneApiSupported({ isSecureContext: true, navigator: {} })).toEqual({ ok: false, reason: 'unsupported' }); + }); + + it('映射常见 getUserMedia 错误', () => { + expect(mapGetUserMediaError({ name: 'NotAllowedError' })).toBe('permission-denied'); + expect(mapGetUserMediaError({ name: 'NotFoundError' })).toBe('not-found'); + expect(mapGetUserMediaError({ name: 'NotReadableError' })).toBe('not-readable'); + expect(mapGetUserMediaError({ name: 'OtherError' })).toBe('unknown'); + }); + + it('停止 MediaStream 的所有音轨', () => { + const stopA = vi.fn(); + const stopB = vi.fn(); + stopMediaStreamTracks({ getTracks: () => [{ stop: stopA }, { stop: stopB }] } as unknown as MediaStream); + expect(stopA).toHaveBeenCalledTimes(1); + expect(stopB).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/games/bark-battle/ui/BarkBattleHud.css b/src/games/bark-battle/ui/BarkBattleHud.css new file mode 100644 index 00000000..65aded4d --- /dev/null +++ b/src/games/bark-battle/ui/BarkBattleHud.css @@ -0,0 +1,278 @@ +.bark-battle-hud { + min-height: 100svh; + color: #fff7ed; + background: radial-gradient(circle at 50% 15%, rgba(251, 191, 36, 0.35), transparent 28%), linear-gradient(180deg, #1f1147 0%, #521b4f 48%, #130a28 100%); + display: flex; + flex-direction: column; + gap: 18px; + padding: max(18px, env(safe-area-inset-top)) 16px max(18px, env(safe-area-inset-bottom)); + box-sizing: border-box; + overflow: hidden; +} + +.bark-battle-hud__topline { + display: grid; + gap: 10px; +} + +.bark-battle-hud__timer { + justify-self: center; + border-radius: 999px; + padding: 8px 16px; + background: rgba(15, 23, 42, 0.56); + font-weight: 900; + letter-spacing: 0.04em; +} + +.bark-battle-energy { + position: relative; + display: flex; + height: 18px; + border: 2px solid rgba(255, 247, 237, 0.78); + border-radius: 999px; + overflow: hidden; + background: rgba(15, 23, 42, 0.48); +} + +.bark-battle-energy__side--player { background: linear-gradient(90deg, #f97316, #facc15); } +.bark-battle-energy__side--opponent { background: linear-gradient(90deg, #60a5fa, #a78bfa); } + +.bark-battle-arena { + flex: 1; + min-height: 0; + display: grid; + grid-template-rows: 1fr auto 1fr; + place-items: center; +} + +.bark-battle-dog { + position: relative; + display: grid; + place-items: center; + gap: 8px; + animation: barkBattleDogPulse 420ms ease-out; +} + +.bark-battle-dog__body { + font-size: clamp(92px, 30vw, 150px); + filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.42)); +} + +.bark-battle-dog--player .bark-battle-dog__body { + transform: rotateY(180deg) translateY(4px); +} + +.bark-battle-dog__label, +.bark-battle-dog__burst, +.bark-battle-vs { + font-weight: 900; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); +} + +.bark-battle-dog__burst { + position: absolute; + top: -18px; + border-radius: 999px; + padding: 5px 10px; + color: #1f1147; + background: #facc15; + box-shadow: 0 0 22px rgba(250, 204, 21, 0.72); + animation: barkBattleBurst 640ms ease-out both; +} + +.bark-battle-dog--opponent .bark-battle-dog__burst { + color: #fff7ed; + background: #7c3aed; + box-shadow: 0 0 22px rgba(124, 58, 237, 0.72); +} + +.bark-battle-vs { + border-radius: 999px; + padding: 10px 18px; + background: rgba(255, 255, 255, 0.16); +} + +.bark-battle-controls, +.bark-battle-result__stats { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; +} + +.bark-battle-controls button, +.bark-battle-primary-button { + border: 0; + border-radius: 999px; + padding: 12px 18px; + color: #1f1147; + background: #fff7ed; + font-weight: 900; +} + +.bark-battle-primary-button { + background: linear-gradient(135deg, #facc15, #fb7185); +} + +.bark-battle-status-card, +.bark-battle-result { + margin: auto; + width: min(92vw, 420px); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 28px; + padding: 24px; + text-align: center; + background: rgba(15, 23, 42, 0.68); + box-shadow: 0 26px 60px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(18px); +} + +.bark-battle-result__stats span { + min-width: 84px; + display: grid; + gap: 4px; +} + +.bark-battle-result__stats strong { + font-size: 28px; +} + +.bark-battle-particles { + position: absolute; + inset: 18% 0 auto; + pointer-events: none; + text-align: center; + font-size: clamp(30px, 10vw, 70px); + font-weight: 950; + letter-spacing: 0.08em; + color: rgba(255, 247, 237, 0.88); + text-shadow: 0 0 18px rgba(250, 204, 21, 0.75); + animation: barkBattleParticlePop 820ms ease-out both; +} + +.bark-battle-debug-panel { + position: fixed; + right: 12px; + bottom: max(12px, env(safe-area-inset-bottom)); + z-index: 8; + width: min(78vw, 240px); + max-height: 56px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 22px; + padding: 10px 12px; + color: #fff7ed; + background: rgba(15, 23, 42, 0.72); + box-shadow: 0 18px 46px rgba(0, 0, 0, 0.28); + backdrop-filter: blur(18px); +} + +.bark-battle-debug-panel--expanded { + width: min(92vw, 340px); + max-height: 42svh; + overflow: auto; +} + +.bark-battle-debug-panel header, +.bark-battle-debug-panel label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.bark-battle-debug-panel header { + min-height: 34px; +} + +.bark-battle-debug-panel__toggle { + border: 0; + border-radius: 999px; + padding: 6px 10px; + color: #1f1147; + background: #facc15; + font-size: 12px; + font-weight: 900; +} + +.bark-battle-debug-panel__body { + display: none; +} + +.bark-battle-debug-panel--expanded .bark-battle-debug-panel__body { + display: block; +} + +.bark-battle-debug-panel label { + margin-top: 8px; + font-size: 12px; +} + +.bark-battle-debug-panel input { + flex: 1; +} + +.bark-battle-debug-panel output { + min-width: 44px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.bark-battle-debug-panel__controls { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.bark-battle-debug-metrics, +.bark-battle-debug-events { + margin: 10px 0 0; + padding: 10px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.1); + font-size: 12px; +} + +.bark-battle-debug-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.bark-battle-debug-metrics__wide { + grid-column: 1 / -1; +} + +.bark-battle-debug-events { + display: grid; + gap: 4px; + padding-left: 28px; +} + +.bark-battle-debug-panel__controls button { + flex: 1; + border: 0; + border-radius: 999px; + padding: 8px 10px; + color: #1f1147; + background: #fff7ed; + font-weight: 800; +} + +@keyframes barkBattleDogPulse { + from { transform: scale(1); } + 45% { transform: scale(1.08); } + to { transform: scale(1); } +} + +@keyframes barkBattleBurst { + from { transform: translateY(18px) scale(0.72); opacity: 0; } + 35% { opacity: 1; } + to { transform: translateY(-38px) scale(1.16); opacity: 0; } +} + +@keyframes barkBattleParticlePop { + from { transform: translateY(28px) scale(0.7); opacity: 0; } + 42% { opacity: 1; } + to { transform: translateY(-80px) scale(1.14); opacity: 0; } +} diff --git a/src/games/bark-battle/ui/BarkBattleHud.tsx b/src/games/bark-battle/ui/BarkBattleHud.tsx new file mode 100644 index 00000000..72210648 --- /dev/null +++ b/src/games/bark-battle/ui/BarkBattleHud.tsx @@ -0,0 +1,97 @@ +import './BarkBattleHud.css'; + +import type { BarkBattleSnapshot } from '../domain/BarkBattleTypes'; + +type BarkBattleHudProps = { + snapshot: BarkBattleSnapshot; + playerPulseKey?: number; + opponentPulseKey?: number; + onStartMicrophone?: () => void; + onMockBark?: () => void; + onMockQuiet?: () => void; + onRestart?: () => void; +}; + +const failureText = { + unsupported: '当前浏览器不支持麦克风输入', + 'permission-denied': '麦克风授权被拒绝', + 'non-secure-context': '当前环境无法使用麦克风', + 'not-found': '未检测到麦克风', + 'not-readable': '麦克风暂时不可读', + 'audio-context-blocked': '音频上下文被拦截', + 'calibration-timeout': '校准超时', + 'calibration-sample-unreadable': '校准样本不可读', + unknown: '麦克风暂时不可用', +}; + +export function BarkBattleHud({ + snapshot, + playerPulseKey = 0, + opponentPulseKey = 0, + onStartMicrophone, + onMockBark, + onMockQuiet, + onRestart, +}: BarkBattleHudProps) { + const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`; + const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`; + const isUnavailable = snapshot.phase === 'unavailable'; + + return ( +
+
+
{(snapshot.remainingMs / 1000).toFixed(1)}s
+
+
+
+
+
+ + {isUnavailable ? ( +
+

{snapshot.errorReason ? failureText[snapshot.errorReason] : '麦克风暂时不可用'}

+ {snapshot.errorReason !== 'unsupported' ? ( + + ) : null} +
+ ) : ( +
+
+ + 🐕 + 你 · {snapshot.player.barkCount} +
+
VS
+
+ + 🐶 + 对手 · {snapshot.opponent.barkCount} +
+
+ )} + +
+ {snapshot.phase === 'permission' ? ( + + ) : null} + + {snapshot.phase === 'finished' ? ( + + ) : null} +
+
+ ); +} diff --git a/src/games/bark-battle/ui/BarkBattleResultPanel.tsx b/src/games/bark-battle/ui/BarkBattleResultPanel.tsx new file mode 100644 index 00000000..234b537c --- /dev/null +++ b/src/games/bark-battle/ui/BarkBattleResultPanel.tsx @@ -0,0 +1,34 @@ +import type { BarkBattleResult } from '../domain/BarkBattleTypes'; + +type BarkBattleResultPanelProps = { + result: BarkBattleResult; + onRestart: () => void; +}; + +export function BarkBattleResultPanel({ result, onRestart }: BarkBattleResultPanelProps) { + const title = result.winner === 'player' ? '汪力压制成功' : result.winner === 'opponent' ? '对手声浪更强' : '势均力敌'; + + return ( +
+

本局结束

+

{title}

+
+ + {result.playerBarkCount} + 玩家叫声 + + + {result.opponentBarkCount} + 对手压制 + + + {result.score} + 声浪分 + +
+ +
+ ); +} diff --git a/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx b/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx new file mode 100644 index 00000000..d4fd9ba7 --- /dev/null +++ b/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + type BarkBattleConfig, + DEFAULT_BARK_BATTLE_CONFIG, +} from '../application/BarkBattleConfig'; +import { BarkBattleController } from '../application/BarkBattleController'; +import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes'; +import { + type BrowserMicrophoneSampler, + startBrowserMicrophoneSampler, +} from '../infrastructure/BrowserMicrophoneInput'; +import { BarkBattleHud } from './BarkBattleHud'; +import { BarkBattleResultPanel } from './BarkBattleResultPanel'; + +type BarkBattleRuntimeShellProps = { + title?: string; +}; + +type DebugEvent = { + id: number; + text: string; +}; + +const DEBUG_CONFIG_FIELDS: Array<{ + key: keyof Pick< + BarkBattleConfig, + | 'roundDurationMs' + | 'countdownMs' + | 'drawThreshold' + | 'barkThreshold' + | 'minBarkGapMs' + | 'balanceFactor' + | 'opponentBasePower' + >; + label: string; + min: number; + max: number; + step: number; +}> = [ + { key: 'roundDurationMs', label: '局长(ms)', min: 1000, max: 60000, step: 1000 }, + { key: 'countdownMs', label: '倒计时(ms)', min: 0, max: 5000, step: 500 }, + { key: 'drawThreshold', label: '平局阈值', min: 0, max: 40, step: 1 }, + { key: 'barkThreshold', label: '叫声阈值', min: 0.1, max: 1, step: 0.05 }, + { key: 'minBarkGapMs', label: '叫声间隔(ms)', min: 100, max: 1200, step: 50 }, + { key: 'balanceFactor', label: '拉锯速度', min: 5, max: 80, step: 1 }, + { key: 'opponentBasePower', label: '对手基础力', min: 0, max: 1, step: 0.05 }, +]; + +const MICROPHONE_FAILURE_REASONS = new Set([ + 'unsupported', + 'permission-denied', + 'non-secure-context', + 'not-found', + 'not-readable', + 'audio-context-blocked', + 'calibration-timeout', + 'calibration-sample-unreadable', + 'unknown', +]); + +function isMicrophoneFailureReason(reason: unknown): reason is MicrophoneFailureReason { + return typeof reason === 'string' && MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason); +} + +export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: BarkBattleRuntimeShellProps) { + const [config, setConfig] = useState(DEFAULT_BARK_BATTLE_CONFIG); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new BarkBattleController(config); + } + const controller = controllerRef.current; + const [snapshot, setSnapshot] = useState(() => controller.getSnapshot()); + const [particleText, setParticleText] = useState(''); + const [inputMode, setInputMode] = useState<'mock' | 'microphone'>('mock'); + const [liveInputVolume, setLiveInputVolume] = useState(0); + const [isDebugExpanded, setIsDebugExpanded] = useState(false); + const [playerPulseKey, setPlayerPulseKey] = useState(0); + const [opponentPulseKey, setOpponentPulseKey] = useState(0); + const [debugEvents, setDebugEvents] = useState([]); + const heldRef = useRef(false); + const lastPlayerBarkCountRef = useRef(0); + const lastOpponentPowerRef = useRef(0); + const debugEventIdRef = useRef(0); + const microphoneSamplerRef = useRef(null); + + const appendDebugEvent = useCallback((text: string) => { + debugEventIdRef.current += 1; + const event = { id: debugEventIdRef.current, text }; + setDebugEvents((current) => [event, ...current].slice(0, 5)); + }, []); + + const syncSnapshot = useCallback(() => { + const nextSnapshot = controller.getSnapshot(); + if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) { + setPlayerPulseKey((current) => current + 1); + appendDebugEvent(`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`); + } + if (nextSnapshot.phase === 'playing' && Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >= 0.08) { + setOpponentPulseKey((current) => current + 1); + appendDebugEvent(`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`); + } + lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount; + lastOpponentPowerRef.current = nextSnapshot.opponent.power; + setSnapshot(nextSnapshot); + }, [appendDebugEvent, controller]); + + const stopMicrophone = useCallback(() => { + microphoneSamplerRef.current?.stop(); + microphoneSamplerRef.current = null; + }, []); + + const startMicrophone = useCallback(async () => { + stopMicrophone(); + try { + controller.startWithMockInput(); + const sampler = await startBrowserMicrophoneSampler((volume, atMs) => { + setLiveInputVolume(volume); + if (volume >= config.barkThreshold) { + appendDebugEvent(`麦克风输入 ${(volume * 100).toFixed(0)}%`); + } + controller.submitInputSample(volume, atMs); + }); + microphoneSamplerRef.current = sampler; + setInputMode('microphone'); + appendDebugEvent('真实麦克风已开启'); + syncSnapshot(); + } catch (error) { + const reason = error && typeof error === 'object' && 'reason' in error ? error.reason : 'unknown'; + const failureReason = isMicrophoneFailureReason(reason) ? reason : 'unknown'; + controller.failMicrophone(failureReason); + appendDebugEvent(`麦克风不可用:${failureReason}`); + syncSnapshot(); + } + }, [appendDebugEvent, config.barkThreshold, controller, stopMicrophone, syncSnapshot]); + + useEffect(() => stopMicrophone, [stopMicrophone]); + + useEffect(() => { + controller.updateConfig(config); + syncSnapshot(); + }, [config, controller, syncSnapshot]); + + useEffect(() => { + const timer = window.setInterval(() => { + controller.tick(100); + if (inputMode === 'mock') { + if (heldRef.current) { + controller.submitMockSample(0.88); + } else { + controller.submitMockSample(0.12); + setLiveInputVolume(0); + } + } + syncSnapshot(); + }, 100); + return () => window.clearInterval(timer); + }, [controller, inputMode, syncSnapshot]); + + const restart = () => { + heldRef.current = false; + stopMicrophone(); + setInputMode('mock'); + setLiveInputVolume(0); + controller.restart(); + setParticleText(''); + setDebugEvents([]); + lastPlayerBarkCountRef.current = 0; + lastOpponentPowerRef.current = 0; + syncSnapshot(); + }; + + const startMock = () => { + stopMicrophone(); + setInputMode('mock'); + setLiveInputVolume(0); + controller.startWithMockInput(); + appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)'); + syncSnapshot(); + }; + + const finishNow = () => { + heldRef.current = false; + stopMicrophone(); + controller.finishNow(); + appendDebugEvent('人工结束对局'); + syncSnapshot(); + }; + + const bark = () => { + controller.forcePlayerBark(0.9); + syncSnapshot(); + setParticleText('汪!'); + window.setTimeout(() => setParticleText(''), 680); + }; + + return ( +
+ { + heldRef.current = false; + }} + onRestart={restart} + /> + + {particleText ?
{particleText}
: null} + {snapshot.result ? : null} +
+ ); +} diff --git a/src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx b/src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx new file mode 100644 index 00000000..9abbef6f --- /dev/null +++ b/src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx @@ -0,0 +1,57 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { BarkBattleSnapshot } from '../../domain/BarkBattleTypes'; +import { BarkBattleHud } from '../BarkBattleHud'; + +function buildSnapshot(overrides: Partial = {}): BarkBattleSnapshot { + return { + phase: 'playing', + uiState: 'playing', + errorReason: null, + statusMessageKey: null, + elapsedMs: 0, + remainingMs: 12_000, + countdownMs: 0, + energy: 40, + player: { barkCount: 3, power: 0.8 }, + opponent: { barkCount: 1, power: 0.25 }, + winner: null, + result: null, + lastEvents: [], + ...overrides, + }; +} + +describe('BarkBattleHud', () => { + it('playing 阶段展示竖屏核心元素、倒计时和双方狗狗朝向', () => { + render( {}} onMockQuiet={() => {}} />); + + expect(screen.getByText('12.0s')).toBeTruthy(); + expect(screen.getByLabelText('玩家狗狗背对屏幕')).toBeTruthy(); + expect(screen.getByLabelText('对手狗狗面向屏幕')).toBeTruthy(); + expect(screen.getByLabelText('声浪能量条').getAttribute('aria-valuenow')).toBe('40'); + }); + + it('energy 正负值会改变玩家侧和对手侧占比', () => { + const { rerender } = render(); + expect(screen.getByTestId('player-energy-fill').getAttribute('style')).toContain('width: 80%'); + + rerender(); + expect(screen.getByTestId('opponent-energy-fill').getAttribute('style')).toContain('width: 80%'); + }); + + it('unsupported 不展示开始声控按钮,permission-denied 展示重试授权入口', () => { + const { rerender } = render( + {}} />, + ); + expect(screen.queryByRole('button', { name: '开始声控' })).toBeNull(); + + rerender( + {}} />, + ); + expect(screen.getByRole('button', { name: '重新授权' })).toBeTruthy(); + }); +}); diff --git a/src/games/bark-battle/ui/__tests__/BarkBattleResultPanel.test.tsx b/src/games/bark-battle/ui/__tests__/BarkBattleResultPanel.test.tsx new file mode 100644 index 00000000..99c11a26 --- /dev/null +++ b/src/games/bark-battle/ui/__tests__/BarkBattleResultPanel.test.tsx @@ -0,0 +1,26 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { BarkBattleResultPanel } from '../BarkBattleResultPanel'; + +describe('BarkBattleResultPanel', () => { + it('展示胜负、叫声次数并支持再来一局', async () => { + const onRestart = vi.fn(); + render( + , + ); + + expect(screen.getByRole('dialog', { name: '对战结算' })).toBeTruthy(); + expect(screen.getByText('汪力压制成功')).toBeTruthy(); + expect(screen.getByText('6')).toBeTruthy(); + + await userEvent.click(screen.getByRole('button', { name: '再来一局' })); + expect(onRestart).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/games/bark-battle/ui/__tests__/BarkBattleRuntimeShell.test.tsx b/src/games/bark-battle/ui/__tests__/BarkBattleRuntimeShell.test.tsx new file mode 100644 index 00000000..722815a1 --- /dev/null +++ b/src/games/bark-battle/ui/__tests__/BarkBattleRuntimeShell.test.tsx @@ -0,0 +1,51 @@ +/* @vitest-environment jsdom */ + +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { BarkBattleRuntimeShell } from '../BarkBattleRuntimeShell'; + +describe('BarkBattleRuntimeShell 调试面板', () => { + it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => { + render(); + + const debugPanel = screen.getByLabelText('调试面板'); + expect(debugPanel).toBeTruthy(); + expect(within(debugPanel).getByRole('button', { name: '展开' })).toBeTruthy(); + + await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' })); + expect(within(debugPanel).getByRole('button', { name: '收起' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '开始' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '结束' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '重置' })).toBeTruthy(); + expect(screen.getByLabelText('叫声阈值')).toBeTruthy(); + expect(screen.getByLabelText('触发反馈')).toBeTruthy(); + expect(screen.getByLabelText('触发日志')).toBeTruthy(); + + await userEvent.click(screen.getByRole('button', { name: '开始' })); + expect(screen.getByText(/countdown|playing/u)).toBeTruthy(); + + await userEvent.click(screen.getByRole('button', { name: '模拟叫声' })); + expect(screen.getAllByText(/玩家叫声触发 #1/u).length).toBeGreaterThan(0); + + await userEvent.click(screen.getByRole('button', { name: '结束' })); + expect(screen.getByRole('dialog', { name: '对战结算' })).toBeTruthy(); + }); + + it('真实声控入口在不支持麦克风时展示失败原因,mock 开始不请求权限', async () => { + render(); + + const debugPanel = screen.getByLabelText('调试面板'); + await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' })); + + await userEvent.click(screen.getByRole('button', { name: '开始声控' })); + + expect(screen.getByText('当前浏览器不支持麦克风输入')).toBeTruthy(); + expect(screen.getAllByText(/麦克风不可用:unsupported/u).length).toBeGreaterThan(0); + + await userEvent.click(screen.getByRole('button', { name: '开始' })); + expect(screen.getAllByText(/开始 mock 对局(不会请求浏览器麦克风权限)/u).length).toBeGreaterThan(0); + expect(screen.getByText(/输入模式:Mock 输入/u)).toBeTruthy(); + }); +}); diff --git a/src/index.css b/src/index.css index 02fa843d..1b387236 100644 --- a/src/index.css +++ b/src/index.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Serif+SC:wght@400;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Serif+SC:wght@400;700&display=swap'); @import 'tailwindcss'; @source not "../dist"; @source not "../dist_check"; @@ -2263,6 +2263,371 @@ body { color: var(--puzzle-runtime-text-soft); } +.baby-object-runtime { + --baby-object-sky: #cfefff; + --baby-object-ground: #7bc36f; + --baby-object-ground-deep: #3f8b48; + --baby-object-panel: rgba(255, 253, 244, 0.84); + --baby-object-panel-border: rgba(72, 118, 72, 0.2); + --baby-object-text: #24422b; + --baby-object-soft: rgba(36, 66, 43, 0.72); + --baby-object-coral: #ff7a7a; + --baby-object-yellow: #ffd166; + --baby-object-blue: #77c8ff; + min-height: 100dvh; + width: 100%; + overflow: hidden; + background: + radial-gradient(circle at 18% 16%, rgba(255, 255, 255, 0.9) 0 6%, transparent 6.4%), + radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.78) 0 7%, transparent 7.4%), + linear-gradient(180deg, #f8fcff 0%, var(--baby-object-sky) 56%, #dff2cf 57%, #b8df9d 100%); + color: var(--baby-object-text); + touch-action: none; +} + +.baby-object-runtime--embedded { + min-height: 100%; +} + +.baby-object-runtime__stage { + position: relative; + height: 100dvh; + min-height: 32rem; + overflow: hidden; + user-select: none; +} + +.baby-object-runtime--embedded .baby-object-runtime__stage { + height: 100%; + min-height: 28rem; +} + +.baby-object-runtime__stage::before { + content: ''; + position: absolute; + inset: auto -10% 0; + height: 39%; + border-radius: 50% 50% 0 0 / 24% 24% 0 0; + background: + radial-gradient(ellipse at 30% 12%, rgba(255, 255, 255, 0.3) 0 7%, transparent 7.4%), + linear-gradient(180deg, var(--baby-object-ground), var(--baby-object-ground-deep)); + box-shadow: inset 0 24px 42px rgba(255, 255, 255, 0.24); +} + +.baby-object-runtime__back, +.baby-object-runtime__counter { + position: absolute; + z-index: 8; + top: max(0.75rem, env(safe-area-inset-top)); + display: inline-flex; + min-height: 2.4rem; + align-items: center; + justify-content: center; + border: 1px solid var(--baby-object-panel-border); + border-radius: 999px; + background: rgba(255, 253, 244, 0.78); + color: var(--baby-object-text); + box-shadow: 0 14px 34px rgba(60, 112, 74, 0.16); + backdrop-filter: blur(12px); +} + +.baby-object-runtime__back { + left: max(0.75rem, env(safe-area-inset-left)); + width: 2.4rem; +} + +.baby-object-runtime__counter { + right: max(0.75rem, env(safe-area-inset-right)); + min-width: 4.7rem; + padding: 0 1rem; + font-size: 0.92rem; + font-weight: 900; +} + +.baby-object-runtime__subtitle { + position: absolute; + z-index: 7; + left: 50%; + top: max(0.85rem, env(safe-area-inset-top)); + transform: translateX(-50%); + max-width: min(72vw, 34rem); + border: 1px solid var(--baby-object-panel-border); + border-radius: 999px; + background: var(--baby-object-panel); + padding: 0.68rem 1.25rem; + text-align: center; + font-size: clamp(1rem, 2.1vw, 1.55rem); + font-weight: 900; + line-height: 1.18; + box-shadow: 0 18px 42px rgba(60, 112, 74, 0.16); + backdrop-filter: blur(12px); +} + +.baby-object-runtime__gift { + position: absolute; + z-index: 4; + left: 50%; + bottom: 29%; + display: grid; + width: clamp(5.5rem, 14vw, 9rem); + aspect-ratio: 1; + place-items: center; + transform: translateX(-50%); + border: 0.45rem solid #ffe7a8; + border-radius: 1.35rem; + background: + linear-gradient(90deg, transparent 42%, rgba(255, 255, 255, 0.35) 42% 58%, transparent 58%), + linear-gradient(180deg, #ff8f70, #ff5d78); + color: #fff7d7; + box-shadow: + 0 18px 0 rgba(146, 67, 47, 0.14), + 0 24px 48px rgba(119, 75, 44, 0.18); + transition: + transform 180ms ease, + border-radius 180ms ease; +} + +.baby-object-runtime__gift--open { + transform: translateX(-50%) translateY(0.35rem) scale(0.94); + border-radius: 1.8rem; +} + +.baby-object-runtime__gift::before { + content: ''; + position: absolute; + inset: -22% -8% auto; + height: 32%; + border-radius: 1.1rem; + background: #ffe7a8; + transform-origin: 20% 100%; + transition: transform 180ms ease; +} + +.baby-object-runtime__gift--open::before { + transform: rotate(-17deg) translateY(-0.5rem); +} + +.baby-object-runtime__gift-icon { + position: relative; + z-index: 1; + width: 42%; + height: 42%; +} + +.baby-object-runtime__item { + position: absolute; + z-index: 5; + left: 50%; + top: 37%; + display: grid; + width: clamp(6.2rem, 15vw, 9.5rem); + aspect-ratio: 1; + place-items: center; + transform: translate(-50%, -50%); + border: 0.2rem solid rgba(255, 255, 255, 0.78); + border-radius: 50%; + background: rgba(255, 253, 244, 0.74); + box-shadow: + 0 18px 42px rgba(61, 106, 72, 0.17), + inset 0 0 0 0.6rem rgba(255, 255, 255, 0.32); + transition: transform 260ms ease; +} + +.baby-object-runtime__item:empty { + opacity: 0; +} + +.baby-object-runtime__item--to-left { + transform: translate(-210%, 118%) scale(0.68) rotate(-12deg); +} + +.baby-object-runtime__item--to-right { + transform: translate(110%, 118%) scale(0.68) rotate(12deg); +} + +.baby-object-runtime__item--wrong-left, +.baby-object-runtime__item--wrong-right { + animation: baby-object-wrong-bounce 0.62s ease-in-out; +} + +@keyframes baby-object-wrong-bounce { + 0%, + 100% { + transform: translate(-50%, -50%); + } + 35% { + transform: translate(-50%, -58%) scale(0.92); + } + 62% { + transform: translate(-50%, -44%) scale(1.04); + } +} + +.baby-object-runtime__item-image { + width: 76%; + height: 76%; + object-fit: contain; +} + +.baby-object-runtime__item-name { + position: absolute; + bottom: -0.9rem; + max-width: 8rem; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 253, 244, 0.86); + padding: 0.22rem 0.7rem; + color: var(--baby-object-text); + font-size: clamp(0.78rem, 1.5vw, 1rem); + font-weight: 900; + text-overflow: ellipsis; + white-space: nowrap; +} + +.baby-object-runtime__feedback { + position: absolute; + z-index: 9; + left: 50%; + top: 22%; + transform: translateX(-50%); + border-radius: 999px; + padding: 0.6rem 1.5rem; + font-size: clamp(1.5rem, 4vw, 3rem); + font-weight: 1000; + line-height: 1; + text-align: center; + animation: baby-object-feedback-pop 0.7s ease-out; +} + +.baby-object-runtime__feedback--correct { + background: rgba(255, 250, 210, 0.9); + color: #2f7d39; + box-shadow: 0 18px 42px rgba(65, 146, 76, 0.2); +} + +.baby-object-runtime__feedback--wrong { + background: rgba(255, 236, 236, 0.92); + color: #cb4b57; + box-shadow: 0 18px 42px rgba(202, 75, 87, 0.18); +} + +.baby-object-runtime__feedback--complete { + background: rgba(255, 246, 204, 0.94); + color: #c47013; + box-shadow: 0 18px 42px rgba(196, 112, 19, 0.18); +} + +@keyframes baby-object-feedback-pop { + 0% { + opacity: 0; + transform: translateX(-50%) translateY(0.8rem) scale(0.8); + } + 55% { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1.08); + } + 100% { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + +.baby-object-runtime__baskets { + position: absolute; + z-index: 6; + inset: auto 0 3.5%; + display: flex; + align-items: end; + justify-content: space-between; + padding: 0 max(5vw, env(safe-area-inset-right)) 0 max(5vw, env(safe-area-inset-left)); + pointer-events: none; +} + +.baby-object-runtime__basket { + position: relative; + width: clamp(6.4rem, 18vw, 11rem); + aspect-ratio: 1 / 0.88; +} + +.baby-object-runtime__basket-icon { + position: absolute; + z-index: 2; + left: 50%; + top: -26%; + display: grid; + width: 54%; + aspect-ratio: 1; + place-items: center; + transform: translateX(-50%); + border: 0.18rem solid rgba(255, 255, 255, 0.78); + border-radius: 50%; + background: rgba(255, 253, 244, 0.88); + box-shadow: 0 10px 22px rgba(60, 112, 74, 0.12); +} + +.baby-object-runtime__basket-image { + width: 74%; + height: 74%; + object-fit: contain; +} + +.baby-object-runtime__basket-body { + position: absolute; + inset: 20% 0 0; + border: 0.28rem solid rgba(139, 84, 40, 0.72); + border-top-width: 0.42rem; + border-radius: 0.8rem 0.8rem 2rem 2rem; + background: + repeating-linear-gradient(90deg, rgba(139, 84, 40, 0.18) 0 0.7rem, transparent 0.7rem 1.4rem), + linear-gradient(180deg, #ffd980, #d99845); + box-shadow: 0 18px 28px rgba(95, 84, 54, 0.2); +} + +.baby-object-runtime__complete { + position: absolute; + z-index: 12; + left: 50%; + top: 50%; + display: flex; + width: min(88vw, 24rem); + flex-direction: column; + align-items: center; + gap: 1rem; + transform: translate(-50%, -50%); + border: 1px solid rgba(255, 221, 124, 0.8); + border-radius: 1.6rem; + background: rgba(255, 253, 244, 0.92); + padding: 1.35rem; + text-align: center; + font-size: clamp(1.35rem, 3vw, 2rem); + font-weight: 1000; + color: #c47013; + box-shadow: 0 24px 70px rgba(107, 84, 41, 0.22); + backdrop-filter: blur(12px); +} + +.baby-object-runtime__complete-actions { + display: grid; + width: 100%; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; +} + +.baby-object-runtime__complete-actions button { + display: inline-flex; + min-height: 2.8rem; + align-items: center; + justify-content: center; + gap: 0.4rem; + border: 0; + border-radius: 999px; + background: linear-gradient(180deg, #ffe7a8, #ffc867); + color: #5d3b15; + font-size: 0.92rem; + font-weight: 900; + box-shadow: 0 10px 22px rgba(129, 83, 24, 0.16); +} + @media (max-width: 639px) { :root { --platform-bottom-nav-height: 3.85rem; @@ -5759,6 +6124,14 @@ button { --child-motion-soft: rgba(39, 65, 42, 0.74); --child-motion-green: #70c16b; --child-motion-sky-accent: #95d2ff; + --child-motion-asset-stage: url('/child-motion-demo/picture-book-grass-stage.png'); + --child-motion-asset-floor: url('/child-motion-demo/picture-book-foreground-grass-v2.png'); + --child-motion-asset-ring: url('/child-motion-demo/picture-book-ground-ring-v2.png'); + --child-motion-asset-avatar: url('/child-motion-demo/picture-book-character-outline-v2.png'); + --child-motion-asset-hud: url('/child-motion-demo/picture-book-hud-strip-v2.png'); + --child-motion-asset-calibration: url('/child-motion-demo/picture-book-calibration-strip-v2.png'); + --child-motion-asset-start-panel: url('/child-motion-demo/picture-book-start-panel-v2.png'); + --child-motion-asset-button: url('/child-motion-demo/picture-book-ui-button-v2.png'); display: grid; width: 100%; min-width: 0; @@ -5899,28 +6272,16 @@ button { .child-motion-stage::before { z-index: 0; - background-image: url('/child-motion-demo/picture-book-grass-stage.webp'); + background-image: var(--child-motion-asset-stage); background-position: center center; background-repeat: no-repeat; background-size: cover; - opacity: 0.88; - filter: saturate(1.02) contrast(0.98) brightness(1.02); + opacity: 1; + filter: saturate(1.01) contrast(0.99); } .child-motion-stage::after { - z-index: 1; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.08) 0%, - transparent 18% - ), - radial-gradient( - ellipse at 50% 82%, - rgba(255, 245, 220, 0.16), - transparent 42% - ), - linear-gradient(180deg, transparent 0 58%, rgba(80, 141, 72, 0.14) 100%); - opacity: 0.95; + display: none; } .child-motion-camera-layer { @@ -5930,25 +6291,9 @@ button { width: 100%; height: 100%; object-fit: cover; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.58), - rgba(255, 255, 255, 0.08) - ), - radial-gradient( - circle at 50% 33%, - rgba(255, 255, 255, 0.42), - transparent 30% - ), - linear-gradient( - 120deg, - rgba(255, 255, 255, 0.1) 0 11%, - transparent 11% 20%, - rgba(255, 255, 255, 0.08) 20% 30%, - transparent 30% 100% - ); + background: transparent; filter: blur(8px) saturate(0.92); - opacity: 0.34; + opacity: 0.22; transform: scale(1.04); mix-blend-mode: soft-light; } @@ -5976,105 +6321,19 @@ button { .child-motion-floor { position: absolute; - right: -8%; - bottom: -19%; - left: -8%; + right: 0; + bottom: -11%; + left: 0; z-index: 2; - height: 47%; - border-radius: 50% 50% 0 0; - background: radial-gradient( - ellipse at 50% 10%, - rgba(255, 255, 255, 0.22), - transparent 30% - ), - radial-gradient( - ellipse at 42% 30%, - rgba(255, 246, 205, 0.2) 0 8%, - transparent 18% - ), - radial-gradient( - ellipse at 70% 25%, - rgba(255, 255, 255, 0.18) 0 5%, - transparent 14% - ), - linear-gradient(180deg, rgba(135, 194, 104, 0.92), rgba(69, 145, 76, 0.98)); - box-shadow: - inset 0 26px 70px rgba(255, 255, 255, 0.16), - inset 0 -38px 68px rgba(52, 94, 46, 0.18); + height: 30%; + border-radius: 0; + background: var(--child-motion-asset-floor) center bottom / 100% auto no-repeat; + box-shadow: none; } .child-motion-floor::before, .child-motion-floor::after { - position: absolute; - border-radius: 999px; - content: ''; -} - -.child-motion-floor::before { - inset: 14% 10% auto 16%; - height: 18%; - background: radial-gradient( - circle at 8% 50%, - rgba(96, 148, 60, 0.68) 0 12%, - transparent 13% - ), - radial-gradient( - circle at 21% 42%, - rgba(96, 148, 60, 0.58) 0 9%, - transparent 10% - ), - radial-gradient( - circle at 33% 55%, - rgba(255, 255, 255, 0.2) 0 7%, - transparent 8% - ), - radial-gradient( - circle at 45% 40%, - rgba(96, 148, 60, 0.62) 0 11%, - transparent 12% - ), - radial-gradient( - circle at 58% 52%, - rgba(255, 255, 255, 0.16) 0 6%, - transparent 7% - ), - radial-gradient( - circle at 69% 42%, - rgba(96, 148, 60, 0.62) 0 10%, - transparent 11% - ), - radial-gradient( - circle at 82% 50%, - rgba(255, 255, 255, 0.18) 0 7%, - transparent 8% - ); - opacity: 0.78; -} - -.child-motion-floor::after { - inset: auto 6% 10%; - height: 15%; - background: radial-gradient( - circle at 18% 50%, - rgba(55, 104, 53, 0.42) 0 10%, - transparent 11% - ), - radial-gradient( - circle at 38% 50%, - rgba(255, 255, 255, 0.12) 0 6%, - transparent 7% - ), - radial-gradient( - circle at 60% 48%, - rgba(55, 104, 53, 0.38) 0 11%, - transparent 12% - ), - radial-gradient( - circle at 80% 52%, - rgba(255, 255, 255, 0.1) 0 5%, - transparent 6% - ); - opacity: 0.68; + display: none; } .child-motion-hud { @@ -6083,103 +6342,87 @@ button { display: flex; align-items: center; gap: clamp(0.6rem, 1.8vw, 1rem); - border: 1px solid var(--child-motion-panel-border); + border: 0; border-radius: clamp(0.75rem, 2vw, 1.25rem); - background: var(--child-motion-panel); - box-shadow: 0 18px 48px rgba(72, 112, 68, 0.12); - backdrop-filter: blur(14px); + box-shadow: none; + backdrop-filter: none; } .child-motion-hud--top { - top: 4.2%; + top: 3.2%; left: 50%; - width: min(72%, 48rem); - min-height: clamp(4.2rem, 11vh, 6.25rem); + justify-content: space-between; + width: min(56%, 46rem); + height: clamp(4.1rem, 12.5%, 6.75rem); transform: translateX(-50%); - padding: clamp(0.65rem, 1.8vw, 1rem) clamp(0.8rem, 2.2vw, 1.25rem); + background: var(--child-motion-asset-hud) center center / cover no-repeat; + padding: clamp(0.45rem, 1.2vw, 0.75rem) clamp(0.72rem, 2vw, 1.25rem); +} + +.child-motion-hud--top > div { + min-width: 0; + flex: 1 1 auto; + padding: 0 clamp(0.35rem, 1vw, 0.75rem); + text-align: center; } .child-motion-hud h1 { margin: 0; color: var(--child-motion-text); - font-size: clamp(1.2rem, 3.2vw, 2rem); + overflow: hidden; + font-size: clamp(1rem, 2.4vw, 1.6rem); font-weight: 900; - line-height: 1.08; + line-height: 1.05; + text-overflow: ellipsis; + white-space: nowrap; } .child-motion-hud p { margin: 0.28rem 0 0; color: var(--child-motion-soft); - font-size: clamp(0.72rem, 1.45vw, 0.98rem); + overflow: hidden; + font-size: clamp(0.64rem, 1.25vw, 0.86rem); font-weight: 700; - line-height: 1.45; + line-height: 1.28; + text-overflow: ellipsis; + white-space: nowrap; } .child-motion-step-count, .child-motion-progress { display: inline-flex; - width: clamp(2.7rem, 7vw, 4rem); - height: clamp(2.7rem, 7vw, 4rem); + min-width: clamp(2.4rem, 6vw, 3.5rem); + min-height: clamp(2.4rem, 6vw, 3.5rem); flex: 0 0 auto; align-items: center; justify-content: center; - border: 1px solid rgba(112, 143, 97, 0.2); + border: 0; border-radius: 999px; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.8), - rgba(242, 248, 236, 0.92) - ); + background: transparent; color: var(--child-motion-text); font-size: clamp(0.72rem, 1.45vw, 0.95rem); font-weight: 900; - box-shadow: 0 8px 20px rgba(96, 132, 82, 0.12); + box-shadow: none; } .child-motion-ring { position: absolute; - bottom: 20.5%; + bottom: 18.8%; z-index: 3; - width: clamp(5.8rem, 13vw, 9rem); - aspect-ratio: 1; - transform: translateX(-50%) rotateX(66deg); + width: clamp(7.8rem, 17vw, 11.6rem); + aspect-ratio: 1200 / 520; + transform: translateX(-50%); border-radius: 999px; - background: conic-gradient( - from -90deg, - rgba(255, 255, 255, 0.88) 0 var(--child-motion-ring-progress), - rgba(102, 190, 95, 0.22) var(--child-motion-ring-progress) 360deg - ); - box-shadow: - 0 0 18px rgba(120, 191, 110, 0.34), - 0 0 0 6px rgba(255, 255, 255, 0.12), - inset 0 0 24px rgba(255, 255, 255, 0.2); + background: var(--child-motion-asset-ring) center / contain no-repeat; + box-shadow: 0 0 20px rgba(120, 191, 110, 0.22); } .child-motion-ring::before { - position: absolute; - inset: 14%; - border-radius: inherit; - background: radial-gradient( - circle at 50% 45%, - rgba(255, 255, 255, 0.1), - transparent 40% - ), - linear-gradient(180deg, rgba(151, 215, 139, 0.82), rgba(73, 151, 74, 0.94)); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.38); - content: ''; + display: none; } .child-motion-ring__core { - position: absolute; - inset: 34%; - border-radius: 999px; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.68), - rgba(150, 231, 137, 0.86) - ); - opacity: 0.62; - box-shadow: 0 0 22px rgba(124, 199, 112, 0.44); + display: none; } .child-motion-ring--active { @@ -6198,15 +6441,23 @@ button { .child-motion-avatar { position: absolute; - bottom: 24%; + bottom: 21.5%; z-index: 5; - width: clamp(3.4rem, 7vw, 5.6rem); - height: clamp(6rem, 13vw, 10rem); + width: clamp(4.2rem, 8.4vw, 6.8rem); + aspect-ratio: 2 / 3; transform: translateX(-50%); - transition: - left 260ms ease, - transform 220ms ease; - filter: drop-shadow(0 6px 14px rgba(56, 92, 55, 0.18)); + isolation: isolate; + transition: left 260ms ease, transform 220ms ease; + filter: drop-shadow(0 6px 14px rgba(56, 92, 55, 0.16)); +} + +.child-motion-avatar::before { + position: absolute; + inset: 0; + z-index: 2; + background: var(--child-motion-asset-avatar) center bottom / contain no-repeat; + opacity: 0.88; + content: ''; } .child-motion-avatar--jumping { @@ -6217,70 +6468,7 @@ button { .child-motion-avatar__body, .child-motion-avatar__arm, .child-motion-avatar__leg { - position: absolute; - display: block; - background: linear-gradient( - 180deg, - rgba(77, 109, 79, 0.44), - rgba(41, 65, 44, 0.7) - ), - rgba(245, 250, 245, 0.1); - opacity: 0.6; - border: 1px solid rgba(239, 249, 235, 0.18); - box-shadow: 0 0 24px rgba(143, 216, 255, 0.12); - backdrop-filter: blur(1px); -} - -.child-motion-avatar__head { - top: 0; - left: 50%; - width: 34%; - aspect-ratio: 1; - transform: translateX(-50%); - border-radius: 999px; -} - -.child-motion-avatar__body { - top: 27%; - left: 50%; - width: 42%; - height: 36%; - transform: translateX(-50%); - border-radius: 999px 999px 45% 45%; -} - -.child-motion-avatar__arm { - top: 33%; - width: 15%; - height: 34%; - border-radius: 999px; -} - -.child-motion-avatar__arm--left { - left: 17%; - transform: rotate(18deg); -} - -.child-motion-avatar__arm--right { - right: 17%; - transform: rotate(-18deg); -} - -.child-motion-avatar__leg { - bottom: 0; - width: 15%; - height: 34%; - border-radius: 999px; -} - -.child-motion-avatar__leg--left { - left: 36%; - transform: rotate(7deg); -} - -.child-motion-avatar__leg--right { - right: 36%; - transform: rotate(-7deg); + display: none; } .child-motion-gesture-guide { @@ -6376,40 +6564,51 @@ button { .child-motion-calibration { position: absolute; right: 3.2%; - bottom: 4%; + bottom: 8.8%; z-index: 8; display: grid; - grid-template-columns: repeat(5, minmax(0, auto)); - gap: 0.45rem; + grid-template-columns: repeat(5, minmax(0, 1fr)); + align-items: center; + gap: clamp(0.12rem, 0.55vw, 0.45rem); + width: min(34%, 30rem); max-width: 82%; - border: 1px solid var(--child-motion-panel-border); + height: clamp(3.1rem, 7.6%, 4.55rem); + border: 0; border-radius: 999px; - background: var(--child-motion-panel); - padding: 0.45rem; - backdrop-filter: blur(14px); - box-shadow: 0 14px 32px rgba(82, 124, 72, 0.1); + background: var(--child-motion-asset-calibration) center center / cover no-repeat; + padding: clamp(0.4rem, 1.1vw, 0.56rem) clamp(0.66rem, 1.5vw, 0.9rem); + backdrop-filter: none; + box-shadow: none; } .child-motion-calibration div { display: grid; - min-width: clamp(3.2rem, 7vw, 4.8rem); + min-width: 0; gap: 0.08rem; + align-content: center; justify-items: center; border-radius: 999px; - background: rgba(255, 255, 255, 0.48); - padding: 0.36rem 0.55rem; + background: transparent; + padding: 0.32rem 0.18rem; + transform: translateY(6%); } .child-motion-calibration span { color: var(--child-motion-soft); - font-size: clamp(0.55rem, 1.2vw, 0.72rem); + overflow: hidden; + max-width: 100%; + font-size: clamp(0.52rem, 1vw, 0.66rem); font-weight: 800; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; } .child-motion-calibration strong { color: var(--child-motion-text); - font-size: clamp(0.72rem, 1.5vw, 0.95rem); + font-size: clamp(0.7rem, 1.25vw, 0.88rem); font-weight: 900; + line-height: 1; } .child-motion-start-panel { @@ -6418,23 +6617,27 @@ button { top: 53%; z-index: 10; display: flex; + width: min(28%, 19rem); + height: clamp(3.8rem, 9%, 5.2rem); transform: translate(-50%, -50%); align-items: center; + justify-content: center; gap: 0.85rem; - border: 1px solid rgba(143, 176, 124, 0.24); + border: 0; border-radius: 1.4rem; - background: rgba(255, 250, 241, 0.76); - padding: clamp(0.85rem, 2vw, 1.15rem); - box-shadow: 0 24px 70px rgba(82, 124, 72, 0.18); - backdrop-filter: blur(14px); + background: var(--child-motion-asset-start-panel) center center / cover no-repeat; + padding: clamp(0.45rem, 1.2vw, 0.7rem); + box-shadow: none; + backdrop-filter: none; } .child-motion-start-panel button { - min-width: clamp(8rem, 18vw, 12rem); - min-height: clamp(3rem, 7vw, 4.2rem); + width: min(82%, 12rem); + min-width: clamp(7.5rem, 14vw, 10.5rem); + min-height: clamp(2.5rem, 5.8vw, 3.4rem); border: 0; border-radius: 999px; - background: linear-gradient(135deg, #88cf74, #9dd3ff); + background: var(--child-motion-asset-button) center center / cover no-repeat; color: #214228; font-size: clamp(1rem, 2.5vw, 1.4rem); font-weight: 950; diff --git a/src/routing/appPageRoutes.ts b/src/routing/appPageRoutes.ts index ac56abe4..dd1738f0 100644 --- a/src/routing/appPageRoutes.ts +++ b/src/routing/appPageRoutes.ts @@ -26,6 +26,10 @@ const STAGE_ROUTE_ENTRIES = [ ['visual-novel-result', '/creation/visual-novel/result'], ['visual-novel-gallery-detail', '/gallery/visual-novel/detail'], ['visual-novel-runtime', '/runtime/visual-novel'], + ['baby-object-match-workspace', '/creation/baby-object-match'], + ['baby-object-match-generating', '/creation/baby-object-match/generating'], + ['baby-object-match-result', '/creation/baby-object-match/result'], + ['baby-object-match-runtime', '/runtime/baby-object-match'], ['puzzle-agent-workspace', '/creation/puzzle/agent'], ['puzzle-result', '/creation/puzzle/result'], ['puzzle-gallery-detail', '/gallery/puzzle/detail'], diff --git a/src/routing/appRoutes.tsx b/src/routing/appRoutes.tsx index d80cf62e..34b27736 100644 --- a/src/routing/appRoutes.tsx +++ b/src/routing/appRoutes.tsx @@ -19,6 +19,9 @@ export type AppRouteMatch = | { kind: 'match3d-playground'; } + | { + kind: 'bark-battle-playground'; + } | { kind: 'child-motion-demo'; } @@ -37,6 +40,7 @@ export type ResolvedAppRoute = { const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent; const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent; const Match3DPlaygroundApp = lazy(() => import('../Match3DPlaygroundApp')) as AppRouteComponent; +const BarkBattlePlaygroundApp = lazy(() => import('../BarkBattlePlaygroundApp')) as AppRouteComponent; const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent; const ChildMotionDemoApp = lazy(() => import('../ChildMotionDemoApp')) as AppRouteComponent; @@ -65,6 +69,12 @@ export function matchAppRoute(pathname: string): AppRouteMatch { }; } + if (normalizedPath === '/bark-battle') { + return { + kind: 'bark-battle-playground', + }; + } + if ( normalizedPath === '/child-motion-demo' && isEdutainmentEntryEnabled() @@ -109,6 +119,15 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute { }; } + if (matchedRoute.kind === 'bark-battle-playground') { + return { + kind: 'bark-battle-playground', + loadingEyebrow: '正在载入汪汪声浪', + loadingText: '正在进入竖屏声浪竞技场...', + Component: BarkBattlePlaygroundApp, + }; + } + if (matchedRoute.kind === 'child-motion-demo') { return { kind: 'child-motion-demo', diff --git a/src/services/authService.ts b/src/services/authService.ts index ee0cab50..407287d2 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -21,6 +21,7 @@ import type { AuthSessionsResponse, AuthSessionSummary, AuthWechatBindPhoneResponse, + AuthWechatBindPhoneRequest, AuthWechatStartResponse, LogoutResponse, PublicUserSearchResponse, @@ -193,15 +194,16 @@ export async function redeemRegistrationInviteCode(inviteCode: string) { } export async function bindWechatPhone(phone: string, code: string) { + const payload: AuthWechatBindPhoneRequest = { + phone: normalizePhoneInput(phone), + code: code.trim(), + }; const response = await requestJson( '/api/auth/wechat/bind-phone', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: normalizePhoneInput(phone), - code: code.trim(), - }), + body: JSON.stringify(payload), }, '绑定手机号失败', ); diff --git a/src/services/edutainment-baby-object/babyObjectMatchClient.test.ts b/src/services/edutainment-baby-object/babyObjectMatchClient.test.ts new file mode 100644 index 00000000..a16cafc9 --- /dev/null +++ b/src/services/edutainment-baby-object/babyObjectMatchClient.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + BABY_OBJECT_MATCH_EDUTAINMENT_TAG, + hasBabyObjectMatchRequiredTag, +} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import { + createBabyObjectMatchDraft, + deleteLocalBabyObjectMatchDraft, + listLocalBabyObjectMatchDrafts, + publishBabyObjectMatchWork, +} from './babyObjectMatchClient'; + +describe('babyObjectMatchClient', () => { + beforeEach(() => { + const store = new Map(); + vi.stubGlobal('window', { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('creates local demo draft with exact edutainment tag', async () => { + vi.stubGlobal('crypto', { + randomUUID: () => '11111111-2222-3333-4444-555555555555', + }); + + const response = await createBabyObjectMatchDraft({ + itemAName: ' 苹果 ', + itemBName: '香蕉', + }); + + expect(response.draft.templateName).toBe('宝贝识物'); + expect(response.draft.itemNames).toEqual(['苹果', '香蕉']); + expect(response.draft.itemAssets).toHaveLength(2); + expect(response.draft.itemAssets[0]?.generationProvider).toBe( + 'placeholder', + ); + expect(response.draft.themeTags).toContain( + BABY_OBJECT_MATCH_EDUTAINMENT_TAG, + ); + expect(hasBabyObjectMatchRequiredTag(response.draft.themeTags)).toBe(true); + }); + + test('rejects draft creation when any item name is empty', async () => { + await expect( + createBabyObjectMatchDraft({ + itemAName: '苹果', + itemBName: ' ', + }), + ).rejects.toThrow('请填写两个物品名称。'); + }); + + test('publish normalizes exact edutainment tag into payload', async () => { + const response = await createBabyObjectMatchDraft({ + itemAName: '杯子', + itemBName: '勺子', + }); + const published = await publishBabyObjectMatchWork({ + draft: { + ...response.draft, + themeTags: ['儿童教育', '寓教于乐 '], + }, + }); + + expect(published.publicWorkCode).toMatch(/^BO-/u); + expect(published.draft.publicationStatus).toBe('published'); + expect(published.draft.themeTags[0]).toBe( + BABY_OBJECT_MATCH_EDUTAINMENT_TAG, + ); + expect(hasBabyObjectMatchRequiredTag(published.draft.themeTags)).toBe(true); + }); + + test('deletes local baby object match draft by profile id', async () => { + const response = await createBabyObjectMatchDraft({ + itemAName: '苹果', + itemBName: '香蕉', + }); + + expect(listLocalBabyObjectMatchDrafts()).toHaveLength(1); + + const nextItems = deleteLocalBabyObjectMatchDraft(response.draft.profileId); + + expect(nextItems).toHaveLength(0); + expect(listLocalBabyObjectMatchDrafts()).toHaveLength(0); + }); +}); diff --git a/src/services/edutainment-baby-object/babyObjectMatchClient.ts b/src/services/edutainment-baby-object/babyObjectMatchClient.ts new file mode 100644 index 00000000..482584e0 --- /dev/null +++ b/src/services/edutainment-baby-object/babyObjectMatchClient.ts @@ -0,0 +1,231 @@ +import type { + BabyObjectMatchDraft, + BabyObjectMatchItemAsset, + BabyObjectMatchPublishRequest, + BabyObjectMatchPublishResponse, + CreateBabyObjectMatchDraftRequest, + SaveBabyObjectMatchDraftRequest, +} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import { + BABY_OBJECT_MATCH_EDUTAINMENT_TAG, + BABY_OBJECT_MATCH_TEMPLATE_ID, + BABY_OBJECT_MATCH_TEMPLATE_NAME, + normalizeBabyObjectMatchTags, + validateBabyObjectMatchItemNames, +} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import { buildBabyObjectMatchPublicWorkCode } from '../publicWorkCode'; + +const STORAGE_KEY = 'genarrative.edutainmentBabyObject.localDrafts.v1'; + +type LocalDraftStore = Record; + +function canUseLocalStorage() { + return ( + typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' + ); +} + +function readLocalDraftStore(): LocalDraftStore { + if (!canUseLocalStorage()) { + return {}; + } + + try { + const rawValue = window.localStorage.getItem(STORAGE_KEY); + if (!rawValue) { + return {}; + } + const parsed = JSON.parse(rawValue) as LocalDraftStore; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function writeLocalDraftStore(store: LocalDraftStore) { + if (!canUseLocalStorage()) { + return; + } + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); +} + +function createLocalId(prefix: string) { + const randomPart = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID().replace(/-/gu, '') + : Math.random().toString(36).slice(2); + + return `${prefix}-${Date.now().toString(36)}-${randomPart.slice(0, 12)}`; +} + +function encodeSvgDataUri(svg: string) { + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + +function buildPlaceholderItemImage(itemName: string, index: number) { + const palettes = [ + { + bg: '#fef3c7', + accent: '#fb7185', + shadow: '#f59e0b', + text: '#7c2d12', + }, + { + bg: '#dbeafe', + accent: '#34d399', + shadow: '#60a5fa', + text: '#064e3b', + }, + ] as const; + const palette = palettes[index % palettes.length]!; + const displayText = itemName.slice(0, 6); + const svg = `${displayText}`; + + return encodeSvgDataUri(svg); +} + +function buildItemAsset( + itemName: string, + index: number, +): BabyObjectMatchItemAsset { + return { + itemId: `baby-object-item-${index + 1}`, + itemName, + imageSrc: buildPlaceholderItemImage(itemName, index), + assetObjectId: null, + generationProvider: 'placeholder', + prompt: `生成适合 4-8 岁儿童识物分类游戏的${itemName}物品图,绘本草地舞台风格,单个物体,透明或干净背景,无文字、无水印、无按钮。`, + }; +} + +function saveDraftToLocalStore(draft: BabyObjectMatchDraft) { + const store = readLocalDraftStore(); + store[draft.profileId] = draft; + writeLocalDraftStore(store); +} + +export function normalizeBabyObjectMatchDraft( + draft: BabyObjectMatchDraft, +): BabyObjectMatchDraft { + const now = new Date().toISOString(); + return { + ...draft, + templateId: BABY_OBJECT_MATCH_TEMPLATE_ID, + templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME, + workTitle: draft.workTitle.trim() || '宝贝识物', + workDescription: draft.workDescription.trim(), + itemNames: [ + draft.itemNames[0].trim(), + draft.itemNames[1].trim(), + ], + itemAssets: [ + { + ...draft.itemAssets[0], + itemName: draft.itemNames[0].trim(), + }, + { + ...draft.itemAssets[1], + itemName: draft.itemNames[1].trim(), + }, + ], + themeTags: normalizeBabyObjectMatchTags(draft.themeTags), + updatedAt: draft.updatedAt || now, + }; +} + +/** + * 当前为本地 Demo 创作链路。真实 image-2 接入后替换为后端接口, + * 但返回契约保持 BabyObjectMatchDraftResponse。 + */ +export async function createBabyObjectMatchDraft( + payload: CreateBabyObjectMatchDraftRequest, +) { + const validated = validateBabyObjectMatchItemNames(payload); + if (!validated.valid) { + throw new Error('请填写两个物品名称。'); + } + + const now = new Date().toISOString(); + const draftId = createLocalId('baby-object-draft'); + const profileId = createLocalId('baby-object-profile'); + const itemNames: [string, string] = [ + validated.itemAName, + validated.itemBName, + ]; + const draft = normalizeBabyObjectMatchDraft({ + draftId, + profileId, + templateId: BABY_OBJECT_MATCH_TEMPLATE_ID, + templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME, + workTitle: '宝贝识物', + workDescription: `${itemNames[0]}和${itemNames[1]}识物分类`, + itemNames, + itemAssets: [buildItemAsset(itemNames[0], 0), buildItemAsset(itemNames[1], 1)], + themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'], + publicationStatus: 'draft', + createdAt: now, + updatedAt: now, + publishedAt: null, + }); + + saveDraftToLocalStore(draft); + return { draft }; +} + +export async function saveBabyObjectMatchDraft( + payload: SaveBabyObjectMatchDraftRequest, +) { + const draft = normalizeBabyObjectMatchDraft({ + ...payload.draft, + updatedAt: new Date().toISOString(), + }); + saveDraftToLocalStore(draft); + + return { draft }; +} + +export async function publishBabyObjectMatchWork( + payload: BabyObjectMatchPublishRequest, +): Promise { + const draft = normalizeBabyObjectMatchDraft({ + ...payload.draft, + publicationStatus: 'published', + publishedAt: payload.draft.publishedAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + saveDraftToLocalStore(draft); + + return { + draft, + publicWorkCode: buildBabyObjectMatchPublicWorkCode(draft.profileId), + }; +} + +export function listLocalBabyObjectMatchDrafts() { + return Object.values(readLocalDraftStore()).sort( + (left, right) => + new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(), + ); +} + +export function deleteLocalBabyObjectMatchDraft(profileId: string) { + const normalizedProfileId = profileId.trim(); + if (!normalizedProfileId) { + return listLocalBabyObjectMatchDrafts(); + } + + const store = readLocalDraftStore(); + delete store[normalizedProfileId]; + writeLocalDraftStore(store); + + return listLocalBabyObjectMatchDrafts(); +} + +export const babyObjectMatchClient = { + createDraft: createBabyObjectMatchDraft, + deleteDraft: deleteLocalBabyObjectMatchDraft, + saveDraft: saveBabyObjectMatchDraft, + publish: publishBabyObjectMatchWork, + listLocalDrafts: listLocalBabyObjectMatchDrafts, +}; diff --git a/src/services/edutainment-baby-object/index.ts b/src/services/edutainment-baby-object/index.ts new file mode 100644 index 00000000..c4daf7a2 --- /dev/null +++ b/src/services/edutainment-baby-object/index.ts @@ -0,0 +1 @@ +export * from './babyObjectMatchClient'; diff --git a/src/services/match3d-runtime/index.ts b/src/services/match3d-runtime/index.ts index 792f1391..42fc9ac6 100644 --- a/src/services/match3d-runtime/index.ts +++ b/src/services/match3d-runtime/index.ts @@ -8,6 +8,11 @@ export { startLocalMatch3DRun, stopLocalMatch3DRun, } from './match3dLocalRuntime'; +export { + createLocalMatch3DRuntimeAdapter, + createServerMatch3DRuntimeAdapter, + type Match3DRuntimeAdapter, +} from './match3dRuntimeAdapter'; export { clickMatch3DItem, finishMatch3DTimeUp, diff --git a/src/services/match3d-runtime/match3dLocalRuntime.ts b/src/services/match3d-runtime/match3dLocalRuntime.ts index 7a55d5be..349b894c 100644 --- a/src/services/match3d-runtime/match3dLocalRuntime.ts +++ b/src/services/match3d-runtime/match3dLocalRuntime.ts @@ -496,12 +496,17 @@ export function buildLocalMatch3DOptimisticRun( }; } +function waitForLocalConfirmation(delayMs: number) { + const scheduler = globalThis.setTimeout; + return new Promise((resolve) => scheduler(resolve, delayMs)); +} + export async function confirmLocalMatch3DClick( run: Match3DRunSnapshot, request: Match3DClickItemRequest, ): Promise { // 中文注释:F3 阶段用本地函数模拟后端权威确认,真实接口接入后保留同一结果语义。 - await new Promise((resolve) => window.setTimeout(resolve, 180)); + await waitForLocalConfirmation(180); const timedRun = normalizeRemainingMs(run); if (timedRun.status !== 'Running') { return { diff --git a/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts b/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts new file mode 100644 index 00000000..351d6df6 --- /dev/null +++ b/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts @@ -0,0 +1,144 @@ +import { expect, test, vi } from 'vitest'; + +import type { + Match3DClickItemRequest, + Match3DRunResponse, + Match3DRunSnapshot, +} from '../../../packages/shared/src/contracts/match3dRuntime'; +import { + createLocalMatch3DRuntimeAdapter, + createServerMatch3DRuntimeAdapter, + type Match3DRuntimeAdapter, + startLocalMatch3DRun, +} from './index'; + +function buildMockRun(runId: string): Match3DRunSnapshot { + return { + runId, + profileId: 'server-profile-1', + ownerUserId: 'server-owner-1', + status: 'Running', + snapshotVersion: 1, + startedAtMs: 1_700_000_000_000, + durationLimitMs: 30_000, + serverNowMs: 1_700_000_000_000, + remainingMs: 30_000, + clearCount: 3, + totalItemCount: 0, + clearedItemCount: 0, + boardVersion: 1, + items: [], + traySlots: [], + failureReason: null, + lastConfirmedActionId: null, + }; +} + +test('server Match3D runtime adapter forwards the full runtime seam lazily', async () => { + const startResponse: Match3DRunResponse = { run: buildMockRun('server-run-start') }; + const getResponse: Match3DRunResponse = { run: buildMockRun('server-run-get') }; + const restartResponse: Match3DRunResponse = { run: buildMockRun('server-run-restart') }; + const stopResponse: Match3DRunResponse = { + run: { ...buildMockRun('server-run-stop'), status: 'Stopped' }, + }; + const finishResponse: Match3DRunResponse = { + run: { ...buildMockRun('server-run-finish'), status: 'Timeout' }, + }; + const clickPayload: Match3DClickItemRequest = { + runId: 'server-run-start', + itemInstanceId: 'item-1', + clientActionId: 'action-1', + clientEventId: 'event-1', + clickedAtMs: 1_700_000_000_001, + clientSnapshotVersion: 1, + }; + const dependencies = { + clickItem: vi.fn().mockResolvedValue({ + status: 'Accepted' as const, + run: buildMockRun('server-run-click'), + }), + finishTimeUp: vi.fn().mockResolvedValue(finishResponse), + getRun: vi.fn().mockResolvedValue(getResponse), + restartRun: vi.fn().mockResolvedValue(restartResponse), + startRun: vi.fn().mockResolvedValue(startResponse), + stopRun: vi.fn().mockResolvedValue(stopResponse), + }; + const adapter = createServerMatch3DRuntimeAdapter(dependencies); + + expect(await adapter.startRun('server-profile-1', { skipRefresh: true })).toBe( + startResponse, + ); + expect(await adapter.getRun('server-run-start')).toBe(getResponse); + expect(await adapter.clickItem('server-run-start', clickPayload)).toEqual({ + status: 'Accepted', + run: buildMockRun('server-run-click'), + }); + expect(await adapter.restartRun('server-run-start')).toBe(restartResponse); + expect(await adapter.stopRun('server-run-restart')).toBe(stopResponse); + expect(await adapter.finishTimeUp('server-run-start')).toBe(finishResponse); + + expect(dependencies.startRun).toHaveBeenCalledWith('server-profile-1', { + skipRefresh: true, + }); + expect(dependencies.getRun).toHaveBeenCalledWith('server-run-start'); + expect(dependencies.clickItem).toHaveBeenCalledWith( + 'server-run-start', + clickPayload, + ); + expect(dependencies.restartRun).toHaveBeenCalledWith('server-run-start'); + expect(dependencies.stopRun).toHaveBeenCalledWith('server-run-restart'); + expect(dependencies.finishTimeUp).toHaveBeenCalledWith('server-run-start'); +}); + +test('local Match3D runtime adapter exposes the same runtime seam as the server client', async () => { + const adapter = createLocalMatch3DRuntimeAdapter({ clearCount: 1 }); + const started = await adapter.startRun('ignored-local-profile'); + const clickableItem = started.run.items.find((item) => item.clickable); + + expect(started.run.profileId).toBe('local-match3d-profile'); + expect(clickableItem).toBeTruthy(); + + const clickResult = await adapter.clickItem(started.run.runId, { + runId: started.run.runId, + itemInstanceId: clickableItem!.itemInstanceId, + clientActionId: 'local-click-1', + clientEventId: 'local-event-1', + clickedAtMs: started.run.serverNowMs ?? Date.now(), + clientSnapshotVersion: started.run.snapshotVersion, + }); + + expect(clickResult.status).toBe('Accepted'); + expect(clickResult.run.snapshotVersion).toBe(started.run.snapshotVersion + 1); + + const restarted = await adapter.restartRun(started.run.runId); + expect(restarted.run.runId).not.toBe(started.run.runId); + + const stopped = await adapter.stopRun(restarted.run.runId); + expect(stopped.run.status).toBe('Stopped'); +}); + +test('local Match3D runtime adapter keeps authority run local to the adapter', async () => { + const adapter = createLocalMatch3DRuntimeAdapter({ initialRun: startLocalMatch3DRun(1) }); + const first = await adapter.getRun('unused-run-id'); + const timedOut = await adapter.finishTimeUp(first.run.runId); + + expect(timedOut.run.status).toBe('Running'); + expect(timedOut.run.runId).toBe(first.run.runId); +}); + +test('server and local Match3D runtime adapters share the same runtime seam', () => { + const adapters: Match3DRuntimeAdapter[] = [ + createLocalMatch3DRuntimeAdapter({ clearCount: 1 }), + createServerMatch3DRuntimeAdapter(), + ]; + + expect(adapters).toHaveLength(2); + for (const adapter of adapters) { + expect(typeof adapter.startRun).toBe('function'); + expect(typeof adapter.getRun).toBe('function'); + expect(typeof adapter.clickItem).toBe('function'); + expect(typeof adapter.restartRun).toBe('function'); + expect(typeof adapter.stopRun).toBe('function'); + expect(typeof adapter.finishTimeUp).toBe('function'); + } +}); diff --git a/src/services/match3d-runtime/match3dRuntimeAdapter.ts b/src/services/match3d-runtime/match3dRuntimeAdapter.ts new file mode 100644 index 00000000..6402ebf3 --- /dev/null +++ b/src/services/match3d-runtime/match3dRuntimeAdapter.ts @@ -0,0 +1,106 @@ +import type { + Match3DClickItemRequest, + Match3DClickItemResult, + Match3DRunResponse, +} from '../../../packages/shared/src/contracts/match3dRuntime'; +import { + confirmLocalMatch3DClick, + resolveLocalMatch3DTimer, + startLocalMatch3DRun, + stopLocalMatch3DRun, +} from './match3dLocalRuntime'; +import { + clickMatch3DItem, + finishMatch3DTimeUp, + getMatch3DRun, + type Match3DRuntimeRequestOptions, + restartMatch3DRun, + startMatch3DRun, + stopMatch3DRun, +} from './match3dRuntimeClient'; + +export type Match3DRuntimeAdapter = { + startRun: ( + profileId: string, + options?: Match3DRuntimeRequestOptions, + ) => Promise; + getRun: (runId: string) => Promise; + clickItem: ( + runId: string, + payload: Match3DClickItemRequest, + ) => Promise; + restartRun: (runId: string) => Promise; + stopRun: (runId: string) => Promise; + finishTimeUp: (runId: string) => Promise; +}; + +export type LocalMatch3DRuntimeAdapterOptions = { + clearCount?: number; + initialRun?: Match3DRunResponse['run']; +}; + +type ServerMatch3DRuntimeAdapterDependencies = { + clickItem: typeof clickMatch3DItem; + finishTimeUp: typeof finishMatch3DTimeUp; + getRun: typeof getMatch3DRun; + restartRun: typeof restartMatch3DRun; + startRun: typeof startMatch3DRun; + stopRun: typeof stopMatch3DRun; +}; + +const defaultServerMatch3DRuntimeAdapterDependencies: ServerMatch3DRuntimeAdapterDependencies = { + clickItem: clickMatch3DItem, + finishTimeUp: finishMatch3DTimeUp, + getRun: getMatch3DRun, + restartRun: restartMatch3DRun, + startRun: startMatch3DRun, + stopRun: stopMatch3DRun, +}; + +export function createServerMatch3DRuntimeAdapter( + dependencies: ServerMatch3DRuntimeAdapterDependencies = + defaultServerMatch3DRuntimeAdapterDependencies, +): Match3DRuntimeAdapter { + return { + clickItem: (runId, payload) => dependencies.clickItem(runId, payload), + finishTimeUp: (runId) => dependencies.finishTimeUp(runId), + getRun: (runId) => dependencies.getRun(runId), + restartRun: (runId) => dependencies.restartRun(runId), + startRun: (profileId, options) => dependencies.startRun(profileId, options), + stopRun: (runId) => dependencies.stopRun(runId), + }; +} + +export function createLocalMatch3DRuntimeAdapter( + options: LocalMatch3DRuntimeAdapterOptions = {}, +): Match3DRuntimeAdapter { + let authorityRun = options.initialRun ?? startLocalMatch3DRun(options.clearCount); + + return { + async startRun() { + authorityRun = startLocalMatch3DRun(options.clearCount); + return { run: authorityRun }; + }, + async getRun() { + authorityRun = resolveLocalMatch3DTimer(authorityRun); + return { run: authorityRun }; + }, + async clickItem(_runId, payload) { + const result = await confirmLocalMatch3DClick(authorityRun, payload); + authorityRun = result.run; + return result; + }, + async restartRun() { + authorityRun = startLocalMatch3DRun(options.clearCount); + return { run: authorityRun }; + }, + async stopRun() { + authorityRun = stopLocalMatch3DRun(authorityRun); + return { run: authorityRun }; + }, + async finishTimeUp() { + authorityRun = resolveLocalMatch3DTimer(authorityRun); + return { run: authorityRun }; + }, + }; +} diff --git a/src/services/match3d-runtime/match3dRuntimeClient.ts b/src/services/match3d-runtime/match3dRuntimeClient.ts index e58615a9..5167093d 100644 --- a/src/services/match3d-runtime/match3dRuntimeClient.ts +++ b/src/services/match3d-runtime/match3dRuntimeClient.ts @@ -25,7 +25,7 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = { maxDelayMs: 360, retryUnsafeMethods: true, }; -type Match3DRuntimeRequestOptions = Pick< +export type Match3DRuntimeRequestOptions = Pick< ApiRequestOptions, | 'authImpact' | 'skipRefresh' diff --git a/src/services/miniGameDraftGenerationProgress.test.ts b/src/services/miniGameDraftGenerationProgress.test.ts index e45cd72a..9b99bcdc 100644 --- a/src/services/miniGameDraftGenerationProgress.test.ts +++ b/src/services/miniGameDraftGenerationProgress.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { + buildBabyObjectMatchGenerationAnchorEntries, buildMatch3DGenerationAnchorEntries, buildMiniGameDraftGenerationProgress, buildPuzzleGenerationAnchorEntries, @@ -227,6 +228,37 @@ describe('miniGameDraftGenerationProgress', () => { ]); }); + test('baby object match generation exposes two item names', () => { + const state = createMiniGameDraftGenerationState('baby-object-match'); + const progress = buildMiniGameDraftGenerationProgress( + state, + state.startedAtMs + 9_000, + ); + const entries = buildBabyObjectMatchGenerationAnchorEntries({ + itemAName: '苹果', + itemBName: '香蕉', + }); + + expect(progress?.steps.map((step) => step.id)).toEqual([ + 'baby-object-draft', + 'baby-object-images', + 'baby-object-ready', + ]); + expect(progress?.phaseId).toBe('baby-object-images'); + expect(entries).toEqual([ + { + id: 'baby-object-item-1', + label: '物品 1', + value: '苹果', + }, + { + id: 'baby-object-item-2', + label: '物品 2', + value: '香蕉', + }, + ]); + }); + test('puzzle generation anchors expose form payload as the display source', () => { const entries = buildPuzzleGenerationAnchorEntries({ sessionId: 'puzzle-session-1', diff --git a/src/services/miniGameDraftGenerationProgress.ts b/src/services/miniGameDraftGenerationProgress.ts index e8fc12be..49abc96b 100644 --- a/src/services/miniGameDraftGenerationProgress.ts +++ b/src/services/miniGameDraftGenerationProgress.ts @@ -1,4 +1,8 @@ import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish'; +import type { + BabyObjectMatchDraft, + CreateBabyObjectMatchDraftRequest, +} from '../../packages/shared/src/contracts/edutainmentBabyObject'; import type { CreateMatch3DSessionRequest, Match3DAgentSessionSnapshot, @@ -18,7 +22,8 @@ export type MiniGameDraftGenerationKind = | 'puzzle' | 'big-fish' | 'square-hole' - | 'match3d'; + | 'match3d' + | 'baby-object-match'; export type MiniGameDraftGenerationPhase = | 'idle' @@ -37,6 +42,9 @@ export type MiniGameDraftGenerationPhase = | 'match3d-upload-images' | 'match3d-generate-views' | 'match3d-ready' + | 'baby-object-draft' + | 'baby-object-images' + | 'baby-object-ready' | 'puzzle-images' | 'puzzle-select-image' | 'ready' @@ -191,6 +199,27 @@ const MATCH3D_PHASE_ORDER: Partial< 'match3d-generate-views': 5, }; +const BABY_OBJECT_MATCH_STEPS = [ + { + id: 'baby-object-draft', + label: '整理识物草稿', + detail: '写入两个物品名称与寓教于乐标签。', + weight: 22, + }, + { + id: 'baby-object-images', + label: '生成物品图', + detail: '为两个物品准备绘本风格图片资产。', + weight: 68, + }, + { + id: 'baby-object-ready', + label: '准备结果页', + detail: '校验草稿字段并进入结果页。', + weight: 10, + }, +] as const satisfies ReadonlyArray; + function clampProgress(value: number) { return Math.max(0, Math.min(100, Math.round(value))); } @@ -205,6 +234,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) { if (kind === 'match3d') { return MATCH3D_STEPS; } + if (kind === 'baby-object-match') { + return BABY_OBJECT_MATCH_STEPS; + } return BIG_FISH_STEPS; } @@ -260,7 +292,9 @@ export function createMiniGameDraftGenerationState( ? 'square-hole-draft' : kind === 'match3d' ? 'match3d-work-title' - : 'compile', + : kind === 'baby-object-match' + ? 'baby-object-draft' + : 'compile', startedAtMs: Date.now(), completedAssetCount: 0, totalAssetCount: 0, @@ -313,6 +347,18 @@ function resolveMatch3DPhaseByElapsedMs( return currentOrder > elapsedOrder ? currentPhase : elapsedPhase; } +function resolveBabyObjectMatchPhaseByElapsedMs( + elapsedMs: number, +): MiniGameDraftGenerationPhase { + if (elapsedMs >= 52_000) { + return 'baby-object-ready'; + } + if (elapsedMs >= 8_000) { + return 'baby-object-images'; + } + return 'baby-object-draft'; +} + function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) { let elapsedBeforePhase = 0; @@ -360,27 +406,34 @@ export function buildMiniGameDraftGenerationProgress( phase: puzzleTimeline.phase, } : state.kind === 'big-fish' && - state.phase !== 'failed' && - state.phase !== 'ready' - ? { - ...state, - phase: resolveBigFishPhaseByElapsedMs(elapsedMs), - } - : state.kind === 'square-hole' && state.phase !== 'failed' && state.phase !== 'ready' ? { ...state, - phase: resolveSquareHolePhaseByElapsedMs(elapsedMs), + phase: resolveBigFishPhaseByElapsedMs(elapsedMs), } - : state.kind === 'match3d' && + : state.kind === 'square-hole' && state.phase !== 'failed' && state.phase !== 'ready' ? { ...state, - phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase), + phase: resolveSquareHolePhaseByElapsedMs(elapsedMs), } - : state; + : state.kind === 'match3d' && + state.phase !== 'failed' && + state.phase !== 'ready' + ? { + ...state, + phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase), + } + : state.kind === 'baby-object-match' && + state.phase !== 'failed' && + state.phase !== 'ready' + ? { + ...state, + phase: resolveBabyObjectMatchPhaseByElapsedMs(elapsedMs), + } + : state; const steps = getStepDefinitions(normalizedState.kind); const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase); @@ -401,13 +454,15 @@ export function buildMiniGameDraftGenerationProgress( ? 1 : normalizedState.kind === 'puzzle' ? (puzzleTimeline?.activeStepProgressRatio ?? 0) - : normalizedState.kind === 'big-fish' - ? 0.55 - : normalizedState.kind === 'square-hole' - ? 0.42 - : normalizedState.kind === 'match3d' - ? 0.5 - : 0; + : normalizedState.kind === 'big-fish' + ? 0.55 + : normalizedState.kind === 'square-hole' + ? 0.42 + : normalizedState.kind === 'match3d' + ? 0.5 + : normalizedState.kind === 'baby-object-match' + ? 0.52 + : 0; const overallProgress = normalizedState.phase === 'failed' ? Math.max(1, completedWeight) @@ -436,7 +491,9 @@ export function buildMiniGameDraftGenerationProgress( ? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。' : normalizedState.kind === 'match3d' ? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。' - : '首关草稿与正式图已准备完成,可进入结果页补作品信息。' + : normalizedState.kind === 'baby-object-match' + ? '宝贝识物草稿已准备完成,可进入结果页继续发布。' + : '首关草稿与正式图已准备完成,可进入结果页补作品信息。' : activeStep.detail), batchLabel: activeStep.label, overallProgress: clampProgress(cappedOverallProgress), @@ -448,13 +505,15 @@ export function buildMiniGameDraftGenerationProgress( ? 0 : normalizedState.kind === 'puzzle' ? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs) - : normalizedState.kind === 'big-fish' - ? Math.max(0, 7_000 - elapsedMs) - : normalizedState.kind === 'square-hole' - ? Math.max(0, 12_000 - elapsedMs) - : normalizedState.kind === 'match3d' - ? Math.max(0, 10 * 60_000 - elapsedMs) - : null, + : normalizedState.kind === 'big-fish' + ? Math.max(0, 7_000 - elapsedMs) + : normalizedState.kind === 'square-hole' + ? Math.max(0, 12_000 - elapsedMs) + : normalizedState.kind === 'match3d' + ? Math.max(0, 10 * 60_000 - elapsedMs) + : normalizedState.kind === 'baby-object-match' + ? Math.max(0, 60_000 - elapsedMs) + : null, activeStepIndex, steps: buildMiniGameProgressSteps( steps, @@ -600,6 +659,22 @@ function resolveMatch3DGeneratedItemCount( return 21; } +export function buildBabyObjectMatchGenerationAnchorEntries( + formPayload: CreateBabyObjectMatchDraftRequest | null | undefined, + draft: BabyObjectMatchDraft | null | undefined = null, +): CustomWorldStructuredAnchorEntry[] { + const itemNames = + formPayload?.itemAName?.trim() || formPayload?.itemBName?.trim() + ? [formPayload.itemAName.trim(), formPayload.itemBName.trim()] + : (draft?.itemNames ?? []); + + return itemNames.filter(Boolean).map((value, index) => ({ + id: `baby-object-item-${index + 1}`, + label: `物品 ${index + 1}`, + value, + })); +} + export function buildSquareHoleGenerationAnchorEntries( session: SquareHoleSessionSnapshot | null | undefined, ): CustomWorldStructuredAnchorEntry[] { diff --git a/src/services/publicWorkCode.ts b/src/services/publicWorkCode.ts index c072cfbb..088328f9 100644 --- a/src/services/publicWorkCode.ts +++ b/src/services/publicWorkCode.ts @@ -45,6 +45,14 @@ export function buildVisualNovelPublicWorkCode(profileId: string) { return `VN-${suffix}`; } +export function buildBabyObjectMatchPublicWorkCode(profileId: string) { + const normalized = normalizePublicCodeText(profileId); + const fallback = normalized || '00000000'; + const suffix = fallback.slice(-8).padStart(8, '0'); + + return `BO-${suffix}`; +} + export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) { const normalizedKeyword = normalizePublicCodeText(keyword); @@ -103,3 +111,16 @@ export function isSameVisualNovelPublicWorkCode( normalizedKeyword === normalizePublicCodeText(profileId) ); } + +export function isSameBabyObjectMatchPublicWorkCode( + keyword: string, + profileId: string, +) { + const normalizedKeyword = normalizePublicCodeText(keyword); + + return ( + normalizedKeyword === + normalizePublicCodeText(buildBabyObjectMatchPublicWorkCode(profileId)) || + normalizedKeyword === normalizePublicCodeText(profileId) + ); +}