完善抓大鹅创作入口与运行态表现
This commit is contained in:
@@ -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. 用户题材主题后续会映射为符合常识预期的物品集合。
|
||||
|
||||
|
||||
@@ -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` 等真实登录请求无法完成。
|
||||
|
||||
@@ -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 恢复后重新完成一次成功同步。
|
||||
@@ -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 分支给出首版常量并通过测试覆盖。
|
||||
|
||||
|
||||
@@ -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. 相关测试、类型检查和编码检查通过。
|
||||
|
||||
@@ -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. 验收
|
||||
|
||||
|
||||
@@ -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 两阶段职责不变。
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
- [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、前端即时反馈/后端权威确认协议,以及可并行开发包。
|
||||
@@ -13,7 +14,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 自动迁移回灌和导入脚本参数。
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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<Match3DItemSnapshot> {
|
||||
fn build_initial_items(
|
||||
clear_count: u32,
|
||||
difficulty: u32,
|
||||
seed: u64,
|
||||
theme_text: &str,
|
||||
) -> Vec<Match3DItemSnapshot> {
|
||||
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<Matc
|
||||
items
|
||||
}
|
||||
|
||||
fn visual_keys_for_theme(theme_text: &str) -> &'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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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(
|
||||
|
||||
@@ -3465,10 +3465,7 @@ pub fn puzzle_point_incentive_claimable_points(total_half_points: u64, claimed_p
|
||||
.saturating_sub(claimed_points)
|
||||
}
|
||||
|
||||
pub fn puzzle_point_incentive_total_after_spend(
|
||||
total_half_points: u64,
|
||||
spent_points: u64,
|
||||
) -> u64 {
|
||||
pub fn puzzle_point_incentive_total_after_spend(total_half_points: u64, spent_points: u64) -> u64 {
|
||||
total_half_points.saturating_add(spent_points)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct SendMatch3DAgentMessageRequest {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub quick_fill_requested: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -2745,10 +2745,11 @@ fn accrue_puzzle_point_incentive(
|
||||
play_count: row.play_count,
|
||||
remix_count: row.remix_count,
|
||||
like_count: row.like_count,
|
||||
point_incentive_total_half_points: module_puzzle::puzzle_point_incentive_total_after_spend(
|
||||
row.point_incentive_total_half_points,
|
||||
spent_points,
|
||||
),
|
||||
point_incentive_total_half_points:
|
||||
module_puzzle::puzzle_point_incentive_total_after_spend(
|
||||
row.point_incentive_total_half_points,
|
||||
spent_points,
|
||||
),
|
||||
point_incentive_claimed_points: row.point_incentive_claimed_points,
|
||||
anchor_pack_json: row.anchor_pack_json.clone(),
|
||||
publish_ready: row.publish_ready,
|
||||
|
||||
@@ -245,6 +245,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 () => {
|
||||
|
||||
@@ -263,6 +263,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
let isActive = true;
|
||||
|
||||
const hydrate = async () => {
|
||||
const callbackResult = consumeAuthCallbackResult();
|
||||
const loadLoginOptions = async () => {
|
||||
const options = await getAuthLoginOptions();
|
||||
if (!isActive) {
|
||||
@@ -291,16 +292,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);
|
||||
|
||||
@@ -113,9 +113,9 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
).toBeTruthy();
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<string, Match3DGeometryAsset> = {
|
||||
'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 <circle cx="50" cy="50" r="36" {...shapeProps} />;
|
||||
case 'triangle':
|
||||
return <path d="M50 12 L89 84 H11Z" {...shapeProps} />;
|
||||
case 'diamond':
|
||||
return <path d="M50 9 L91 50 L50 91 L9 50Z" {...shapeProps} />;
|
||||
case 'square':
|
||||
return <rect x="16" y="16" width="68" height="68" rx="8" {...shapeProps} />;
|
||||
case 'star':
|
||||
return (
|
||||
<path
|
||||
d="M50 8 L61 36 L91 38 L68 58 L76 88 L50 72 L24 88 L32 58 L9 38 L39 36Z"
|
||||
{...shapeProps}
|
||||
/>
|
||||
);
|
||||
case 'hexagon':
|
||||
return <path d="M28 12 H72 L94 50 L72 88 H28 L6 50Z" {...shapeProps} />;
|
||||
case 'capsule':
|
||||
return <rect x="10" y="28" width="80" height="44" rx="22" {...shapeProps} />;
|
||||
case 'heart':
|
||||
return (
|
||||
<path
|
||||
d="M50 86 C25 66 13 52 17 34 C20 18 40 16 50 31 C60 16 80 18 83 34 C87 52 75 66 50 86Z"
|
||||
{...shapeProps}
|
||||
/>
|
||||
);
|
||||
case 'trapezoid':
|
||||
return <path d="M27 18 H73 L90 82 H10Z" {...shapeProps} />;
|
||||
case 'parallelogram':
|
||||
return <path d="M34 16 H88 L66 84 H12Z" {...shapeProps} />;
|
||||
default:
|
||||
return <circle cx="50" cy="50" r="36" {...shapeProps} />;
|
||||
}
|
||||
}
|
||||
|
||||
function Match3DVisualIcon({
|
||||
visualKey,
|
||||
className = '',
|
||||
}: {
|
||||
visualKey: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const asset = resolveGeometryAsset(visualKey);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`pointer-events-none h-full w-full drop-shadow-[0_5px_7px_rgba(15,23,42,0.36)] ${className}`}
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden
|
||||
focusable={false}
|
||||
data-testid={`match3d-visual-${visualKey}`}
|
||||
data-shape={asset.shape}
|
||||
>
|
||||
{renderGeometryShape(asset)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white/35 bg-gradient-to-br ${visualSeed.colorClassName} text-sm font-black text-white shadow-[0_10px_18px_rgba(15,23,42,0.32)] transition-all duration-300 [text-shadow:0_1px_2px_rgba(15,23,42,0.65)] ${itemStateClass}`}
|
||||
className={`absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center bg-transparent p-0 transition-all duration-300 ${itemStateClass}`}
|
||||
style={{
|
||||
left: `${item.x * 100}%`,
|
||||
top: `${item.y * 100}%`,
|
||||
left: `${frame.x * 100}%`,
|
||||
top: `${frame.y * 100}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: item.layer,
|
||||
zIndex: item.layer + 10,
|
||||
}}
|
||||
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
|
||||
data-testid={`match3d-item-${item.itemInstanceId}`}
|
||||
disabled={disabled || !item.clickable || !isItemState(item.state, 'in_board')}
|
||||
disabled={
|
||||
disabled || !item.clickable || !isItemState(item.state, 'in_board')
|
||||
}
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
<span className="relative z-10">{visualSeed.label}</span>
|
||||
<span className="absolute inset-[16%] rounded-full bg-white/24" />
|
||||
<span className="absolute left-[18%] top-[14%] h-[18%] w-[28%] rounded-full bg-white/42 blur-[1px]" />
|
||||
<Match3DVisualIcon visualKey={item.visualKey} className="relative z-10" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) {
|
||||
if (!slot.visualKey) {
|
||||
return <span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />;
|
||||
return (
|
||||
<span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />
|
||||
);
|
||||
}
|
||||
const visualSeed = resolveVisualSeed(slot.visualKey);
|
||||
return (
|
||||
<span
|
||||
className={`flex h-full w-full items-center justify-center rounded-xl border border-white/35 bg-gradient-to-br ${visualSeed.colorClassName} text-xs font-black text-white shadow-[0_8px_16px_rgba(15,23,42,0.24)] [text-shadow:0_1px_2px_rgba(15,23,42,0.62)]`}
|
||||
className="flex h-full w-full items-center justify-center p-1"
|
||||
aria-label={visualSeed.label}
|
||||
>
|
||||
{visualSeed.label}
|
||||
<Match3DVisualIcon visualKey={slot.visualKey} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -228,14 +522,18 @@ function Match3DSettlement({
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<span
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-full ${
|
||||
won ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'
|
||||
won
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-rose-100 text-rose-700'
|
||||
}`}
|
||||
>
|
||||
{won ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-xl font-black">{title}</h2>
|
||||
<p className="text-sm font-semibold text-slate-500">{description}</p>
|
||||
<p className="text-sm font-semibold text-slate-500">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -271,9 +569,8 @@ export function Match3DRuntimeShell({
|
||||
}: Match3DRuntimeShellProps) {
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
|
||||
const [feedbackEvent, setFeedbackEvent] = useState<Match3DFeedbackEvent | null>(
|
||||
null,
|
||||
);
|
||||
const [feedbackEvent, setFeedbackEvent] =
|
||||
useState<Match3DFeedbackEvent | null>(null);
|
||||
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -371,7 +668,7 @@ export function Match3DRuntimeShell({
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-slate-950 text-white">
|
||||
{isBusy ? '载入中' : error ?? '暂无运行态'}
|
||||
{isBusy ? '载入中' : (error ?? '暂无运行态')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -422,7 +719,7 @@ export function Match3DRuntimeShell({
|
||||
onPointerDown={handleBoardPointerDown}
|
||||
data-testid="match3d-board"
|
||||
>
|
||||
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
|
||||
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
|
||||
{run.items.map((item) => (
|
||||
<Match3DToken
|
||||
key={item.itemInstanceId}
|
||||
|
||||
@@ -1081,12 +1081,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
const galleryResponse = await listMatch3DGallery();
|
||||
setMatch3DGalleryEntries(galleryResponse.items);
|
||||
return galleryResponse.items;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// 中文注释:公开广场是首页展示数据,失败时只降级为空列表;
|
||||
// 不写入创作错误态,避免挡住抓大鹅共创入口。
|
||||
setMatch3DGalleryEntries([]);
|
||||
setMatch3DError(resolveMatch3DErrorMessage(error, '读取抓大鹅广场失败。'));
|
||||
return [];
|
||||
}
|
||||
}, [resolveMatch3DErrorMessage]);
|
||||
}, []);
|
||||
|
||||
const refreshPuzzleShelf = useCallback(async () => {
|
||||
setIsPuzzleLoadingLibrary(true);
|
||||
@@ -1211,6 +1212,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isBigFishCreationVisible
|
||||
? refreshBigFishGallery()
|
||||
: Promise.resolve([] as BigFishWorkSummary[]),
|
||||
refreshMatch3DGallery(),
|
||||
refreshPuzzleGallery(),
|
||||
]);
|
||||
return latestSession;
|
||||
@@ -1812,7 +1814,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
(type: PlatformCreationTypeId) => {
|
||||
if (
|
||||
type === 'rpg' ||
|
||||
type === 'match3d' ||
|
||||
type === 'airp' ||
|
||||
type === 'visual-novel'
|
||||
) {
|
||||
@@ -1830,6 +1831,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'match3d') {
|
||||
runProtectedAction(() => {
|
||||
void openMatch3DAgentWorkspace();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'puzzle') {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleAgentWorkspace();
|
||||
@@ -1838,6 +1846,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
[
|
||||
openBigFishAgentWorkspace,
|
||||
openMatch3DAgentWorkspace,
|
||||
openPuzzleAgentWorkspace,
|
||||
prepareCreationLaunch,
|
||||
runProtectedAction,
|
||||
@@ -5067,7 +5076,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
}}
|
||||
onSelectMatch3D={() => {
|
||||
// 抓大鹅创作入口当前为敬请期待;保留回调防御,避免旧入口绕过锁定态。
|
||||
runProtectedAction(() => {
|
||||
void openMatch3DAgentWorkspace();
|
||||
});
|
||||
}}
|
||||
onSelectPuzzle={() => {
|
||||
runProtectedAction(() => {
|
||||
|
||||
@@ -68,9 +68,9 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
locked: true,
|
||||
subtitle: '经典消除玩法',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
|
||||
@@ -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';
|
||||
@@ -124,17 +127,6 @@ async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
expect(await screen.findByText('角色扮演')).toBeTruthy();
|
||||
}
|
||||
|
||||
async function expectRpgCreationLocked(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
) {
|
||||
await openCreationHub(user);
|
||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
|
||||
await user.click(rpgButton);
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function openExistingRpgDraft(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
actionName: string | RegExp = /继续(?:完善|创作)/u,
|
||||
@@ -433,6 +425,21 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
|
||||
Match3DAgentWorkspace: ({
|
||||
session,
|
||||
}: {
|
||||
session: { sessionId: string; messages: Array<{ text: string }> } | null;
|
||||
}) => (
|
||||
<div className="match3d-agent-workspace-mock">
|
||||
<div>抓大鹅工作区:{session?.sessionId ?? 'missing-session'}</div>
|
||||
{session?.messages.map((message) => (
|
||||
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
|
||||
Match3DRuntimeShell: ({
|
||||
run,
|
||||
@@ -670,6 +677,59 @@ function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockMatch3DAgentSession(
|
||||
overrides: Partial<Match3DAgentSessionSnapshot> = {},
|
||||
): 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 {
|
||||
@@ -2500,6 +2560,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(<TestWrapper withAuth />);
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -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, '读取平台数据失败。'),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user