1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-29 23:10:43 +08:00
parent 0395bd7ec6
commit 89ab1bf1c0
20 changed files with 204 additions and 244 deletions

View File

@@ -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. 底部不常驻大段文案
如需操作提示,只允许短暂轻提示,不允许占据长期版面。

View File

@@ -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 或扣费逻辑塞回前端。

View File

@@ -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` 展示和游玩,不阻断历史作品。

View File

@@ -103,6 +103,15 @@
3. 结算弹窗显示时,如果真实榜单尚未回写完成,可以显示加载态;但不能回退到假数据。
4. 下一关开始后,当前关卡榜单状态清空。
## 7.1 2026-04-29 与前端拖动裁决的对齐
当前拼图拖动、合并、拆分与通关判定完全由前端运行态负责,后端排行榜接口只负责真实成绩表与榜单聚合:
1. 排行榜提交不得依赖 SpacetimeDB 里的旧棋盘快照已经通过后端拖动接口进入 `cleared`
2. 后端仍校验 `profileId``gridSize`、昵称和成绩,并把当前提交写入真实成绩表。
3. 后端响应里的 `leaderboardEntries` 是唯一需要合并回前端当前 run 的数据。
4. 前端不能用排行榜响应里的旧棋盘快照覆盖本地拖动后的棋盘,否则会把刚刚通关的前端状态回滚。
## 8. 测试要求
至少覆盖:

View File

@@ -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. 当前实现判断标准
当下面结果成立时,视为这一轮目标达成:

View File

@@ -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<Event>` 真流式输出的后端落地口径。

View File

@@ -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(

View File

@@ -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 元素"));
}

View File

@@ -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<AppState>,
@@ -1062,58 +1062,6 @@ pub async fn swap_puzzle_pieces(
))
}
pub async fn drag_puzzle_piece_or_group(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<DragPuzzlePieceRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
AxumPath(run_id): AxumPath<String>,
@@ -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");
}
}

View File

@@ -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)

View File

@@ -18,7 +18,7 @@ import {
const PLACEHOLDER_PUZZLE_IMAGE =
'data:image/svg+xml;utf8,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1280">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fef3c7" />
@@ -30,13 +30,13 @@ const PLACEHOLDER_PUZZLE_IMAGE =
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
</radialGradient>
</defs>
<rect width="720" height="1280" fill="url(#sky)" />
<circle cx="310" cy="318" r="210" fill="url(#glow)" />
<path d="M0 860 C118 808 226 894 348 828 C474 760 594 824 720 774 V1280 H0 Z" fill="#1e1b4b" opacity="0.9" />
<path d="M0 1010 C142 954 282 1040 428 974 C552 918 638 938 720 904 V1280 H0 Z" fill="#111827" opacity="0.78" />
<path d="M86 310 C184 242 302 252 376 334 C460 426 574 386 646 310" fill="none" stroke="#fff7ed" stroke-width="16" stroke-linecap="round" opacity="0.72" />
<path d="M128 610 h464" stroke="#ffffff" stroke-width="14" stroke-linecap="round" opacity="0.3" />
<path d="M174 704 h372" stroke="#ffffff" stroke-width="10" stroke-linecap="round" opacity="0.22" />
<rect width="1024" height="1024" fill="url(#sky)" />
<circle cx="378" cy="286" r="230" fill="url(#glow)" />
<path d="M0 690 C168 626 296 724 446 666 C596 606 744 628 1024 536 V1024 H0 Z" fill="#1e1b4b" opacity="0.9" />
<path d="M0 822 C190 760 328 850 516 790 C672 738 824 754 1024 704 V1024 H0 Z" fill="#111827" opacity="0.78" />
<path d="M138 328 C226 266 340 266 420 338 C518 426 632 390 740 326 C834 272 920 300 960 362" fill="none" stroke="#fff7ed" stroke-width="18" stroke-linecap="round" opacity="0.72" />
<path d="M190 548 h640" stroke="#ffffff" stroke-width="16" stroke-linecap="round" opacity="0.3" />
<path d="M268 628 h488" stroke="#ffffff" stroke-width="12" stroke-linecap="round" opacity="0.22" />
</svg>`);
function buildPlaceholderPuzzleWork(): PuzzleWorkSummary {

View File

@@ -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);

View File

@@ -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)]'}`}
>
<div className="aspect-[9/16] overflow-hidden bg-[var(--platform-subpanel-fill)]">
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={asset.ownerLabel || '历史拼图素材'}
@@ -509,7 +509,7 @@ function PuzzlePictureEditor({
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mx-auto mt-3 aspect-[9/16] w-full max-w-[24rem] overflow-hidden rounded-[1.25rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
<div className="mx-auto mt-3 aspect-square w-full max-w-[24rem] overflow-hidden rounded-[1.25rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
{formalImageSrc ? (
<ResolvedAssetImage
src={formalImageSrc}
@@ -725,7 +725,7 @@ function PuzzlePublishDialog({
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="aspect-[9/16] overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
<div className="aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
{formalImageSrc ? (
<ResolvedAssetImage
src={formalImageSrc}

View File

@@ -206,7 +206,7 @@ test('右上角设置按钮打开拼图设置并支持音量调节', () => {
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
});
test('拼图棋盘使用 9:16 竖屏舞台承载切块', () => {
test('拼图棋盘使用贴近移动端边缘的正方形舞台承载切块', () => {
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={{
@@ -229,9 +229,10 @@ test('拼图棋盘使用 9:16 竖屏舞台承载切块', () => {
);
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();
});

View File

@@ -331,7 +331,8 @@ type PuzzleHintDemoState = {
/**
* 拼图运行时壳层。
* 前端维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决
* 后端继续负责开始关卡、下一关候选、道具扣费、排行榜等服务侧能力。
*/
export function PuzzleRuntimeShell({
run,
@@ -1110,11 +1111,11 @@ export function PuzzleRuntimeShell({
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center p-3 pt-28 pb-32 sm:p-4">
<div className="absolute inset-0 flex items-center justify-center px-1 py-3 pt-28 pb-32 sm:p-4">
<div
ref={boardRef}
data-testid="puzzle-board"
className="relative grid aspect-[9/16] w-full max-w-[min(96vw,calc(56.25vh_-_8.5rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border border-white/16 bg-white/8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:rounded-[1.45rem]"
className="relative grid aspect-square w-full max-w-[min(99vw,calc(100vh_-_16.5rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border border-white/16 bg-white/8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:max-w-[min(92vw,calc(100vh_-_17rem))] sm:rounded-[1.45rem]"
style={{
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${board.rows}, minmax(0, 1fr))`,

View File

@@ -138,7 +138,6 @@ vi.mock('../../services/puzzle-gallery', () => ({
vi.mock('../../services/puzzle-runtime', () => ({
advanceLocalPuzzleNextLevel: vi.fn(),
dragPuzzlePieceOrGroup: vi.fn(),
startPuzzleRun: vi.fn(),
swapPuzzlePieces: vi.fn(),
submitPuzzleLeaderboard: vi.fn(),

View File

@@ -1,12 +1,10 @@
export {
advanceLocalPuzzleNextLevel,
advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
getPuzzleRun,
puzzleRuntimeClient,
startPuzzleRun,
submitPuzzleLeaderboard,
swapPuzzlePieces,
updatePuzzleRunPause,
usePuzzleRuntimeProp,
} from './puzzleRuntimeClient';

View File

@@ -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([]);

View File

@@ -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,
};
}

View File

@@ -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<PuzzleRunResponse>(
`${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,
};