Merge remote-tracking branch 'origin/master' into codex/ddd
# Conflicts: # docs/technical/README.md # docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md # docs/technical/SPACETIMEDB_TABLE_CATALOG.md # scripts/generate-spacetime-bindings.mjs # server-rs/crates/api-server/src/app.rs # server-rs/crates/api-server/src/assets.rs # server-rs/crates/api-server/src/big_fish.rs # server-rs/crates/api-server/src/custom_world_ai.rs # server-rs/crates/api-server/src/llm.rs # server-rs/crates/api-server/src/main.rs # server-rs/crates/api-server/src/puzzle.rs # server-rs/crates/api-server/src/runtime_profile.rs # server-rs/crates/api-server/src/runtime_story/compat/ai.rs # server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs # server-rs/crates/api-server/src/runtime_story/compat/presentation.rs # server-rs/crates/api-server/src/runtime_story/compat/tests.rs # server-rs/crates/api-server/src/state.rs # server-rs/crates/module-auth/src/lib.rs # server-rs/crates/module-big-fish/src/lib.rs # server-rs/crates/module-custom-world/src/lib.rs # server-rs/crates/module-puzzle/src/lib.rs # server-rs/crates/module-runtime/src/lib.rs # server-rs/crates/spacetime-client/src/big_fish.rs # server-rs/crates/spacetime-client/src/lib.rs # server-rs/crates/spacetime-client/src/mapper.rs # server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_run_type.rs # server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_big_fish_draft_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_puzzle_agent_draft_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_big_fish_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_puzzle_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_runtime_snapshot_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/drag_puzzle_piece_or_group_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_big_fish_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_custom_world_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/generate_big_fish_asset_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_big_fish_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_run_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_profile_save_archives_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_gallery_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/mod.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_big_fish_game_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resume_profile_save_archive_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_generated_images_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/select_puzzle_cover_image_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/start_puzzle_run_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_big_fish_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_agent_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/swap_puzzle_pieces_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_snapshot_and_return_procedure.rs # server-rs/crates/spacetime-module/src/auth/procedures.rs # server-rs/crates/spacetime-module/src/custom_world/mod.rs # server-rs/crates/spacetime-module/src/lib.rs # server-rs/crates/spacetime-module/src/migration.rs # server-rs/crates/spacetime-module/src/puzzle.rs # server-rs/crates/spacetime-module/src/runtime/profile.rs # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx # src/services/aiService.ts # src/services/puzzle-runtime/puzzleRuntimeClient.ts
This commit is contained in:
@@ -6,4 +6,6 @@ export {
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
swapPuzzlePieces,
|
||||
updatePuzzleRunPause,
|
||||
usePuzzleRuntimeProp,
|
||||
} from './puzzleRuntimeClient';
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import {
|
||||
applyLocalPuzzleFreezeTime,
|
||||
advanceLocalPuzzleLevel,
|
||||
dragLocalPuzzlePiece,
|
||||
extendLocalPuzzleTime,
|
||||
isLocalPuzzleRun,
|
||||
refreshLocalPuzzleTimer,
|
||||
restartLocalPuzzleLevel,
|
||||
resolvePuzzleRestartLevelId,
|
||||
setLocalPuzzlePaused,
|
||||
startLocalPuzzleRun,
|
||||
submitLocalPuzzleLeaderboard,
|
||||
swapLocalPuzzlePieces,
|
||||
@@ -26,6 +32,7 @@ const baseWork: PuzzleWorkSummary = {
|
||||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||||
publishedAt: '2026-04-25T00:00:00.000Z',
|
||||
playCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
};
|
||||
|
||||
@@ -80,6 +87,50 @@ function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
}
|
||||
|
||||
describe('puzzleLocalRuntime', () => {
|
||||
test('本地关卡切割和倒计时按正式配置推进并循环', () => {
|
||||
let run = startLocalPuzzleRun(baseWork);
|
||||
const actual = [run];
|
||||
for (let index = 0; index < 15; index += 1) {
|
||||
run = advanceLocalPuzzleLevel({
|
||||
...run,
|
||||
clearedLevelCount: run.clearedLevelCount + 1,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
status: 'cleared',
|
||||
clearedAtMs: Date.now(),
|
||||
elapsedMs: 1_000,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
actual.push(run);
|
||||
}
|
||||
|
||||
expect(
|
||||
actual.map((item) => [
|
||||
item.currentLevel?.gridSize,
|
||||
item.currentLevel?.timeLimitMs,
|
||||
]),
|
||||
).toEqual([
|
||||
[3, 300_000],
|
||||
[4, 300_000],
|
||||
[5, 300_000],
|
||||
[5, 210_000],
|
||||
[5, 210_000],
|
||||
[6, 240_000],
|
||||
[5, 210_000],
|
||||
[7, 270_000],
|
||||
[5, 240_000],
|
||||
[7, 270_000],
|
||||
[5, 210_000],
|
||||
[6, 240_000],
|
||||
[5, 210_000],
|
||||
[7, 270_000],
|
||||
[5, 240_000],
|
||||
[7, 270_000],
|
||||
]);
|
||||
});
|
||||
|
||||
test('每次启动都会生成不同的初始打乱样式', async () => {
|
||||
const firstRun = startLocalPuzzleRun(baseWork);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
@@ -88,10 +139,9 @@ describe('puzzleLocalRuntime', () => {
|
||||
piece.currentRow,
|
||||
piece.currentCol,
|
||||
]);
|
||||
const secondPositions = secondRun.currentLevel?.board.pieces.map((piece) => [
|
||||
piece.currentRow,
|
||||
piece.currentCol,
|
||||
]);
|
||||
const secondPositions = secondRun.currentLevel?.board.pieces.map(
|
||||
(piece) => [piece.currentRow, piece.currentCol],
|
||||
);
|
||||
|
||||
expect(firstPositions).not.toEqual(secondPositions);
|
||||
});
|
||||
@@ -132,7 +182,10 @@ describe('puzzleLocalRuntime', () => {
|
||||
'piece-7': [1, 2],
|
||||
'piece-8': [2, 1],
|
||||
};
|
||||
const current = layout[piece.pieceId] ?? [piece.currentRow, piece.currentCol];
|
||||
const current = layout[piece.pieceId] ?? [
|
||||
piece.currentRow,
|
||||
piece.currentCol,
|
||||
];
|
||||
return {
|
||||
...piece,
|
||||
currentRow: current[0],
|
||||
@@ -265,7 +318,9 @@ describe('puzzleLocalRuntime', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const occupiedCells = nextBoard.pieces.map((piece) => `${piece.currentRow}:${piece.currentCol}`);
|
||||
const occupiedCells = nextBoard.pieces.map(
|
||||
(piece) => `${piece.currentRow}:${piece.currentCol}`,
|
||||
);
|
||||
expect(new Set(occupiedCells).size).toBe(nextBoard.pieces.length);
|
||||
expect(
|
||||
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-5'),
|
||||
@@ -288,7 +343,9 @@ describe('puzzleLocalRuntime', () => {
|
||||
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
|
||||
|
||||
expect(clearedRun.currentLevel?.status).toBe('cleared');
|
||||
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
|
||||
expect(clearedRun.recommendedNextProfileId).toBeNull();
|
||||
expect(clearedRun.nextLevelMode).toBe('none');
|
||||
expect(clearedRun.recommendedNextWorks).toEqual([]);
|
||||
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
|
||||
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(clearedRun.leaderboardEntries).toEqual([]);
|
||||
@@ -302,6 +359,8 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(nextRun.currentLevel?.elapsedMs).toBeNull();
|
||||
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(nextRun.recommendedNextProfileId).toBeNull();
|
||||
expect(nextRun.nextLevelMode).toBe('none');
|
||||
expect(nextRun.recommendedNextWorks).toEqual([]);
|
||||
});
|
||||
|
||||
test('连续推进下一关会重新打乱棋盘', () => {
|
||||
@@ -312,9 +371,15 @@ describe('puzzleLocalRuntime', () => {
|
||||
|
||||
expect(secondRun.currentLevelIndex).toBe(2);
|
||||
expect(thirdRun.currentLevelIndex).toBe(3);
|
||||
expect(boardPositionSignature(secondRun)).not.toBe(boardPositionSignature(thirdRun));
|
||||
expect(hasAnyOriginalNeighborPair(secondRun.currentLevel?.board.pieces ?? [])).toBe(false);
|
||||
expect(hasAnyOriginalNeighborPair(thirdRun.currentLevel?.board.pieces ?? [])).toBe(false);
|
||||
expect(boardPositionSignature(secondRun)).not.toBe(
|
||||
boardPositionSignature(thirdRun),
|
||||
);
|
||||
expect(
|
||||
hasAnyOriginalNeighborPair(secondRun.currentLevel?.board.pieces ?? []),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasAnyOriginalNeighborPair(thirdRun.currentLevel?.board.pieces ?? []),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('本地 run 通关后用本地排行榜兜底,不再依赖后端 runId', () => {
|
||||
@@ -337,4 +402,184 @@ describe('puzzleLocalRuntime', () => {
|
||||
leaderboardRun.leaderboardEntries,
|
||||
);
|
||||
});
|
||||
|
||||
test('本地倒计时超时后进入失败状态并拒绝继续移动', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const expiredRun = {
|
||||
...run,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
startedAtMs: Date.now() - run.currentLevel.timeLimitMs - 1_000,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
const timedRun = refreshLocalPuzzleTimer(expiredRun);
|
||||
const nextRun = dragLocalPuzzlePiece(timedRun, {
|
||||
pieceId: 'piece-0',
|
||||
targetRow: 0,
|
||||
targetCol: 0,
|
||||
});
|
||||
|
||||
expect(timedRun.currentLevel?.status).toBe('failed');
|
||||
expect(timedRun.currentLevel?.remainingMs).toBe(0);
|
||||
expect(nextRun).toBe(timedRun);
|
||||
});
|
||||
|
||||
test('本地失败关卡可以续时一分钟', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const failedRun = refreshLocalPuzzleTimer({
|
||||
...run,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
startedAtMs: Date.now() - run.currentLevel.timeLimitMs - 1_000,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
const extendedRun = extendLocalPuzzleTime(failedRun);
|
||||
|
||||
expect(extendedRun.currentLevel?.status).toBe('playing');
|
||||
expect(extendedRun.currentLevel?.remainingMs).toBe(60_000);
|
||||
expect(extendedRun.currentLevel?.elapsedMs).toBeNull();
|
||||
expect(extendedRun.currentLevel?.pauseStartedAtMs).toBeNull();
|
||||
expect(extendedRun.currentLevel?.freezeUntilMs).toBeNull();
|
||||
});
|
||||
|
||||
test('本地失败关卡重新开始会保留关卡索引并重建棋盘', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const failedRun = refreshLocalPuzzleTimer({
|
||||
...run,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
startedAtMs: Date.now() - run.currentLevel.timeLimitMs - 1_000,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
const restartedRun = restartLocalPuzzleLevel(failedRun);
|
||||
|
||||
expect(restartedRun.runId).not.toBe(failedRun.runId);
|
||||
expect(restartedRun.currentLevel?.status).toBe('playing');
|
||||
expect(restartedRun.currentLevel?.levelIndex).toBe(
|
||||
failedRun.currentLevel?.levelIndex,
|
||||
);
|
||||
expect(restartedRun.currentLevel?.remainingMs).toBe(
|
||||
restartedRun.currentLevel?.timeLimitMs,
|
||||
);
|
||||
expect(boardPositionSignature(restartedRun)).not.toBe(
|
||||
boardPositionSignature(failedRun),
|
||||
);
|
||||
});
|
||||
|
||||
test('失败重开优先使用当前关卡 id,旧快照缺失时按关卡序号兜底', () => {
|
||||
const workWithLevels: PuzzleWorkSummary = {
|
||||
...baseWork,
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '第一关',
|
||||
pictureDescription: '第一关画面',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/level-1.png',
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
{
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '第二关',
|
||||
pictureDescription: '第二关画面',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/level-2.png',
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
};
|
||||
const run = startLocalPuzzleRun(workWithLevels);
|
||||
const secondLevelRun = {
|
||||
...run,
|
||||
currentLevelIndex: 2,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
levelIndex: 2,
|
||||
levelId: null,
|
||||
status: 'failed' as const,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
expect(resolvePuzzleRestartLevelId(secondLevelRun, workWithLevels)).toBe(
|
||||
'puzzle-level-2',
|
||||
);
|
||||
expect(
|
||||
resolvePuzzleRestartLevelId(
|
||||
{
|
||||
...secondLevelRun,
|
||||
currentLevel: secondLevelRun.currentLevel
|
||||
? {
|
||||
...secondLevelRun.currentLevel,
|
||||
levelId: 'explicit-level',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
workWithLevels,
|
||||
),
|
||||
).toBe('explicit-level');
|
||||
});
|
||||
|
||||
test('暂停和冻结时间不会消耗本地倒计时', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const pausedRun = setLocalPuzzlePaused(
|
||||
{
|
||||
...run,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
startedAtMs: Date.now() - 5_000,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
true,
|
||||
);
|
||||
const pausedStartedAt =
|
||||
pausedRun.currentLevel?.pauseStartedAtMs ?? Date.now();
|
||||
const pausedAfterWait = refreshLocalPuzzleTimer({
|
||||
...pausedRun,
|
||||
currentLevel: pausedRun.currentLevel
|
||||
? {
|
||||
...pausedRun.currentLevel,
|
||||
startedAtMs: pausedRun.currentLevel.startedAtMs - 5_000,
|
||||
pauseStartedAtMs: pausedStartedAt - 5_000,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const frozenRun = applyLocalPuzzleFreezeTime(pausedAfterWait);
|
||||
const freezeStartedAt =
|
||||
frozenRun.currentLevel?.freezeStartedAtMs ?? Date.now();
|
||||
const frozenAfterWait = refreshLocalPuzzleTimer({
|
||||
...frozenRun,
|
||||
currentLevel: frozenRun.currentLevel
|
||||
? {
|
||||
...frozenRun.currentLevel,
|
||||
startedAtMs: frozenRun.currentLevel.startedAtMs - 5_000,
|
||||
freezeStartedAtMs: freezeStartedAt - 5_000,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
expect(pausedAfterWait.currentLevel?.remainingMs).toBe(
|
||||
pausedRun.currentLevel?.remainingMs,
|
||||
);
|
||||
expect(frozenAfterWait.currentLevel?.remainingMs).toBe(
|
||||
frozenRun.currentLevel?.remainingMs,
|
||||
);
|
||||
expect(frozenAfterWait.currentLevel?.pauseStartedAtMs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,18 +7,78 @@ import type {
|
||||
PuzzleMergedGroupState,
|
||||
PuzzlePieceState,
|
||||
PuzzleRunSnapshot,
|
||||
PuzzleRuntimeLevelSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
|
||||
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
|
||||
const PUZZLE_FREEZE_TIME_DURATION_MS = 10_000;
|
||||
const PUZZLE_EXTEND_TIME_DURATION_MS = 60_000;
|
||||
let localPuzzleRunSequence = 0;
|
||||
type PuzzleLevelConfig = {
|
||||
gridSize: PuzzleGridSize;
|
||||
timeLimitMs: number;
|
||||
};
|
||||
|
||||
// 中文注释:本地兜底必须和后端按同一关卡序号解析切割规格与倒计时。
|
||||
function resolvePuzzleLevelConfig(levelIndex: number): PuzzleLevelConfig {
|
||||
const normalizedLevelIndex = Math.max(1, Math.floor(levelIndex || 1));
|
||||
switch (normalizedLevelIndex) {
|
||||
case 1:
|
||||
return { gridSize: 3, timeLimitMs: 300_000 };
|
||||
case 2:
|
||||
return { gridSize: 4, timeLimitMs: 300_000 };
|
||||
case 3:
|
||||
return { gridSize: 5, timeLimitMs: 300_000 };
|
||||
case 4:
|
||||
return { gridSize: 5, timeLimitMs: 210_000 };
|
||||
default: {
|
||||
const loopIndex = ((Math.max(5, normalizedLevelIndex) - 5) % 6) + 5;
|
||||
switch (loopIndex) {
|
||||
case 5:
|
||||
return { gridSize: 5, timeLimitMs: 210_000 };
|
||||
case 6:
|
||||
return { gridSize: 6, timeLimitMs: 240_000 };
|
||||
case 7:
|
||||
return { gridSize: 5, timeLimitMs: 210_000 };
|
||||
case 8:
|
||||
return { gridSize: 7, timeLimitMs: 270_000 };
|
||||
case 9:
|
||||
return { gridSize: 5, timeLimitMs: 240_000 };
|
||||
default:
|
||||
return { gridSize: 7, timeLimitMs: 270_000 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
|
||||
return clearedLevelCount >= 3 ? 4 : 3;
|
||||
return resolvePuzzleLevelConfig(clearedLevelCount + 1).gridSize;
|
||||
}
|
||||
|
||||
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS = 64;
|
||||
|
||||
function buildLocalPuzzleRunId(profileId: string) {
|
||||
localPuzzleRunSequence = (localPuzzleRunSequence + 1) % 1_000_000;
|
||||
return `${LOCAL_PUZZLE_RUN_ID_PREFIX}${profileId}-${Date.now()}-${localPuzzleRunSequence}`;
|
||||
}
|
||||
|
||||
export function resolvePuzzleRestartLevelId(
|
||||
run: PuzzleRunSnapshot,
|
||||
work: PuzzleWorkSummary | null | undefined,
|
||||
): string | null {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
currentLevel.levelId ??
|
||||
work?.levels?.[Math.max(0, currentLevel.levelIndex - 1)]?.levelId ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function buildShuffleSeed(...parts: Array<string | number>) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const part of parts.join('|')) {
|
||||
@@ -70,7 +130,11 @@ function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
|
||||
row: Math.floor(index / gridSize),
|
||||
col: index % gridSize,
|
||||
}));
|
||||
for (let attempt = 0; attempt < PUZZLE_INITIAL_SHUFFLE_ATTEMPTS; attempt += 1) {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < PUZZLE_INITIAL_SHUFFLE_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
const shuffled = shufflePositions(
|
||||
positions,
|
||||
(seed + Math.imul(attempt, 2654435761)) >>> 0,
|
||||
@@ -81,7 +145,11 @@ function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
|
||||
return shuffled;
|
||||
}
|
||||
}
|
||||
return buildOriginalNeighborFreePositions(gridSize, seed) ?? positions;
|
||||
return (
|
||||
buildDeterministicNeighborFreePositions(gridSize, seed) ??
|
||||
buildOriginalNeighborFreePositions(gridSize, seed) ??
|
||||
positions
|
||||
);
|
||||
}
|
||||
|
||||
function boardCellKey(row: number, col: number) {
|
||||
@@ -92,6 +160,107 @@ function clampElapsedMs(value: number) {
|
||||
return Math.max(1_000, Math.round(value));
|
||||
}
|
||||
|
||||
function resolvePuzzleLevelTimeLimitMs(levelIndex: number) {
|
||||
return resolvePuzzleLevelConfig(levelIndex || 1).timeLimitMs;
|
||||
}
|
||||
|
||||
function resolveActiveFreezeElapsedMs(
|
||||
level: PuzzleRuntimeLevelSnapshot,
|
||||
nowMs: number,
|
||||
) {
|
||||
if (!level.freezeStartedAtMs || !level.freezeUntilMs) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(nowMs, level.freezeUntilMs) - level.freezeStartedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEffectiveElapsedMs(
|
||||
level: PuzzleRuntimeLevelSnapshot,
|
||||
nowMs: number,
|
||||
) {
|
||||
const pauseElapsedMs = level.pauseStartedAtMs
|
||||
? Math.max(0, nowMs - level.pauseStartedAtMs)
|
||||
: 0;
|
||||
return Math.max(
|
||||
0,
|
||||
nowMs -
|
||||
level.startedAtMs -
|
||||
level.pausedAccumulatedMs -
|
||||
pauseElapsedMs -
|
||||
level.freezeAccumulatedMs -
|
||||
resolveActiveFreezeElapsedMs(level, nowMs),
|
||||
);
|
||||
}
|
||||
|
||||
function settleExpiredFreeze(
|
||||
level: PuzzleRuntimeLevelSnapshot,
|
||||
nowMs: number,
|
||||
): PuzzleRuntimeLevelSnapshot {
|
||||
if (
|
||||
!level.freezeStartedAtMs ||
|
||||
!level.freezeUntilMs ||
|
||||
nowMs < level.freezeUntilMs
|
||||
) {
|
||||
return level;
|
||||
}
|
||||
return {
|
||||
...level,
|
||||
freezeAccumulatedMs:
|
||||
level.freezeAccumulatedMs +
|
||||
Math.max(0, level.freezeUntilMs - level.freezeStartedAtMs),
|
||||
freezeStartedAtMs: null,
|
||||
freezeUntilMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function withResolvedTimer(run: PuzzleRunSnapshot, nowMs = Date.now()) {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
return run;
|
||||
}
|
||||
const settledLevel = settleExpiredFreeze(currentLevel, nowMs);
|
||||
const remainingMs = Math.max(
|
||||
0,
|
||||
settledLevel.timeLimitMs - resolveEffectiveElapsedMs(settledLevel, nowMs),
|
||||
);
|
||||
return {
|
||||
...run,
|
||||
currentLevel: {
|
||||
...settledLevel,
|
||||
remainingMs,
|
||||
status: remainingMs <= 0 ? ('failed' as const) : settledLevel.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildLevelTimerFields(levelIndex: number) {
|
||||
const timeLimitMs = resolvePuzzleLevelTimeLimitMs(levelIndex);
|
||||
return {
|
||||
timeLimitMs,
|
||||
remainingMs: timeLimitMs,
|
||||
pausedAccumulatedMs: 0,
|
||||
pauseStartedAtMs: null,
|
||||
freezeAccumulatedMs: 0,
|
||||
freezeStartedAtMs: null,
|
||||
freezeUntilMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function closePauseForLevel(level: PuzzleRuntimeLevelSnapshot, nowMs: number) {
|
||||
if (!level.pauseStartedAtMs) {
|
||||
return level;
|
||||
}
|
||||
return {
|
||||
...level,
|
||||
pausedAccumulatedMs:
|
||||
level.pausedAccumulatedMs + Math.max(0, nowMs - level.pauseStartedAtMs),
|
||||
pauseStartedAtMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function neighborCells(row: number, col: number): PuzzleCellPosition[] {
|
||||
return [
|
||||
row > 0 ? { row: row - 1, col } : null,
|
||||
@@ -127,18 +296,6 @@ function buildPiecesFromPositions(
|
||||
}));
|
||||
}
|
||||
|
||||
function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
const piecesByCell = new Map(
|
||||
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
|
||||
);
|
||||
return pieces.some((piece) =>
|
||||
neighborCells(piece.currentRow, piece.currentCol).some((neighbor) => {
|
||||
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
|
||||
return Boolean(neighborPiece && areCorrectNeighbors(piece, neighborPiece));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function areOriginalNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) {
|
||||
return (
|
||||
Math.abs(right.correctRow - left.correctRow) +
|
||||
@@ -149,12 +306,19 @@ function areOriginalNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) {
|
||||
|
||||
function hasAnyOriginalNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
const piecesByCell = new Map(
|
||||
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
|
||||
pieces.map((piece) => [
|
||||
boardCellKey(piece.currentRow, piece.currentCol),
|
||||
piece,
|
||||
]),
|
||||
);
|
||||
return pieces.some((piece) =>
|
||||
neighborCells(piece.currentRow, piece.currentCol).some((neighbor) => {
|
||||
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
|
||||
return Boolean(neighborPiece && areOriginalNeighbors(piece, neighborPiece));
|
||||
const neighborPiece = piecesByCell.get(
|
||||
boardCellKey(neighbor.row, neighbor.col),
|
||||
);
|
||||
return Boolean(
|
||||
neighborPiece && areOriginalNeighbors(piece, neighborPiece),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -168,6 +332,127 @@ function seededOrderKey(seed: number, value: number) {
|
||||
return (state ^ (state >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
function buildDeterministicNeighborFreePositions(
|
||||
gridSize: PuzzleGridSize,
|
||||
seed: number,
|
||||
) {
|
||||
if (gridSize === 3) {
|
||||
return buildSeeded3x3NeighborFreePositions(seed);
|
||||
}
|
||||
if (gridSize === 4 || gridSize === 6) {
|
||||
return buildAffineNeighborFreePositions(gridSize, 1, 1, 2, 1, seed);
|
||||
}
|
||||
if (gridSize === 5 || gridSize === 7) {
|
||||
return buildAffineNeighborFreePositions(
|
||||
gridSize,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
gridSize - 1,
|
||||
seed,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildSeeded3x3NeighborFreePositions(seed: number) {
|
||||
const layouts: Array<Array<[number, number]>> = [
|
||||
[
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[1, 2],
|
||||
[2, 0],
|
||||
[0, 2],
|
||||
[2, 1],
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
[0, 0],
|
||||
],
|
||||
[
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[1, 2],
|
||||
[2, 0],
|
||||
[0, 2],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
[
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[1, 2],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
[0, 2],
|
||||
[2, 1],
|
||||
],
|
||||
[
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[1, 2],
|
||||
[2, 1],
|
||||
[0, 2],
|
||||
[2, 0],
|
||||
[0, 0],
|
||||
[2, 2],
|
||||
[1, 1],
|
||||
],
|
||||
[
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[1, 2],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
[2, 1],
|
||||
[1, 1],
|
||||
[2, 0],
|
||||
[0, 0],
|
||||
],
|
||||
[
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[2, 1],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
[1, 2],
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
];
|
||||
const layout = layouts[Math.abs(seed) % layouts.length] ?? layouts[0];
|
||||
return (
|
||||
layout?.map(([row, col]) => ({
|
||||
row,
|
||||
col,
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function buildAffineNeighborFreePositions(
|
||||
gridSize: PuzzleGridSize,
|
||||
rowFromRow: number,
|
||||
rowFromCol: number,
|
||||
colFromRow: number,
|
||||
colFromCol: number,
|
||||
seed: number,
|
||||
) {
|
||||
const rowOffset = seed % gridSize;
|
||||
const colOffset = Math.floor(seed / gridSize) % gridSize;
|
||||
return Array.from({ length: gridSize * gridSize }, (_, index) => {
|
||||
const row = Math.floor(index / gridSize);
|
||||
const col = index % gridSize;
|
||||
return {
|
||||
row: (rowFromRow * row + rowFromCol * col + rowOffset) % gridSize,
|
||||
col: (colFromRow * row + colFromCol * col + colOffset) % gridSize,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildOriginalNeighborFreePositions(
|
||||
gridSize: PuzzleGridSize,
|
||||
seed: number,
|
||||
@@ -242,11 +527,14 @@ function violatesOriginalNeighborFreeRule(
|
||||
return false;
|
||||
}
|
||||
const originalNeighbors =
|
||||
Math.abs(Math.floor(pieceIndex / gridSize) - Math.floor(placedIndex / gridSize)) +
|
||||
Math.abs(
|
||||
Math.floor(pieceIndex / gridSize) - Math.floor(placedIndex / gridSize),
|
||||
) +
|
||||
Math.abs((pieceIndex % gridSize) - (placedIndex % gridSize)) ===
|
||||
1;
|
||||
const currentNeighbors =
|
||||
Math.abs(cell.row - placedCell.row) + Math.abs(cell.col - placedCell.col) ===
|
||||
Math.abs(cell.row - placedCell.row) +
|
||||
Math.abs(cell.col - placedCell.col) ===
|
||||
1;
|
||||
return originalNeighbors && currentNeighbors;
|
||||
});
|
||||
@@ -256,7 +544,10 @@ function resolveMergedGroups(
|
||||
pieces: PuzzlePieceState[],
|
||||
): PuzzleMergedGroupState[] {
|
||||
const piecesByCell = new Map(
|
||||
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
|
||||
pieces.map((piece) => [
|
||||
boardCellKey(piece.currentRow, piece.currentCol),
|
||||
piece,
|
||||
]),
|
||||
);
|
||||
const piecesById = new Map(pieces.map((piece) => [piece.pieceId, piece]));
|
||||
const visited = new Set<string>();
|
||||
@@ -285,7 +576,9 @@ function resolveMergedGroups(
|
||||
currentPiece.currentRow,
|
||||
currentPiece.currentCol,
|
||||
)) {
|
||||
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
|
||||
const neighborPiece = piecesByCell.get(
|
||||
boardCellKey(neighbor.row, neighbor.col),
|
||||
);
|
||||
if (neighborPiece && areCorrectNeighbors(currentPiece, neighborPiece)) {
|
||||
queue.push(neighborPiece.pieceId);
|
||||
}
|
||||
@@ -332,7 +625,8 @@ function rebuildBoardSnapshot(
|
||||
piece.currentCol === piece.correctCol,
|
||||
);
|
||||
const allPiecesMergedIntoOneGroup = mergedGroups.some(
|
||||
(group) => group.pieceIds.length === nextPieces.length && nextPieces.length > 1,
|
||||
(group) =>
|
||||
group.pieceIds.length === nextPieces.length && nextPieces.length > 1,
|
||||
);
|
||||
const allTilesResolved =
|
||||
allPiecesInCorrectCells || allPiecesMergedIntoOneGroup;
|
||||
@@ -365,36 +659,44 @@ function applyNextBoard(
|
||||
run: PuzzleRunSnapshot,
|
||||
nextBoard: PuzzleBoardSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
if (!run.currentLevel) {
|
||||
const timedRun = withResolvedTimer(run);
|
||||
if (!timedRun.currentLevel || timedRun.currentLevel.status === 'failed') {
|
||||
return run;
|
||||
}
|
||||
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
|
||||
const nextClearedLevelCount =
|
||||
status === 'cleared' && run.currentLevel.status !== 'cleared'
|
||||
? run.clearedLevelCount + 1
|
||||
: run.clearedLevelCount;
|
||||
const justCleared = status === 'cleared' && run.currentLevel.status !== 'cleared';
|
||||
status === 'cleared' && timedRun.currentLevel.status !== 'cleared'
|
||||
? timedRun.clearedLevelCount + 1
|
||||
: timedRun.clearedLevelCount;
|
||||
const justCleared =
|
||||
status === 'cleared' && timedRun.currentLevel.status !== 'cleared';
|
||||
const nowMs = Date.now();
|
||||
const clearedAtMs = justCleared ? nowMs : (run.currentLevel.clearedAtMs ?? null);
|
||||
const clearedAtMs = justCleared
|
||||
? nowMs
|
||||
: (timedRun.currentLevel.clearedAtMs ?? null);
|
||||
const elapsedMs = justCleared
|
||||
? clampElapsedMs(nowMs - run.currentLevel.startedAtMs)
|
||||
: (run.currentLevel.elapsedMs ?? null);
|
||||
? clampElapsedMs(resolveEffectiveElapsedMs(timedRun.currentLevel, nowMs))
|
||||
: (timedRun.currentLevel.elapsedMs ?? null);
|
||||
return {
|
||||
...run,
|
||||
...timedRun,
|
||||
clearedLevelCount: nextClearedLevelCount,
|
||||
currentLevel: {
|
||||
...run.currentLevel,
|
||||
...timedRun.currentLevel,
|
||||
board: nextBoard,
|
||||
status,
|
||||
clearedAtMs,
|
||||
elapsedMs,
|
||||
leaderboardEntries: justCleared ? [] : run.currentLevel.leaderboardEntries,
|
||||
remainingMs: justCleared ? 0 : timedRun.currentLevel.remainingMs,
|
||||
leaderboardEntries: justCleared
|
||||
? []
|
||||
: timedRun.currentLevel.leaderboardEntries,
|
||||
},
|
||||
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
|
||||
recommendedNextProfileId:
|
||||
status === 'cleared'
|
||||
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
|
||||
: run.recommendedNextProfileId,
|
||||
recommendedNextProfileId: run.recommendedNextProfileId,
|
||||
nextLevelMode: run.nextLevelMode ?? 'none',
|
||||
nextLevelProfileId: run.nextLevelProfileId ?? null,
|
||||
nextLevelId: run.nextLevelId ?? null,
|
||||
recommendedNextWorks: run.recommendedNextWorks ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -447,25 +749,42 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
...currentLevel,
|
||||
runId: run.runId,
|
||||
levelIndex: nextLevelIndex,
|
||||
levelId: null,
|
||||
gridSize,
|
||||
profileId: nextProfileId,
|
||||
levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
|
||||
board: buildInitialBoard(gridSize, run.runId, nextProfileId, nextLevelIndex),
|
||||
board: buildInitialBoard(
|
||||
gridSize,
|
||||
run.runId,
|
||||
nextProfileId,
|
||||
nextLevelIndex,
|
||||
),
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
...buildLevelTimerFields(nextLevelIndex),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: 'none',
|
||||
nextLevelProfileId: null,
|
||||
nextLevelId: null,
|
||||
recommendedNextWorks: [],
|
||||
leaderboardEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
|
||||
export function startLocalPuzzleRun(
|
||||
item: PuzzleWorkSummary,
|
||||
): PuzzleRunSnapshot {
|
||||
const gridSize = resolvePuzzleGridSize(0);
|
||||
const runId = `${LOCAL_PUZZLE_RUN_ID_PREFIX}${item.profileId}-${Date.now()}`;
|
||||
const runId = buildLocalPuzzleRunId(item.profileId);
|
||||
const startedAtMs = Date.now();
|
||||
const firstLevel = item.levels?.[0] ?? null;
|
||||
const firstLevelName = firstLevel?.levelName || item.levelName;
|
||||
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
|
||||
const secondLevel = item.levels?.[1] ?? null;
|
||||
return {
|
||||
runId,
|
||||
entryProfileId: item.profileId,
|
||||
@@ -477,20 +796,26 @@ export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot
|
||||
currentLevel: {
|
||||
runId,
|
||||
levelIndex: 1,
|
||||
levelId: firstLevel?.levelId ?? null,
|
||||
gridSize,
|
||||
profileId: item.profileId,
|
||||
levelName: item.levelName,
|
||||
levelName: firstLevelName,
|
||||
authorDisplayName: item.authorDisplayName,
|
||||
themeTags: item.themeTags,
|
||||
coverImageSrc: item.coverImageSrc,
|
||||
coverImageSrc: firstCoverImageSrc,
|
||||
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
...buildLevelTimerFields(1),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: secondLevel ? 'sameWork' : 'none',
|
||||
nextLevelProfileId: secondLevel ? item.profileId : null,
|
||||
nextLevelId: secondLevel?.levelId ?? null,
|
||||
recommendedNextWorks: [],
|
||||
leaderboardEntries: [],
|
||||
};
|
||||
}
|
||||
@@ -499,15 +824,18 @@ export function swapLocalPuzzlePieces(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: SwapPuzzlePiecesRequest,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||
return run;
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
return timedRun;
|
||||
}
|
||||
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
||||
const first = pieces.find((piece) => piece.pieceId === payload.firstPieceId);
|
||||
const second = pieces.find((piece) => piece.pieceId === payload.secondPieceId);
|
||||
const second = pieces.find(
|
||||
(piece) => piece.pieceId === payload.secondPieceId,
|
||||
);
|
||||
if (!first || !second) {
|
||||
return run;
|
||||
return timedRun;
|
||||
}
|
||||
const firstPosition = { row: first.currentRow, col: first.currentCol };
|
||||
first.currentRow = second.currentRow;
|
||||
@@ -515,7 +843,10 @@ export function swapLocalPuzzlePieces(
|
||||
second.currentRow = firstPosition.row;
|
||||
second.currentCol = firstPosition.col;
|
||||
|
||||
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||
return applyNextBoard(
|
||||
timedRun,
|
||||
rebuildBoardSnapshot(currentLevel.gridSize, pieces),
|
||||
);
|
||||
}
|
||||
|
||||
function dragSinglePiece(
|
||||
@@ -591,7 +922,8 @@ function dragGroup(
|
||||
col: piece.currentCol,
|
||||
}))
|
||||
.filter(
|
||||
(position) => !targetCellKeys.has(boardCellKey(position.row, position.col)),
|
||||
(position) =>
|
||||
!targetCellKeys.has(boardCellKey(position.row, position.col)),
|
||||
)
|
||||
.sort((left, right) => left.row - right.row || left.col - right.col);
|
||||
const occupyingPieces = targetPositions
|
||||
@@ -607,7 +939,8 @@ function dragGroup(
|
||||
.filter((piece): piece is PuzzlePieceState => Boolean(piece))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.currentRow - right.currentRow || left.currentCol - right.currentCol,
|
||||
left.currentRow - right.currentRow ||
|
||||
left.currentCol - right.currentCol,
|
||||
);
|
||||
|
||||
if (occupyingPieces.length !== vacatedPositions.length) {
|
||||
@@ -636,9 +969,10 @@ export function dragLocalPuzzlePiece(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||
return run;
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
return timedRun;
|
||||
}
|
||||
if (
|
||||
payload.targetRow < 0 ||
|
||||
@@ -646,12 +980,12 @@ export function dragLocalPuzzlePiece(
|
||||
payload.targetRow >= currentLevel.gridSize ||
|
||||
payload.targetCol >= currentLevel.gridSize
|
||||
) {
|
||||
return run;
|
||||
return timedRun;
|
||||
}
|
||||
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
||||
const moving = pieces.find((piece) => piece.pieceId === payload.pieceId);
|
||||
if (!moving) {
|
||||
return run;
|
||||
return timedRun;
|
||||
}
|
||||
|
||||
if (moving.mergedGroupId) {
|
||||
@@ -663,19 +997,57 @@ export function dragLocalPuzzlePiece(
|
||||
currentLevel.gridSize,
|
||||
);
|
||||
if (!moved) {
|
||||
return run;
|
||||
return timedRun;
|
||||
}
|
||||
} else {
|
||||
dragSinglePiece(pieces, moving, payload.targetRow, payload.targetCol);
|
||||
}
|
||||
|
||||
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||
return applyNextBoard(
|
||||
timedRun,
|
||||
rebuildBoardSnapshot(currentLevel.gridSize, pieces),
|
||||
);
|
||||
}
|
||||
|
||||
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
export function advanceLocalPuzzleLevel(
|
||||
run: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
return buildFallbackLocalLevel(run);
|
||||
}
|
||||
|
||||
export function restartLocalPuzzleLevel(
|
||||
run: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel) {
|
||||
return run;
|
||||
}
|
||||
|
||||
const runId = buildLocalPuzzleRunId(currentLevel.profileId);
|
||||
const startedAtMs = Date.now();
|
||||
return {
|
||||
...run,
|
||||
runId,
|
||||
leaderboardEntries: [],
|
||||
currentLevel: {
|
||||
...currentLevel,
|
||||
runId,
|
||||
board: buildInitialBoard(
|
||||
currentLevel.gridSize,
|
||||
runId,
|
||||
currentLevel.profileId,
|
||||
currentLevel.levelIndex,
|
||||
),
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
...buildLevelTimerFields(currentLevel.levelIndex),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前拼图运行态是否为前端本地兜底 run。
|
||||
* 这类 run 没有后端持久化记录,不能再调用依赖真实 runId 的排行榜接口。
|
||||
@@ -717,3 +1089,90 @@ export function submitLocalPuzzleLeaderboard(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshLocalPuzzleTimer(
|
||||
run: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
return withResolvedTimer(run);
|
||||
}
|
||||
|
||||
export function setLocalPuzzlePaused(
|
||||
run: PuzzleRunSnapshot,
|
||||
paused: boolean,
|
||||
): PuzzleRunSnapshot {
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
return timedRun;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
if (paused) {
|
||||
return {
|
||||
...timedRun,
|
||||
currentLevel: {
|
||||
...currentLevel,
|
||||
pauseStartedAtMs: currentLevel.pauseStartedAtMs ?? nowMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...timedRun,
|
||||
currentLevel: closePauseForLevel(currentLevel, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyLocalPuzzleFreezeTime(
|
||||
run: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
return timedRun;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const activeLevel = closePauseForLevel(currentLevel, nowMs);
|
||||
return {
|
||||
...timedRun,
|
||||
currentLevel: {
|
||||
...activeLevel,
|
||||
freezeStartedAtMs: nowMs,
|
||||
freezeUntilMs: nowMs + PUZZLE_FREEZE_TIME_DURATION_MS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function extendLocalPuzzleTime(
|
||||
run: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'failed') {
|
||||
return timedRun;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const consumedBeforeExtend = Math.max(
|
||||
0,
|
||||
currentLevel.timeLimitMs - PUZZLE_EXTEND_TIME_DURATION_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
...timedRun,
|
||||
currentLevel: {
|
||||
...currentLevel,
|
||||
status: 'playing',
|
||||
startedAtMs: nowMs - consumedBeforeExtend,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
remainingMs: PUZZLE_EXTEND_TIME_DURATION_MS,
|
||||
pausedAccumulatedMs: 0,
|
||||
pauseStartedAtMs: null,
|
||||
freezeAccumulatedMs: 0,
|
||||
freezeStartedAtMs: null,
|
||||
freezeUntilMs: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
SwapPuzzlePiecesRequest,
|
||||
UpdatePuzzleRuntimePauseRequest,
|
||||
UsePuzzleRuntimePropRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
@@ -75,27 +77,6 @@ export async function swapPuzzlePieces(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交单块或合并块拖动请求。
|
||||
*/
|
||||
export async function dragPuzzlePieceOrGroup(
|
||||
runId: string,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'拖动拼图块失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入推荐出的下一关。
|
||||
*/
|
||||
@@ -133,6 +114,48 @@ export async function submitPuzzleLeaderboard(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停或恢复正式拼图运行态计时。
|
||||
*/
|
||||
export async function updatePuzzleRunPause(
|
||||
runId: string,
|
||||
payload: UpdatePuzzleRuntimePauseRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/pause`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'更新拼图计时状态失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用正式拼图道具,服务端负责扣除光点并更新运行态。
|
||||
*/
|
||||
export async function usePuzzleRuntimeProp(
|
||||
runId: string,
|
||||
payload: UsePuzzleRuntimePropRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/props`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'使用拼图道具失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const puzzleRuntimeClient = {
|
||||
advanceNextLevel: advancePuzzleNextLevel,
|
||||
drag: dragPuzzlePieceOrGroup,
|
||||
@@ -140,4 +163,6 @@ export const puzzleRuntimeClient = {
|
||||
submitLeaderboard: submitPuzzleLeaderboard,
|
||||
startRun: startPuzzleRun,
|
||||
swap: swapPuzzlePieces,
|
||||
updatePause: updatePuzzleRunPause,
|
||||
useProp: usePuzzleRuntimeProp,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user