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

This commit is contained in:
2026-05-02 20:43:41 +08:00
parent 543ccf2509
commit 5831703156
36 changed files with 799 additions and 254 deletions

View File

@@ -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 或代理配置,先更新本文和测试,再改工程实现。

View File

@@ -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 拼图模块测试和编码检查通过。

View File

@@ -20,6 +20,14 @@
5. 面板次按钮为 `保存并退出`,点击后关闭面板并执行原返回逻辑。
6. 非首次点击返回不再弹出面板,直接执行原返回逻辑。
## UI 布局
1. 面板保持居中独立弹层,移动端宽度不超过屏幕安全边距,桌面端保持紧凑。
2. 面板只展示标题与两个行动按钮,不增加说明性文案。
3. 标题使用两行居中排版,顶部可以放无文字图标强化游戏感。
4. `作品改造` 为主按钮,视觉权重高于 `保存并退出`
5. 两个按钮纵向排列,固定触控高度,确保移动端易点击。
## 首次状态
首次曝光是浏览器侧 UI 引导状态,不是业务真相态:

View File

@@ -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` 作为历史收尾记录保留;若与本文冲突,以本文的“低延迟棋盘前端裁决,非即时链路后端持久化”口径为准。

View File

@@ -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`、切割规格和倒计时。

View File

@@ -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 白名单。

View File

@@ -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、绑定状态。

View File

@@ -53,7 +53,7 @@
10. `RefreshSessionRecord`
11. `AuthStoreSnapshotRecord`
12. 密码长度、短信验证码长度、验证码 TTL、冷却、失败次数等领域常量。
13. 手机号规范化、手机号脱敏、公开叙世号规范化、验证码 key 构造等纯函数。
13. 手机号规范化、手机号脱敏、公开百梦号规范化、验证码 key 构造等纯函数。
本次将以下写入输入落入 `commands.rs`

View File

@@ -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 模式使用资格校验。

View File

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

View File

@@ -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 发布成功后由父层回传分享数据并打开面板。

View File

@@ -124,6 +124,10 @@ export interface DragPuzzlePieceRequest {
targetCol: number;
}
export interface AdvancePuzzleNextLevelRequest {
targetProfileId?: string | null;
}
export interface UsePuzzleRuntimePropRequest {
propKind: PuzzleRuntimePropKind;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("兑换码已停用"),

View File

@@ -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 == "光点回合数")
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1769,17 +1769,36 @@ fn advance_puzzle_next_level_tx(
let same_work_next_profile =
selected_profile_level_after_runtime_level(&current_profile, current_level)
.map(|level| profile_for_single_level(&current_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(
&current_profile,
&current_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
};

View File

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

View File

@@ -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={

View File

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

View File

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

View File

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

View File

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

View 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,
}),
}),
);
});
});

View File

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

View File

@@ -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 }),
}
: {}),
},
'进入下一关失败',
{

View File

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