init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
export {
advanceLocalPuzzleNextLevel,
advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
getPuzzleRun,
puzzleRuntimeClient,
startPuzzleRun,
swapPuzzlePieces,
} from './puzzleRuntimeClient';

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from 'vitest';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import {
advanceLocalPuzzleLevel,
dragLocalPuzzlePiece,
startLocalPuzzleRun,
} from './puzzleLocalRuntime';
const baseWork: PuzzleWorkSummary = {
workId: 'work-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
sourceSessionId: null,
authorDisplayName: '测试作者',
levelName: '测试拼图',
summary: '服务层测试用拼图。',
themeTags: ['测试', '拼图'],
coverImageSrc: '/generated-puzzle-assets/test.png',
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T00:00:00.000Z',
publishedAt: '2026-04-25T00:00:00.000Z',
playCount: 0,
publishReady: true,
};
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
let nextRun = run;
for (let index = 0; index < 12; index += 1) {
const currentLevel = nextRun.currentLevel;
if (!currentLevel || currentLevel.status === 'cleared') {
return nextRun;
}
const misplacedPiece = currentLevel.board.pieces.find(
(piece) =>
piece.currentRow !== piece.correctRow ||
piece.currentCol !== piece.correctCol,
);
if (!misplacedPiece) {
return nextRun;
}
nextRun = dragLocalPuzzlePiece(nextRun, {
pieceId: misplacedPiece.pieceId,
targetRow: misplacedPiece.correctRow,
targetCol: misplacedPiece.correctCol,
});
}
return nextRun;
}
describe('puzzleLocalRuntime', () => {
test('通关后提供下一关入口并能推进到新棋盘', () => {
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
expect(clearedRun.currentLevel?.status).toBe('cleared');
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
const nextRun = advanceLocalPuzzleLevel(clearedRun);
expect(nextRun.currentLevelIndex).toBe(2);
expect(nextRun.currentLevel?.status).toBe('playing');
expect(nextRun.currentLevel?.levelName).toBe('测试拼图 · 第 2 关');
expect(nextRun.currentLevel?.board.allTilesResolved).toBe(false);
expect(nextRun.recommendedNextProfileId).toBeNull();
});
});

View File

@@ -0,0 +1,244 @@
import type {
DragPuzzlePieceRequest,
PuzzleBoardSnapshot,
PuzzleGridSize,
PuzzlePieceState,
PuzzleRunSnapshot,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
return clearedLevelCount >= 3 ? 4 : 3;
}
function buildInitialPositions(gridSize: PuzzleGridSize) {
const positions = Array.from({ length: gridSize * gridSize }, (_, index) => ({
row: Math.floor(index / gridSize),
col: index % gridSize,
}));
return positions.slice(1).concat(positions.slice(0, 1));
}
function rebuildBoardSnapshot(
gridSize: PuzzleGridSize,
pieces: PuzzlePieceState[],
): PuzzleBoardSnapshot {
const resolvedPieceIds = new Set(
pieces
.filter(
(piece) =>
piece.currentRow === piece.correctRow &&
piece.currentCol === piece.correctCol,
)
.map((piece) => piece.pieceId),
);
const allTilesResolved = resolvedPieceIds.size === pieces.length;
return {
rows: gridSize,
cols: gridSize,
pieces: pieces.map((piece) => ({
...piece,
mergedGroupId: resolvedPieceIds.has(piece.pieceId)
? 'resolved-main'
: null,
})),
mergedGroups: resolvedPieceIds.size
? [
{
groupId: 'resolved-main',
pieceIds: Array.from(resolvedPieceIds),
occupiedCells: pieces
.filter((piece) => resolvedPieceIds.has(piece.pieceId))
.map((piece) => ({
row: piece.currentRow,
col: piece.currentCol,
})),
},
]
: [],
selectedPieceId: null,
allTilesResolved,
};
}
function buildInitialBoard(gridSize: PuzzleGridSize): PuzzleBoardSnapshot {
const shuffledPositions = buildInitialPositions(gridSize);
const pieces = Array.from({ length: gridSize * gridSize }, (_, index) => {
const correctRow = Math.floor(index / gridSize);
const correctCol = index % gridSize;
const current = shuffledPositions[index] ?? { row: correctRow, col: correctCol };
return {
pieceId: `piece-${index}`,
correctRow,
correctCol,
currentRow: current.row,
currentCol: current.col,
mergedGroupId: null,
};
});
return rebuildBoardSnapshot(gridSize, pieces);
}
function applyNextBoard(
run: PuzzleRunSnapshot,
nextBoard: PuzzleBoardSnapshot,
): PuzzleRunSnapshot {
if (!run.currentLevel) {
return run;
}
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
const nextClearedLevelCount =
status === 'cleared' && run.currentLevel.status !== 'cleared'
? run.clearedLevelCount + 1
: run.clearedLevelCount;
return {
...run,
clearedLevelCount: nextClearedLevelCount,
currentLevel: {
...run.currentLevel,
board: nextBoard,
status,
},
recommendedNextProfileId:
status === 'cleared'
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
: run.recommendedNextProfileId,
};
}
function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) {
return `${entryProfileId}::local-level-${levelIndex}`;
}
// 第一版单机兜底没有后端推荐池时,才沿用当前作品图片生成可推进的临时关卡名。
function buildLocalLevelName(previousLevelName: string, levelIndex: number) {
return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex}`;
}
// 本地兜底只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。
function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
const currentLevel = run.currentLevel;
if (!currentLevel || currentLevel.status !== 'cleared') {
return run;
}
const nextLevelIndex = run.currentLevelIndex + 1;
const gridSize = resolvePuzzleGridSize(run.clearedLevelCount);
const nextProfileId =
run.recommendedNextProfileId ??
buildLocalNextProfileId(run.entryProfileId, nextLevelIndex);
return {
...run,
currentLevelIndex: nextLevelIndex,
currentGridSize: gridSize,
playedProfileIds: run.playedProfileIds.includes(nextProfileId)
? run.playedProfileIds
: [...run.playedProfileIds, nextProfileId],
previousLevelTags: currentLevel.themeTags,
currentLevel: {
...currentLevel,
runId: run.runId,
levelIndex: nextLevelIndex,
gridSize,
profileId: nextProfileId,
levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
board: buildInitialBoard(gridSize),
status: 'playing',
},
recommendedNextProfileId: null,
};
}
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
const gridSize = resolvePuzzleGridSize(0);
return {
runId: `local-puzzle-run-${item.profileId}-${Date.now()}`,
entryProfileId: item.profileId,
clearedLevelCount: 0,
currentLevelIndex: 1,
currentGridSize: gridSize,
playedProfileIds: [item.profileId],
previousLevelTags: item.themeTags,
currentLevel: {
runId: `local-puzzle-run-${item.profileId}`,
levelIndex: 1,
gridSize,
profileId: item.profileId,
levelName: item.levelName,
authorDisplayName: item.authorDisplayName,
themeTags: item.themeTags,
coverImageSrc: item.coverImageSrc,
board: buildInitialBoard(gridSize),
status: 'playing',
},
recommendedNextProfileId: null,
};
}
export function swapLocalPuzzlePieces(
run: PuzzleRunSnapshot,
payload: SwapPuzzlePiecesRequest,
): PuzzleRunSnapshot {
const currentLevel = run.currentLevel;
if (!currentLevel || currentLevel.status === 'cleared') {
return run;
}
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);
if (!first || !second) {
return run;
}
const firstPosition = { row: first.currentRow, col: first.currentCol };
first.currentRow = second.currentRow;
first.currentCol = second.currentCol;
second.currentRow = firstPosition.row;
second.currentCol = firstPosition.col;
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
}
export function dragLocalPuzzlePiece(
run: PuzzleRunSnapshot,
payload: DragPuzzlePieceRequest,
): PuzzleRunSnapshot {
const currentLevel = run.currentLevel;
if (!currentLevel || currentLevel.status === 'cleared') {
return run;
}
if (
payload.targetRow < 0 ||
payload.targetCol < 0 ||
payload.targetRow >= currentLevel.gridSize ||
payload.targetCol >= currentLevel.gridSize
) {
return run;
}
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
const moving = pieces.find((piece) => piece.pieceId === payload.pieceId);
if (!moving) {
return run;
}
const occupying = pieces.find(
(piece) =>
piece.pieceId !== payload.pieceId &&
piece.currentRow === payload.targetRow &&
piece.currentCol === payload.targetCol,
);
const source = { row: moving.currentRow, col: moving.currentCol };
moving.currentRow = payload.targetRow;
moving.currentCol = payload.targetCol;
if (occupying) {
occupying.currentRow = source.row;
occupying.currentCol = source.col;
}
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
}
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
return buildFallbackLocalLevel(run);
}

View File

@@ -0,0 +1,142 @@
import type {
AdvanceLocalPuzzleNextLevelRequest,
DragPuzzlePieceRequest,
PuzzleRunResponse,
StartPuzzleRunRequest,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs';
const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
/**
* 从某个已发布拼图作品开始一次 run。
*/
export async function startPuzzleRun(payload: StartPuzzleRunRequest) {
return requestJson<PuzzleRunResponse>(
PUZZLE_RUNTIME_API_BASE,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'启动拼图玩法失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
/**
* 读取拼图运行态快照。
*/
export async function getPuzzleRun(runId: string) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}`,
{
method: 'GET',
},
'读取拼图运行快照失败',
{
retry: PUZZLE_RUNTIME_READ_RETRY,
},
);
}
/**
* 提交两块交换请求。
*/
export async function swapPuzzlePieces(
runId: string,
payload: SwapPuzzlePiecesRequest,
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/swap`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'交换拼图块失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
/**
* 提交单块或合并块拖动请求。
*/
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,
},
);
}
/**
* 进入推荐出的下一关。
*/
export async function advancePuzzleNextLevel(runId: string) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
{
method: 'POST',
},
'进入下一关失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
/**
* 单机运行态进入下一关,图片来源选择全部由后端裁决。
*/
export async function advanceLocalPuzzleNextLevel(
payload: AdvanceLocalPuzzleNextLevelRequest,
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/local-next-level`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'进入下一关失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
export const puzzleRuntimeClient = {
advanceLocalNextLevel: advanceLocalPuzzleNextLevel,
advanceNextLevel: advancePuzzleNextLevel,
drag: dragPuzzlePieceOrGroup,
getRun: getPuzzleRun,
startRun: startPuzzleRun,
swap: swapPuzzlePieces,
};