拼图和大鱼吃小鱼补充游玩记录
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-28 11:11:01 +08:00
parent a9febe7678
commit 3cdbf36859
27 changed files with 419 additions and 48 deletions

View File

@@ -200,6 +200,7 @@ function mapBigFishWorkToShelfItem(
id: 'level-motion-ready-count',
label: `动作 ${item.levelMotionReadyCount}`,
},
{ id: 'play-count', label: `游玩 ${item.playCount ?? 0}` },
...(item.backgroundReady
? [
{

View File

@@ -60,6 +60,7 @@ import {
import {
deleteBigFishWork,
listBigFishWorks,
recordBigFishWorkPlay,
} from '../../services/big-fish-works';
import {
readCustomWorldAgentUiState,
@@ -91,6 +92,7 @@ import {
} from '../../services/puzzle-gallery';
import {
advanceLocalPuzzleNextLevel,
startPuzzleRun,
submitPuzzleLeaderboard,
} from '../../services/puzzle-runtime';
import {
@@ -1147,11 +1149,21 @@ export function PlatformEntryFlowShellImpl({
return;
}
setBigFishError(null);
setBigFishRuntimeShare(null);
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
}, [bigFishSession, setSelectionStage]);
const run = async () => {
setBigFishError(null);
setBigFishRuntimeShare(null);
if (bigFishSession.stage === 'published') {
await recordBigFishWorkPlay(bigFishSession.sessionId);
await refreshBigFishShelf();
}
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
};
void run().catch((error) => {
setBigFishError(resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'));
});
}, [bigFishSession, refreshBigFishShelf, resolveBigFishErrorMessage, setSelectionStage]);
const restartBigFishRun = useCallback(() => {
if (!bigFishSession && !bigFishRun) {
@@ -1175,8 +1187,9 @@ export function PlatformEntryFlowShellImpl({
try {
const { item } = await getPuzzleGalleryDetail(profileId);
const { run } = await startPuzzleRun({ profileId: item.profileId });
setSelectedPuzzleDetail(item);
setPuzzleRun(startLocalPuzzleRun(item));
setPuzzleRun(run);
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
setSelectionStage('puzzle-runtime');
pushAppHistoryPath(

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { syncGameStatePlayTime } from '../../data/runtimeStats';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { isAbortError } from '../../services/apiClient';
import { rpgSnapshotClient } from '../../services/rpg-runtime';
@@ -37,6 +38,10 @@ function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
};
}
function buildPersistedGameState(gameState: GameState) {
return syncGameStatePlayTime(gameState);
}
export type UseRpgSessionPersistenceParams = {
authenticatedUserId: string | null;
gameState: GameState;
@@ -208,9 +213,10 @@ export function useRpgSessionPersistence({
if (!canPersist) return;
const timeoutId = window.setTimeout(() => {
const persistedGameState = buildPersistedGameState(gameState);
void persistSnapshot({
payload: {
gameState,
gameState: persistedGameState,
bottomTab,
currentStory,
},
@@ -235,9 +241,10 @@ export function useRpgSessionPersistence({
return false;
}
const persistedGameState = buildPersistedGameState(nextGameState);
const snapshot = await persistSnapshot({
payload: {
gameState: nextGameState,
gameState: persistedGameState,
bottomTab: nextBottomTab,
currentStory: nextStory,
},

View File

@@ -161,3 +161,68 @@ test('unauthenticated runtime skips remote snapshot hydration', async () => {
expect(screen.getByTestId('saved-game').textContent).toBe('no');
expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled();
});
test('authenticated runtime autosave syncs live play time before remote snapshot upload', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-27T10:00:02.000Z'));
storageMocks.putSaveSnapshot.mockResolvedValue({
gameState: {},
bottomTab: 'adventure',
currentStory: null,
});
const gameState = {
runtimePersistenceDisabled: false,
runtimeMode: 'play',
currentScene: 'Story',
worldType: 'CUSTOM',
playerCharacter: { id: 'hero-1' },
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: '2026-04-27T10:00:00.000Z',
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
} as GameState;
function AutosaveHarness() {
useRpgSessionPersistence({
authenticatedUserId: 'user-1',
gameState,
bottomTab: 'adventure' as BottomTab,
currentStory: { streaming: false } as StoryMoment,
isLoading: false,
setGameState: () => {},
setBottomTab: () => {},
hydrateStoryState: () => {},
resetStoryState: () => {},
});
return null;
}
render(<AutosaveHarness />);
await act(async () => {
vi.advanceTimersByTime(400);
await Promise.resolve();
await Promise.resolve();
});
expect(storageMocks.putSaveSnapshot).toHaveBeenCalledTimes(1);
expect(storageMocks.putSaveSnapshot).toHaveBeenCalledWith(
expect.objectContaining({
gameState: expect.objectContaining({
runtimeStats: expect.objectContaining({
playTimeMs: 2400,
lastPlayTickAt: '2026-04-27T10:00:02.400Z',
}),
}),
}),
expect.objectContaining({
signal: expect.any(AbortSignal),
}),
);
});

View File

@@ -46,7 +46,24 @@ export async function deleteBigFishWork(sessionId: string) {
);
}
/**
* 记录已发布大鱼吃小鱼作品的一次正式进入。
*/
export async function recordBigFishWorkPlay(sessionId: string) {
return requestJson<BigFishWorksResponse>(
`${BIG_FISH_WORKS_API_BASE}/${encodeURIComponent(sessionId)}/play`,
{
method: 'POST',
},
'记录大鱼吃小鱼游玩次数失败',
{
retry: BIG_FISH_WORKS_WRITE_RETRY,
},
);
}
export const bigFishWorksClient = {
delete: deleteBigFishWork,
list: listBigFishWorks,
recordPlay: recordBigFishWorkPlay,
};

View File

@@ -2,4 +2,5 @@ export {
bigFishWorksClient,
deleteBigFishWork,
listBigFishWorks,
recordBigFishWorkPlay,
} from './bigFishWorksClient';