1
This commit is contained in:
@@ -26,14 +26,15 @@ import {
|
||||
getAuthLoginOptions,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getPublicAuthUserById,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
getPublicAuthUserById,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
updateAuthProfile,
|
||||
} from './authService';
|
||||
|
||||
function createLocalStorageMock() {
|
||||
@@ -87,6 +88,7 @@ describe('authService', () => {
|
||||
publicUserCode: 'SY-00000001',
|
||||
username: 'phone_00000001',
|
||||
displayName: '138****8000',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
@@ -115,6 +117,40 @@ describe('authService', () => {
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update profile trims nickname and posts avatar data url', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_1',
|
||||
publicUserCode: 'SY-00000001',
|
||||
username: 'phone_00000001',
|
||||
displayName: '旅人甲',
|
||||
avatarUrl: 'data:image/png;base64,AAAA',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await updateAuthProfile({
|
||||
displayName: ' 旅人甲 ',
|
||||
avatarDataUrl: ' data:image/png;base64,AAAA ',
|
||||
});
|
||||
|
||||
expect(user.avatarUrl).toBe('data:image/png;base64,AAAA');
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/profile/me',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
displayName: '旅人甲',
|
||||
avatarDataUrl: 'data:image/png;base64,AAAA',
|
||||
}),
|
||||
}),
|
||||
'更新资料失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends phone login code through the auth endpoint', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -175,6 +211,7 @@ describe('authService', () => {
|
||||
publicUserCode: 'SY-00000004',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
@@ -211,6 +248,7 @@ describe('authService', () => {
|
||||
publicUserCode: 'SY-00000005',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'wechat',
|
||||
bindingStatus: 'active',
|
||||
@@ -232,6 +270,7 @@ describe('authService', () => {
|
||||
publicUserCode: 'SY-00000006',
|
||||
username: '139****9000',
|
||||
displayName: '139****9000',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '139****9000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
|
||||
@@ -13,12 +13,13 @@ import type {
|
||||
AuthPhoneChangeResponse,
|
||||
AuthPhoneLoginResponse,
|
||||
AuthPhoneSendCodeResponse,
|
||||
AuthProfileUpdateRequest,
|
||||
AuthProfileUpdateResponse,
|
||||
AuthRevokeSessionResponse,
|
||||
AuthRiskBlocksResponse,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
@@ -261,6 +262,23 @@ export async function changePassword(
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function updateAuthProfile(payload: AuthProfileUpdateRequest) {
|
||||
const response = await requestJson<AuthProfileUpdateResponse>(
|
||||
'/api/profile/me',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
displayName: payload.displayName?.trim() || undefined,
|
||||
avatarDataUrl: payload.avatarDataUrl?.trim() || undefined,
|
||||
}),
|
||||
},
|
||||
'更新资料失败',
|
||||
);
|
||||
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function resetPassword(
|
||||
phone: string,
|
||||
code: string,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
buildPuzzleGenerationAnchorEntries,
|
||||
type MiniGameDraftGenerationState,
|
||||
} from './miniGameDraftGenerationProgress';
|
||||
|
||||
@@ -63,4 +64,65 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
'玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。',
|
||||
);
|
||||
});
|
||||
|
||||
test('puzzle generation anchors expose only title and picture description', () => {
|
||||
const entries = buildPuzzleGenerationAnchorEntries({
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 1,
|
||||
progressPercent: 0,
|
||||
stage: 'collecting_anchors',
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'themePromise',
|
||||
label: '题材承诺',
|
||||
value: '雨夜猫街',
|
||||
status: 'locked',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visualSubject',
|
||||
label: '画面主体',
|
||||
value: '一只猫在雨夜灯牌下回头。',
|
||||
status: 'locked',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visualMood',
|
||||
label: '视觉气质',
|
||||
value: '清晰、适合拼图切块',
|
||||
status: 'inferred',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'compositionHooks',
|
||||
label: '拼图记忆点',
|
||||
value: '主体轮廓、色块分区、局部细节',
|
||||
status: 'inferred',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tagsAndForbidden',
|
||||
label: '标签与禁忌',
|
||||
value: '猫咪、雨夜、拼图;禁止标题字',
|
||||
status: 'inferred',
|
||||
},
|
||||
},
|
||||
draft: null,
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'puzzle-title',
|
||||
label: '拼图标题',
|
||||
value: '雨夜猫街',
|
||||
},
|
||||
{
|
||||
id: 'picture-description',
|
||||
label: '画面描述',
|
||||
value: '一只猫在雨夜灯牌下回头。',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,18 +109,15 @@ function buildMiniGameProgressSteps(
|
||||
) {
|
||||
return steps.map((step, index) => {
|
||||
const isCompleted = state.phase === 'ready' || index < activeStepIndex;
|
||||
const isActive = state.phase !== 'failed' && !isCompleted && index === activeStepIndex;
|
||||
const isActive =
|
||||
state.phase !== 'failed' && !isCompleted && index === activeStepIndex;
|
||||
const isAssetStep = step.id === state.phase && state.totalAssetCount > 0;
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
detail: step.detail,
|
||||
completed: isCompleted
|
||||
? 1
|
||||
: isAssetStep
|
||||
? state.completedAssetCount
|
||||
: 0,
|
||||
completed: isCompleted ? 1 : isAssetStep ? state.completedAssetCount : 0,
|
||||
total: isAssetStep ? state.totalAssetCount : 1,
|
||||
status: isCompleted ? 'completed' : isActive ? 'active' : 'pending',
|
||||
} satisfies CustomWorldGenerationStep;
|
||||
@@ -140,7 +137,9 @@ export function createMiniGameDraftGenerationState(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBigFishPhaseByElapsedMs(elapsedMs: number): MiniGameDraftGenerationPhase {
|
||||
function resolveBigFishPhaseByElapsedMs(
|
||||
elapsedMs: number,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (elapsedMs >= 4_500) {
|
||||
return 'big-fish-runtime';
|
||||
}
|
||||
@@ -172,12 +171,18 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
const steps = getStepDefinitions(normalizedState.kind);
|
||||
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
|
||||
const completedWeight = steps
|
||||
.slice(0, normalizedState.phase === 'ready' ? steps.length : activeStepIndex)
|
||||
.slice(
|
||||
0,
|
||||
normalizedState.phase === 'ready' ? steps.length : activeStepIndex,
|
||||
)
|
||||
.reduce((sum, step) => sum + step.weight, 0);
|
||||
const activeStep = steps[activeStepIndex] ?? steps[0];
|
||||
const assetRatio =
|
||||
normalizedState.totalAssetCount > 0
|
||||
? Math.min(1, normalizedState.completedAssetCount / normalizedState.totalAssetCount)
|
||||
? Math.min(
|
||||
1,
|
||||
normalizedState.completedAssetCount / normalizedState.totalAssetCount,
|
||||
)
|
||||
: normalizedState.phase === 'ready'
|
||||
? 1
|
||||
: normalizedState.kind === 'big-fish'
|
||||
@@ -228,27 +233,17 @@ export function buildPuzzleGenerationAnchorEntries(
|
||||
return [];
|
||||
}
|
||||
|
||||
const draft = session.draft;
|
||||
const entries: Array<MiniGameAnchorSource | null> = [
|
||||
session.anchorPack.themePromise,
|
||||
session.anchorPack.visualSubject,
|
||||
session.anchorPack.visualMood,
|
||||
session.anchorPack.compositionHooks,
|
||||
session.anchorPack.tagsAndForbidden,
|
||||
draft
|
||||
? {
|
||||
key: 'draft-summary',
|
||||
label: '草稿摘要',
|
||||
value: draft.summary,
|
||||
}
|
||||
: null,
|
||||
draft?.coverImageSrc
|
||||
? {
|
||||
key: 'cover-image',
|
||||
label: '正式图片',
|
||||
value: '已生成并应用',
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: 'puzzle-title',
|
||||
label: '拼图标题',
|
||||
value: session.draft?.levelName || session.anchorPack.themePromise.value,
|
||||
},
|
||||
{
|
||||
key: 'picture-description',
|
||||
label: '画面描述',
|
||||
value: session.draft?.summary || session.anchorPack.visualSubject.value,
|
||||
},
|
||||
];
|
||||
|
||||
return entries
|
||||
@@ -283,7 +278,10 @@ export function buildBigFishGenerationAnchorEntries(
|
||||
key: 'level-characters',
|
||||
label: '角色描述',
|
||||
value: draft.levels
|
||||
.map((level) => `Lv.${level.level} ${level.name}:${level.oneLineFantasy}`)
|
||||
.map(
|
||||
(level) =>
|
||||
`Lv.${level.level} ${level.name}:${level.oneLineFantasy}`,
|
||||
)
|
||||
.join('\n'),
|
||||
}
|
||||
: null,
|
||||
|
||||
52
src/services/puzzle-gallery/puzzleGalleryClient.test.ts
Normal file
52
src/services/puzzle-gallery/puzzleGalleryClient.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
import { getPuzzleGalleryDetail, listPuzzleGallery } from './puzzleGalleryClient';
|
||||
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ items: [] });
|
||||
});
|
||||
|
||||
test('listPuzzleGallery reads public gallery without auth refresh coupling', async () => {
|
||||
await listPuzzleGallery();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/gallery',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取拼图广场失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('getPuzzleGalleryDetail reads public detail without auth refresh coupling', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
item: {
|
||||
profileId: 'puzzle-profile-1',
|
||||
},
|
||||
});
|
||||
|
||||
await getPuzzleGalleryDetail('puzzle-profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/gallery/puzzle-profile-1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取拼图广场详情失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
PuzzleWorksResponse,
|
||||
PuzzleWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const PUZZLE_GALLERY_API_BASE = '/api/runtime/puzzle/gallery';
|
||||
@@ -24,6 +26,8 @@ export async function listPuzzleGallery() {
|
||||
'读取拼图广场失败',
|
||||
{
|
||||
retry: PUZZLE_GALLERY_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -40,6 +44,8 @@ export async function getPuzzleGalleryDetail(profileId: string) {
|
||||
'读取拼图广场详情失败',
|
||||
{
|
||||
retry: PUZZLE_GALLERY_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,4 +7,6 @@ export {
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
swapPuzzlePieces,
|
||||
updatePuzzleRunPause,
|
||||
usePuzzleRuntimeProp,
|
||||
} from './puzzleRuntimeClient';
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import {
|
||||
applyLocalPuzzleFreezeTime,
|
||||
advanceLocalPuzzleLevel,
|
||||
dragLocalPuzzlePiece,
|
||||
isLocalPuzzleRun,
|
||||
refreshLocalPuzzleTimer,
|
||||
setLocalPuzzlePaused,
|
||||
startLocalPuzzleRun,
|
||||
submitLocalPuzzleLeaderboard,
|
||||
swapLocalPuzzlePieces,
|
||||
@@ -89,10 +92,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);
|
||||
});
|
||||
@@ -133,7 +135,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],
|
||||
@@ -266,7 +271,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'),
|
||||
@@ -289,7 +296,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).toBe(
|
||||
'profile-1::local-level-2',
|
||||
);
|
||||
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
|
||||
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(clearedRun.leaderboardEntries).toEqual([]);
|
||||
@@ -313,9 +322,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', () => {
|
||||
@@ -338,4 +353,77 @@ 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 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,11 +7,17 @@ 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_LEVEL_TIME_LIMIT_MS_BY_GRID_SIZE: Record<PuzzleGridSize, number> = {
|
||||
3: 180_000,
|
||||
4: 300_000,
|
||||
};
|
||||
|
||||
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
|
||||
return clearedLevelCount >= 3 ? 4 : 3;
|
||||
@@ -92,6 +98,100 @@ function clampElapsedMs(value: number) {
|
||||
return Math.max(1_000, Math.round(value));
|
||||
}
|
||||
|
||||
function resolvePuzzleLevelTimeLimitMs(gridSize: PuzzleGridSize) {
|
||||
return PUZZLE_LEVEL_TIME_LIMIT_MS_BY_GRID_SIZE[gridSize];
|
||||
}
|
||||
|
||||
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(gridSize: PuzzleGridSize) {
|
||||
const timeLimitMs = resolvePuzzleLevelTimeLimitMs(gridSize);
|
||||
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,
|
||||
@@ -365,30 +465,37 @@ 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:
|
||||
@@ -455,6 +562,7 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
...buildLevelTimerFields(gridSize),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
@@ -488,6 +596,7 @@ export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
...buildLevelTimerFields(gridSize),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
@@ -499,15 +608,16 @@ 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);
|
||||
if (!first || !second) {
|
||||
return run;
|
||||
return timedRun;
|
||||
}
|
||||
const firstPosition = { row: first.currentRow, col: first.currentCol };
|
||||
first.currentRow = second.currentRow;
|
||||
@@ -515,7 +625,7 @@ 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(
|
||||
@@ -636,9 +746,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 +757,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,13 +774,13 @@ 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 {
|
||||
@@ -717,3 +828,55 @@ 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
SwapPuzzlePiecesRequest,
|
||||
UpdatePuzzleRuntimePauseRequest,
|
||||
UsePuzzleRuntimePropRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
@@ -134,6 +136,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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单机运行态进入下一关,图片来源选择全部由后端裁决。
|
||||
*/
|
||||
@@ -162,4 +206,6 @@ export const puzzleRuntimeClient = {
|
||||
submitLeaderboard: submitPuzzleLeaderboard,
|
||||
startRun: startPuzzleRun,
|
||||
swap: swapPuzzlePieces,
|
||||
updatePause: updatePuzzleRunPause,
|
||||
useProp: usePuzzleRuntimeProp,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
getRpgEntryWorldLibraryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
|
||||
@@ -7,6 +7,7 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
getRpgEntryWorldLibraryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
@@ -72,6 +73,26 @@ describe('rpgEntryLibraryClient world library routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reads owned library detail from the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
});
|
||||
|
||||
await getRpgEntryWorldLibraryDetail('profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取作品详情失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes world profile through the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
|
||||
@@ -115,6 +115,22 @@ export async function recordRpgEntryWorldGalleryPlay(
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function getRpgEntryWorldLibraryDetail(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function upsertRpgEntryWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RuntimeRequestOptions = {},
|
||||
@@ -199,6 +215,7 @@ export const rpgEntryLibraryClient = {
|
||||
listWorldGallery: listRpgEntryWorldGallery,
|
||||
getWorldGalleryDetail: getRpgEntryWorldGalleryDetail,
|
||||
getWorldGalleryDetailByCode: getRpgEntryWorldGalleryDetailByCode,
|
||||
getWorldLibraryDetail: getRpgEntryWorldLibraryDetail,
|
||||
remixWorldGallery: remixRpgEntryWorldGallery,
|
||||
recordWorldGalleryPlay: recordRpgEntryWorldGalleryPlay,
|
||||
upsertWorldProfile: upsertRpgEntryWorldProfile,
|
||||
|
||||
Reference in New Issue
Block a user