This commit is contained in:
@@ -200,6 +200,7 @@ function mapBigFishWorkToShelfItem(
|
||||
id: 'level-motion-ready-count',
|
||||
label: `动作 ${item.levelMotionReadyCount}`,
|
||||
},
|
||||
{ id: 'play-count', label: `游玩 ${item.playCount ?? 0}` },
|
||||
...(item.backgroundReady
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -2,4 +2,5 @@ export {
|
||||
bigFishWorksClient,
|
||||
deleteBigFishWork,
|
||||
listBigFishWorks,
|
||||
recordBigFishWorkPlay,
|
||||
} from './bigFishWorksClient';
|
||||
|
||||
Reference in New Issue
Block a user