diff --git a/.env.local b/.env.local index ef534f5b..95f32753 100644 --- a/.env.local +++ b/.env.local @@ -56,7 +56,7 @@ GENARRATIVE_SPACETIME_TOKEN="" GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="https://maincloud.spacetimedb.com" GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr" -GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUsyN05YUjBaQkRUVEVCNlFQQjFXNzU2MiIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NzU1NTQ1NiwiZXhwIjoxODQwNjI3NDU2fQ.iy5qN-3lGPQnkya-wsABtqEgRk1VM2XGxTfxuLV5-eTMfX8cR20sWSx7pnoZcLEwYOkz6cEOb4krhMJmTeBax9Z114o_iwISau3wjjHbeKL9or-039zfYfKb3TtJo3_DZaJSu-ECcMZNl4P1zLmtoRSwl-_AMET4sGzPw0_qR-e49_QGDJz1EEhr7aphybl1xCejCebM8XiJjaRz48vL7-lkwBl90uP-0h7Xx8ToTT2h1egmlcYAvaJalVLHIQqzyYxPUT_Zw9TW7VYExZLhJWdGpQzEm0aXZ2fbch9qVrKpZP2xQ9YjppuLxUFFJeQwhmFf6yc67s6J7LqNvL2-ZA" +GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="" # admin GENARRATIVE_ADMIN_USERNAME=admin diff --git a/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md b/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md index ee6d0e31..ad4b7664 100644 --- a/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md +++ b/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md @@ -151,7 +151,9 @@ Agent 的职责是帮助用户确认可以直接编译 demo 的最小配置: 题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。 -首版 demo 不要求真实生成题材物品素材,只需保留题材字段,并使用 `10` 种颜色形状组合且差异足够明显的素材完成玩法验证。 +首版 demo 不接入真实图片生成。运行态可消除物统一使用纯色几何体表现,不使用透明气泡,也不在图案上放文字标识。题材仍决定后端生成的 `visualKey` 和尺寸比例,但前端首版用差异化颜色与几何造型表现可消除物,例如圆形、三角形、菱形、五角星、梯形、平行四边形等,避免玩家在堆叠状态下难以辨认。 + +水果图形资产需要具备常识可感知的相对大小关系,但不要求真实比例绝对精准。首版固定规则为:西瓜明显大于苹果;苹果、橙子、梨、桃子为中等尺寸;葡萄、李子、青柠等小型水果略小。该尺寸由后端运行态物品 `radius` 下发,前端只按快照表现。 ### 需要消除次数 @@ -249,7 +251,9 @@ Agent 的职责是帮助用户确认可以直接编译 demo 的最小配置: 1. 圆形空间使用俯视角。 2. 背景环境资源后续可以尝试伪 3D 视角效果。 3. 圆形空间边界是中间交互图案的边界。 -4. 物品不能超出圆形边界。 +4. 物品不能超出圆形边界,也不能被边界压住或裁切。 +5. 运行态快照中的 `x / y / radius` 使用前端可直接渲染的 `0~1` 归一化坐标;圆心固定为 `(0.5, 0.5)`,圆形可用半径为 `0.5`。 +6. 后端生成和前端兜底渲染都必须满足 `distance((x, y), (0.5, 0.5)) + radius <= 0.5 - safeMargin`,`safeMargin` 至少覆盖圆形边框和视觉阴影,避免可消除图案贴边裁切。 ## 8.3 物品生成规模 @@ -273,8 +277,8 @@ totalItemCount = clearCount * 3 首版 demo 使用 2D 图案素材。 -1. demo 只需提供 `10` 种颜色形状组合。 -2. `10` 种素材需要差异足够明显。 +1. demo 至少提供 `10` 种颜色与几何造型组合素材。 +2. 当题材为水果时,后端仍可切换到 `10` 种水果视觉键和尺寸比例,但前端首版必须把这些视觉键映射为无文字的纯色几何体,不能显示为水果图、透明气泡或文字标记。 3. 后续可以尝试替换为伪 3D 或 3D 模型。 4. 用户题材主题后续会映射为符合常识预期的物品集合。 diff --git a/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md b/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md index a3b56830..789acbe5 100644 --- a/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md +++ b/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md @@ -106,3 +106,12 @@ 2. 响应字段命名与前端约定一致 3. 配置开关可稳定映射到返回数组 4. 文档、任务清单与测试已同步更新 + +## 8. 2026-05-01 前端降级修复记录 + +本地联调时若 `api-server` 未启动或 Vite 代理暂时返回 `500`,`GET /api/auth/login-options` 会失败。前端必须继续遵循第 5.3 节约束: + +1. `AuthGate` 在 `login-options` 读取失败时设置 `availableLoginMethods = ["password"]`。 +2. 该失败只代表登录方式配置探测失败,不代表登录功能不可用,因此不把 `读取登录方式失败` 写入登录弹窗错误条。 +3. 登录弹窗仍展示密码登录表单,玩家可继续登录后进入创作链路。 +4. 本地仍需要启动 `api-server`,否则后续 `POST /api/auth/entry` 等真实登录请求无法完成。 diff --git a/docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md b/docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md new file mode 100644 index 00000000..8367add3 --- /dev/null +++ b/docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md @@ -0,0 +1,105 @@ +# 认证快照同步与抓大鹅本地联调修复记录 + +日期:`2026-05-01` + +## 1. 现场问题 + +本地访问 `http://127.0.0.1:3000` 时出现两类失败: + +1. 验证码登录成功后,接口返回 `同步认证快照失败`。 +2. 抓大鹅创作页请求报 `Failed to initiate WebSocket connection ... HTTP error: 503 Service Unavailable`,或同源创作接口直接 `404`。 + +## 2. 根因 + +### 2.1 Maincloud 目标库挂起 + +CLI 直接查询 `xushi-p4wfr` 返回: + +```text +Error: database is suspended +HTTP status server error (503 Service Unavailable) +``` + +这说明 `maincloud.spacetimedb.com` 入口在线,但具体数据库 `xushi-p4wfr` 当前不可订阅、不可查 schema、不可执行 SQL。所有依赖该库的 procedure 都会失败。 + +### 2.2 认证快照同步被当成硬失败 + +手机号、密码、刷新、退出等认证流程会先更新本地 `auth_store`,然后调用 SpacetimeDB 同步认证快照。旧逻辑把同步失败直接转为 HTTP 500,导致本地会话已经创建成功,响应却被远端快照同步失败阻断。 + +### 2.3 Vite 未代理 `/api/creation` + +抓大鹅创作接口挂在: + +```text +/api/creation/match3d/* +``` + +但 Vite 代理只覆盖了 `/api/auth`、`/api/runtime` 等路径,未覆盖 `/api/creation`,因此浏览器同源请求会被 Vite 返回 `404`,没有进入 Rust `api-server`。 + +## 3. 修复 + +### 3.1 认证快照同步改为非阻断 + +`AppState::sync_auth_store_snapshot_to_spacetime` 保持导出本地快照、写入 SpacetimeDB、导入正式表的顺序,但当远端写入或导入失败时只写 warn 日志并返回 `Ok(())`。 + +设计边界: + +1. 当前认证请求的即时真相源是本地 `auth_store`。 +2. SpacetimeDB 认证快照用于跨进程恢复和正式表投影。 +3. 远端库挂起或网络异常只降级远端恢复能力,不回滚已经成功的登录、刷新、退出和资料更新。 + +### 3.2 Vite 补齐创作接口代理 + +`vite.config.ts` 新增: + +```ts +'/api/creation': { + target: runtimeServerTarget, + changeOrigin: true, + secure: false, +}, +``` + +前端仍只请求同源 `/api/creation/match3d/*`,不直连 Rust 端口。 + +## 4. 本地可跑链路 + +Maincloud `xushi-p4wfr` 挂起期间,抓大鹅本地体验应使用本地 SpacetimeDB: + +```powershell +spacetime --root-dir=server-rs/.spacetimedb/local start --edition standalone --listen-addr 127.0.0.1:3101 +$env:GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="codex-local-bootstrap-secret-20260501" +spacetime --root-dir=server-rs/.spacetimedb/local publish xushi-p4wfr --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes +``` + +再让 Rust API 指向本地库: + +```powershell +$env:GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="http://127.0.0.1:3101" +$env:GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr" +$env:GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="" +npm run api-server:maincloud +``` + +最后重启前端: + +```powershell +$env:RUST_SERVER_TARGET="http://127.0.0.1:3100" +$env:GENARRATIVE_RUNTIME_SERVER_TARGET="http://127.0.0.1:3100" +npm run dev:web +``` + +## 5. 验证结果 + +已验证: + +1. `GET http://127.0.0.1:3000/api/auth/login-options` 返回 `["phone","password"]`。 +2. `GET http://127.0.0.1:3000/api/runtime/match3d/gallery` 返回 `{"items":[]}`,不再返回 SpacetimeDB 503。 +3. 未登录请求 `POST http://127.0.0.1:3000/api/creation/match3d/sessions` 返回 `401`,说明同源请求已进入 Rust 鉴权层,不再被 Vite `404`。 +4. 隔离端口指向挂起的 Maincloud 并使用 mock 短信时,手机号验证码登录返回 `200` 和 token;日志只记录“认证快照写入 SpacetimeDB 失败,当前认证流程继续”。 + +## 6. 后续 + +1. Maincloud `xushi-p4wfr` 仍需恢复数据库挂起状态,否则正式云端玩法 procedure 仍不可用。 +2. 本地开发如只为体验抓大鹅,可继续使用本地 SpacetimeDB 链路。 +3. 认证快照同步失败会影响进程重启后的云端恢复完整性,需要在 Maincloud 恢复后重新完成一次成功同步。 diff --git a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md index 317ef0b6..69c7091b 100644 --- a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md +++ b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md @@ -460,6 +460,9 @@ interface Match3DItemSnapshot { 1. `Flying` 可以作为前端表现态使用,不要求后端逐帧落库。 2. 后端主要确认 `InBoard -> InTray -> Cleared` 的权威状态变化。 3. `clickable` 是后端计算给前端的可点击快照,前端命中检测必须尊重它。 +4. `x / y / radius` 统一使用 `0~1` 归一化舞台坐标。圆心为 `(0.5, 0.5)`,圆形可用半径为 `0.5`。 +5. 后端生成物品时必须保证 `distance((x, y), (0.5, 0.5)) + radius <= 0.5 - safeMargin`。首版 `safeMargin` 用于覆盖圆形边框和阴影,避免物品被边界压住或裁切。 +6. 前端渲染收到旧快照或异常坐标时,可以只做显示层兜底收束,但不得把兜底后的表现坐标写回为规则真相。 ## 8.3 `Match3DTraySlot` @@ -517,11 +520,14 @@ totalItemCount = clearCount * 3 ## 9.3 demo 视觉素材 -首版使用 `10` 种颜色形状组合素材。 +首版使用内置视觉键和前端内置几何图形资产,不接真实图片生成。 -1. `visualKey` 固定为内置素材 key。 -2. 题材主题先进入作品配置和 Agent 文案,不强制生成题材素材。 -3. 后续接入真实题材素材前,必须另补资产生成方案。 +1. 水果题材必须使用 `watermelon-green / apple-red / banana-yellow / grape-purple / melon-green / berry-blue / peach-pink / plum-indigo / lime-lime / orange-orange` 这组内置水果视觉键;前端首版将其映射为纯色几何体,不渲染水果写实图,也不能显示为带文字或透明气泡的小球。 +2. 非水果题材暂使用 `red_circle / yellow_triangle / purple_diamond / green_square / blue_star / orange_hexagon / cyan_capsule / pink_heart / lime_leaf / white_moon` 这组兜底颜色形状视觉键。 +3. `visualKey` 不允许在前端统一兜底为同一个素材;未知 key 至少要有稳定的颜色差异,避免多个不同 `itemTypeId` 被玩家误认为同一种物品。 +4. 运行态图案必须使用实心、高饱和、无文字的几何 SVG,至少覆盖圆形、三角形、菱形、方形、五角星、六边形、胶囊、心形、梯形、平行四边形等多种轮廓;外层命中按钮不得再显示半透明气泡底。 +5. 水果题材的相对尺寸由后端权威半径决定,首版要求西瓜明显大于苹果,苹果、橙子、桃子等中型水果大于葡萄、李子、青柠等小型水果;前端不得自行改写规则半径,只负责按快照表现。 +6. 后续接入真实题材图片素材前,必须另补资产生成方案。 ## 9.4 难度 @@ -532,6 +538,7 @@ totalItemCount = clearCount * 3 1. 难度越高,物品尺寸可整体略小。 2. 难度越高,堆叠层级可略深。 3. 难度越高,首屏可直接三消的可见组合可略少。 +4. 同一局内允许有轻微尺寸差异,但每个物品仍必须完整落在圆形空间内。 具体数值不在 A0 冻死,由 B1 领域 crate 分支给出首版常量并通过测试覆盖。 diff --git a/docs/technical/MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md b/docs/technical/MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md index 0d34e2ee..2c14788e 100644 --- a/docs/technical/MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md +++ b/docs/technical/MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md @@ -1,27 +1,34 @@ -# 抓大鹅创作入口敬请期待 2026-05-01 +# 抓大鹅创作入口开放与错误隔离 2026-05-01 ## 1. 背景 -抓大鹅 Match3D 玩法域已存在创作工作区、结果承接和后续后端接入文档,但当前产品节奏需要先收起创作页入口,避免玩家从创作页直接进入未完全开放的抓大鹅创作链路。 +抓大鹅 Match3D 玩法域已完成当前 demo 主链接入,本轮恢复创作页入口,使玩家可以从创作中心直接进入抓大鹅共创工作台。同时,平台首页会并行读取 RPG、拼图、抓大鹅等公开广场数据,公开广场接口未就绪、空表或临时失败不应污染创作入口错误态,也不应表现成登录异常。 ## 2. 落地边界 -本轮只调整平台创作入口展示与点击防线: +本轮只调整平台创作入口展示、点击分流与公开广场错误隔离: 1. `PLATFORM_CREATION_TYPES` 中 `match3d` 保持展示,标题仍为 `抓大鹅`。 -2. `match3d` 的副标题与 badge 统一显示 `敬请期待`。 -3. `match3d.locked` 设为 `true`,创作页首屏卡片和创作类型弹层都会变为不可点击。 -4. 平台分流回调继续保留 `match3d` 防御返回,避免旧 UI 状态或旧入口绕过锁定态。 +2. `match3d` 的副标题显示 `经典消除玩法`,badge 显示 `可创建`。 +3. `match3d.locked` 设为 `false`,创作页首屏卡片和创作类型弹层均可点击。 +4. 首屏卡片的 `handleCreationHubCreateType('match3d')` 必须走登录保护后调用 `openMatch3DAgentWorkspace()`。 +5. 创作类型弹层的 `onSelectMatch3D` 必须走同一条登录保护与工作台打开链路。 +6. 公开抓大鹅广场读取失败只清空抓大鹅公开列表,不写入 `match3dError`,避免把公开数据失败展示为创作工作台错误。 +7. RPG 公开作品广场读取失败只降级为空列表,不提升为整个平台错误;私有作品库、创作作品列表等受保护请求失败仍保留错误提示。 ## 3. 非目标 1. 不删除 `src/components/match3d-creation/`、`src/services/match3d-creation/` 或已完成的 Match3D 玩法域代码。 2. 不修改 SpacetimeDB 表、procedure、bindings 或 `migration.rs`。 3. 不改变已发布抓大鹅作品的详情、运行态和后续恢复入口能力。 +4. 不在本轮补做公开广场接口的后端业务兜底;前端只对公开读取失败做非阻塞降级。 ## 4. 验收点 1. 创作页能看到 `抓大鹅` 卡片。 -2. 该卡片显示 `敬请期待`,且按钮 disabled。 -3. 创作类型弹层中的 `抓大鹅` 同样显示 `敬请期待`,且不可点击。 -4. 相关测试、类型检查和编码检查通过。 +2. 该卡片显示 `经典消除玩法`,且按钮可点击。 +3. 登录态点击创作页首屏 `抓大鹅` 卡片后进入抓大鹅共创工作区。 +4. 未登录点击 `抓大鹅` 入口时弹出登录面板,不静默吞掉点击。 +5. 抓大鹅公开广场读取失败时,创作页不显示 `读取抓大鹅广场失败`,抓大鹅入口仍可进入。 +6. RPG 公开作品广场读取失败时,首页不显示阻塞性的 `读取作品广场失败`,创作页仍可正常打开。 +7. 相关测试、类型检查和编码检查通过。 diff --git a/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md b/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md index 8d57c6c3..1411fc43 100644 --- a/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md +++ b/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md @@ -98,10 +98,12 @@ server-rs/crates/module-match3d 1. `clearCount` 必须是正整数。 2. `totalItemCount = clearCount * 3`。 3. 难度范围为 `1~10`。 -4. 首版内置 `10` 种 demo 视觉 key。 -5. 当 `clearCount > 10` 时,复用视觉 key,并保证每种物品数量仍为 `3` 的倍数。 -6. 初始布局使用确定性 seed 生成圆形空间内的 2D 坐标。 -7. 可点击判定只做 2D 近似:若物品被更高层物品完全覆盖,则不可点击;否则可点击。 +4. 首版内置水果题材视觉 key 和颜色形状兜底视觉 key。 +5. 当题材包含水果语义时,使用水果视觉 key;其他题材使用颜色形状兜底 key。 +6. 当 `clearCount > 10` 时,复用视觉 key,并保证每种物品数量仍为 `3` 的倍数。 +7. 初始布局使用确定性 seed 生成圆形空间内的 2D 坐标。 +8. 坐标使用 `0~1` 归一化舞台坐标,圆心为 `(0.5, 0.5)`;生成时必须保证 `distance((x, y), (0.5, 0.5)) + radius <= 0.5 - safeMargin`,避免物品被圆形边界压住或裁切。 +9. 可点击判定只做 2D 近似:若物品被更高层物品完全覆盖,则不可点击;否则可点击。 ## 6. 验收 diff --git a/docs/technical/MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md b/docs/technical/MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md index c0a4210e..e6a95861 100644 --- a/docs/technical/MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md +++ b/docs/technical/MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md @@ -80,11 +80,16 @@ POST /api/runtime/match3d/runs/{run_id}/time-up ## 3. 创作 Agent 当前口径 -B5 首版先采用确定性配置抽取,不在本阶段新增真实 LLM prompt: +B5 首版先采用确定性配置抽取,不在本阶段新增真实 LLM prompt。 -1. 创建会话时可从 `themeText / seedText / clearCount / difficulty / referenceImageSrc` 形成配置。 -2. 发送消息时根据用户文本或 `quickFillRequested` 更新题材、需要消除次数和难度。 -3. `match3d_compile_draft` 动作调用 SpacetimeDB `compile_match3d_draft`,生成 draft work profile。 +2026-05-01 起,抓大鹅创作入口必须按三轮 Agent 问答收集配置,不能在用户未回答前用默认值生成“已确认”回复: + +1. `POST /api/creation/match3d/sessions` 创建会话后,首条 assistant 消息固定为“你想创作什么题材”。 +2. 用户第一轮回复只写入题材,assistant 继续问“需要消除多少次才能通关”。 +3. 用户第二轮回复只写入需要消除次数,assistant 继续问“如果难度是从1-10,你要创作的关卡是难度几”。 +4. 用户第三轮回复写入难度后,assistant 才返回“已确认:...”,并把进度推进到 `100`、stage 推进到 `ReadyToCompile`。 +5. SpacetimeDB 当前配置快照仍要求合法数值,因此 `api-server` facade 可以在 `config_json` 内保留兜底合法值,但回复、进度和是否允许生成结果页必须以三轮问答进度为准。 +6. `match3d_compile_draft` 动作只能在三项收集完成后调用 SpacetimeDB `compile_match3d_draft`,生成 draft work profile。 后续若要接真实 LLM turn,应复用现有创作 Agent 公共编排,并保持 submit/finalize 两阶段职责不变。 diff --git a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md index 0acb3623..4847ac0d 100644 --- a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md +++ b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md @@ -20,8 +20,8 @@ | --- | --- | --- | --- | | 角色扮演 | 是 | 是 | 点击后进入 RPG Agent 共创工作台 | | 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 | -| 拼图 | 是 | 是 | 当前唯一可见且可创建入口 | -| 抓大鹅 | 是 | 否 | 保留入口,显示敬请期待 | +| 拼图 | 是 | 是 | 点击后进入拼图 Agent 共创工作台 | +| 抓大鹅 | 是 | 是 | 点击后进入抓大鹅 Agent 共创工作台 | | AIRP | 是 | 否 | 保留入口,显示敬请期待 | | 视觉小说 | 是 | 否 | 保留入口,显示敬请期待 | diff --git a/docs/technical/README.md b/docs/technical/README.md index 6cee2ff0..a9cdf785 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -9,6 +9,7 @@ - [PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md](./PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md):记录拼图图片生成接入 APIMart `gpt-image-2`、`gemini-3.1-flash-image-preview`,以及画面描述框内模型切换与后端路由边界。 - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。 - [SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md](./SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md):记录本地 standalone 启动时报 `mismatched database identity` 的 root-dir/replica 数据残留根因、备份重建步骤和脚本诊断口径。 +- [AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md](./AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md):记录 Maincloud `xushi-p4wfr` 挂起导致认证快照同步和抓大鹅创作失败的根因、认证同步非阻断修复、`/api/creation` Vite 代理补齐和本地 SpacetimeDB 可跑链路。 - [LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md](./LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md):冻结 RPG 运行时剧情推理使用 `doubao-seed-character-251128` 的 `/chat/completions`,以及所有模板创作大模型推理使用 `deepseek-v3-2-251201` 的 `/responses`。 - [PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md](./PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md):冻结邀请码从“我的 Tab 填写”迁到注册环节的前后端边界、`profile_invite_code.metadata_json` 表结构扩展、管理员邀请码虚拟主体和奖励规则。 - [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md):冻结抓大鹅 Match3D 首版 demo 的独立玩法域、表与 procedure、HTTP facade、前端即时反馈/后端权威确认协议,以及可并行开发包。 @@ -16,7 +17,7 @@ - [MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md](./MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md):记录抓大鹅 F1 创作入口、Agent 工作区、参考图入口、本地 mock client 与后续 B5 HTTP facade 替换点。 - [MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md](./MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md):冻结抓大鹅 F2 结果页、基础信息编辑、发布前试玩入口、发布门槛、自动保存和已发布作品二次编辑恢复口径。 - [MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md](./MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md):记录抓大鹅 B4+B5 已落地的 SpacetimeDB bindings、`spacetime-client` facade、`api-server` HTTP 路由、shared contract 对齐和验收命令。 -- [MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md](./MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md):记录抓大鹅创作页入口临时改为“敬请期待”、不可点击,以及保留既有 Match3D 能力不删除的边界。 +- [MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md](./MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md):记录抓大鹅创作页入口重新开放、首屏与弹层分流一致,以及公开广场失败不污染创作错误态的边界。 - [MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md](./MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md):记录抓大鹅 Match3D 第一至第三波完成度复核、Q1 主链集成落点、定向验收命令和遗留风险。 - [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。 - [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。 diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 00968841..65606337 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -58,6 +58,9 @@ const MATCH3D_RUNTIME_PROVIDER: &str = "match3d-runtime"; const MATCH3D_DEFAULT_THEME: &str = "缤纷玩具"; const MATCH3D_DEFAULT_CLEAR_COUNT: u32 = 12; const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4; +const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材"; +const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关"; +const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作的关卡是难度几"; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -90,7 +93,7 @@ pub async fn create_match3d_agent_session( let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; let config = build_config_from_create_request(&payload); let seed_text = build_seed_text(&payload, &config); - let welcome_message_text = build_match3d_assistant_reply(&config); + let welcome_message_text = MATCH3D_QUESTION_THEME.to_string(); let session = state .spacetime_client() @@ -810,9 +813,10 @@ async fn submit_and_finalize_match3d_message( map_match3d_client_error(error), ) })?; + let next_turn = submitted.current_turn.saturating_add(1); let next_config = build_config_from_message(&submitted, &payload); - let assistant_reply = build_match3d_assistant_reply(&next_config); - let progress_percent = resolve_progress_percent(&next_config); + let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn); + let progress_percent = resolve_progress_percent_for_turn(next_turn); let stage = if progress_percent >= 100 { "ReadyToCompile" } else { @@ -865,6 +869,14 @@ async fn compile_match3d_draft_for_session( map_match3d_client_error(error), ) })?; + if session.current_turn < 3 || session.progress_percent < 100 { + return Err(match3d_bad_request( + request_context, + MATCH3D_AGENT_PROVIDER, + "match3d 创作配置尚未确认完成", + )); + } + let config = resolve_config_or_default(session.config.as_ref()); let tags_json = tags .as_ref() @@ -901,8 +913,12 @@ fn map_match3d_agent_session_response( session_id: session.session_id, current_turn: session.current_turn, progress_percent: session.progress_percent, - stage: session.stage, - anchor_pack: map_match3d_anchor_pack_response(session.anchor_pack), + stage: session.stage.clone(), + anchor_pack: map_match3d_anchor_pack_response_for_turn( + session.anchor_pack, + session.current_turn, + session.stage.as_str(), + ), config: session.config.map(map_match3d_config_response), draft: session.draft.map(map_match3d_draft_response), messages: session @@ -916,11 +932,35 @@ fn map_match3d_agent_session_response( } } -fn map_match3d_anchor_pack_response(anchor: Match3DAnchorPackRecord) -> Match3DAnchorPackResponse { +fn map_match3d_anchor_pack_response_for_turn( + anchor: Match3DAnchorPackRecord, + current_turn: u32, + stage: &str, +) -> Match3DAnchorPackResponse { + let is_ready = matches!( + stage, + "ReadyToCompile" + | "ready_to_compile" + | "DraftCompiled" + | "draft_compiled" + | "draft_ready" + | "ReadyToPublish" + | "ready_to_publish" + | "Published" + | "published" + ); + let collected_count = if is_ready { 3 } else { current_turn.min(3) }; + Match3DAnchorPackResponse { - theme: map_match3d_anchor_item_response(anchor.theme), - clear_count: map_match3d_anchor_item_response(anchor.clear_count), - difficulty: map_match3d_anchor_item_response(anchor.difficulty), + theme: map_match3d_anchor_item_response_for_collected(anchor.theme, collected_count >= 1), + clear_count: map_match3d_anchor_item_response_for_collected( + anchor.clear_count, + collected_count >= 2, + ), + difficulty: map_match3d_anchor_item_response_for_collected( + anchor.difficulty, + collected_count >= 3, + ), } } @@ -933,6 +973,22 @@ fn map_match3d_anchor_item_response(anchor: Match3DAnchorItemRecord) -> Match3DA } } +fn map_match3d_anchor_item_response_for_collected( + anchor: Match3DAnchorItemRecord, + collected: bool, +) -> Match3DAnchorItemResponse { + if collected { + return map_match3d_anchor_item_response(anchor); + } + + Match3DAnchorItemResponse { + key: anchor.key, + label: anchor.label, + value: String::new(), + status: "missing".to_string(), + } +} + fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCreatorConfigResponse { Match3DCreatorConfigResponse { theme_text: config.theme_text, @@ -1096,31 +1152,51 @@ fn build_config_from_message( payload: &SendMatch3DAgentMessageRequest, ) -> Match3DConfigJson { let current = resolve_config_or_default(session.config.as_ref()); - if payload.quick_fill_requested.unwrap_or(false) || payload.text.contains("自动配置") { - return Match3DConfigJson { - theme_text: if current.theme_text.trim().is_empty() { + let text = payload.text.trim(); + let reference_image_src = payload + .reference_image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or(current.reference_image_src); + let quick_fill_requested = + payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置"); + + let mut theme_text = current.theme_text; + let mut clear_count = current.clear_count.max(1); + let mut difficulty = current.difficulty.clamp(1, 10); + + match session.current_turn { + 0 => { + theme_text = if quick_fill_requested { MATCH3D_DEFAULT_THEME.to_string() } else { - current.theme_text - }, - reference_image_src: current.reference_image_src, - clear_count: current.clear_count.max(1), - difficulty: current.difficulty.clamp(1, 10), - }; + parse_theme_answer(text).unwrap_or(theme_text) + }; + } + 1 => { + clear_count = if quick_fill_requested { + clear_count + } else { + parse_number_after_keywords(text, &["消除", "次数", "clearCount"]) + .unwrap_or(clear_count) + } + .max(1); + } + _ => { + difficulty = if quick_fill_requested { + difficulty + } else { + parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty) + } + .clamp(1, 10); + } } - let text = payload.text.trim(); - let theme_text = parse_theme_from_text(text).unwrap_or(current.theme_text); - let clear_count = parse_number_after_keywords(text, &["消除", "次数", "clearCount"]) - .unwrap_or(current.clear_count) - .max(1); - let difficulty = parse_number_after_keywords(text, &["难度", "difficulty"]) - .unwrap_or(current.difficulty) - .clamp(1, 10); - Match3DConfigJson { theme_text, - reference_image_src: current.reference_image_src, + reference_image_src, clear_count, difficulty, } @@ -1174,19 +1250,25 @@ fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String { ) } -fn resolve_progress_percent(config: &Match3DConfigJson) -> u32 { - let completed = [ - !config.theme_text.trim().is_empty(), - config.clear_count > 0, - (1..=10).contains(&config.difficulty), - ] - .into_iter() - .filter(|done| *done) - .count(); - ((completed as u32) * 100) / 3 +fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String { + match current_turn { + 0 => MATCH3D_QUESTION_THEME.to_string(), + 1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(), + 2 => MATCH3D_QUESTION_DIFFICULTY.to_string(), + _ => build_match3d_assistant_reply(config), + } } -fn parse_theme_from_text(text: &str) -> Option { +fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 { + match current_turn { + 0 => 0, + 1 => 33, + 2 => 66, + _ => 100, + } +} + +fn parse_theme_answer(text: &str) -> Option { for marker in ["题材", "主题"] { if let Some((_, value)) = text.split_once(marker) { let normalized = value @@ -1416,3 +1498,81 @@ fn current_utc_micros() -> i64 { fn current_utc_ms() -> i64 { current_utc_micros().saturating_div(1000) } + +#[cfg(test)] +mod tests { + use super::*; + + fn config(theme_text: &str, clear_count: u32, difficulty: u32) -> Match3DConfigJson { + Match3DConfigJson { + theme_text: theme_text.to_string(), + reference_image_src: None, + clear_count, + difficulty, + } + } + + #[test] + fn match3d_agent_reply_asks_three_questions_before_confirmation() { + let current = config("水果", 4, 6); + + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 0), + MATCH3D_QUESTION_THEME + ); + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 1), + MATCH3D_QUESTION_CLEAR_COUNT + ); + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 2), + MATCH3D_QUESTION_DIFFICULTY + ); + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 3), + "已确认:水果题材,需要消除 4 次,共 12 件物品,难度 6。" + ); + } + + #[test] + fn match3d_agent_progress_follows_question_turns() { + assert_eq!(resolve_progress_percent_for_turn(0), 0); + assert_eq!(resolve_progress_percent_for_turn(1), 33); + assert_eq!(resolve_progress_percent_for_turn(2), 66); + assert_eq!(resolve_progress_percent_for_turn(3), 100); + assert_eq!(resolve_progress_percent_for_turn(8), 100); + } + + #[test] + fn match3d_anchor_pack_masks_uncollected_default_values() { + let pack = Match3DAnchorPackRecord { + theme: Match3DAnchorItemRecord { + key: "theme".to_string(), + label: "题材主题".to_string(), + value: "缤纷玩具".to_string(), + status: "confirmed".to_string(), + }, + clear_count: Match3DAnchorItemRecord { + key: "clearCount".to_string(), + label: "需要消除次数".to_string(), + value: "12".to_string(), + status: "confirmed".to_string(), + }, + difficulty: Match3DAnchorItemRecord { + key: "difficulty".to_string(), + label: "难度".to_string(), + value: "4".to_string(), + status: "confirmed".to_string(), + }, + }; + + let response = map_match3d_anchor_pack_response_for_turn(pack, 0, "Collecting"); + + assert_eq!(response.theme.value, ""); + assert_eq!(response.theme.status, "missing"); + assert_eq!(response.clear_count.value, ""); + assert_eq!(response.clear_count.status, "missing"); + assert_eq!(response.difficulty.value, ""); + assert_eq!(response.difficulty.status, "missing"); + } +} diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index ba6d10d1..be00d928 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -224,13 +224,29 @@ impl AppState { OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000, ) .map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?; + // 本地 auth_store 是当前认证请求的即时真相源;SpacetimeDB 快照用于跨进程恢复。 + // 远端数据库挂起或网络异常时,只降级远端恢复能力,不能让已成功的登录/刷新/退出回滚为失败。 #[cfg(not(test))] - self.spacetime_client + if let Err(error) = self + .spacetime_client .upsert_auth_store_snapshot(snapshot_json, updated_at_micros) - .await?; - // ????????????????????????????????? + .await + { + warn!( + error = %error, + "认证快照写入 SpacetimeDB 失败,当前认证流程继续" + ); + return Ok(()); + } + // 写入快照后尝试拆入正式认证表;失败只影响远端表恢复,不阻断当前认证响应。 #[cfg(not(test))] - self.spacetime_client.import_auth_store_snapshot().await?; + if let Err(error) = self.spacetime_client.import_auth_store_snapshot().await { + warn!( + error = %error, + "认证快照导入 SpacetimeDB 正式表失败,当前认证流程继续" + ); + return Ok(()); + } #[cfg(not(test))] Ok(()) } diff --git a/server-rs/crates/module-match3d/src/lib.rs b/server-rs/crates/module-match3d/src/lib.rs index 2c3f901d..1e7b0400 100644 --- a/server-rs/crates/module-match3d/src/lib.rs +++ b/server-rs/crates/module-match3d/src/lib.rs @@ -15,10 +15,26 @@ pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3; pub const MATCH3D_MIN_DIFFICULTY: u32 = 1; pub const MATCH3D_MAX_DIFFICULTY: u32 = 10; pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000; -pub const MATCH3D_BOARD_RADIUS: f32 = 1.0; +pub const MATCH3D_BOARD_CENTER: f32 = 0.5; +pub const MATCH3D_BOARD_RADIUS: f32 = 0.5; +pub const MATCH3D_BOARD_SAFE_MARGIN: f32 = 0.035; -// 首版 demo 使用固定 10 组颜色形状 key;后续真实题材素材接入时仍保持 item_type_id 三个一组。 -const MATCH3D_DEMO_VISUAL_KEYS: [&str; 10] = [ +// 中文注释:首版 demo 不接真实图片生成,但水果题材必须先给出可辨认的水果内置视觉键。 +const MATCH3D_FRUIT_VISUAL_KEYS: [&str; 10] = [ + "watermelon-green", + "apple-red", + "banana-yellow", + "grape-purple", + "melon-green", + "berry-blue", + "peach-pink", + "plum-indigo", + "lime-lime", + "orange-orange", +]; + +// 中文注释:非水果题材使用颜色形状兜底 key;前端必须逐个渲染,不能统一兜成同一图案。 +const MATCH3D_SHAPE_VISUAL_KEYS: [&str; 10] = [ "red_circle", "yellow_triangle", "purple_diamond", @@ -428,7 +444,12 @@ pub fn start_run_with_seed_at( total_item_count, cleared_item_count: 0, board_version: 1, - items: build_initial_items(config.clear_count, config.difficulty, seed), + items: build_initial_items( + config.clear_count, + config.difficulty, + seed, + &config.theme_text, + ), tray_slots: empty_tray_slots(), failure_reason: None, last_confirmed_action_id: None, @@ -561,18 +582,26 @@ pub fn refresh_clickable_flags(run: &mut Match3DRunSnapshot) { } } -fn build_initial_items(clear_count: u32, difficulty: u32, seed: u64) -> Vec { +fn build_initial_items( + clear_count: u32, + difficulty: u32, + seed: u64, + theme_text: &str, +) -> Vec { let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64); - let radius = resolve_item_radius(difficulty); + let base_radius = resolve_item_radius(difficulty); + let visual_keys = visual_keys_for_theme(theme_text); let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize); for clear_index in 0..clear_count { - let visual_index = (clear_index as usize) % MATCH3D_DEMO_VISUAL_KEYS.len(); + let visual_index = (clear_index as usize) % visual_keys.len(); let item_type_id = format!("match3d-type-{:02}", visual_index + 1); - let visual_key = MATCH3D_DEMO_VISUAL_KEYS[visual_index].to_string(); + let visual_key = visual_keys[visual_index].to_string(); for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR { - let (x, y) = random_point_in_circle(&mut rng, MATCH3D_BOARD_RADIUS - radius); + let radius = + resolve_item_radius_variant(base_radius, &visual_key, visual_index, copy_index); + let (x, y) = random_point_in_circle(&mut rng, max_spawn_offset(radius)); let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index; items.push(Match3DItemSnapshot { item_instance_id: format!("match3d-item-{instance_index:04}"), @@ -601,21 +630,87 @@ fn build_initial_items(clear_count: u32, difficulty: u32, seed: u64) -> Vec &'static [&'static str; 10] { + if is_fruit_theme(theme_text) { + &MATCH3D_FRUIT_VISUAL_KEYS + } else { + &MATCH3D_SHAPE_VISUAL_KEYS + } +} + +fn is_fruit_theme(theme_text: &str) -> bool { + let normalized = theme_text.trim().to_lowercase(); + [ + "水果", "果蔬", "果物", "fruit", "fruits", "苹果", "香蕉", "葡萄", "西瓜", "草莓", "桃", + "李", "柠", "橙", "梨", + ] + .iter() + .any(|marker| normalized.contains(marker)) +} + fn resolve_item_radius(difficulty: u32) -> f32 { let clamped = difficulty.clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY); let radius = 0.105 - (clamped as f32 - 1.0) * 0.0055; radius.max(0.052) } +fn resolve_item_radius_variant( + base_radius: f32, + visual_key: &str, + visual_index: usize, + copy_index: u32, +) -> f32 { + let copy_delta = (copy_index as f32 - 1.0) * 0.002; + if is_fruit_visual_key(visual_key) { + return (base_radius * fruit_visual_size_scale(visual_key) + copy_delta).clamp(0.04, 0.13); + } + + let type_delta = ((visual_index % 5) as f32 - 2.0) * 0.004; + (base_radius + type_delta + copy_delta).clamp(0.045, 0.12) +} + +fn is_fruit_visual_key(visual_key: &str) -> bool { + matches!( + visual_key, + "watermelon-green" + | "apple-red" + | "banana-yellow" + | "grape-purple" + | "melon-green" + | "berry-blue" + | "peach-pink" + | "plum-indigo" + | "lime-lime" + | "orange-orange" + | "pear-cyan" + ) +} + +fn fruit_visual_size_scale(visual_key: &str) -> f32 { + match visual_key { + "watermelon-green" => 1.24, + "melon-green" => 1.12, + "banana-yellow" => 1.04, + "apple-red" | "orange-orange" | "peach-pink" | "pear-cyan" => 1.0, + "plum-indigo" | "lime-lime" => 0.86, + "grape-purple" | "berry-blue" => 0.78, + _ => 1.0, + } +} + +fn max_spawn_offset(radius: f32) -> f32 { + (MATCH3D_BOARD_RADIUS - MATCH3D_BOARD_SAFE_MARGIN - radius).max(0.0) +} + fn random_point_in_circle(rng: &mut DeterministicRng, max_radius: f32) -> (f32, f32) { for _ in 0..24 { let x = rng.next_unit_signed() * max_radius; let y = rng.next_unit_signed() * max_radius; if x * x + y * y <= max_radius * max_radius { - return (x, y); + return (MATCH3D_BOARD_CENTER + x, MATCH3D_BOARD_CENTER + y); } } - (0.0, 0.0) + (MATCH3D_BOARD_CENTER, MATCH3D_BOARD_CENTER) } fn fully_covers( @@ -888,6 +983,117 @@ mod tests { assert!(counts.values().all(|count| count % 3 == 0)); } + #[test] + fn initial_run_uses_slightly_different_item_sizes() { + let run = start_run_with_seed_at( + "run-size".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(6), + 21, + 1_000, + ) + .expect("run should start"); + + let mut radii = run + .items + .iter() + .map(|item| (item.radius * 1_000.0).round() as u32) + .collect::>(); + radii.sort(); + radii.dedup(); + + assert!(radii.len() > 1); + } + + #[test] + fn fruit_theme_generates_fruit_visuals_inside_board() { + let run = start_run_with_seed_at( + "run-fruit".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(10), + 12, + 1_000, + ) + .expect("run should start"); + + let visual_keys = run + .items + .iter() + .map(|item| item.visual_key.as_str()) + .collect::>(); + assert!(visual_keys.contains(&"watermelon-green")); + assert!(visual_keys.contains(&"apple-red")); + assert!(visual_keys.contains(&"banana-yellow")); + assert!(!visual_keys.contains(&"red_circle")); + + for item in &run.items { + let dx = item.x - MATCH3D_BOARD_CENTER; + let dy = item.y - MATCH3D_BOARD_CENTER; + let distance = (dx * dx + dy * dy).sqrt(); + assert!( + distance + item.radius <= MATCH3D_BOARD_RADIUS - MATCH3D_BOARD_SAFE_MARGIN + 0.0001, + "item {} should stay inside board: x={}, y={}, radius={}", + item.item_instance_id, + item.x, + item.y, + item.radius + ); + } + } + + #[test] + fn fruit_theme_uses_common_sense_relative_sizes() { + let run = start_run_with_seed_at( + "run-fruit-size".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(10), + 27, + 1_000, + ) + .expect("run should start"); + + let max_radius_for_visual = |visual_key: &str| { + run.items + .iter() + .filter(|item| item.visual_key == visual_key) + .map(|item| item.radius) + .fold(0.0, f32::max) + }; + + let watermelon = max_radius_for_visual("watermelon-green"); + let apple = max_radius_for_visual("apple-red"); + let grape = max_radius_for_visual("grape-purple"); + + assert!(watermelon > apple); + assert!(apple > grape); + } + + #[test] + fn non_fruit_theme_generates_shape_visuals() { + let config = build_creator_config("玩具", None, 3, 4).expect("config should be valid"); + let run = start_run_with_seed_at( + "run-shapes".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &config, + 13, + 1_000, + ) + .expect("run should start"); + + let visual_keys = run + .items + .iter() + .map(|item| item.visual_key.as_str()) + .collect::>(); + assert!(visual_keys.contains(&"red_circle")); + assert!(visual_keys.contains(&"yellow_triangle")); + assert!(!visual_keys.contains(&"apple-red")); + } + #[test] fn clicking_three_same_items_clears_and_wins() { let mut run = start_run_with_seed_at( diff --git a/server-rs/crates/shared-contracts/src/match3d_agent.rs b/server-rs/crates/shared-contracts/src/match3d_agent.rs index 0b74357f..8db4ea95 100644 --- a/server-rs/crates/shared-contracts/src/match3d_agent.rs +++ b/server-rs/crates/shared-contracts/src/match3d_agent.rs @@ -22,6 +22,8 @@ pub struct SendMatch3DAgentMessageRequest { pub text: String, #[serde(default)] pub quick_fill_requested: Option, + #[serde(default)] + pub reference_image_src: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index b93ead92..0a743656 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -267,6 +267,7 @@ test('auth gate keeps password entry available when login options are empty', as const dialog = screen.getByRole('dialog', { name: '账号入口' }); expect(within(dialog).getByLabelText('密码')).toBeTruthy(); expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull(); + expect(within(dialog).queryByText('读取登录方式失败')).toBeNull(); }); test('auth gate falls back to password entry when login options request fails', async () => { diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index df87a481..99be0b9c 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -269,6 +269,7 @@ export function AuthGate({ children }: AuthGateProps) { let isActive = true; const hydrate = async () => { + const callbackResult = consumeAuthCallbackResult(); const loadLoginOptions = async () => { const options = await getAuthLoginOptions(); if (!isActive) { @@ -297,16 +298,13 @@ export function AuthGate({ children }: AuthGateProps) { setAvailableLoginMethods(FALLBACK_LOGIN_METHODS); setUser(null); - setError( - optionsError instanceof Error - ? optionsError.message - : '读取登录方式失败,请稍后再试。', - ); + // 中文注释:登录方式接口失败时按产品约定保留密码登录入口; + // 这里不展示接口读取错误,避免用户误以为登录本身不可用。 + setError(callbackResult?.error ?? ''); setStatus('unauthenticated'); } }; - const callbackResult = consumeAuthCallbackResult(); if (callbackResult?.error && isActive) { setError(callbackResult.error); setShowLoginModal(true); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 23f743a9..63ac1330 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -112,9 +112,9 @@ test('creation hub reflects updated draft title summary and counts after rerende Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect((rpgButton as HTMLButtonElement).disabled).toBe(false); - expect((match3dButton as HTMLButtonElement).disabled).toBe(true); + expect((match3dButton as HTMLButtonElement).disabled).toBe(false); expect( - within(match3dButton).getAllByText('敬请期待').length, + within(match3dButton).getAllByText('经典消除玩法').length, ).toBeGreaterThan(0); expect(puzzleButton).toBeTruthy(); expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull(); diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index 0c09cdc3..d8bc92a2 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -61,8 +61,119 @@ test('点击可见物品后先乐观入槽再等待确认', async () => { expect(clickableItem).toBeTruthy(); const { onClickItem, onOptimisticRunChange } = renderRuntime(run); - fireEvent.click(screen.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`)); + fireEvent.click( + screen.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`), + ); expect(onOptimisticRunChange).toHaveBeenCalled(); await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1)); }); + +test('后端形状视觉键不会被统一兜底成红色苹字', () => { + const run = startLocalMatch3DRun(2); + run.items = run.items.slice(0, 2).map((item, index) => ({ + ...item, + itemInstanceId: `shape-${index}`, + itemTypeId: `shape-type-${index}`, + visualKey: index === 0 ? 'red_circle' : 'yellow_triangle', + x: 0.42 + index * 0.16, + y: 0.5, + layer: index, + clickable: true, + })); + renderRuntime(run); + + expect(screen.getByTestId('match3d-visual-red_circle')).toBeTruthy(); + expect(screen.getByTestId('match3d-visual-yellow_triangle')).toBeTruthy(); + expect(screen.queryAllByText('苹')).toHaveLength(0); +}); + +test('水果题材视觉键也渲染为无文字纯色几何体', () => { + const run = startLocalMatch3DRun(3); + run.items = run.items.slice(0, 3).map((item, index) => ({ + ...item, + itemInstanceId: `fruit-${index}`, + itemTypeId: `fruit-type-${index}`, + visualKey: + index === 0 + ? 'watermelon-green' + : index === 1 + ? 'apple-red' + : 'grape-purple', + x: 0.35 + index * 0.15, + y: 0.5, + radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07, + layer: index, + clickable: true, + })); + renderRuntime(run); + + expect(screen.getByTestId('match3d-visual-watermelon-green')).toBeTruthy(); + expect( + screen.getByTestId('match3d-visual-apple-red').getAttribute('data-shape'), + ).toBe('heart'); + expect( + screen + .getByTestId('match3d-visual-grape-purple') + .getAttribute('data-shape'), + ).toBe('star'); + expect(screen.queryByText('苹果')).toBeNull(); + expect(screen.queryByText('苹')).toBeNull(); +}); + +test('运行态支持梯形和平行四边形等差异化几何造型', () => { + const run = startLocalMatch3DRun(3); + run.items = run.items.slice(0, 3).map((item, index) => ({ + ...item, + itemInstanceId: `geometry-${index}`, + itemTypeId: `geometry-type-${index}`, + visualKey: + index === 0 + ? 'peach-pink' + : index === 1 + ? 'banana-yellow' + : 'orange_hexagon', + x: 0.35 + index * 0.15, + y: 0.5, + layer: index, + clickable: true, + })); + renderRuntime(run); + + expect( + screen.getByTestId('match3d-visual-peach-pink').getAttribute('data-shape'), + ).toBe('trapezoid'); + expect( + screen + .getByTestId('match3d-visual-banana-yellow') + .getAttribute('data-shape'), + ).toBe('parallelogram'); + expect( + screen + .getByTestId('match3d-visual-orange_hexagon') + .getAttribute('data-shape'), + ).toBe('hexagon'); +}); + +test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => { + const run = startLocalMatch3DRun(1); + const item = run.items[0]!; + run.items = [ + { + ...item, + itemInstanceId: 'legacy-outside', + visualKey: 'apple-red', + x: -0.4, + y: 0.5, + radius: 0.1, + clickable: true, + }, + ]; + renderRuntime(run); + + const token = screen.getByTestId( + 'match3d-item-legacy-outside', + ) as HTMLElement; + expect(parseFloat(token.style.left)).toBeGreaterThanOrEqual(0); + expect(parseFloat(token.style.left)).toBeLessThanOrEqual(100); +}); diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.tsx index e411cf18..342e18df 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.tsx @@ -41,6 +41,174 @@ type Match3DFeedbackEvent = { kind: 'cleared' | 'rejected'; itemIds: string[]; }; +type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number]; +type Match3DGeometryShape = + | 'circle' + | 'triangle' + | 'diamond' + | 'square' + | 'star' + | 'hexagon' + | 'capsule' + | 'heart' + | 'trapezoid' + | 'parallelogram'; +type Match3DGeometryAsset = { + shape: Match3DGeometryShape; + fill: string; + stroke: string; +}; + +const MATCH3D_RENDER_CENTER = 0.5; +const MATCH3D_RENDER_RADIUS = 0.5; +const MATCH3D_RENDER_SAFE_MARGIN = 0.035; +const MATCH3D_GEOMETRY_ASSETS: Record = { + 'watermelon-green': { + shape: 'circle', + fill: '#16a34a', + stroke: '#14532d', + }, + 'apple-red': { + shape: 'heart', + fill: '#ef4444', + stroke: '#991b1b', + }, + 'banana-yellow': { + shape: 'parallelogram', + fill: '#facc15', + stroke: '#a16207', + }, + 'grape-purple': { + shape: 'star', + fill: '#8b5cf6', + stroke: '#5b21b6', + }, + 'melon-green': { + shape: 'hexagon', + fill: '#84cc16', + stroke: '#3f6212', + }, + 'berry-blue': { + shape: 'diamond', + fill: '#2563eb', + stroke: '#1e3a8a', + }, + 'peach-pink': { + shape: 'trapezoid', + fill: '#fb7185', + stroke: '#be123c', + }, + 'plum-indigo': { + shape: 'capsule', + fill: '#4f46e5', + stroke: '#312e81', + }, + 'lime-lime': { + shape: 'square', + fill: '#65a30d', + stroke: '#365314', + }, + 'orange-orange': { + shape: 'triangle', + fill: '#f97316', + stroke: '#9a3412', + }, + 'pear-cyan': { + shape: 'parallelogram', + fill: '#06b6d4', + stroke: '#155e75', + }, + red_circle: { + shape: 'circle', + fill: '#ef4444', + stroke: '#991b1b', + }, + yellow_triangle: { + shape: 'triangle', + fill: '#facc15', + stroke: '#a16207', + }, + purple_diamond: { + shape: 'diamond', + fill: '#7c3aed', + stroke: '#4c1d95', + }, + green_square: { + shape: 'square', + fill: '#16a34a', + stroke: '#14532d', + }, + blue_star: { + shape: 'star', + fill: '#0ea5e9', + stroke: '#075985', + }, + orange_hexagon: { + shape: 'hexagon', + fill: '#f97316', + stroke: '#9a3412', + }, + cyan_capsule: { + shape: 'capsule', + fill: '#06b6d4', + stroke: '#155e75', + }, + pink_heart: { + shape: 'heart', + fill: '#ec4899', + stroke: '#9d174d', + }, + lime_leaf: { + shape: 'trapezoid', + fill: '#84cc16', + stroke: '#3f6212', + }, + white_moon: { + shape: 'parallelogram', + fill: '#e2e8f0', + stroke: '#64748b', + }, +}; +const MATCH3D_UNKNOWN_GEOMETRY_ASSETS: Match3DGeometryAsset[] = [ + { shape: 'circle', fill: '#f43f5e', stroke: '#9f1239' }, + { shape: 'triangle', fill: '#f59e0b', stroke: '#92400e' }, + { shape: 'diamond', fill: '#8b5cf6', stroke: '#5b21b6' }, + { shape: 'star', fill: '#10b981', stroke: '#065f46' }, + { shape: 'trapezoid', fill: '#0ea5e9', stroke: '#075985' }, + { shape: 'parallelogram', fill: '#14b8a6', stroke: '#115e59' }, +]; +const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [ + { + itemTypeId: 'unknown-rose', + visualKey: 'unknown-rose', + colorClassName: 'from-rose-400 to-red-600', + label: '一', + }, + { + itemTypeId: 'unknown-amber', + visualKey: 'unknown-amber', + colorClassName: 'from-yellow-300 to-amber-500', + label: '二', + }, + { + itemTypeId: 'unknown-violet', + visualKey: 'unknown-violet', + colorClassName: 'from-violet-400 to-purple-700', + label: '三', + }, + { + itemTypeId: 'unknown-emerald', + visualKey: 'unknown-emerald', + colorClassName: 'from-emerald-300 to-green-600', + label: '四', + }, + { + itemTypeId: 'unknown-sky', + visualKey: 'unknown-sky', + colorClassName: 'from-sky-300 to-blue-600', + label: '五', + }, +]; function formatTimer(value: number) { const totalSeconds = Math.max(0, Math.ceil(value / 1000)); @@ -49,7 +217,11 @@ function formatTimer(value: number) { return `${minutes}:${seconds.toString().padStart(2, '0')}`; } -function formatElapsed(startedAtMs: number, remainingMs: number, durationLimitMs: number) { +function formatElapsed( + startedAtMs: number, + remainingMs: number, + durationLimitMs: number, +) { const elapsedMs = Math.max(0, durationLimitMs - remainingMs); const totalSeconds = Math.floor(elapsedMs / 1000); const minutes = Math.floor(totalSeconds / 60); @@ -57,11 +229,128 @@ function formatElapsed(startedAtMs: number, remainingMs: number, durationLimitMs return `${minutes}:${seconds.toString().padStart(2, '0')}`; } +function hashVisualKey(visualKey: string) { + let hash = 0; + for (const char of visualKey) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + } + return hash; +} + function resolveVisualSeed(visualKey: string) { - return ( - MATCH3D_VISUAL_SEEDS.find((seed) => seed.visualKey === visualKey) ?? - MATCH3D_VISUAL_SEEDS[0]! + const knownSeed = MATCH3D_VISUAL_SEEDS.find( + (seed) => seed.visualKey === visualKey, ); + if (knownSeed) { + return knownSeed; + } + return MATCH3D_UNKNOWN_VISUAL_SEEDS[ + hashVisualKey(visualKey) % MATCH3D_UNKNOWN_VISUAL_SEEDS.length + ]!; +} + +function resolveGeometryAsset(visualKey: string): Match3DGeometryAsset { + return ( + MATCH3D_GEOMETRY_ASSETS[visualKey] ?? + MATCH3D_UNKNOWN_GEOMETRY_ASSETS[ + hashVisualKey(visualKey) % MATCH3D_UNKNOWN_GEOMETRY_ASSETS.length + ]! + ); +} + +function renderGeometryShape(asset: Match3DGeometryAsset) { + const shapeProps = { + fill: asset.fill, + stroke: asset.stroke, + strokeWidth: 6, + strokeLinejoin: 'round' as const, + }; + + switch (asset.shape) { + case 'circle': + return ; + case 'triangle': + return ; + case 'diamond': + return ; + case 'square': + return ; + case 'star': + return ( + + ); + case 'hexagon': + return ; + case 'capsule': + return ; + case 'heart': + return ( + + ); + case 'trapezoid': + return ; + case 'parallelogram': + return ; + default: + return ; + } +} + +function Match3DVisualIcon({ + visualKey, + className = '', +}: { + visualKey: string; + className?: string; +}) { + const asset = resolveGeometryAsset(visualKey); + + return ( + + {renderGeometryShape(asset)} + + ); +} + +function resolveRenderableItemFrame(item: Match3DItemSnapshot) { + const maxRadius = MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN; + const radius = Math.min( + Math.max(Number.isFinite(item.radius) ? item.radius : 0.06, 0.035), + maxRadius, + ); + const rawX = Number.isFinite(item.x) ? item.x : MATCH3D_RENDER_CENTER; + const rawY = Number.isFinite(item.y) ? item.y : MATCH3D_RENDER_CENTER; + const dx = rawX - MATCH3D_RENDER_CENTER; + const dy = rawY - MATCH3D_RENDER_CENTER; + const distance = Math.hypot(dx, dy); + const maxDistance = Math.max( + 0, + MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN - radius, + ); + + if (distance <= maxDistance || distance <= 0) { + return { x: rawX, y: rawY, radius }; + } + + const ratio = maxDistance / distance; + return { + x: MATCH3D_RENDER_CENTER + dx * ratio, + y: MATCH3D_RENDER_CENTER + dy * ratio, + radius, + }; } function buildClientEventId(itemInstanceId: string) { @@ -81,9 +370,11 @@ function isItemState( state: Match3DItemSnapshot['state'], expected: 'in_board' | 'in_tray' | 'cleared' | 'flying', ) { - return String(state) - .replace(/([a-z])([A-Z])/gu, '$1_$2') - .toLowerCase() === expected; + return ( + String(state) + .replace(/([a-z])([A-Z])/gu, '$1_$2') + .toLowerCase() === expected + ); } function isPointInsideCircle( @@ -91,14 +382,11 @@ function isPointInsideCircle( pointY: number, item: Match3DItemSnapshot, ) { - return Math.hypot(pointX - item.x, pointY - item.y) <= item.radius; + const frame = resolveRenderableItemFrame(item); + return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius; } -function findHitItem( - run: Match3DRunSnapshot, - pointX: number, - pointY: number, -) { +function findHitItem(run: Match3DRunSnapshot, pointX: number, pointY: number) { return run.items .filter( (item) => @@ -151,51 +439,57 @@ function Match3DToken({ onClick: (item: Match3DItemSnapshot) => void; }) { const visualSeed = resolveVisualSeed(item.visualKey); - const size = `${item.radius * 200}%`; - const itemStateClass = - isItemState(item.state, 'flying') - ? 'scale-75 opacity-0' - : item.clickable - ? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95' - : 'opacity-48'; + const frame = resolveRenderableItemFrame(item); + const size = `${frame.radius * 200}%`; + const itemStateClass = isItemState(item.state, 'flying') + ? 'scale-75 opacity-0' + : item.clickable + ? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95' + : 'opacity-48'; - if (!isItemState(item.state, 'in_board') && !isItemState(item.state, 'flying')) { + if ( + !isItemState(item.state, 'in_board') && + !isItemState(item.state, 'flying') + ) { return null; } return ( ); } function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) { if (!slot.visualKey) { - return ; + return ( + + ); } const visualSeed = resolveVisualSeed(slot.visualKey); return ( - {visualSeed.label} + ); } @@ -228,14 +522,18 @@ function Match3DSettlement({
{won ? : }

{title}

-

{description}

+

+ {description} +

@@ -271,9 +569,8 @@ export function Match3DRuntimeShell({ }: Match3DRuntimeShellProps) { const stageRef = useRef(null); const [pendingClick, setPendingClick] = useState(null); - const [feedbackEvent, setFeedbackEvent] = useState( - null, - ); + const [feedbackEvent, setFeedbackEvent] = + useState(null); const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0); useEffect(() => { @@ -371,7 +668,7 @@ export function Match3DRuntimeShell({ if (!run) { return (
- {isBusy ? '载入中' : error ?? '暂无运行态'} + {isBusy ? '载入中' : (error ?? '暂无运行态')}
); } @@ -422,7 +719,7 @@ export function Match3DRuntimeShell({ onPointerDown={handleBoardPointerDown} data-testid="match3d-board" > -
+
{run.items.map((item) => ( { setIsPuzzleLoadingLibrary(true); @@ -1238,6 +1237,7 @@ export function PlatformEntryFlowShellImpl({ isBigFishCreationVisible ? refreshBigFishGallery() : Promise.resolve([] as BigFishWorkSummary[]), + refreshMatch3DGallery(), refreshPuzzleGallery(), ]); return latestSession; @@ -1680,6 +1680,24 @@ export function PlatformEntryFlowShellImpl({ await bigFishFlow.openWorkspace(); }, [bigFishFlow]); + const openMatch3DAgentWorkspace = useCallback(async () => { + setMatch3DSession(null); + setMatch3DProfile(null); + setMatch3DRun(null); + setMatch3DError(null); + setStreamingMatch3DReplyText(''); + setIsStreamingMatch3DReply(false); + await match3dFlow.openWorkspace(); + }, [ + match3dFlow, + setIsStreamingMatch3DReply, + setMatch3DError, + setMatch3DProfile, + setMatch3DRun, + setMatch3DSession, + setStreamingMatch3DReplyText, + ]); + const openPuzzleAgentWorkspace = useCallback(async () => { setPuzzleRun(null); setPuzzleOperation(null); @@ -1824,7 +1842,7 @@ export function PlatformEntryFlowShellImpl({ const handleCreationHubCreateType = useCallback( (type: PlatformCreationTypeId) => { - if (type === 'match3d' || type === 'airp' || type === 'visual-novel') { + if (type === 'airp' || type === 'visual-novel') { return; } @@ -1846,6 +1864,13 @@ export function PlatformEntryFlowShellImpl({ return; } + if (type === 'match3d') { + runProtectedAction(() => { + void openMatch3DAgentWorkspace(); + }); + return; + } + if (type === 'puzzle') { runProtectedAction(() => { void openPuzzleAgentWorkspace(); @@ -1854,6 +1879,7 @@ export function PlatformEntryFlowShellImpl({ }, [ openBigFishAgentWorkspace, + openMatch3DAgentWorkspace, openPuzzleAgentWorkspace, prepareCreationLaunch, runProtectedAction, @@ -5108,7 +5134,9 @@ export function PlatformEntryFlowShellImpl({ }); }} onSelectMatch3D={() => { - // 抓大鹅创作入口当前为敬请期待;保留回调防御,避免旧入口绕过锁定态。 + runProtectedAction(() => { + void openMatch3DAgentWorkspace(); + }); }} onSelectPuzzle={() => { runProtectedAction(() => { diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 539674d0..1298a7d0 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -10,6 +10,9 @@ import type { CustomWorldAgentSessionSnapshot, CustomWorldWorkSummary, } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { + Match3DAgentSessionSnapshot, +} from '../../../packages/shared/src/contracts/match3dAgent'; import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; @@ -428,6 +431,21 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({ ), })); +vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({ + Match3DAgentWorkspace: ({ + session, + }: { + session: { sessionId: string; messages: Array<{ text: string }> } | null; + }) => ( +
+
抓大鹅工作区:{session?.sessionId ?? 'missing-session'}
+ {session?.messages.map((message) => ( +
{message.text}
+ ))} +
+ ), +})); + vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({ Match3DRuntimeShell: ({ run, @@ -666,6 +684,59 @@ function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot { }; } +function buildMockMatch3DAgentSession( + overrides: Partial = {}, +): Match3DAgentSessionSnapshot { + const sessionId = overrides.sessionId ?? 'match3d-agent-session-1'; + + return { + sessionId, + currentTurn: 0, + progressPercent: 20, + stage: 'collecting', + anchorPack: { + theme: { + key: 'theme', + label: '题材主题', + value: '水果消除', + status: 'confirmed', + }, + clearCount: { + key: 'clearCount', + label: '需要消除次数', + value: '4', + status: 'confirmed', + }, + difficulty: { + key: 'difficulty', + label: '难度', + value: '5', + status: 'confirmed', + }, + }, + config: { + themeText: '水果消除', + referenceImageSrc: null, + clearCount: 4, + difficulty: 5, + }, + draft: null, + messages: [ + { + id: 'match3d-message-1', + role: 'assistant', + kind: 'chat', + text: '我们先确定抓大鹅题材、消除次数和难度。', + createdAt: '2026-05-01T10:00:00.000Z', + }, + ], + lastAssistantReply: '我们先确定抓大鹅题材、消除次数和难度。', + publishedProfileId: null, + updatedAt: '2026-05-01T10:00:00.000Z', + ...overrides, + }; +} + function buildMockRpgGalleryDetail( entry: CustomWorldGalleryCard, ): CustomWorldLibraryEntry { @@ -2637,6 +2708,38 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull(); }); +test('match3d creation card opens workspace even when public galleries fail', async () => { + const user = userEvent.setup(); + const match3dSession = buildMockMatch3DAgentSession(); + + vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce( + new Error('读取作品广场失败'), + ); + vi.mocked(listMatch3DGallery).mockRejectedValueOnce( + new Error('读取抓大鹅广场失败'), + ); + vi.mocked(match3dCreationClient.createSession).mockResolvedValueOnce({ + session: match3dSession, + }); + + render(); + + await openCreationHub(user); + expect(screen.queryByText('读取作品广场失败')).toBeNull(); + expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull(); + + const button = screen.getByRole('button', { + name: /抓大鹅.*经典消除玩法/u, + }); + expect(button as HTMLButtonElement).toHaveProperty('disabled', false); + await user.click(button); + + await waitFor(() => { + expect(match3dCreationClient.createSession).toHaveBeenCalledWith({}); + }); + expect(await screen.findByText('抓大鹅工作区:match3d-agent-session-1')).toBeTruthy(); +}); + test('puzzle draft card restores the bound agent session and opens the result view', async () => { const user = userEvent.setup(); diff --git a/src/components/rpg-entry/useRpgEntryBootstrap.ts b/src/components/rpg-entry/useRpgEntryBootstrap.ts index fb7f17fd..9f3d9c78 100644 --- a/src/components/rpg-entry/useRpgEntryBootstrap.ts +++ b/src/components/rpg-entry/useRpgEntryBootstrap.ts @@ -270,6 +270,8 @@ export function useRpgEntryBootstrap( if (galleryEntriesResult.status === 'fulfilled') { setPublishedGalleryEntries(galleryEntriesResult.value); } else { + // 中文注释:公开广场只影响首页展示,失败时降级为空列表; + // 私有作品库和创作作品列表的受保护失败才需要阻塞提示。 setPublishedGalleryEntries([]); } @@ -277,17 +279,14 @@ export function useRpgEntryBootstrap( (canReadProtectedData && libraryEntriesResult.status === 'rejected') || (canReadProtectedData && - workEntriesResult.status === 'rejected') || - galleryEntriesResult.status === 'rejected' + workEntriesResult.status === 'rejected') ) { const platformFailure = libraryEntriesResult.status === 'rejected' ? libraryEntriesResult.reason : workEntriesResult.status === 'rejected' ? workEntriesResult.reason - : galleryEntriesResult.status === 'rejected' - ? galleryEntriesResult.reason - : null; + : null; setPlatformError( resolveRpgEntryErrorMessage(platformFailure, '读取平台数据失败。'), ); diff --git a/src/config/newWorkEntryConfig.ts b/src/config/newWorkEntryConfig.ts index fced1cfd..a5c6cc72 100644 --- a/src/config/newWorkEntryConfig.ts +++ b/src/config/newWorkEntryConfig.ts @@ -41,10 +41,10 @@ export const NEW_WORK_ENTRY_CONFIG = { { id: 'match3d', title: '抓大鹅', - subtitle: '敬请期待', - badge: '敬请期待', + subtitle: '经典消除玩法', + badge: '可创建', visible: true, - open: false, + open: true, }, { id: 'airp', diff --git a/src/services/match3d-runtime/match3dLocalRuntime.ts b/src/services/match3d-runtime/match3dLocalRuntime.ts index 011995b2..bbe3efd1 100644 --- a/src/services/match3d-runtime/match3dLocalRuntime.ts +++ b/src/services/match3d-runtime/match3dLocalRuntime.ts @@ -14,68 +14,147 @@ type Match3DVisualSeed = { visualKey: string; colorClassName: string; label: string; + sizeScale?: number; }; export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [ + // 中文注释:水果题材内置视觉键要和后端 module-match3d 保持一致,避免不同物品被兜底成同一图案。 + { + itemTypeId: 'watermelon', + visualKey: 'watermelon-green', + colorClassName: 'from-emerald-500 to-green-800', + label: '西瓜', + sizeScale: 1.24, + }, { itemTypeId: 'apple', visualKey: 'apple-red', colorClassName: 'from-rose-400 to-red-600', - label: '苹', + label: '苹果', + sizeScale: 1, }, { itemTypeId: 'banana', visualKey: 'banana-yellow', colorClassName: 'from-yellow-300 to-amber-500', - label: '蕉', + label: '香蕉', + sizeScale: 1.04, }, { itemTypeId: 'grape', visualKey: 'grape-purple', colorClassName: 'from-violet-400 to-purple-700', - label: '萄', + label: '葡萄', + sizeScale: 0.78, }, { itemTypeId: 'melon', visualKey: 'melon-green', colorClassName: 'from-emerald-300 to-green-600', - label: '瓜', + label: '甜瓜', + sizeScale: 1.12, }, { itemTypeId: 'berry', visualKey: 'berry-blue', colorClassName: 'from-sky-300 to-blue-600', - label: '莓', + label: '蓝莓', + sizeScale: 0.78, }, { itemTypeId: 'peach', visualKey: 'peach-pink', colorClassName: 'from-pink-300 to-orange-400', - label: '桃', + label: '桃子', + sizeScale: 1, }, { itemTypeId: 'plum', visualKey: 'plum-indigo', colorClassName: 'from-indigo-300 to-indigo-700', - label: '李', + label: '李子', + sizeScale: 0.86, }, { itemTypeId: 'lime', visualKey: 'lime-lime', colorClassName: 'from-lime-300 to-lime-600', - label: '柠', + label: '青柠', + sizeScale: 0.86, }, { itemTypeId: 'orange', visualKey: 'orange-orange', colorClassName: 'from-orange-300 to-orange-600', - label: '橙', + label: '橙子', + sizeScale: 1, }, { - itemTypeId: 'candy', - visualKey: 'candy-cyan', + itemTypeId: 'pear', + visualKey: 'pear-cyan', colorClassName: 'from-cyan-300 to-teal-600', - label: '糖', + label: '梨', + sizeScale: 1, + }, + { + itemTypeId: 'red-circle', + visualKey: 'red_circle', + colorClassName: 'from-rose-400 to-red-600', + label: '圆', + }, + { + itemTypeId: 'yellow-triangle', + visualKey: 'yellow_triangle', + colorClassName: 'from-yellow-300 to-amber-500', + label: '三', + }, + { + itemTypeId: 'purple-diamond', + visualKey: 'purple_diamond', + colorClassName: 'from-violet-400 to-purple-700', + label: '菱', + }, + { + itemTypeId: 'green-square', + visualKey: 'green_square', + colorClassName: 'from-emerald-300 to-green-600', + label: '方', + }, + { + itemTypeId: 'blue-star', + visualKey: 'blue_star', + colorClassName: 'from-sky-300 to-blue-600', + label: '星', + }, + { + itemTypeId: 'orange-hexagon', + visualKey: 'orange_hexagon', + colorClassName: 'from-orange-300 to-orange-600', + label: '六', + }, + { + itemTypeId: 'cyan-capsule', + visualKey: 'cyan_capsule', + colorClassName: 'from-cyan-300 to-teal-600', + label: '胶', + }, + { + itemTypeId: 'pink-heart', + visualKey: 'pink_heart', + colorClassName: 'from-pink-300 to-rose-500', + label: '心', + }, + { + itemTypeId: 'lime-leaf', + visualKey: 'lime_leaf', + colorClassName: 'from-lime-300 to-lime-600', + label: '叶', + }, + { + itemTypeId: 'white-moon', + visualKey: 'white_moon', + colorClassName: 'from-slate-100 to-slate-400', + label: '月', }, ]; @@ -117,8 +196,11 @@ function buildItem( const angle = index * 0.86 + copyIndex * 0.22; const spread = 0.16 + (ring % 4) * 0.085; const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026; - const y = 0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02; - const radius = 0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004; + const y = + 0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02; + const baseRadius = + 0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004; + const radius = baseRadius * (seed.sizeScale ?? 1); return { itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`, itemTypeId: seed.itemTypeId, @@ -142,7 +224,10 @@ function recomputeClickable(items: Match3DItemSnapshot[]) { }; } const coveredByHigherLayer = boardItems.some((other) => { - if (other.itemInstanceId === item.itemInstanceId || other.layer <= item.layer) { + if ( + other.itemInstanceId === item.itemInstanceId || + other.layer <= item.layer + ) { return false; } const distance = Math.hypot(other.x - item.x, other.y - item.y); @@ -173,7 +258,9 @@ function resolveRunStatus(run: Match3DRunSnapshot): Match3DRunSnapshot { remainingMs: Math.max(0, run.remainingMs), }; } - const trayIsFull = run.traySlots.every((slot) => Boolean(slot.itemInstanceId)); + const trayIsFull = run.traySlots.every((slot) => + Boolean(slot.itemInstanceId), + ); if (trayIsFull) { return { ...run, @@ -202,7 +289,9 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) { ]); } - const matchedSlots = [...slotsByType.values()].find((slots) => slots.length >= 3); + const matchedSlots = [...slotsByType.values()].find( + (slots) => slots.length >= 3, + ); if (!matchedSlots) { return { run, @@ -213,7 +302,9 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) { const clearedItemInstanceIds = matchedSlots .slice(0, 3) .map((slot) => slot.itemInstanceId) - .filter((itemInstanceId): itemInstanceId is string => Boolean(itemInstanceId)); + .filter((itemInstanceId): itemInstanceId is string => + Boolean(itemInstanceId), + ); const clearedSet = new Set(clearedItemInstanceIds); const nextRun = { ...run, @@ -241,11 +332,17 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) { export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot { const normalizedClearCount = Math.max(1, Math.round(clearCount)); - const typeCount = Math.min(MATCH3D_VISUAL_SEEDS.length, normalizedClearCount); + const typeCount = Math.min(10, normalizedClearCount); const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) => Array.from({ length: 3 }, (_, copyOffset) => { - const seed = MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ?? MATCH3D_VISUAL_SEEDS[0]!; - return buildItem(seed, clearIndex * 3 + copyOffset, clearIndex * 3 + copyOffset); + const seed = + MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ?? + MATCH3D_VISUAL_SEEDS[0]!; + return buildItem( + seed, + clearIndex * 3 + copyOffset, + clearIndex * 3 + copyOffset, + ); }), ).flat(); const nowMs = Date.now(); @@ -274,7 +371,9 @@ export function buildLocalMatch3DOptimisticRun( run: Match3DRunSnapshot, itemInstanceId: string, ): Match3DRunSnapshot { - const targetItem = run.items.find((item) => item.itemInstanceId === itemInstanceId); + const targetItem = run.items.find( + (item) => item.itemInstanceId === itemInstanceId, + ); const nextTrayIndex = findNextTrayIndex(run.traySlots); if (!targetItem || targetItem.state !== 'InBoard' || nextTrayIndex < 0) { return run; @@ -397,7 +496,9 @@ export async function confirmLocalMatch3DClick( }; } -export function stopLocalMatch3DRun(run: Match3DRunSnapshot): Match3DRunSnapshot { +export function stopLocalMatch3DRun( + run: Match3DRunSnapshot, +): Match3DRunSnapshot { if (run.status !== 'Running') { return run; } diff --git a/vite.config.ts b/vite.config.ts index 20f954ae..f722f57f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -107,6 +107,11 @@ export default defineConfig(({mode}) => { changeOrigin: true, secure: false, }, + '/api/creation': { + target: runtimeServerTarget, + changeOrigin: true, + secure: false, + }, '/api/story': { target: runtimeServerTarget, changeOrigin: true,