@@ -0,0 +1,37 @@
|
||||
# Profile 主链 Vite 代理修复
|
||||
|
||||
## 1. 问题
|
||||
|
||||
“我的”和“存档”页面在本地开发环境报:
|
||||
|
||||
```text
|
||||
Unexpected token '<', "<!doctype "... is not valid JSON
|
||||
```
|
||||
|
||||
这不是后端返回了坏 JSON,而是前端请求 `/api/profile/*` 时没有命中 Vite 代理,Vite 将请求按 SPA fallback 返回了 `index.html`。`requestJson` 随后对 HTML 执行 `JSON.parse`,首字符 `<` 触发该错误。
|
||||
|
||||
## 2. 现有约束
|
||||
|
||||
DDD 路由矩阵已冻结 profile 主链:
|
||||
|
||||
1. “我的”与存档读取统一走 `/api/profile/*`。
|
||||
2. 旧 `/api/runtime/profile/*` 已取消挂载,不允许前端回退到旧路径。
|
||||
3. 后端 `api-server` 已挂载 `/api/profile/dashboard`、`/api/profile/save-archives` 等路由,问题只在本地 Vite 代理层。
|
||||
|
||||
## 3. 修复
|
||||
|
||||
`vite.config.ts` 在现有 `/api/auth`、`/api/runtime` 等代理旁补齐:
|
||||
|
||||
```ts
|
||||
'/api/profile': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
```
|
||||
|
||||
这样 profile 主链请求在 `npm run dev:web` 下会直接转发到 Rust API server,不再落到前端入口页。
|
||||
|
||||
## 4. 回归
|
||||
|
||||
新增 `src/config/viteProxyConfig.test.ts`,断言 Vite server proxy 必须包含 `/api/profile`。后续若再调整 profile route 或代理配置,先更新本文和测试,再改工程实现。
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
1. 通关后默认点击“下一关”,优先加载当前拼图作品的下一关。
|
||||
2. 当前作品没有下一关时,后端按标签语义相似度选出相似度最高的三个已发布作品。
|
||||
3. 用户在通关弹窗里点击候选作品后,进入该作品并从第 `1` 关重新开始。
|
||||
3. 用户在通关弹窗里点击候选作品后,切换到候选作品的第一张图,但运行时关卡序号、切割规格和倒计时继续按当前 run 累进。
|
||||
4. 移动端优先,候选卡片要紧凑,不写玩法说明类文案。
|
||||
|
||||
## 数据契约
|
||||
@@ -51,7 +51,8 @@
|
||||
- 返回最高的 3 个候选
|
||||
4. `advance_puzzle_next_level`:
|
||||
- `nextLevelMode = sameWork` 时加载当前作品的下一关,并继续当前 run。
|
||||
- `nextLevelMode = similarWorks` 时默认加载候选第一项,并把 `entryProfileId / clearedLevelCount / currentLevelIndex` 重置到目标作品第 `1` 关。
|
||||
- `nextLevelMode = similarWorks` 时默认加载候选第一项的第一张图;正式 UI 点击具体候选作品时通过 `targetProfileId` 指定候选。
|
||||
- 任何跨作品进入都只切换图片来源,不重置 `entryProfileId / clearedLevelCount / currentLevelIndex`,并按当前 run 的下一关配置切割规格和倒计时。
|
||||
5. `local-next-level` 兼容接口同样优先找同作品下一关;没有时返回 `similarWorks` 候选并保持当前通关 run,只有候选池为空时才进入旧草稿兜底。
|
||||
|
||||
## 前端规则
|
||||
@@ -70,6 +71,6 @@
|
||||
|
||||
1. 当前作品有下一关时,点击“下一关”进入当前作品下一关。
|
||||
2. 当前作品没有下一关时,通关弹窗显示最多 3 个相似作品。
|
||||
3. 点击相似作品后进入该作品第 `1` 关,HUD 关卡序号、切割规格和倒计时都按第 `1` 关显示。
|
||||
3. 点击相似作品后进入该作品第一张图,HUD 关卡序号、切割规格和倒计时继续按运行时下一关显示。
|
||||
4. 旧 `recommendedNextProfileId` 为空时,只要 `nextLevelMode = sameWork`,按钮仍可用。
|
||||
5. 拼图 runtime 单测、Rust 拼图模块测试和编码检查通过。
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
5. 面板次按钮为 `保存并退出`,点击后关闭面板并执行原返回逻辑。
|
||||
6. 非首次点击返回不再弹出面板,直接执行原返回逻辑。
|
||||
|
||||
## UI 布局
|
||||
|
||||
1. 面板保持居中独立弹层,移动端宽度不超过屏幕安全边距,桌面端保持紧凑。
|
||||
2. 面板只展示标题与两个行动按钮,不增加说明性文案。
|
||||
3. 标题使用两行居中排版,顶部可以放无文字图标强化游戏感。
|
||||
4. `作品改造` 为主按钮,视觉权重高于 `保存并退出`。
|
||||
5. 两个按钮纵向排列,固定触控高度,确保移动端易点击。
|
||||
|
||||
## 首次状态
|
||||
|
||||
首次曝光是浏览器侧 UI 引导状态,不是业务真相态:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# 拼图运行态低延迟交互前端化修正 2026-05-02
|
||||
|
||||
## 背景
|
||||
|
||||
本次检查发现正式平台入口的拼图运行态存在后端裁决回流:
|
||||
|
||||
1. 作品详情、公开作品卡和结果页试玩会启动后端 run。
|
||||
2. `PuzzleRuntimeShell` 的交换和拖动回调在非本地 run 时会调用 `swapPuzzlePieces`、`dragPuzzlePieceOrGroup`。
|
||||
3. 服务端 run snapshot 如果直接覆盖前端当前棋盘,会让移动、交换、合并和通关反馈出现延迟或回退。
|
||||
|
||||
这与 PRD 中“前端以本地计算得到的 `allTilesResolved = true` 或 `status = cleared` 作为本关通关真相;后端不再参与拼块布局裁决”的规则冲突。
|
||||
|
||||
## 修正口径
|
||||
|
||||
正式平台入口采用混合运行态:
|
||||
|
||||
1. 正式平台开局仍调用后端 `startPuzzleRun`,保留真实 `runId`、游玩记录、排行榜和下一关存储锚点。
|
||||
2. 点击交换只调用 `swapLocalPuzzlePieces`。
|
||||
3. 拖动单块或合并块只调用 `dragLocalPuzzlePiece`。
|
||||
4. 自动合并、拆分、合并块整体平移、被覆盖块交换和通关判定都以前端当前 `PuzzleRunSnapshot` 为准。
|
||||
5. 通关后调用后端 `submitPuzzleLeaderboard` 持久化成绩并读取真实排行榜;前端只合并排行榜与下一关 handoff,不用后端棋盘覆盖当前棋盘。
|
||||
6. 点击同作品下一关调用后端 `advancePuzzleNextLevel`,由 SpacetimeDB 返回新的运行态快照。
|
||||
7. 当前作品没有下一关时,通关弹窗展示后端 handoff 返回的相似作品;用户点击具体候选作品时直接 `startPuzzleRun(profileId, null)`,从目标作品第 `1` 关重新开始。
|
||||
8. 失败状态点击“重新开始”时,正式 run 使用当前关 `levelId` 重新 `startPuzzleRun`,草稿/本地 run 使用本地重建,二者都保留当前失败关卡。
|
||||
9. 结果页草稿试玩没有正式后端 run 时,继续使用本地 run、local leaderboard 和本地下一关兜底。
|
||||
|
||||
## 工程落点
|
||||
|
||||
1. `src/services/puzzle-runtime/puzzleLocalRuntime.ts`
|
||||
- `startLocalPuzzleRun` 支持按 `levelId` 启动。
|
||||
- `advanceLocalPuzzleLevel` 仅作为草稿试玩和无后端 run 的兜底。
|
||||
- 正式平台的移动、交换、合并、拆分和通关裁决仍复用本地函数,避免交互延迟。
|
||||
2. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
- 平台拼图开局、恢复存档、排行榜、下一关接回后端。
|
||||
- 正式平台入口不再调用 `/api/runtime/puzzle/runs/{runId}/swap`、`/drag`。
|
||||
- 后端排行榜返回的 run 只合并排行榜和 `nextLevelMode / nextLevelProfileId / nextLevelId / recommendedNextWorks`,不覆盖当前棋盘。
|
||||
- 相似作品候选卡点击启动目标作品新 run;失败重开按当前关 `levelId` 启动新 run。
|
||||
3. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
|
||||
- 公开拼图玩法交互测试断言前端本地交换函数被调用。
|
||||
- 同时断言后端 `swap / drag` 不参与棋盘交互,后端 `leaderboard / next-level` 继续参与非即时链路。
|
||||
|
||||
## 边界
|
||||
|
||||
本次只收回拼图玩法内的移动、交换、合并、拆分和通关裁决。创作 Agent、作品保存、发布、公开广场读取、作品详情读取、作品改造、排行榜、同作品下一关、相似候选生成、失败重开和游玩记录仍走现有后端链路。
|
||||
|
||||
旧 `SERVER_RS_DDD_WP_PZ_RUNTIME_BACKEND_TRUTH_CLOSURE_2026-05-01.md` 作为历史收尾记录保留;若与本文冲突,以本文的“低延迟棋盘前端裁决,非即时链路后端持久化”口径为准。
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
第 11 关开始,每 6 关循环复用第 5 关到第 10 关的配置,即 `5x5/210000ms`、`6x6/240000ms`、`5x5/210000ms`、`7x7/270000ms`、`5x5/240000ms`、`7x7/270000ms`。
|
||||
|
||||
同作品下一关必须使用同一个运行时关卡序号继续推进。跨作品相似推荐代表进入新作品,必须从目标作品第 `1` 关重新开始。
|
||||
同作品下一关必须使用同一个运行时关卡序号继续推进。跨作品相似推荐只切换到候选作品的第一张图,运行时关卡序号、切割规格和倒计时继续按当前 run 累进,不重置难度循环。
|
||||
|
||||
失败状态点击“重新开始”时,不进入作品第 `1` 关,而是重开当前失败关卡:前端需要传当前关 `levelId`,服务端按该 `levelId` 在作品内的位置恢复 `currentLevelIndex`、切割规格和倒计时。
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md](./PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md):记录拼图正式平台入口移动、交换、合并、拆分和通关裁决收回前端即时运行态,排行榜、下一关和游玩记录继续由后端持久化处理。
|
||||
- [PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md](./PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md):记录“我的”和“存档”页面在本地把 `/api/profile/*` 请求落到 Vite SPA fallback、导致 HTML 被当 JSON 解析的根因,以及 `/api/profile` 代理补齐与回归测试。
|
||||
- [SERVER_RS_DDD_WP_DEL_CLEANUP_2026-05-01.md](./SERVER_RS_DDD_WP_DEL_CLEANUP_2026-05-01.md):记录 `WP-DEL 删除旧层与命名收口`,物理删除旧 runtime story HTTP DTO、前端 `Rpg*` alias、旧 `/api/custom-world/*` 非 runtime 前缀、Puzzle `local-next-level` 入口和 `/generated-*` 资产直读代理;生成资产读取统一走 OSS read-url 链路。
|
||||
- [SERVER_RS_DDD_WP_API_BFF_CLOSURE_2026-05-01.md](./SERVER_RS_DDD_WP_API_BFF_CLOSURE_2026-05-01.md):记录 `WP-API api-server BFF` 收尾,补齐 `/api/llm/chat/completions` 的 `stream=true` SSE 代理,明确手机号/微信配置门控和角色动画资产占位不阻塞本次 BFF 关闭。
|
||||
- [SERVER_RS_DDD_WP_AS_ASSET_CHAIN_CLOSURE_2026-05-01.md](./SERVER_RS_DDD_WP_AS_ASSET_CHAIN_CLOSURE_2026-05-01.md):记录 `WP-AS Assets` 资产主链收尾,补齐资产领域事件、`asset_event` event table、OSS 确认、API facade、Rust bindings、表目录和 migration 白名单。
|
||||
|
||||
@@ -109,7 +109,7 @@ src/
|
||||
|
||||
聚合:
|
||||
|
||||
1. `AuthUser`:账号、公开叙世号、登录方式、绑定状态、token version。
|
||||
1. `AuthUser`:账号、公开百梦号、登录方式、绑定状态、token version。
|
||||
2. `RefreshSession`:refresh token hash、客户端信息、过期、吊销、last seen。
|
||||
3. `SmsVerification`:手机号、场景、验证码状态、冷却、失败次数。
|
||||
4. `WechatBinding`:微信 provider 身份、union id、绑定状态。
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
10. `RefreshSessionRecord`
|
||||
11. `AuthStoreSnapshotRecord`
|
||||
12. 密码长度、短信验证码长度、验证码 TTL、冷却、失败次数等领域常量。
|
||||
13. 手机号规范化、手机号脱敏、公开叙世号规范化、验证码 key 构造等纯函数。
|
||||
13. 手机号规范化、手机号脱敏、公开百梦号规范化、验证码 key 构造等纯函数。
|
||||
|
||||
本次将以下写入输入落入 `commands.rs`:
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
本次继续把留在 SpacetimeDB adapter 中的纯规则收回 `module-runtime`:
|
||||
|
||||
1. played world、snapshot wallet ledger、save archive、recharge order、recharge wallet ledger、redeem usage、redeem ledger 等 ID 生成规则。
|
||||
2. 首充叙世币奖励计算。
|
||||
2. 首充光点奖励计算。
|
||||
3. 会员购买续期时间计算。
|
||||
4. 邀请码 deterministic 生成、邀请链接、每日奖励窗口和邀请人奖励上限判断。
|
||||
5. 兑换码 public / unique / private 模式使用资格校验。
|
||||
|
||||
@@ -249,7 +249,7 @@ SELECT * FROM profile_membership WHERE user_id = '<user_id>';
|
||||
|
||||
### `profile_recharge_order`
|
||||
|
||||
- 作用:充值订单表,记录用户购买叙世币或会员的订单、支付渠道、支付时间、积分变更和会员到期时间。
|
||||
- 作用:充值订单表,记录用户购买光点或会员的订单、支付渠道、支付时间、积分变更和会员到期时间。
|
||||
- 结构:`order_id PK: String`, `user_id: String`, `product_id: String`, `product_title: String`, `kind: RuntimeProfileRechargeProductKind`, `amount_cents: u64`, `status: RuntimeProfileRechargeOrderStatus`, `payment_channel: String`, `paid_at: Timestamp`, `created_at: Timestamp`, `points_delta: i64`, `membership_expires_at: Option<Timestamp>`。
|
||||
- 索引:`user_id`, `(user_id, created_at)`。
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
2. 标题下显示可复制的分享文本。
|
||||
3. 分享文本下方显示主按钮“分享”,点击后复制完整分享文本。
|
||||
4. 页面底部显示三个分享渠道 icon:微信、QQ、抖音。
|
||||
5. 移动端使用底部弹层,桌面端居中展示,复用 `UnifiedModal` 的平台弹窗外壳。
|
||||
5. 移动端与桌面端都使用居中独立面板,复用 `UnifiedModal` 的平台弹窗外壳。
|
||||
|
||||
## 分享文本
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
|
||||
仓库现有 `media/social-media-group/wechat.png` 与 `qq.png` 是社群二维码,不作为本面板渠道 icon 使用。渠道 icon 采用轻量圆形文字标识,避免误导用户进入社群。
|
||||
|
||||
## 面板样式约束
|
||||
|
||||
分享面板通过 `UnifiedModal` portal 挂载到页面根部时,需要在遮罩层补齐当前平台主题类,避免主题变量脱离页面容器后丢失。面板外壳继续使用 `platform-modal-shell` 的 `--platform-modal-fill` 背景,并在移动端覆盖平台弹窗默认底部抽屉布局,保持居中显示。
|
||||
|
||||
## 接入范围
|
||||
|
||||
- `RpgCreationResultActionBar`:RPG 发布成功后由父层回传分享数据并打开面板。
|
||||
|
||||
@@ -124,6 +124,10 @@ export interface DragPuzzlePieceRequest {
|
||||
targetCol: number;
|
||||
}
|
||||
|
||||
export interface AdvancePuzzleNextLevelRequest {
|
||||
targetProfileId?: string | null;
|
||||
}
|
||||
|
||||
export interface UsePuzzleRuntimePropRequest {
|
||||
propKind: PuzzleRuntimePropKind;
|
||||
}
|
||||
|
||||
@@ -36,11 +36,12 @@ use shared_contracts::{
|
||||
},
|
||||
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
||||
puzzle_runtime::{
|
||||
DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse,
|
||||
PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
|
||||
PuzzleRecommendedNextWorkResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
|
||||
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
|
||||
SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
|
||||
AdvancePuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
||||
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
|
||||
PuzzlePieceStateResponse, PuzzleRecommendedNextWorkResponse, PuzzleRunResponse,
|
||||
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest,
|
||||
UsePuzzleRuntimePropRequest,
|
||||
},
|
||||
puzzle_works::{
|
||||
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||
@@ -1367,14 +1368,34 @@ pub async fn advance_puzzle_next_level(
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<AdvancePuzzleNextLevelRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
|
||||
let payload = match payload {
|
||||
Ok(Json(payload)) => payload,
|
||||
Err(error) if error.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => {
|
||||
AdvancePuzzleNextLevelRequest {
|
||||
target_profile_id: None,
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(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(),
|
||||
})),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput {
|
||||
run_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
target_profile_id: payload.target_profile_id,
|
||||
advanced_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -227,7 +227,7 @@ pub fn build_system_username(prefix: &str, sequence: u64) -> String {
|
||||
format!("{prefix}_{sequence:08}")
|
||||
}
|
||||
|
||||
// 公开叙世号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。
|
||||
// 公开百梦号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。
|
||||
pub fn build_public_user_code(sequence: u64) -> String {
|
||||
format!("SY-{sequence:08}")
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ impl fmt::Display for PasswordEntryError {
|
||||
Self::InvalidDisplayName => f.write_str("昵称格式不正确"),
|
||||
Self::InvalidAvatarDataUrl => f.write_str("头像图片格式不正确"),
|
||||
Self::EmptyProfileUpdate => f.write_str("请至少修改昵称或头像"),
|
||||
Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
|
||||
Self::InvalidPublicUserCode => f.write_str("百梦号格式不正确"),
|
||||
Self::InvalidCredentials => f.write_str("手机号或密码错误"),
|
||||
Self::UserNotFound => f.write_str("用户不存在"),
|
||||
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
|
||||
|
||||
@@ -1373,8 +1373,8 @@ pub fn advance_to_new_work_first_level_at(
|
||||
return Err(PuzzleFieldError::InvalidOperation);
|
||||
}
|
||||
|
||||
// 中文注释:跨作品代表进入一个新作品,关卡序号、切割规格和倒计时都从第 1 关重新开始。
|
||||
let next_level_index = 1;
|
||||
// 中文注释:跨作品只切换到候选作品的第一张图,运行时关卡序号和难度循环继续累进。
|
||||
let next_level_index = run.current_level_index + 1;
|
||||
let level_config = resolve_puzzle_level_config(next_level_index);
|
||||
let grid_size = level_config.grid_size;
|
||||
let shuffle_seed = puzzle_shuffle_seed(
|
||||
@@ -1391,8 +1391,8 @@ pub fn advance_to_new_work_first_level_at(
|
||||
|
||||
Ok(PuzzleRunSnapshot {
|
||||
run_id: run.run_id.clone(),
|
||||
entry_profile_id: next_profile.profile_id.clone(),
|
||||
cleared_level_count: 0,
|
||||
entry_profile_id: run.entry_profile_id.clone(),
|
||||
cleared_level_count: run.cleared_level_count,
|
||||
current_level_index: next_level_index,
|
||||
current_grid_size: grid_size,
|
||||
played_profile_ids,
|
||||
@@ -2998,7 +2998,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_to_new_work_first_level_restarts_level_progress() {
|
||||
fn advance_to_new_work_first_profile_level_keeps_runtime_progress() {
|
||||
let first_profile = build_published_profile("entry", "owner-a", vec!["奇幻", "遗迹"]);
|
||||
let next_profile = build_published_profile("next", "owner-b", vec!["奇幻", "魔法"]);
|
||||
let mut run = start_run("run-cross-work".to_string(), &first_profile, 2).expect("run");
|
||||
@@ -3011,14 +3011,14 @@ mod tests {
|
||||
let next_run =
|
||||
advance_to_new_work_first_level_at(&run, &next_profile, 3_000).expect("next run");
|
||||
|
||||
assert_eq!(next_run.entry_profile_id, "next");
|
||||
assert_eq!(next_run.cleared_level_count, 0);
|
||||
assert_eq!(next_run.current_level_index, 1);
|
||||
assert_eq!(next_run.entry_profile_id, "entry");
|
||||
assert_eq!(next_run.cleared_level_count, 3);
|
||||
assert_eq!(next_run.current_level_index, 4);
|
||||
let next_level = next_run.current_level.expect("next level");
|
||||
assert_eq!(next_level.profile_id, "next");
|
||||
assert_eq!(next_level.level_index, 1);
|
||||
assert_eq!(next_level.grid_size, 3);
|
||||
assert_eq!(next_level.time_limit_ms, 300_000);
|
||||
assert_eq!(next_level.level_index, 4);
|
||||
assert_eq!(next_level.grid_size, 5);
|
||||
assert_eq!(next_level.time_limit_ms, 210_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -213,6 +213,8 @@ pub struct PuzzleRunDragInput {
|
||||
pub struct PuzzleRunNextLevelInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
#[serde(default)]
|
||||
pub target_profile_id: Option<String>,
|
||||
pub advanced_at_micros: i64,
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
|
||||
Self::WalletAmountOverflow => f.write_str("profile.wallet_amount 超出上限"),
|
||||
Self::WalletBalanceOverflow => f.write_str("profile.wallet_balance 超出上限"),
|
||||
Self::InsufficientWalletBalance => f.write_str("叙世币余额不足"),
|
||||
Self::InsufficientWalletBalance => f.write_str("光点余额不足"),
|
||||
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
|
||||
Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
|
||||
Self::RedeemCodeDisabled => f.write_str("兑换码已停用"),
|
||||
|
||||
@@ -22,57 +22,57 @@ pub fn runtime_profile_recharge_point_products() -> Vec<RuntimeProfileRechargePr
|
||||
vec![
|
||||
build_points_recharge_product(
|
||||
"points_60",
|
||||
"60叙世币",
|
||||
"60光点",
|
||||
600,
|
||||
60,
|
||||
60,
|
||||
"首充双倍",
|
||||
"首充送60叙世币",
|
||||
"首充送60光点",
|
||||
),
|
||||
build_points_recharge_product(
|
||||
"points_180",
|
||||
"180叙世币",
|
||||
"180光点",
|
||||
1800,
|
||||
180,
|
||||
180,
|
||||
"首充双倍",
|
||||
"首充送180叙世币",
|
||||
"首充送180光点",
|
||||
),
|
||||
build_points_recharge_product(
|
||||
"points_300",
|
||||
"300叙世币",
|
||||
"300光点",
|
||||
3000,
|
||||
300,
|
||||
300,
|
||||
"首充双倍",
|
||||
"首充送300叙世币",
|
||||
"首充送300光点",
|
||||
),
|
||||
build_points_recharge_product(
|
||||
"points_680",
|
||||
"680叙世币",
|
||||
"680光点",
|
||||
6800,
|
||||
680,
|
||||
680,
|
||||
"首充双倍",
|
||||
"首充送680叙世币",
|
||||
"首充送680光点",
|
||||
),
|
||||
build_points_recharge_product(
|
||||
"points_1280",
|
||||
"1280叙世币",
|
||||
"1280光点",
|
||||
12800,
|
||||
1280,
|
||||
1280,
|
||||
"首充双倍",
|
||||
"首充送1280叙世币",
|
||||
"首充送1280光点",
|
||||
),
|
||||
build_points_recharge_product(
|
||||
"points_3280",
|
||||
"3280叙世币",
|
||||
"3280光点",
|
||||
32800,
|
||||
3280,
|
||||
3280,
|
||||
"首充双倍",
|
||||
"首充送3280叙世币",
|
||||
"首充送3280光点",
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -121,7 +121,7 @@ pub fn runtime_profile_membership_benefits() -> Vec<RuntimeProfileMembershipBene
|
||||
year_value: "¥248".to_string(),
|
||||
},
|
||||
RuntimeProfileMembershipBenefitSnapshot {
|
||||
benefit_name: "免叙世币回合数".to_string(),
|
||||
benefit_name: "免光点回合数".to_string(),
|
||||
normal_value: "30".to_string(),
|
||||
month_value: "100".to_string(),
|
||||
season_value: "100".to_string(),
|
||||
@@ -457,14 +457,14 @@ mod tests {
|
||||
|
||||
assert_eq!(point_products.len(), 6);
|
||||
assert_eq!(point_products[0].product_id, "points_60");
|
||||
assert_eq!(point_products[0].title, "60叙世币");
|
||||
assert_eq!(point_products[0].title, "60光点");
|
||||
assert_eq!(point_products[0].price_cents, 600);
|
||||
assert_eq!(point_products[0].bonus_points, 60);
|
||||
assert_eq!(point_products[0].description, "首充送60叙世币");
|
||||
assert_eq!(point_products[0].description, "首充送60光点");
|
||||
assert_eq!(point_products[5].product_id, "points_3280");
|
||||
assert_eq!(point_products[5].price_cents, 32800);
|
||||
assert_eq!(point_products[5].bonus_points, 3280);
|
||||
assert_eq!(point_products[5].description, "首充送3280叙世币");
|
||||
assert_eq!(point_products[5].description, "首充送3280光点");
|
||||
assert_eq!(membership_products.len(), 3);
|
||||
assert_eq!(membership_products[0].title, "月卡");
|
||||
assert_eq!(membership_products[0].price_cents, 2800);
|
||||
@@ -474,7 +474,7 @@ mod tests {
|
||||
assert!(
|
||||
benefits
|
||||
.iter()
|
||||
.any(|benefit| benefit.benefit_name == "免叙世币回合数")
|
||||
.any(|benefit| benefit.benefit_name == "免光点回合数")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ pub struct DragPuzzlePieceRequest {
|
||||
pub target_col: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdvancePuzzleNextLevelRequest {
|
||||
#[serde(default)]
|
||||
pub target_profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsePuzzleRuntimePropRequest {
|
||||
|
||||
@@ -4783,6 +4783,7 @@ pub struct PuzzleRunDragRecordInput {
|
||||
pub struct PuzzleRunNextLevelRecordInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub target_profile_id: Option<String>,
|
||||
pub advanced_at_micros: i64,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
// This was generated using spacetimedb cli version 2.1.0 (commit 10a4779b1338eff3708493d87496b51842a7c412).
|
||||
// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d).
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
@@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
pub struct PuzzleRunNextLevelInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub target_profile_id: Option<String>,
|
||||
pub advanced_at_micros: i64,
|
||||
}
|
||||
|
||||
|
||||
@@ -559,6 +559,7 @@ impl SpacetimeClient {
|
||||
let procedure_input = PuzzleRunNextLevelInput {
|
||||
run_id: input.run_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
target_profile_id: input.target_profile_id,
|
||||
advanced_at_micros: input.advanced_at_micros,
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ pub struct CustomWorldProfile {
|
||||
owner_user_id: String,
|
||||
// 作品公开编号是稳定分享键,第一次发布时分配,后续重复发布沿用。
|
||||
public_work_code: Option<String>,
|
||||
// 作者公开叙世号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
|
||||
// 作者公开百梦号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
|
||||
author_public_user_code: Option<String>,
|
||||
source_agent_session_id: Option<String>,
|
||||
publication_status: CustomWorldPublicationStatus,
|
||||
|
||||
@@ -1769,17 +1769,36 @@ fn advance_puzzle_next_level_tx(
|
||||
let same_work_next_profile =
|
||||
selected_profile_level_after_runtime_level(¤t_profile, current_level)
|
||||
.map(|level| profile_for_single_level(¤t_profile, &level));
|
||||
let candidates = if same_work_next_profile.is_none() {
|
||||
list_published_puzzle_profiles(ctx)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let similar_work_next_profile = if same_work_next_profile.is_none() {
|
||||
let candidates = list_published_puzzle_profiles(ctx)?;
|
||||
select_next_profiles(
|
||||
let selected_candidates = select_next_profiles(
|
||||
¤t_profile,
|
||||
¤t_run.played_profile_ids,
|
||||
&candidates,
|
||||
1,
|
||||
3,
|
||||
);
|
||||
Some(
|
||||
if let Some(target_profile_id) = input.target_profile_id.as_ref().and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}) {
|
||||
selected_candidates
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.profile_id == target_profile_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "目标拼图作品不在当前下一关候选中".to_string())?
|
||||
} else {
|
||||
selected_candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.cloned()
|
||||
.ok_or_else(|| "没有可用的下一关候选".to_string())?
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.next()
|
||||
.cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -47,6 +47,11 @@ describe('PublishShareModal', () => {
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
|
||||
expect(dialog.parentElement?.className).toContain('!items-center');
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||
expect(dialog.className).toContain('platform-modal-shell');
|
||||
expect(dialog.className).toContain('rounded-[1.75rem]');
|
||||
expect(dialog.getAttribute('style')).toBeNull();
|
||||
expect(within(dialog).getByText(/邀请你来玩《暖灯猫街》/u)).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Check, Copy, MessageCircle, Music2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
buildPublishShareText,
|
||||
type PublishShareModalPayload,
|
||||
@@ -44,6 +45,7 @@ export function PublishShareModal({
|
||||
payload,
|
||||
onClose,
|
||||
}: PublishShareModalProps) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
@@ -89,7 +91,8 @@ export function PublishShareModal({
|
||||
title="分享给朋友"
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
panelClassName="platform-remap-surface"
|
||||
overlayClassName={`platform-theme platform-theme--${platformTheme} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
|
||||
footerClassName="justify-center border-t-0 px-4 pb-5 pt-0 sm:px-5"
|
||||
footer={
|
||||
|
||||
@@ -132,6 +132,8 @@ import {
|
||||
remixPuzzleGalleryWork,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import {
|
||||
advancePuzzleNextLevel,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
} from '../../services/puzzle-runtime';
|
||||
import {
|
||||
@@ -141,6 +143,7 @@ import {
|
||||
extendLocalPuzzleTime,
|
||||
isLocalPuzzleRun,
|
||||
refreshLocalPuzzleTimer,
|
||||
resolvePuzzleRestartLevelId,
|
||||
restartLocalPuzzleLevel,
|
||||
setLocalPuzzlePaused,
|
||||
startLocalPuzzleRun,
|
||||
@@ -876,31 +879,20 @@ function mergePuzzleServiceRuntimeState(
|
||||
}
|
||||
|
||||
const serviceLevel = serviceRun.currentLevel;
|
||||
if (
|
||||
currentRun.currentLevel.status === 'cleared' &&
|
||||
serviceLevel.status !== 'cleared'
|
||||
) {
|
||||
return {
|
||||
...currentRun,
|
||||
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
|
||||
nextLevelMode: serviceRun.nextLevelMode,
|
||||
nextLevelProfileId: serviceRun.nextLevelProfileId,
|
||||
nextLevelId: serviceRun.nextLevelId,
|
||||
recommendedNextWorks: serviceRun.recommendedNextWorks,
|
||||
leaderboardEntries:
|
||||
currentRun.currentLevel.leaderboardEntries.length > 0
|
||||
? currentRun.currentLevel.leaderboardEntries
|
||||
: currentRun.leaderboardEntries,
|
||||
};
|
||||
}
|
||||
|
||||
const leaderboardEntries =
|
||||
serviceLevel.leaderboardEntries.length > 0
|
||||
? serviceLevel.leaderboardEntries
|
||||
: serviceRun.leaderboardEntries;
|
||||
|
||||
// 中文注释:拼块布局和通关状态由前端即时裁决;后端快照只合并榜单与下一关 handoff。
|
||||
return {
|
||||
...currentRun,
|
||||
runId: serviceRun.runId,
|
||||
entryProfileId: serviceRun.entryProfileId,
|
||||
clearedLevelCount: Math.max(
|
||||
currentRun.clearedLevelCount,
|
||||
serviceRun.clearedLevelCount,
|
||||
),
|
||||
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
|
||||
nextLevelMode: serviceRun.nextLevelMode,
|
||||
nextLevelProfileId: serviceRun.nextLevelProfileId,
|
||||
@@ -909,18 +901,10 @@ function mergePuzzleServiceRuntimeState(
|
||||
leaderboardEntries,
|
||||
currentLevel: {
|
||||
...currentRun.currentLevel,
|
||||
status: serviceLevel.status,
|
||||
startedAtMs: serviceLevel.startedAtMs,
|
||||
clearedAtMs: serviceLevel.clearedAtMs,
|
||||
elapsedMs: serviceLevel.elapsedMs,
|
||||
timeLimitMs: serviceLevel.timeLimitMs,
|
||||
remainingMs: serviceLevel.remainingMs,
|
||||
pausedAccumulatedMs: serviceLevel.pausedAccumulatedMs,
|
||||
pauseStartedAtMs: serviceLevel.pauseStartedAtMs,
|
||||
freezeAccumulatedMs: serviceLevel.freezeAccumulatedMs,
|
||||
freezeStartedAtMs: serviceLevel.freezeStartedAtMs,
|
||||
freezeUntilMs: serviceLevel.freezeUntilMs,
|
||||
leaderboardEntries,
|
||||
leaderboardEntries:
|
||||
leaderboardEntries.length > 0
|
||||
? leaderboardEntries
|
||||
: currentRun.currentLevel.leaderboardEntries,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2181,8 +2165,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const item = detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
|
||||
const run = startLocalPuzzleRun(item, levelId ?? null);
|
||||
const item =
|
||||
detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
|
||||
const { run } = await startPuzzleRun({
|
||||
profileId: item.profileId,
|
||||
levelId: levelId ?? null,
|
||||
});
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeReturnStage(returnStage);
|
||||
@@ -2411,14 +2399,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
try {
|
||||
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
|
||||
setPuzzleRun(
|
||||
swapLocalPuzzlePieces(
|
||||
puzzleRun,
|
||||
payload,
|
||||
isLocalPuzzleRun(puzzleRun) ? selectedPuzzleDetail : null,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, selectedPuzzleDetail],
|
||||
);
|
||||
|
||||
const dragPuzzlePiece = useCallback(
|
||||
@@ -2430,14 +2424,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
try {
|
||||
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
|
||||
setPuzzleRun(
|
||||
dragLocalPuzzlePiece(
|
||||
puzzleRun,
|
||||
payload,
|
||||
isLocalPuzzleRun(puzzleRun) ? selectedPuzzleDetail : null,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, selectedPuzzleDetail],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2515,18 +2515,46 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
|
||||
const restartPuzzleCurrentLevel = useCallback(async () => {
|
||||
const currentLevel = puzzleRun?.currentLevel ?? null;
|
||||
if (!puzzleRun || !currentLevel || isPuzzleBusy) {
|
||||
const currentRun = puzzleRunRef.current ?? puzzleRun;
|
||||
const currentLevel = currentRun?.currentLevel ?? null;
|
||||
if (!currentRun || !currentLevel || isPuzzleBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPuzzleError(null);
|
||||
const nextRun = restartLocalPuzzleLevel(puzzleRunRef.current ?? puzzleRun);
|
||||
puzzleRunRef.current = nextRun;
|
||||
setPuzzleRun(nextRun);
|
||||
setIsPuzzleBusy(true);
|
||||
try {
|
||||
if (isLocalPuzzleRun(currentRun)) {
|
||||
const nextRun = restartLocalPuzzleLevel(currentRun);
|
||||
puzzleRunRef.current = nextRun;
|
||||
setPuzzleRun(nextRun);
|
||||
return;
|
||||
}
|
||||
|
||||
const detailItem =
|
||||
selectedPuzzleDetail?.profileId === currentLevel.profileId
|
||||
? selectedPuzzleDetail
|
||||
: await getPuzzleGalleryDetail(currentLevel.profileId).then(
|
||||
(response) => response.item,
|
||||
);
|
||||
const { run } = await startPuzzleRun({
|
||||
profileId: currentLevel.profileId,
|
||||
levelId: resolvePuzzleRestartLevelId(currentRun, detailItem),
|
||||
});
|
||||
setSelectedPuzzleDetail(detailItem);
|
||||
puzzleRunRef.current = run;
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '重新开始拼图关卡失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
}, [
|
||||
isPuzzleBusy,
|
||||
puzzleRun,
|
||||
resolvePuzzleErrorMessage,
|
||||
selectedPuzzleDetail,
|
||||
setIsPuzzleBusy,
|
||||
setPuzzleError,
|
||||
]);
|
||||
|
||||
@@ -2565,14 +2593,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
gameState.currentLevelId.trim()
|
||||
? gameState.currentLevelId
|
||||
: null;
|
||||
const item = selectedPuzzleDetail?.profileId === profileId
|
||||
? selectedPuzzleDetail
|
||||
: await getPuzzleGalleryDetail(profileId).then((response) => response.item);
|
||||
const nextRun = startLocalPuzzleRun(item, levelId);
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(nextRun);
|
||||
setPuzzleRuntimeReturnStage('platform');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
const item =
|
||||
selectedPuzzleDetail?.profileId === profileId
|
||||
? selectedPuzzleDetail
|
||||
: await getPuzzleGalleryDetail(profileId).then(
|
||||
(response) => response.item,
|
||||
);
|
||||
await startPuzzleRunFromProfile(
|
||||
item.profileId,
|
||||
'platform',
|
||||
item,
|
||||
false,
|
||||
levelId,
|
||||
);
|
||||
} catch (error) {
|
||||
platformBootstrap.setSaveError(
|
||||
resolvePuzzleErrorMessage(error, '恢复拼图存档失败。'),
|
||||
@@ -2587,7 +2620,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
selectedPuzzleDetail,
|
||||
resolvePuzzleErrorMessage,
|
||||
setPuzzleError,
|
||||
setSelectionStage,
|
||||
startPuzzleRunFromProfile,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2651,7 +2684,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
const advancePuzzleLevel = useCallback(
|
||||
async (target?: { profileId?: string; levelId?: string | null }) => {
|
||||
async (_target?: { profileId?: string; levelId?: string | null }) => {
|
||||
if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) {
|
||||
return;
|
||||
}
|
||||
@@ -2665,12 +2698,43 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const nextRun = advanceLocalPuzzleLevel(
|
||||
puzzleRun,
|
||||
selectedPuzzleDetail,
|
||||
target,
|
||||
);
|
||||
setPuzzleRun(nextRun);
|
||||
if (isLocalPuzzleRun(puzzleRun)) {
|
||||
const nextRun = advanceLocalPuzzleLevel(
|
||||
puzzleRun,
|
||||
selectedPuzzleDetail,
|
||||
_target,
|
||||
);
|
||||
setPuzzleRun(nextRun);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetProfileId = _target?.profileId?.trim() ?? '';
|
||||
if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) {
|
||||
const itemPromise =
|
||||
selectedPuzzleDetail?.profileId === targetProfileId
|
||||
? Promise.resolve(selectedPuzzleDetail)
|
||||
: getPuzzleGalleryDetail(targetProfileId).then(
|
||||
(response) => response.item,
|
||||
);
|
||||
const [{ run }, item] = await Promise.all([
|
||||
advancePuzzleNextLevel(puzzleRun.runId, {
|
||||
targetProfileId,
|
||||
}),
|
||||
itemPromise,
|
||||
]);
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(run);
|
||||
pushAppHistoryPath(
|
||||
buildPublicWorkStagePath(
|
||||
'puzzle-runtime',
|
||||
buildPuzzlePublicWorkCode(item.profileId),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { run } = await advancePuzzleNextLevel(puzzleRun.runId);
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
||||
} finally {
|
||||
@@ -3563,7 +3627,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) {
|
||||
const work = mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail);
|
||||
const work =
|
||||
selectedPuzzleDetail?.profileId === selectedPublicWorkDetail.profileId
|
||||
? selectedPuzzleDetail
|
||||
: mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail);
|
||||
if (!work) {
|
||||
setPublicWorkDetailError(
|
||||
'当前拼图作品信息不完整,暂时无法进入玩法。',
|
||||
@@ -3628,10 +3695,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
isPublicWorkDetailBusy,
|
||||
runProtectedAction,
|
||||
selectedDetailEntry,
|
||||
selectedPuzzleDetail,
|
||||
selectedPublicWorkDetail,
|
||||
startBigFishRunFromWork,
|
||||
startMatch3DRunFromProfile,
|
||||
startPuzzleRunFromProfile,
|
||||
startMatch3DRunFromProfile,
|
||||
]);
|
||||
|
||||
const remixPublicWork = useCallback(
|
||||
|
||||
@@ -132,7 +132,7 @@ function syncDraftFromEditState(
|
||||
workTitle: editState.workTitle.trim() || draft.workTitle,
|
||||
workDescription: editState.workDescription.trim(),
|
||||
levelName: primaryLevel.levelName,
|
||||
summary: primaryLevel.pictureDescription,
|
||||
summary: editState.workDescription.trim(),
|
||||
themeTags: editState.themeTags,
|
||||
candidates: primaryLevel.candidates,
|
||||
selectedCandidateId: primaryLevel.selectedCandidateId,
|
||||
@@ -1378,7 +1378,7 @@ export function PuzzleResultView({
|
||||
workTitle: normalizedState.workTitle,
|
||||
workDescription: normalizedState.workDescription,
|
||||
levelName: firstLevel.levelName,
|
||||
summary: firstLevel.pictureDescription,
|
||||
summary: normalizedState.workDescription,
|
||||
themeTags: normalizedState.themeTags,
|
||||
coverImageSrc: resolveLevelFormalImageSrc(firstLevel) || null,
|
||||
coverAssetId: firstLevel.coverAssetId ?? null,
|
||||
@@ -1531,7 +1531,7 @@ export function PuzzleResultView({
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
levelName: firstLevel.levelName.trim(),
|
||||
summary: firstLevel.pictureDescription.trim(),
|
||||
summary: editState.workDescription.trim(),
|
||||
themeTags: editState.themeTags,
|
||||
levelsJson: JSON.stringify(editState.levels),
|
||||
});
|
||||
@@ -1555,7 +1555,7 @@ export function PuzzleResultView({
|
||||
candidateCount: 1,
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
summary: activeLevel.pictureDescription.trim(),
|
||||
summary: editState.workDescription.trim(),
|
||||
themeTags: editState.themeTags,
|
||||
levelsJson: JSON.stringify(editState.levels),
|
||||
});
|
||||
|
||||
@@ -1743,26 +1743,30 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
{isExitRemodelPromptOpen ? (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-slate-950/72 px-4 py-6 backdrop-blur-sm"
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-slate-950/76 px-4 py-6 backdrop-blur-md"
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-exit-remodel-title"
|
||||
className="flex w-full max-w-[22rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
|
||||
className="relative flex w-full max-w-[21rem] flex-col overflow-hidden rounded-[1.35rem] border border-amber-200/24 bg-[linear-gradient(180deg,rgba(30,41,59,0.98),rgba(2,6,23,0.98))] shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="px-5 pt-6 text-center">
|
||||
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-amber-200/70 to-transparent" />
|
||||
<header className="flex flex-col items-center px-6 pt-7 text-center">
|
||||
<div className="mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-amber-200/28 bg-amber-200/12 shadow-[0_16px_42px_rgba(251,191,36,0.18)]">
|
||||
<Sparkles className="h-7 w-7 text-amber-200" />
|
||||
</div>
|
||||
<h2
|
||||
id="puzzle-exit-remodel-title"
|
||||
className="text-2xl font-black leading-tight text-white"
|
||||
className="text-[1.75rem] font-black leading-[1.08] text-white"
|
||||
>
|
||||
体验不佳?
|
||||
<br />
|
||||
试试改造功能!
|
||||
</h2>
|
||||
</header>
|
||||
<footer className="grid gap-3 px-5 py-5">
|
||||
<footer className="grid gap-3 px-5 pb-5 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
@@ -1770,7 +1774,7 @@ export function PuzzleRuntimeShell({
|
||||
setIsExitRemodelPromptOpen(false);
|
||||
void onRemodelWork?.(exitPromptProfileId);
|
||||
}}
|
||||
className="rounded-full bg-amber-200 px-5 py-3 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="min-h-[3.25rem] rounded-2xl bg-amber-200 px-5 text-sm font-black text-slate-950 shadow-[0_14px_34px_rgba(251,191,36,0.24)] transition hover:bg-amber-100 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
作品改造
|
||||
</button>
|
||||
@@ -1780,7 +1784,7 @@ export function PuzzleRuntimeShell({
|
||||
setIsExitRemodelPromptOpen(false);
|
||||
onBack();
|
||||
}}
|
||||
className="rounded-full border border-white/14 bg-black/24 px-5 py-3 text-sm font-black text-white transition hover:bg-white/10"
|
||||
className="min-h-[3rem] rounded-2xl border border-white/14 bg-white/8 px-5 text-sm font-bold text-white/92 transition hover:bg-white/12 active:translate-y-px"
|
||||
>
|
||||
保存并退出
|
||||
</button>
|
||||
|
||||
@@ -1782,6 +1782,21 @@ beforeEach(() => {
|
||||
vi.mocked(remixPuzzleGalleryWork).mockRejectedValue(
|
||||
new Error('未启用拼图 remix'),
|
||||
);
|
||||
vi.mocked(startPuzzleRun).mockImplementation(async (payload) => {
|
||||
const run = buildMockPuzzleRun(payload.profileId, '后端拼图关卡');
|
||||
return {
|
||||
run: {
|
||||
...run,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
levelId: payload.levelId ?? run.currentLevel.levelId,
|
||||
startedAtMs: Date.now(),
|
||||
}
|
||||
: run.currentLevel,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mocked(advancePuzzleNextLevel).mockImplementation(async (runId) => ({
|
||||
run: buildMockPuzzleRun(`${runId}-next-profile`, '后端推荐下一关'),
|
||||
}));
|
||||
@@ -2496,12 +2511,6 @@ test('published puzzle works appear on home and mobile game category channel', a
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: publishedPuzzleWork,
|
||||
});
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: buildMockPuzzleRun(
|
||||
publishedPuzzleWork.profileId,
|
||||
publishedPuzzleWork.levelName,
|
||||
),
|
||||
});
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
@@ -2587,12 +2596,6 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: publishedPuzzleWork,
|
||||
});
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: buildMockPuzzleRun(
|
||||
publishedPuzzleWork.profileId,
|
||||
publishedPuzzleWork.levelName,
|
||||
),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
@@ -2959,63 +2962,24 @@ test('published puzzle work card restores its source session for editing', async
|
||||
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('formal puzzle next level uses backend run and leaderboard keeps frontend level snapshot', async () => {
|
||||
test('formal puzzle runtime uses frontend move merge logic and backend leaderboard next level', async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstLevelLeaderboardEntries = [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '测试玩家',
|
||||
elapsedMs: 12_000,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
];
|
||||
const firstLevel = buildClearedPuzzleRun({
|
||||
const clearedFirstLevel = buildClearedPuzzleRun({
|
||||
runId: 'run-puzzle-profile-public-1',
|
||||
entryProfileId: 'puzzle-profile-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelName: '雨夜猫塔',
|
||||
levelIndex: 1,
|
||||
elapsedMs: 12_000,
|
||||
recommendedNextProfileId: 'puzzle-profile-public-2',
|
||||
leaderboardEntries: firstLevelLeaderboardEntries,
|
||||
elapsedMs: 18_000,
|
||||
});
|
||||
const secondLevelBase = buildMockPuzzleRun(
|
||||
'puzzle-profile-public-2',
|
||||
'星桥机关',
|
||||
);
|
||||
const secondLevel: PuzzleRunSnapshot = {
|
||||
...secondLevelBase,
|
||||
runId: firstLevel.runId,
|
||||
entryProfileId: firstLevel.entryProfileId,
|
||||
currentLevelIndex: 2,
|
||||
playedProfileIds: [
|
||||
'puzzle-profile-public-1',
|
||||
'puzzle-profile-public-2',
|
||||
],
|
||||
currentLevel: {
|
||||
...secondLevelBase.currentLevel!,
|
||||
runId: firstLevel.runId,
|
||||
levelIndex: 2,
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
const clearedFirstLevelWithNext = {
|
||||
...clearedFirstLevel,
|
||||
recommendedNextProfileId: 'puzzle-profile-public-1',
|
||||
nextLevelMode: 'sameWork' as const,
|
||||
nextLevelProfileId: 'puzzle-profile-public-1',
|
||||
nextLevelId: 'puzzle-level-2',
|
||||
recommendedNextWorks: [],
|
||||
};
|
||||
const clearedSecondLevel = buildClearedPuzzleRun({
|
||||
runId: firstLevel.runId,
|
||||
entryProfileId: firstLevel.entryProfileId,
|
||||
profileId: 'puzzle-profile-public-2',
|
||||
levelName: '星桥机关',
|
||||
levelIndex: 2,
|
||||
elapsedMs: 18_000,
|
||||
});
|
||||
const serviceLeaderboardRun = buildClearedPuzzleRun({
|
||||
runId: firstLevel.runId,
|
||||
entryProfileId: firstLevel.entryProfileId,
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelName: '雨夜猫塔',
|
||||
levelIndex: 1,
|
||||
elapsedMs: 18_000,
|
||||
recommendedNextProfileId: 'puzzle-profile-public-2',
|
||||
});
|
||||
const leaderboardEntries = [
|
||||
{
|
||||
rank: 1,
|
||||
@@ -3024,27 +2988,49 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({ run: firstLevel });
|
||||
vi.mocked(advancePuzzleNextLevel).mockResolvedValue({ run: secondLevel });
|
||||
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
|
||||
run: {
|
||||
...serviceLeaderboardRun,
|
||||
const backendLeaderboardRun = {
|
||||
...clearedFirstLevelWithNext,
|
||||
leaderboardEntries,
|
||||
currentLevel: {
|
||||
...clearedFirstLevelWithNext.currentLevel!,
|
||||
leaderboardEntries,
|
||||
},
|
||||
};
|
||||
const backendSecondLevel = {
|
||||
...buildMockPuzzleRun('puzzle-profile-public-1', '星桥机关'),
|
||||
runId: clearedFirstLevel.runId,
|
||||
entryProfileId: clearedFirstLevel.entryProfileId,
|
||||
currentLevelIndex: 2,
|
||||
currentLevel: {
|
||||
...buildMockPuzzleRun('puzzle-profile-public-1', '星桥机关')
|
||||
.currentLevel!,
|
||||
runId: clearedFirstLevel.runId,
|
||||
levelIndex: 2,
|
||||
levelId: 'puzzle-level-2',
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
};
|
||||
const backendStartedRun = buildMockPuzzleRun(
|
||||
'puzzle-profile-public-1',
|
||||
'雨夜猫塔',
|
||||
);
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: {
|
||||
...backendStartedRun,
|
||||
currentLevel: {
|
||||
...serviceLeaderboardRun.currentLevel!,
|
||||
leaderboardEntries,
|
||||
...backendStartedRun.currentLevel!,
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(dragPuzzlePieceOrGroup).mockResolvedValue({
|
||||
run: clearedSecondLevel,
|
||||
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
|
||||
run: backendLeaderboardRun,
|
||||
});
|
||||
vi.mocked(swapPuzzlePieces).mockResolvedValue({
|
||||
run: clearedSecondLevel,
|
||||
vi.mocked(advancePuzzleNextLevel).mockResolvedValue({
|
||||
run: backendSecondLevel,
|
||||
});
|
||||
vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedSecondLevel);
|
||||
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedSecondLevel);
|
||||
vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedFirstLevel);
|
||||
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedFirstLevel);
|
||||
|
||||
const puzzleWork: PuzzleWorkSummary = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
@@ -3063,6 +3049,28 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
||||
playCount: 8,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫塔',
|
||||
pictureDescription: '雨夜猫塔首关。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
{
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '星桥机关第二关。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
@@ -3071,7 +3079,6 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: puzzleWork,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
@@ -3082,39 +3089,216 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith({
|
||||
levelId: null,
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
});
|
||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '下一关' }, { timeout: 3000 }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(firstLevel.runId);
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith({
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelId: null,
|
||||
});
|
||||
expect(advancePuzzleNextLevel).toHaveBeenCalledTimes(1);
|
||||
expect((await screen.findAllByText('星桥机关')).length).toBeGreaterThan(0);
|
||||
|
||||
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
|
||||
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitPuzzleLeaderboard).toHaveBeenCalledWith(firstLevel.runId, {
|
||||
profileId: 'puzzle-profile-public-2',
|
||||
gridSize: 3,
|
||||
elapsedMs: 18_000,
|
||||
nickname: '测试玩家',
|
||||
});
|
||||
expect(swapLocalPuzzlePieces).toHaveBeenCalled();
|
||||
});
|
||||
expect(swapPuzzlePieces).not.toHaveBeenCalled();
|
||||
expect(dragPuzzlePieceOrGroup).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(submitPuzzleLeaderboard).toHaveBeenCalledWith(
|
||||
clearedFirstLevel.runId,
|
||||
{
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
gridSize: 3,
|
||||
elapsedMs: 18_000,
|
||||
nickname: '测试玩家',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '通关完成' }, { timeout: 3000 }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
const dialog = await screen.findByRole(
|
||||
'dialog',
|
||||
{ name: '通关完成' },
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(screen.getByText('测试玩家')).toBeTruthy();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
||||
clearedFirstLevel.runId,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
(await screen.findAllByText('星桥机关', undefined, {
|
||||
timeout: 3000,
|
||||
})).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('formal puzzle similar work keeps current run level progression', async () => {
|
||||
const user = userEvent.setup();
|
||||
const clearedThirdLevel = buildClearedPuzzleRun({
|
||||
runId: 'run-puzzle-profile-public-1',
|
||||
entryProfileId: 'puzzle-profile-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelName: '雨夜猫塔',
|
||||
levelIndex: 3,
|
||||
elapsedMs: 18_000,
|
||||
recommendedNextProfileId: 'puzzle-profile-similar-2',
|
||||
});
|
||||
const clearedThirdLevelWithCandidates: PuzzleRunSnapshot = {
|
||||
...clearedThirdLevel,
|
||||
nextLevelMode: 'similarWorks',
|
||||
nextLevelProfileId: 'puzzle-profile-similar-1',
|
||||
nextLevelId: null,
|
||||
recommendedNextWorks: [
|
||||
{
|
||||
profileId: 'puzzle-profile-similar-1',
|
||||
levelName: '雾海遗迹',
|
||||
authorDisplayName: '星桥旅人',
|
||||
themeTags: ['奇幻', '遗迹'],
|
||||
coverImageSrc: null,
|
||||
similarityScore: 0.91,
|
||||
},
|
||||
{
|
||||
profileId: 'puzzle-profile-similar-2',
|
||||
levelName: '风塔试炼',
|
||||
authorDisplayName: '晨风',
|
||||
themeTags: ['奇幻', '机关'],
|
||||
coverImageSrc: null,
|
||||
similarityScore: 0.84,
|
||||
},
|
||||
],
|
||||
};
|
||||
const similarFourthLevel = {
|
||||
...buildMockPuzzleRun('puzzle-profile-similar-2', '风塔试炼'),
|
||||
runId: clearedThirdLevel.runId,
|
||||
entryProfileId: clearedThirdLevel.entryProfileId,
|
||||
currentLevelIndex: 4,
|
||||
currentGridSize: 5 as const,
|
||||
playedProfileIds: [
|
||||
'puzzle-profile-public-1',
|
||||
'puzzle-profile-similar-2',
|
||||
],
|
||||
currentLevel: {
|
||||
...buildMockPuzzleRun('puzzle-profile-similar-2', '风塔试炼')
|
||||
.currentLevel!,
|
||||
runId: clearedThirdLevel.runId,
|
||||
levelIndex: 4,
|
||||
levelId: 'similar-level-1',
|
||||
gridSize: 5 as const,
|
||||
timeLimitMs: 210_000,
|
||||
remainingMs: 210_000,
|
||||
startedAtMs: Date.now(),
|
||||
board: {
|
||||
rows: 5,
|
||||
cols: 5,
|
||||
selectedPieceId: null,
|
||||
allTilesResolved: false,
|
||||
mergedGroups: [],
|
||||
pieces: Array.from({ length: 25 }, (_, index) => ({
|
||||
pieceId: `piece-${index}`,
|
||||
correctRow: Math.floor(index / 5),
|
||||
correctCol: index % 5,
|
||||
currentRow: Math.floor(index / 5),
|
||||
currentCol: index % 5,
|
||||
mergedGroupId: null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
const backendStartedRun = buildMockPuzzleRun(
|
||||
'puzzle-profile-public-1',
|
||||
'雨夜猫塔',
|
||||
);
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: {
|
||||
...backendStartedRun,
|
||||
currentLevel: {
|
||||
...backendStartedRun.currentLevel!,
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
|
||||
run: clearedThirdLevelWithCandidates,
|
||||
});
|
||||
vi.mocked(advancePuzzleNextLevel).mockResolvedValue({
|
||||
run: similarFourthLevel,
|
||||
});
|
||||
vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedThirdLevel);
|
||||
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedThirdLevel);
|
||||
|
||||
const entryWork: PuzzleWorkSummary = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '雨夜猫塔',
|
||||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||||
playCount: 8,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
};
|
||||
const similarWork: PuzzleWorkSummary = {
|
||||
...entryWork,
|
||||
workId: 'puzzle-work-similar-2',
|
||||
profileId: 'puzzle-profile-similar-2',
|
||||
levelName: '风塔试炼',
|
||||
summary: '另一套奇幻机关拼图。',
|
||||
};
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [entryWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
|
||||
item: profileId === similarWork.profileId ? similarWork : entryWork,
|
||||
}));
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||
});
|
||||
vi.mocked(startPuzzleRun).mockClear();
|
||||
|
||||
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
|
||||
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
|
||||
|
||||
const dialog = await screen.findByRole(
|
||||
'dialog',
|
||||
{ name: '通关完成' },
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await user.click(within(dialog).getByRole('button', { name: /风塔试炼/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
||||
clearedThirdLevel.runId,
|
||||
{ targetProfileId: 'puzzle-profile-similar-2' },
|
||||
);
|
||||
});
|
||||
expect(startPuzzleRun).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('第 4 关')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(document.querySelectorAll('[data-piece-id]').length).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
test('first puzzle runtime back click can open remix result page', async () => {
|
||||
@@ -3177,9 +3361,6 @@ test('first puzzle runtime back click can open remix result page', async () => {
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: puzzleWork,
|
||||
});
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: buildMockPuzzleRun(puzzleWork.profileId, puzzleWork.levelName),
|
||||
});
|
||||
vi.mocked(remixPuzzleGalleryWork).mockResolvedValue({
|
||||
session: remixSession,
|
||||
});
|
||||
@@ -4795,7 +4976,7 @@ test('creation hub published work experience button enters world directly', asyn
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creation hub published work card no longer exposes direct delete action', async () => {
|
||||
test('creation hub published work card keeps delete action guarded by detail flow', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const publishedWork = {
|
||||
@@ -4867,6 +5048,6 @@ test('creation hub published work card no longer exposes direct delete action',
|
||||
await openCreationHub(user);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /查看详情/u })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
|
||||
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
24
src/config/viteProxyConfig.test.ts
Normal file
24
src/config/viteProxyConfig.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import viteConfig from '../../vite.config';
|
||||
|
||||
describe('vite dev api proxy', () => {
|
||||
it('forwards the profile main route to the Rust API server', async () => {
|
||||
const resolvedConfig =
|
||||
typeof viteConfig === 'function'
|
||||
? await viteConfig({ command: 'serve', mode: 'test' })
|
||||
: viteConfig;
|
||||
|
||||
// 中文注释:`/api/profile/*` 是“我的”和“存档”页面的主链路由;
|
||||
// 本地 Vite 若漏配代理,会把请求回退到 index.html,前端再按 JSON 解析就会报 `Unexpected token '<'`。
|
||||
expect(resolvedConfig.server?.proxy).toEqual(
|
||||
expect.objectContaining({
|
||||
'/api/profile': expect.objectContaining({
|
||||
target: expect.any(String),
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
PuzzleRuntimeLevelSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
|
||||
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
|
||||
@@ -53,10 +54,6 @@ function resolvePuzzleLevelConfig(levelIndex: number): PuzzleLevelConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
|
||||
return resolvePuzzleLevelConfig(clearedLevelCount + 1).gridSize;
|
||||
}
|
||||
|
||||
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS = 64;
|
||||
|
||||
function buildLocalPuzzleRunId(profileId: string) {
|
||||
@@ -724,20 +721,88 @@ function buildLocalLevelName(previousLevelName: string, levelIndex: number) {
|
||||
}
|
||||
|
||||
// 本地兜底只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。
|
||||
function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
function resolveWorkLevelIndexById(
|
||||
levels: PuzzleDraftLevel[] | undefined,
|
||||
levelId: string | null | undefined,
|
||||
) {
|
||||
if (!levelId) {
|
||||
return -1;
|
||||
}
|
||||
return levels?.findIndex((level) => level.levelId === levelId) ?? -1;
|
||||
}
|
||||
|
||||
function resolveWorkLevelById(
|
||||
levels: PuzzleDraftLevel[] | undefined,
|
||||
levelId: string | null | undefined,
|
||||
) {
|
||||
const levelIndex = resolveWorkLevelIndexById(levels, levelId);
|
||||
return levelIndex >= 0 ? (levels?.[levelIndex] ?? null) : null;
|
||||
}
|
||||
|
||||
function resolveNextSameWorkLevel(
|
||||
work: PuzzleWorkSummary | null | undefined,
|
||||
currentLevel: PuzzleRuntimeLevelSnapshot,
|
||||
) {
|
||||
const levels = work?.levels;
|
||||
if (!levels?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentLevelIndexById = resolveWorkLevelIndexById(
|
||||
levels,
|
||||
currentLevel.levelId,
|
||||
);
|
||||
const nextLevelIndex =
|
||||
currentLevelIndexById >= 0
|
||||
? currentLevelIndexById + 1
|
||||
: currentLevel.levelIndex;
|
||||
return levels[nextLevelIndex] ?? null;
|
||||
}
|
||||
|
||||
function applyLocalNextLevelHandoff(
|
||||
run: PuzzleRunSnapshot,
|
||||
work: PuzzleWorkSummary | null | undefined,
|
||||
currentLevel: PuzzleRuntimeLevelSnapshot,
|
||||
) {
|
||||
const nextLevel = resolveNextSameWorkLevel(work, currentLevel);
|
||||
return {
|
||||
...run,
|
||||
nextLevelMode: nextLevel ? ('sameWork' as const) : ('none' as const),
|
||||
nextLevelProfileId: nextLevel ? currentLevel.profileId : null,
|
||||
nextLevelId: nextLevel?.levelId ?? null,
|
||||
recommendedNextProfileId: nextLevel ? currentLevel.profileId : null,
|
||||
recommendedNextWorks: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackLocalLevel(
|
||||
run: PuzzleRunSnapshot,
|
||||
work?: PuzzleWorkSummary | null,
|
||||
target?: { profileId?: string; levelId?: string | null },
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'cleared') {
|
||||
return run;
|
||||
}
|
||||
|
||||
const nextLevelIndex = run.currentLevelIndex + 1;
|
||||
const gridSize = resolvePuzzleGridSize(run.clearedLevelCount);
|
||||
const gridSize = resolvePuzzleLevelConfig(nextLevelIndex).gridSize;
|
||||
const nextProfileId =
|
||||
run.recommendedNextProfileId ??
|
||||
target?.profileId?.trim() ||
|
||||
run.nextLevelProfileId ||
|
||||
run.recommendedNextProfileId ||
|
||||
buildLocalNextProfileId(run.entryProfileId, nextLevelIndex);
|
||||
const nextLevel =
|
||||
resolveWorkLevelById(work?.levels, target?.levelId ?? run.nextLevelId) ??
|
||||
resolveNextSameWorkLevel(work, currentLevel);
|
||||
const startedAtMs = Date.now();
|
||||
const nextLevelName =
|
||||
nextLevel?.levelName ??
|
||||
buildLocalLevelName(currentLevel.levelName, nextLevelIndex);
|
||||
const nextCoverImageSrc =
|
||||
nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc;
|
||||
|
||||
return {
|
||||
const nextRun: PuzzleRunSnapshot = {
|
||||
...run,
|
||||
currentLevelIndex: nextLevelIndex,
|
||||
currentGridSize: gridSize,
|
||||
@@ -749,10 +814,10 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
...currentLevel,
|
||||
runId: run.runId,
|
||||
levelIndex: nextLevelIndex,
|
||||
levelId: null,
|
||||
levelId: nextLevel?.levelId ?? null,
|
||||
gridSize,
|
||||
profileId: nextProfileId,
|
||||
levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
|
||||
levelName: nextLevelName,
|
||||
board: buildInitialBoard(
|
||||
gridSize,
|
||||
run.runId,
|
||||
@@ -763,28 +828,32 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
coverImageSrc: nextCoverImageSrc,
|
||||
...buildLevelTimerFields(nextLevelIndex),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: 'none',
|
||||
nextLevelProfileId: null,
|
||||
nextLevelId: null,
|
||||
recommendedNextWorks: [],
|
||||
leaderboardEntries: [],
|
||||
};
|
||||
|
||||
if (!nextRun.currentLevel) {
|
||||
return nextRun;
|
||||
}
|
||||
return applyLocalNextLevelHandoff(nextRun, work, nextRun.currentLevel);
|
||||
}
|
||||
|
||||
export function startLocalPuzzleRun(
|
||||
item: PuzzleWorkSummary,
|
||||
levelId?: string | null,
|
||||
): PuzzleRunSnapshot {
|
||||
const gridSize = resolvePuzzleGridSize(0);
|
||||
const gridSize = resolvePuzzleLevelConfig(1).gridSize;
|
||||
const runId = buildLocalPuzzleRunId(item.profileId);
|
||||
const startedAtMs = Date.now();
|
||||
const firstLevel = item.levels?.[0] ?? null;
|
||||
const requestedLevelIndex = resolveWorkLevelIndexById(item.levels, levelId);
|
||||
const currentLevelIndex = requestedLevelIndex >= 0 ? requestedLevelIndex : 0;
|
||||
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
|
||||
const firstLevelName = firstLevel?.levelName || item.levelName;
|
||||
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
|
||||
const secondLevel = item.levels?.[1] ?? null;
|
||||
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
|
||||
return {
|
||||
runId,
|
||||
entryProfileId: item.profileId,
|
||||
@@ -811,10 +880,10 @@ export function startLocalPuzzleRun(
|
||||
...buildLevelTimerFields(1),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: secondLevel ? 'sameWork' : 'none',
|
||||
nextLevelProfileId: secondLevel ? item.profileId : null,
|
||||
nextLevelId: secondLevel?.levelId ?? null,
|
||||
recommendedNextProfileId: nextSameWorkLevel ? item.profileId : null,
|
||||
nextLevelMode: nextSameWorkLevel ? 'sameWork' : 'none',
|
||||
nextLevelProfileId: nextSameWorkLevel ? item.profileId : null,
|
||||
nextLevelId: nextSameWorkLevel?.levelId ?? null,
|
||||
recommendedNextWorks: [],
|
||||
leaderboardEntries: [],
|
||||
};
|
||||
@@ -823,6 +892,7 @@ export function startLocalPuzzleRun(
|
||||
export function swapLocalPuzzlePieces(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: SwapPuzzlePiecesRequest,
|
||||
work?: PuzzleWorkSummary | null,
|
||||
): PuzzleRunSnapshot {
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
@@ -843,10 +913,13 @@ export function swapLocalPuzzlePieces(
|
||||
second.currentRow = firstPosition.row;
|
||||
second.currentCol = firstPosition.col;
|
||||
|
||||
return applyNextBoard(
|
||||
const nextRun = applyNextBoard(
|
||||
timedRun,
|
||||
rebuildBoardSnapshot(currentLevel.gridSize, pieces),
|
||||
);
|
||||
return nextRun.currentLevel?.status === 'cleared'
|
||||
? syncLocalPuzzleRunHandoff(nextRun, work)
|
||||
: nextRun;
|
||||
}
|
||||
|
||||
function dragSinglePiece(
|
||||
@@ -968,6 +1041,7 @@ function dragGroup(
|
||||
export function dragLocalPuzzlePiece(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
work?: PuzzleWorkSummary | null,
|
||||
): PuzzleRunSnapshot {
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
@@ -1003,16 +1077,32 @@ export function dragLocalPuzzlePiece(
|
||||
dragSinglePiece(pieces, moving, payload.targetRow, payload.targetCol);
|
||||
}
|
||||
|
||||
return applyNextBoard(
|
||||
const nextRun = applyNextBoard(
|
||||
timedRun,
|
||||
rebuildBoardSnapshot(currentLevel.gridSize, pieces),
|
||||
);
|
||||
return nextRun.currentLevel?.status === 'cleared'
|
||||
? syncLocalPuzzleRunHandoff(nextRun, work)
|
||||
: nextRun;
|
||||
}
|
||||
|
||||
export function advanceLocalPuzzleLevel(
|
||||
run: PuzzleRunSnapshot,
|
||||
work?: PuzzleWorkSummary | null,
|
||||
target?: { profileId?: string; levelId?: string | null },
|
||||
): PuzzleRunSnapshot {
|
||||
return buildFallbackLocalLevel(run);
|
||||
return buildFallbackLocalLevel(run, work, target);
|
||||
}
|
||||
|
||||
export function syncLocalPuzzleRunHandoff(
|
||||
run: PuzzleRunSnapshot,
|
||||
work: PuzzleWorkSummary | null | undefined,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel) {
|
||||
return run;
|
||||
}
|
||||
return applyLocalNextLevelHandoff(run, work, currentLevel);
|
||||
}
|
||||
|
||||
export function restartLocalPuzzleLevel(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
AdvancePuzzleNextLevelRequest,
|
||||
PuzzleRunResponse,
|
||||
StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
@@ -101,11 +102,21 @@ export async function dragPuzzlePieceOrGroup(
|
||||
/**
|
||||
* 进入推荐出的下一关。
|
||||
*/
|
||||
export async function advancePuzzleNextLevel(runId: string) {
|
||||
export async function advancePuzzleNextLevel(
|
||||
runId: string,
|
||||
payload: AdvancePuzzleNextLevelRequest = {},
|
||||
) {
|
||||
const targetProfileId = payload.targetProfileId?.trim() ?? '';
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
|
||||
{
|
||||
method: 'POST',
|
||||
...(targetProfileId
|
||||
? {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ targetProfileId }),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
'进入下一关失败',
|
||||
{
|
||||
|
||||
@@ -67,6 +67,11 @@ export default defineConfig(({mode}) => {
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/api/profile': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/api/runtime': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user