From 89ab1bf1c0025f6b6620757de5f73a67edf65d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Wed, 29 Apr 2026 23:10:43 +0800 Subject: [PATCH] 1 --- ...ATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md | 72 ++++++++------- ...AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md | 42 +++++++++ ...E_AND_RUNTIME_9_16_ALIGNMENT_2026-04-29.md | 42 --------- ...ZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md | 9 ++ ...E_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md | 10 +++ docs/technical/README.md | 1 + server-rs/crates/api-server/src/app.rs | 18 ++-- .../api-server/src/prompt/puzzle_image.rs | 6 +- server-rs/crates/api-server/src/puzzle.rs | 78 +++------------- .../crates/spacetime-module/src/puzzle.rs | 8 +- src/PuzzlePlaygroundApp.tsx | 16 ++-- .../PlatformEntryFlowShellImpl.tsx | 90 ++++++++++++------- .../puzzle-result/PuzzleResultView.tsx | 6 +- .../PuzzleRuntimeShell.test.tsx | 7 +- .../puzzle-runtime/PuzzleRuntimeShell.tsx | 7 +- ...gEntryFlowShell.agent.interaction.test.tsx | 1 - src/services/puzzle-runtime/index.ts | 2 - .../puzzle-runtime/puzzleLocalRuntime.test.ts | 4 +- .../puzzle-runtime/puzzleLocalRuntime.ts | 5 +- .../puzzle-runtime/puzzleRuntimeClient.ts | 24 ----- 20 files changed, 204 insertions(+), 244 deletions(-) create mode 100644 docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md delete mode 100644 docs/technical/PUZZLE_IMAGE_AND_RUNTIME_9_16_ALIGNMENT_2026-04-29.md diff --git a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md index dbf94a58..91a41b85 100644 --- a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md @@ -89,17 +89,17 @@ - 合并块整体拖动 - 单块拖到合并块位置时拆分合并块 10. 游戏画面必须显示作者信息和关卡名。 -11. 前端只负责表现和交互输入,逻辑、数据、关卡裁决、推荐计算、状态存储全部放到 `server-rs` 后端,由 `Axum + SpacetimeDB + OSS` 方案承接。 +11. 拼块交换、拖动、合并、拆分和本关通关判定由前端即时裁决;`server-rs` 继续承接素材、开局、下一关推荐、扣费、排行榜和跨端服务侧状态。 -### 第一版单机例外说明 2026-04-24 +### 运行态前端裁决说明 2026-04-29 -为了先把拼图玩法跑通,第一版运行态采用单机本地版本,作为上面总原则的阶段性例外: +为了保证拖动手感和正式链路玩法一致,拼图运行态采用前端即时裁决: 1. Agent 会话、结果页草稿、正式候选图生成、封面确认、发布、作品读取,仍然全部走 Rust 后端。 -2. 进入拼图玩法后的 `run` 只在前端本地内存中存在。 -3. 交换、拖动、通关判断不写回后端。 -4. 关闭玩法后不保留本次运行态,不做断点续玩。 -5. 后续如果要做跨端续玩、多端同步或排行榜,再把运行态真相源收回后端。 +2. 进入拼图玩法后,拼块布局、合并组、拆分结果和本关通关状态由前端基于当前 `PuzzleRunSnapshot` 计算。 +3. 交换、拖动、合并、拆分、通关判断不写回后端。 +4. 正式 run 的下一关推荐、道具扣费、暂停计时同步、排行榜提交继续走后端。 +5. 后续如果要做跨端续玩或多端同步,必须先更新本文档,再决定哪些运行态真相源收回后端。 --- @@ -114,7 +114,7 @@ 5. 不做复杂剧情文本或玩法说明文本默认堆在 UI 中。 6. 不做独立于平台创作中心之外的新创作站点。 7. 不做前端本地计算下一关推荐结果。 -8. 不做前端本地裁决拼块合并、拆分和关卡完成。 +8. 不做前端本地计算下一关推荐、扣费或排行榜;拼块交换、拖动、合并、拆分和本关通关判定由前端即时裁决。 9. 不把拼图玩法继续命名挂在 `customWorld` 或 `rpgWorld` 老前缀下。 --- @@ -374,8 +374,8 @@ interface PuzzleAnchorPack { 拼图图片的正式资产要求: -1. 官方拼图原图统一使用 `9:16` 竖屏比例。 -2. 建议第一版正式生成尺寸为 `720 x 1280`。 +1. 官方拼图原图统一使用 `1:1` 正方形比例。 +2. 建议第一版正式生成尺寸为 `1024 x 1024`。 3. 图中不允许生成标题字、水印、边框、按钮或 UI。 4. 图像主体必须足够清晰,切成 `4*4` 后仍然有辨识信息。 @@ -502,7 +502,7 @@ tagSimilarityScore = 画面要求: 1. 拼图舞台占满可用全屏区域 -2. 真正可操作的拼图棋盘按 `9:16` 竖屏比例填满安全区域 +2. 真正可操作的拼图棋盘按正方形比例填满安全区域,并在移动端贴近屏幕两侧边缘 3. 棋盘外延空间用同图模糊背景或纯净氛围底承接 4. 不默认堆玩法说明文字 @@ -625,7 +625,7 @@ V1 规则如下: ## 9.11 重算范围 -为了避免前端和后端做整盘重复计算,每次操作后只重算受影响区域: +为了避免前端每次操作后做整盘重复计算,只重算受影响区域: 1. 本次发生交换的源格子 2. 本次发生交换的目标格子 @@ -639,7 +639,7 @@ V1 规则如下: 1. 所有拼块合并成 `1` 个覆盖全盘的大合并块 2. 所有拼块都回到原始正确位置 -在正式实现中,建议以后端 `allTilesResolved = true` 作为唯一真相。 +在正式实现中,前端以本地计算得到的 `allTilesResolved = true` 或关卡 `status = cleared` 作为本关通关真相;后端不再参与拼块布局裁决。 ## 9.13 限时与失败 @@ -755,17 +755,17 @@ interface PuzzleRunSnapshot { 1. 展示 Agent 聊天界面 2. 展示结果页 3. 展示拼图画布、选中态、拖动反馈、合并反馈 -4. 发起交换、拖动、发布、开始游戏等请求 +4. 即时裁决拼块交换、拖动、合并、拆分和本关通关状态 +5. 发起发布、开始游戏、下一关、道具、排行榜等请求 前端不负责: 1. 解析锚点完成度 2. 计算推荐下一关 3. 计算标签相似度 -4. 判定哪些块应该合并 -5. 判定合并块何时拆分 -6. 判定通关 -7. 保存 run 状态 +4. 计算下一关推荐 +5. 保存跨端 run 状态 +6. 执行道具扣费或排行榜写入 ## 11.2 后端职责 @@ -777,10 +777,9 @@ interface PuzzleRunSnapshot { 4. 发布作品到拼图广场 5. 创建 run 6. 初始化关卡棋盘 -7. 裁决交换、合并、拆分、拖动结果 -8. 判定通关 -9. 计算下一关推荐 -10. 保存当前 run 快照 +7. 计算下一关推荐 +8. 保存当前 run 的关卡入口、计时、道具和排行榜相关状态 +9. 兼容保留旧交换接口;拖动接口不作为 Rust API 默认能力暴露,前端不依赖后端裁决拼块布局 --- @@ -797,11 +796,12 @@ interface PuzzleRunSnapshot { - 结果页交互 - 拼图画布渲染 - HUD、选中态、拖动态、合并反馈表现 + - 拼块交换、拖动、合并、拆分与本关通关判定 3. 前端不得承担: - 推荐算法 - - run 状态持久化 - - 拼块合并与拆分裁决 - - 通关判定 + - run 状态跨端持久化 + - 道具扣费 + - 排行榜写入 4. 若后续拼图运行时需要实时订阅或读取 `SpacetimeDB` 数据,前端接入必须显式以 `spacetimedb-typescript` 约束为准。 ### HTTP 与外部副作用层 @@ -818,15 +818,16 @@ interface PuzzleRunSnapshot { ### 状态真相源 -1. 拼图玩法的运行时状态、作品状态、Agent 会话状态、广场投影状态,统一以 `SpacetimeDB` 为唯一真相源。 -2. `SpacetimeDB` 中应承担: +1. 拼图玩法的作品状态、Agent 会话状态、广场投影状态、下一关推荐、道具扣费和排行榜等服务侧状态,统一以 `SpacetimeDB` 为唯一真相源。 +2. 当前关卡的拼块布局、合并组、拆分结果和本关通关状态,运行中以前端即时计算结果为准,不要求每一步写回 `SpacetimeDB`。 +3. `SpacetimeDB` 中应承担: - 拼图作品 profile 表 - 拼图 Agent session / message / operation 表 - - 拼图 run 与关卡状态表 - - 拼块与合并组状态表 + - 拼图 run 入口、关卡服务状态与排行榜表 + - 下一关候选、道具扣费和榜单聚合所需状态 - 拼图广场投影表 - 标签相似度计算所需的规范化标签字段 -3. 所有真正修改状态的行为必须通过 reducer 或 procedure 完成,不能由 Axum 或前端偷偷改状态。 +4. 所有服务侧状态修改必须通过 reducer 或 procedure 完成,不能由 Axum 或前端偷偷改服务侧状态。 ### 资产存储 @@ -1036,9 +1037,12 @@ interface PuzzleRunSnapshot { 1. `POST /api/runtime/puzzle-runtime/runs` 2. `GET /api/runtime/puzzle-runtime/runs/:runId` -3. `POST /api/runtime/puzzle-runtime/runs/:runId/swap` -4. `POST /api/runtime/puzzle-runtime/runs/:runId/drag` -5. `POST /api/runtime/puzzle-runtime/runs/:runId/next-level` +3. `POST /api/runtime/puzzle-runtime/runs/:runId/next-level` +4. `POST /api/runtime/puzzle-runtime/runs/:runId/pause` +5. `POST /api/runtime/puzzle-runtime/runs/:runId/props` +6. `POST /api/runtime/puzzle-runtime/runs/:runId/leaderboard` + +`swap` 兼容接口可以保留,但前端默认不再调用;`drag` 不作为默认 HTTP 入口暴露。拼块布局由前端即时裁决。 --- @@ -1084,7 +1088,7 @@ interface PuzzleRunSnapshot { 建议布局: 1. 顶部轻量 HUD -2. 中间 `9:16` 竖屏拼图棋盘 +2. 中间正方形拼图棋盘,移动端贴近屏幕两侧边缘 3. 底部不常驻大段文案 如需操作提示,只允许短暂轻提示,不允许占据长期版面。 diff --git a/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md b/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md new file mode 100644 index 00000000..deaa95c0 --- /dev/null +++ b/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md @@ -0,0 +1,42 @@ +# 拼图图片与前端规则裁决对齐 2026-04-29 + +## 背景 + +本轮明确调整拼图运行态边界: + +1. 拼图生成图片重新回到 `1:1` 正方形。 +2. 拼图中的拖动、交换、合并、拆分与通关判定由前端即时计算。 +3. 移动端运行时棋盘需要贴近屏幕两侧边缘,减少无效留白。 + +此前误按 `9:16` 竖屏统一图片和棋盘,会让拼图块在移动端可操作面积不足,也和拼图素材的切块体验不匹配。本轮回到正方形棋盘与正方形生图。 + +## 落地结论 + +### 1. 图片生成 + +1. 拼图生成图固定使用 `1024*1024`。 +2. 文生图和参考图生图共用同一个尺寸常量,禁止一条链路仍生成竖屏或横版图。 +3. 拼图图片提示词明确写入 `1:1 正方形画布`,继续保留 `3x3 或 4x4 拼图切块`、主体清晰、层次明确、无文字水印等约束。 +4. 图片生成仍由 `api-server` 执行。SpacetimeDB reducer 不做网络 I/O。 + +### 2. 前端规则裁决 + +1. 运行态的交换、拖动、合并、拆分、通关判定由前端基于 `PuzzleRunSnapshot` 即时计算。 +2. 正式 run 与本地测试 run 复用同一套前端规则函数,避免正式链路和测试链路玩法漂移。 +3. 后端仍负责开始 run、进入下一关、道具扣费、暂停计时同步、排行榜提交、作品与下一关候选读取。 +4. 正式 run 的 `/drag` 后端 HTTP 接口已撤出 Rust API;拖动不再有后端入口。`/swap` 暂作点击交换兼容入口,拖动中的交换由前端本地规则完成。 + +### 3. 移动端棋盘布局 + +1. 运行时棋盘根容器恢复 `aspect-square`。 +2. 移动端横向 padding 收紧到 `0.25rem`,棋盘宽度使用 `min(99vw, 可用高度)`,尽量贴近屏幕两侧边缘。 +3. 单格不设置固定最小高度,避免移动端被单格撑破。 +4. 顶部 HUD 与底部道具仍保留安全区,不能遮挡棋盘可操作区域。 + +## 验收 + +1. 点击拼图草稿生成或重新生成画面时,后端请求 DashScope 的 `size` 为 `1024*1024`。 +2. 图片提示词包含 `1:1 正方形拼图关卡`。 +3. 正式拼图 run 中拖动拼块后,前端立即更新棋盘、合并块和通关状态,不再等待 `/drag`。 +4. 移动端运行时棋盘为正方形,并尽量贴近屏幕两侧边缘。 +5. 下一关、道具、排行榜仍走现有后端链路,不把外部 I/O 或扣费逻辑塞回前端。 diff --git a/docs/technical/PUZZLE_IMAGE_AND_RUNTIME_9_16_ALIGNMENT_2026-04-29.md b/docs/technical/PUZZLE_IMAGE_AND_RUNTIME_9_16_ALIGNMENT_2026-04-29.md deleted file mode 100644 index 66eff222..00000000 --- a/docs/technical/PUZZLE_IMAGE_AND_RUNTIME_9_16_ALIGNMENT_2026-04-29.md +++ /dev/null @@ -1,42 +0,0 @@ -# 拼图图片生成与运行时 9:16 对齐 2026-04-29 - -## 背景 - -拼图生成图和运行时画面需要统一为竖屏游戏口径。此前链路里存在两类不一致: - -1. 旧方案按 `1:1` 正方形生成与承载。 -2. 上一轮误按 `16:9` 横版对齐,和本轮竖屏玩法目标相反。 - -本次统一为 `9:16` 竖屏尺寸,确保生成图、结果页预览、发布正式图、历史素材缩略和实际游戏棋盘使用同一画面比例。 - -## 落地结论 - -### 1. 图片生成 - -1. 拼图生成图固定使用 `720*1280`。 -2. 文生图和参考图生图共用同一个尺寸常量,禁止一条链路仍生成正方形或横版图。 -3. 拼图图片提示词明确写入 `9:16 竖屏画布`,并继续保留 `3x3 或 4x4 拼图切块`、主体清晰、层次明确、无文字水印等约束。 -4. 图片生成仍由 `api-server` 执行。SpacetimeDB reducer 只负责 session、draft、candidate、work profile 的确定性落库,不做网络 I/O。 - -### 2. 结果页与素材选择 - -1. 画面预览容器使用 `aspect-[9/16]`。 -2. 发布弹窗正式图使用 `aspect-[9/16]`。 -3. 历史拼图素材卡片缩略图使用 `aspect-[9/16]`。 -4. 图片显示继续使用 `object-cover`,兼容历史正方形或横版素材,但新生成素材的真相比例为 `9:16`。 - -### 3. 运行时棋盘 - -1. `PuzzleRuntimeShell` 继续作为唯一运行时承载组件,不新增页面。 -2. 棋盘根容器使用 `aspect-[9/16]`,并显式设置行列网格,3x3 / 4x4 都在竖屏舞台内切片。 -3. 棋盘最大宽度按可用视口高度反推,避免桌面端竖屏棋盘被宽容器撑出首屏。 -4. 单格不设置固定最小高度,避免移动端竖屏棋盘被单格高度撑破。 -5. 拼图片背景切片仍按 `board.cols * 100%` 与 `board.rows * 100%` 计算,比例由棋盘容器统一决定。 - -## 验收 - -1. 点击拼图草稿生成或重新生成画面时,后端请求 DashScope 的 `size` 为 `720*1280`。 -2. 结果页画面预览、发布弹窗正式图、历史素材缩略图均为 `9:16`。 -3. 进入拼图运行时后,棋盘整体为 `9:16` 竖屏,不再是正方形或横版。 -4. 移动端和桌面端运行时棋盘不被单格最小高度撑出首屏,顶部标题、底部状态与棋盘不重叠。 -5. 旧正方形或横版素材仍能被 `object-cover` 展示和游玩,不阻断历史作品。 diff --git a/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md b/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md index da371b46..a316399c 100644 --- a/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md +++ b/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md @@ -103,6 +103,15 @@ 3. 结算弹窗显示时,如果真实榜单尚未回写完成,可以显示加载态;但不能回退到假数据。 4. 下一关开始后,当前关卡榜单状态清空。 +## 7.1 2026-04-29 与前端拖动裁决的对齐 + +当前拼图拖动、合并、拆分与通关判定完全由前端运行态负责,后端排行榜接口只负责真实成绩表与榜单聚合: + +1. 排行榜提交不得依赖 SpacetimeDB 里的旧棋盘快照已经通过后端拖动接口进入 `cleared`。 +2. 后端仍校验 `profileId`、`gridSize`、昵称和成绩,并把当前提交写入真实成绩表。 +3. 后端响应里的 `leaderboardEntries` 是唯一需要合并回前端当前 run 的数据。 +4. 前端不能用排行榜响应里的旧棋盘快照覆盖本地拖动后的棋盘,否则会把刚刚通关的前端状态回滚。 + ## 8. 测试要求 至少覆盖: diff --git a/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md b/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md index f945798c..258a4cb6 100644 --- a/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md +++ b/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md @@ -65,6 +65,16 @@ 7. 每次进入下一关都会重置棋盘、推进关卡序号,并按已通关数量切换 `3x3 / 4x4` 8. 当前不依赖后端 `start/swap/drag/next-level` 接口保存过程状态 +## 6. 2026-04-29 拖动责任边界修正 + +拼图运行态的拖动逻辑完全交给前端: + +1. `pointerup` 解析出的目标格只调用前端 `dragLocalPuzzlePiece`。 +2. 单块拖动、合并块整体平移、被覆盖块交换、拆分、重新合并、通关判定,都以前端当前 `PuzzleRunSnapshot` 为准。 +3. Rust API 不再暴露 `/api/runtime/puzzle/runs/{runId}/drag` 给前端调用;后端旧 procedure 仅作为历史兼容实现,不作为当前运行态入口。 +4. 真实排行榜仍由后端成绩表负责;提交成功后,前端只把后端返回的 `leaderboardEntries` 合并回当前本地棋盘快照,不能用后端旧棋盘覆盖前端拖动后的状态。 +5. 下一关仍通过 `advanceLocalPuzzleNextLevel` 把前端当前 run 交给 Rust API 生成候选关卡,后端只裁决图片来源与新关卡初始化,不保存上一关拖动过程。 + ## 5. 当前实现判断标准 当下面结果成立时,视为这一轮目标达成: diff --git a/docs/technical/README.md b/docs/technical/README.md index b0302183..6abe55c4 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -12,6 +12,7 @@ - [BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md](./BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md):记录大鱼吃小鱼草稿进度页从单步 compile 改为多阶段感知展示,以及大鱼会话读取在 Maincloud 抖动时增加短重试与超时语义收口的修复口径。 - [BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md](./BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md):记录大鱼吃小鱼草稿生成、生图、动作三类提示词从业务脚本中抽离到独立 `prompt/big_fish.rs` 模块的边界与职责划分。 - [BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md](./BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md):记录大鱼吃小鱼等级主图与动作关键帧正式图在 Rust 后端复用 RPG 角色主图透明背景 alpha 后处理的对齐口径,并明确场地背景不走该处理。 +- [PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md](./PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md):记录拼图生成图片回到 1:1,运行时拖动、交换、合并与拆分由前端即时裁决,以及移动端棋盘贴近屏幕边缘的落地边界。 - [PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md](./PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md):记录拼图结果页名称与标签编辑自动保存、发布门槛统一到 `3~6` 标签,以及前端发布校验不再被旧 session blocker 卡死的修复口径。 - [SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md](./SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md):记录发布包 `start.sh` 只输出“SpacetimeDB 进程在就绪前退出”时的诊断补强,启动失败或超时时自动回显 `logs/spacetimedb.log`、`server ping`、端口监听和 root-dir 相关进程。 - [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse` 真流式输出的后端落地口径。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index e52cfe32..db4e12e3 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -83,12 +83,11 @@ use crate::{ profile_identity::update_profile_identity, puzzle::{ advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session, - delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action, - get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, - get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, - remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message, - submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces, - update_puzzle_run_pause, use_puzzle_runtime_prop, + delete_puzzle_work, execute_puzzle_agent_action, get_puzzle_agent_session, + get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, + list_puzzle_gallery, put_puzzle_work, remix_puzzle_gallery_work, start_puzzle_run, + stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard, + swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -777,13 +776,6 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) - .route( - "/api/runtime/puzzle/runs/{run_id}/drag", - post(drag_puzzle_piece_or_group).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) .route( "/api/runtime/puzzle/runs/{run_id}/next-level", post(advance_puzzle_next_level).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/prompt/puzzle_image.rs b/server-rs/crates/api-server/src/prompt/puzzle_image.rs index 03d244a3..ebb9b204 100644 --- a/server-rs/crates/api-server/src/prompt/puzzle_image.rs +++ b/server-rs/crates/api-server/src/prompt/puzzle_image.rs @@ -9,10 +9,10 @@ pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str = pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String { format!( concat!( - "请生成一张适合 9:16 竖屏拼图关卡的高清插画。", + "请生成一张适合 1:1 正方形拼图关卡的高清插画。", "关卡名:{level_name}。", "画面主体:{prompt}。", - "画面要求:9:16 竖屏画布,适配 3x3 或 4x4 拼图切块,", + "画面要求:1:1 正方形画布,适配 3x3 或 4x4 拼图切块,", "主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,", "避免文字、水印、边框和 UI 元素。" ), @@ -31,7 +31,7 @@ mod tests { assert!(prompt.contains("雨夜神庙")); assert!(prompt.contains("猫咪在发光遗迹前寻找线索")); - assert!(prompt.contains("9:16 竖屏拼图关卡")); + assert!(prompt.contains("1:1 正方形拼图关卡")); assert!(prompt.contains("3x3 或 4x4")); assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index eb271525..3a4e3d20 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -36,11 +36,11 @@ use shared_contracts::{ }, puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse}, puzzle_runtime::{ - AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, - PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, - PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse, - PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, - SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest, + AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse, + PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, + PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, + StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, + UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest, }, puzzle_works::{ PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, @@ -57,10 +57,10 @@ use spacetime_client::{ PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, - PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, - PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, - PuzzleWorkUpsertRecordInput, SpacetimeClientError, + PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, + PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, + PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, + SpacetimeClientError, }; use std::convert::Infallible; use tokio::time::sleep; @@ -86,7 +86,7 @@ const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery"; const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime"; const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash"; const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; -const PUZZLE_GENERATED_IMAGE_SIZE: &str = "720*1280"; +const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024"; pub async fn create_puzzle_agent_session( State(state): State, @@ -1062,58 +1062,6 @@ pub async fn swap_puzzle_pieces( )) } -pub async fn drag_puzzle_piece_or_group( - State(state): State, - AxumPath(run_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; - ensure_non_empty( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - &payload.piece_id, - "pieceId", - )?; - - let run = state - .spacetime_client() - .drag_puzzle_piece_or_group(PuzzleRunDragRecordInput { - run_id, - owner_user_id: authenticated.claims().user_id().to_string(), - piece_id: payload.piece_id, - target_row: payload.target_row, - target_col: payload.target_col, - dragged_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleRunResponse { - run: map_puzzle_run_response(run), - }, - )) -} - pub async fn advance_puzzle_next_level( State(state): State, AxumPath(run_id): AxumPath, @@ -1994,7 +1942,7 @@ async fn generate_puzzle_image_candidates( None => None, }; // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与 DashScope 图生图都必须停留在 api-server。 - // 中文注释:拼图作品资产统一按 9:16 竖屏生成,运行时棋盘也按同一比例切块承载。 + // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 let generated = match reference_image.as_deref() { Some(reference_image) => { create_puzzle_image_to_image_generation( @@ -2411,8 +2359,8 @@ mod tests { } #[test] - fn puzzle_generated_image_size_is_portrait_9_16() { - assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "720*1280"); + fn puzzle_generated_image_size_is_square_1_1() { + assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024"); } } diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 976a5bc4..bd44dfe5 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1551,9 +1551,6 @@ fn submit_puzzle_leaderboard_entry_tx( .current_level .as_ref() .ok_or_else(|| "拼图关卡不存在".to_string())?; - if current_level.status != PuzzleRuntimeLevelStatus::Cleared { - return Err("当前关卡尚未通关".to_string()); - } if current_level.profile_id != input.profile_id { return Err("提交成绩的拼图作品与当前关卡不匹配".to_string()); } @@ -1592,9 +1589,14 @@ fn submit_puzzle_leaderboard_entry_tx( 10, ); if let Some(level) = run.current_level.as_mut() { + // 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。 + // 因此提交榜单时不能要求 SpacetimeDB 里的旧棋盘快照也已经通关。 + level.status = PuzzleRuntimeLevelStatus::Cleared; + level.cleared_at_ms = Some(micros_to_millis(input.submitted_at_micros)); level.elapsed_ms = Some(input.elapsed_ms.max(1_000)); level.leaderboard_entries = leaderboard_entries.clone(); } + run.cleared_level_count = run.cleared_level_count.max(run.current_level_index); run.leaderboard_entries = leaderboard_entries; replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros); Ok(run) diff --git a/src/PuzzlePlaygroundApp.tsx b/src/PuzzlePlaygroundApp.tsx index 544de368..d5794bfd 100644 --- a/src/PuzzlePlaygroundApp.tsx +++ b/src/PuzzlePlaygroundApp.tsx @@ -18,7 +18,7 @@ import { const PLACEHOLDER_PUZZLE_IMAGE = 'data:image/svg+xml;utf8,' + encodeURIComponent(` - + @@ -30,13 +30,13 @@ const PLACEHOLDER_PUZZLE_IMAGE = - - - - - - - + + + + + + + `); function buildPlaceholderPuzzleWork(): PuzzleWorkSummary { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 69e5ebce..8696b0d6 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -99,11 +99,9 @@ import { } from '../../services/puzzle-gallery'; import { advanceLocalPuzzleNextLevel, - dragPuzzlePieceOrGroup, getPuzzleRun, startPuzzleRun, submitPuzzleLeaderboard, - swapPuzzlePieces, updatePuzzleRunPause, usePuzzleRuntimeProp as consumePuzzleRuntimeProp, } from '../../services/puzzle-runtime'; @@ -557,6 +555,37 @@ function LazyPanelFallback({ label }: { label: string }) { ); } +function mergePuzzleServiceRuntimeState( + currentRun: PuzzleRunSnapshot, + serviceRun: PuzzleRunSnapshot, +): PuzzleRunSnapshot { + if (!currentRun.currentLevel || !serviceRun.currentLevel) { + return currentRun; + } + + const serviceLevel = serviceRun.currentLevel; + const leaderboardEntries = + serviceLevel.leaderboardEntries.length > 0 + ? serviceLevel.leaderboardEntries + : serviceRun.leaderboardEntries; + + return { + ...currentRun, + leaderboardEntries, + currentLevel: { + ...currentRun.currentLevel, + timeLimitMs: serviceLevel.timeLimitMs, + remainingMs: serviceLevel.remainingMs, + pausedAccumulatedMs: serviceLevel.pausedAccumulatedMs, + pauseStartedAtMs: serviceLevel.pauseStartedAtMs, + freezeAccumulatedMs: serviceLevel.freezeAccumulatedMs, + freezeStartedAtMs: serviceLevel.freezeStartedAtMs, + freezeUntilMs: serviceLevel.freezeUntilMs, + leaderboardEntries, + }, + }; +} + export function PlatformEntryFlowShellImpl({ selectionStage, setSelectionStage, @@ -1565,20 +1594,10 @@ export function PlatformEntryFlowShellImpl({ } setPuzzleError(null); - if (isLocalPuzzleRun(puzzleRun)) { - setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload)); - return; - } - - void swapPuzzlePieces(puzzleRun.runId, payload) - .then(({ run }) => { - setPuzzleRun(run); - }) - .catch((error) => { - setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。')); - }); + // 交换、合并与通关判定都由前端即时裁决,正式 run 不再等待后端 /swap。 + setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload)); }, - [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError], + [isPuzzleBusy, puzzleRun, setPuzzleError], ); const dragPuzzlePiece = useCallback( @@ -1588,20 +1607,11 @@ export function PlatformEntryFlowShellImpl({ } setPuzzleError(null); - if (isLocalPuzzleRun(puzzleRun)) { - setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload)); - return; - } - - void dragPuzzlePieceOrGroup(puzzleRun.runId, payload) - .then(({ run }) => { - setPuzzleRun(run); - }) - .catch((error) => { - setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。')); - }); + // 拖动落点、合并、拆分与通关判定都属于前端即时交互裁决。 + // 后端只保留开局、道具、下一关与真实排行榜等服务侧能力。 + setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload)); }, - [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError], + [isPuzzleBusy, puzzleRun, setPuzzleError], ); useEffect(() => { @@ -1641,7 +1651,9 @@ export function PlatformEntryFlowShellImpl({ const { run } = await updatePuzzleRunPause(puzzleRun.runId, { paused, }); - setPuzzleRun(run); + setPuzzleRun((currentRun) => + currentRun ? mergePuzzleServiceRuntimeState(currentRun, run) : currentRun, + ); void platformBootstrap.refreshProfileDashboard(); } catch (error) { setPuzzleError( @@ -1669,7 +1681,9 @@ export function PlatformEntryFlowShellImpl({ try { const { run } = await getPuzzleRun(puzzleRun.runId); - setPuzzleRun(run); + setPuzzleRun((currentRun) => + currentRun ? mergePuzzleServiceRuntimeState(currentRun, run) : currentRun, + ); } catch (error) { setPuzzleError( resolvePuzzleErrorMessage(error, '同步拼图失败状态失败。'), @@ -1703,9 +1717,14 @@ export function PlatformEntryFlowShellImpl({ const { run } = await consumePuzzleRuntimeProp(puzzleRun.runId, { propKind, }); - setPuzzleRun(run); + const nextRun = mergePuzzleServiceRuntimeState( + puzzleRunRef.current ?? puzzleRun, + run, + ); + puzzleRunRef.current = nextRun; + setPuzzleRun(nextRun); void platformBootstrap.refreshProfileDashboard(); - return run; + return nextRun; }, [platformBootstrap, puzzleRun], ); @@ -1744,7 +1763,12 @@ export function PlatformEntryFlowShellImpl({ void submitPuzzleLeaderboard(puzzleRun.runId, payload) .then(({ run }) => { - setPuzzleRun(run); + setPuzzleRun((currentRun) => { + if (!currentRun) { + return currentRun; + } + return mergePuzzleServiceRuntimeState(currentRun, run); + }); }) .catch((error) => { submittedPuzzleLeaderboardKeysRef.current.delete(submitKey); diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index ee1f5382..7906bb67 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -427,7 +427,7 @@ function PuzzleHistoryAssetPickerDialog({ onClick={() => onSelect(asset)} className={`overflow-hidden rounded-[1.35rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`} > -
+
画面预览
-
+
{formalImageSrc ? ( 正式图
-
+
{formalImageSrc ? ( { expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77); }); -test('拼图棋盘使用 9:16 竖屏舞台承载切块', () => { +test('拼图棋盘使用贴近移动端边缘的正方形舞台承载切块', () => { const { container } = renderPuzzleRuntime( { ); const board = screen.getByTestId('puzzle-board'); - expect(board.className).toContain('aspect-[9/16]'); + expect(board.className).toContain('aspect-square'); + expect(board.className).toContain('max-w-[min(99vw,calc(100vh_-_16.5rem))]'); expect(board.className).not.toContain('aspect-video'); - expect(board.className).not.toContain('aspect-square'); + expect(board.className).not.toContain('aspect-[9/16]'); expect(board.getAttribute('style')).toContain('grid-template-rows'); expect(container.querySelector('.min-h-\\[4\\.5rem\\]')).toBeNull(); }); diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx index 8b8517e1..9ec2706d 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx @@ -331,7 +331,8 @@ type PuzzleHintDemoState = { /** * 拼图运行时壳层。 - * 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。 + * 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。 + * 后端继续负责开始关卡、下一关候选、道具扣费、排行榜等服务侧能力。 */ export function PuzzleRuntimeShell({ run, @@ -1110,11 +1111,11 @@ export function PuzzleRuntimeShell({
-
+
({ vi.mock('../../services/puzzle-runtime', () => ({ advanceLocalPuzzleNextLevel: vi.fn(), - dragPuzzlePieceOrGroup: vi.fn(), startPuzzleRun: vi.fn(), swapPuzzlePieces: vi.fn(), submitPuzzleLeaderboard: vi.fn(), diff --git a/src/services/puzzle-runtime/index.ts b/src/services/puzzle-runtime/index.ts index cdd9ceca..8faabce8 100644 --- a/src/services/puzzle-runtime/index.ts +++ b/src/services/puzzle-runtime/index.ts @@ -1,12 +1,10 @@ export { advanceLocalPuzzleNextLevel, advancePuzzleNextLevel, - dragPuzzlePieceOrGroup, getPuzzleRun, puzzleRuntimeClient, startPuzzleRun, submitPuzzleLeaderboard, - swapPuzzlePieces, updatePuzzleRunPause, usePuzzleRuntimeProp, } from './puzzleRuntimeClient'; diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts index c07baced..a1e4b393 100644 --- a/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts @@ -296,9 +296,7 @@ describe('puzzleLocalRuntime', () => { const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork)); expect(clearedRun.currentLevel?.status).toBe('cleared'); - expect(clearedRun.recommendedNextProfileId).toBe( - 'profile-1::local-level-2', - ); + expect(clearedRun.recommendedNextProfileId).toBeNull(); expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0); expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]); expect(clearedRun.leaderboardEntries).toEqual([]); diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.ts index 7fd600c9..af1835dd 100644 --- a/src/services/puzzle-runtime/puzzleLocalRuntime.ts +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.ts @@ -498,10 +498,7 @@ function applyNextBoard( : timedRun.currentLevel.leaderboardEntries, }, leaderboardEntries: justCleared ? [] : run.leaderboardEntries, - recommendedNextProfileId: - status === 'cleared' - ? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1) - : run.recommendedNextProfileId, + recommendedNextProfileId: run.recommendedNextProfileId, }; } diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.ts index d4ac6986..b0bc81c9 100644 --- a/src/services/puzzle-runtime/puzzleRuntimeClient.ts +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.ts @@ -1,6 +1,5 @@ import type { AdvanceLocalPuzzleNextLevelRequest, - DragPuzzlePieceRequest, PuzzleRunResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, @@ -78,27 +77,6 @@ export async function swapPuzzlePieces( ); } -/** - * 提交单块或合并块拖动请求。 - */ -export async function dragPuzzlePieceOrGroup( - runId: string, - payload: DragPuzzlePieceRequest, -) { - return requestJson( - `${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - '拖动拼图块失败', - { - retry: PUZZLE_RUNTIME_WRITE_RETRY, - }, - ); -} - /** * 进入推荐出的下一关。 */ @@ -201,11 +179,9 @@ export async function advanceLocalPuzzleNextLevel( export const puzzleRuntimeClient = { advanceLocalNextLevel: advanceLocalPuzzleNextLevel, advanceNextLevel: advancePuzzleNextLevel, - drag: dragPuzzlePieceOrGroup, getRun: getPuzzleRun, submitLeaderboard: submitPuzzleLeaderboard, startRun: startPuzzleRun, - swap: swapPuzzlePieces, updatePause: updatePuzzleRunPause, useProp: usePuzzleRuntimeProp, };