Integrate unfinished server-rs refactor worklists

This commit is contained in:
2026-04-30 13:39:06 +08:00
parent 62934b0809
commit 7ab0933f6d
676 changed files with 24487 additions and 21531 deletions

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import type { BigFishRuntimeSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import { BigFishRuntimeShell } from './BigFishRuntimeShell';
@@ -49,10 +49,6 @@ function dispatchPointerEvent(
}
describe('BigFishRuntimeShell', () => {
afterEach(() => {
vi.useRealTimers();
});
test('renders restart and exit actions after a failed run', () => {
const onBack = vi.fn();
const onRestart = vi.fn();
@@ -111,8 +107,7 @@ describe('BigFishRuntimeShell', () => {
expect(screen.queryByRole('dialog', { name: '玩法规则' })).toBeNull();
});
test('keeps moving in the last sampled direction after drag ends', () => {
vi.useFakeTimers();
test('keeps moving in the last sampled direction after drag ends', async () => {
const onSubmitInput = vi.fn();
const { container } = render(
@@ -141,9 +136,10 @@ describe('BigFishRuntimeShell', () => {
clientY: 100,
});
});
act(() => {
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(onSubmitInput).toHaveBeenCalledWith({ x: 1, y: 0 });
});
const callCountAfterDrag = onSubmitInput.mock.calls.length;
act(() => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: 1,
@@ -151,8 +147,9 @@ describe('BigFishRuntimeShell', () => {
clientY: 100,
});
});
act(() => {
vi.advanceTimersByTime(220);
await waitFor(() => {
expect(onSubmitInput.mock.calls.length).toBeGreaterThan(callCountAfterDrag);
});
expect(onSubmitInput).toHaveBeenLastCalledWith({ x: 1, y: 0 });

View File

@@ -56,9 +56,9 @@ import {
} from '../../services/big-fish-creation';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import {
advanceLocalBigFishRuntimeRun,
recordBigFishPlay,
startLocalBigFishRuntimeRun,
startBigFishRun as startBigFishRuntimeRun,
submitBigFishInput as submitBigFishRuntimeInput,
} from '../../services/big-fish-runtime';
import {
deleteBigFishWork,
@@ -456,6 +456,7 @@ export function PlatformEntryFlowShellImpl({
>(null);
const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] =
useState<BigFishRuntimeSessionSource>(null);
const bigFishInputInFlightRef = useRef(false);
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
const [bigFishGenerationState, setBigFishGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
@@ -1182,7 +1183,7 @@ export function PlatformEntryFlowShellImpl({
}
}, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]);
const startBigFishRun = useCallback(() => {
const startBigFishRun = useCallback(async () => {
if (!bigFishSession) {
return;
}
@@ -1191,16 +1192,25 @@ export function PlatformEntryFlowShellImpl({
setBigFishError(null);
setBigFishRuntimeShare(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource('draft');
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
setBigFishRuntimeStartedAt(null);
setBigFishRun(null);
try {
const { run } = await startBigFishRuntimeRun(sessionId);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource('draft');
setBigFishRun(run);
setSelectionStage('big-fish-runtime');
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
);
});
void refreshBigFishShelf();
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼玩失败。'),
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩失败。'),
);
});
void refreshBigFishShelf();
}
}, [
bigFishSession,
refreshBigFishShelf,
@@ -1208,7 +1218,7 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage,
]);
const restartBigFishRun = useCallback(() => {
const restartBigFishRun = useCallback(async () => {
if (!bigFishSession && !bigFishRun) {
return;
}
@@ -1222,23 +1232,26 @@ export function PlatformEntryFlowShellImpl({
if (bigFishSession) {
setBigFishRuntimeShare(null);
}
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work');
setBigFishRun(
startLocalBigFishRuntimeRun({
session: bigFishSession,
work: bigFishRuntimeWork,
}),
);
setSelectionStage('big-fish-runtime');
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
setBigFishRuntimeStartedAt(null);
setBigFishRun(null);
try {
const { run } = await startBigFishRuntimeRun(sessionId);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work');
setBigFishRun(run);
setSelectionStage('big-fish-runtime');
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
);
});
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼玩失败。'),
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩失败。'),
);
});
}
}, [
bigFishRun,
bigFishRuntimeWork,
bigFishSession,
resolveBigFishErrorMessage,
setSelectionStage,
@@ -1327,18 +1340,31 @@ export function PlatformEntryFlowShellImpl({
);
const submitBigFishInput = useCallback(
(payload: SubmitBigFishInputRequest) => {
if (!bigFishRun || bigFishRun.status !== 'running') {
async (payload: SubmitBigFishInputRequest) => {
if (
!bigFishRun ||
bigFishRun.status !== 'running' ||
bigFishInputInFlightRef.current
) {
return;
}
setBigFishRun((currentRun) =>
currentRun
? advanceLocalBigFishRuntimeRun(currentRun, payload)
: currentRun,
);
bigFishInputInFlightRef.current = true;
try {
const { run } = await submitBigFishRuntimeInput(
bigFishRun.runId,
payload,
);
setBigFishRun(run);
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '同步大鱼吃小鱼输入失败。'),
);
} finally {
bigFishInputInFlightRef.current = false;
}
},
[bigFishRun],
[bigFishRun, resolveBigFishErrorMessage, setBigFishError],
);
const reportBigFishObservedPlayTime = useCallback(() => {
@@ -1793,7 +1819,7 @@ export function PlatformEntryFlowShellImpl({
);
const startBigFishRunFromWork = useCallback(
(item: BigFishWorkSummary) => {
async (item: BigFishWorkSummary) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
@@ -1808,18 +1834,27 @@ export function PlatformEntryFlowShellImpl({
title: item.title,
publicWorkCode,
});
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeStartedAt(null);
setBigFishRuntimeSessionSource('work');
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
setSelectionStage('big-fish-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
);
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
setBigFishRun(null);
try {
const { run } = await startBigFishRuntimeRun(sessionId);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRun(run);
setSelectionStage('big-fish-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
);
});
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
);
});
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'),
);
}
},
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
);
@@ -2018,10 +2053,10 @@ export function PlatformEntryFlowShellImpl({
(entry) => entry.sourceSessionId === sessionId,
);
if (matchedEntry) {
startBigFishRunFromWork(matchedEntry);
void startBigFishRunFromWork(matchedEntry);
return;
}
startBigFishRunFromWork({
void startBigFishRunFromWork({
workId: `big-fish:${sessionId}`,
sourceSessionId: sessionId,
ownerUserId: work.ownerUserId ?? '',

View File

@@ -17,7 +17,7 @@ import {
getBigFishCreationSession,
} from '../../services/big-fish-creation';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import { startLocalBigFishRuntimeRun } from '../../services/big-fish-runtime';
import { startBigFishRun } from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import {
createPuzzleAgentSession,
@@ -155,8 +155,9 @@ vi.mock('../../services/big-fish-gallery', () => ({
}));
vi.mock('../../services/big-fish-runtime', () => ({
advanceLocalBigFishRuntimeRun: vi.fn((run) => run),
startLocalBigFishRuntimeRun: vi.fn(),
recordBigFishPlay: vi.fn().mockResolvedValue({ items: [] }),
startBigFishRun: vi.fn(),
submitBigFishInput: vi.fn(),
}));
vi.mock('../../services/puzzle-agent', () => ({
@@ -1091,28 +1092,30 @@ beforeEach(() => {
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [],
});
vi.mocked(startLocalBigFishRuntimeRun).mockReturnValue({
runId: 'big-fish-run-1',
sessionId: 'big-fish-session-public-1',
status: 'running',
tick: 0,
playerLevel: 1,
winLevel: 8,
leaderEntityId: 'owned-1',
ownedEntities: [
{
entityId: 'owned-1',
level: 1,
position: { x: 0, y: 0 },
radius: 12,
offscreenSeconds: 0,
},
],
wildEntities: [],
cameraCenter: { x: 0, y: 0 },
lastInput: { x: 0, y: 0 },
eventLog: ['机械鱼群开始巡游。'],
updatedAt: '2026-04-25T12:12:00.000Z',
vi.mocked(startBigFishRun).mockResolvedValue({
run: {
runId: 'big-fish-run-1',
sessionId: 'big-fish-session-public-1',
status: 'running',
tick: 0,
playerLevel: 1,
winLevel: 8,
leaderEntityId: 'owned-1',
ownedEntities: [
{
entityId: 'owned-1',
level: 1,
position: { x: 0, y: 0 },
radius: 12,
offscreenSeconds: 0,
},
],
wildEntities: [],
cameraCenter: { x: 0, y: 0 },
lastInput: { x: 0, y: 0 },
eventLog: ['机械鱼群开始巡游。'],
updatedAt: '2026-04-25T12:12:00.000Z',
},
});
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [],
@@ -2067,11 +2070,9 @@ test('public code search opens a published big fish work by BF code', async () =
await user.click(screen.getByRole('button', { name: '搜索' }));
await waitFor(() => {
expect(startLocalBigFishRuntimeRun).toHaveBeenCalledWith({
work: expect.objectContaining({
sourceSessionId: 'big-fish-session-public-1',
}),
});
expect(startBigFishRun).toHaveBeenCalledWith(
'big-fish-session-public-1',
);
});
expect(await screen.findByText('Lv.1/8 · 进行中')).toBeTruthy();
expect(getBigFishCreationSession).not.toHaveBeenCalledWith(

View File

@@ -4,10 +4,18 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import { AnimationState, WorldType, type GameState } from '../../types';
import {
AnimationState,
WorldType,
type GameState,
type StoryMoment,
} from '../../types';
import { RpgRuntimeShell } from './RpgRuntimeShell';
import type { RpgRuntimeShellProps } from './types';
const noop = () => {};
const asyncFalse = async () => false;
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => null,
}));
@@ -34,7 +42,7 @@ vi.mock('./useRpgRuntimeShellViewModel', () => ({
shouldMountNpcModals: false,
visibleGameState: mockVisibleGameState,
visibleCurrentStory: {
storyText: '测试故事',
text: '测试故事',
options: [],
},
sceneTransitionPhase: 'idle',
@@ -97,22 +105,23 @@ function createGameState(
description: '测试角色',
backstory: '测试背景',
personality: '冷静',
motivation: '完成测试',
combatStyle: '均衡',
role: '主角',
avatar: '',
portrait: '',
imageSrc: '',
initialAffinity: 0,
relationshipHooks: [],
tags: [],
assetFolder: '',
assetVariant: '',
attributes: {
strength: 5,
agility: 5,
intelligence: 5,
spirit: 5,
},
backstoryReveal: {
publicSummary: '测试',
privateChatUnlockAffinity: 60,
chapters: [],
},
skills: [],
initialItems: [],
adventureOpenings: {},
},
runtimeMode,
runtimePersistenceDisabled: runtimePersistenceDisabled ?? false,
@@ -176,14 +185,15 @@ function buildProps(
runtimePersistenceDisabled?: boolean,
): RpgRuntimeShellProps {
const gameState = createGameState(runtimeMode, runtimePersistenceDisabled);
const currentStory: StoryMoment = {
text: '测试故事',
options: [],
};
mockVisibleGameState = gameState;
return {
session: {
gameState,
currentStory: {
storyText: '测试故事',
options: [],
},
currentStory,
isLoading: false,
aiError: null,
bottomTab: 'adventure',
@@ -201,36 +211,66 @@ function buildProps(
exitNpcChat: () => false,
handleMapTravelToScene: () => false,
npcUi: {
isNpcModalOpen: false,
currentNpcEncounter: null,
selectedNpc: null,
isGeneratingNpcResponse: false,
npcResponseError: null,
generatedNpcText: '',
npcResponseOptions: [],
selectedOptionId: null,
tradeModal: null,
giftModal: null,
recruitModal: null,
setTradeMode: noop,
selectTradeNpcItem: noop,
selectTradePlayerItem: noop,
setTradeQuantity: noop,
closeTradeModal: noop,
confirmTrade: noop,
selectGiftItem: noop,
closeGiftModal: noop,
confirmGift: noop,
selectRecruitRelease: noop,
closeRecruitModal: noop,
confirmRecruit: noop,
},
characterChatUi: {
isCharacterChatModalOpen: false,
activeCharacter: null,
modal: null,
openChat: noop,
closeChat: noop,
setDraft: noop,
useSuggestion: noop,
refreshSuggestions: noop,
sendDraft: noop,
},
inventoryUi: {
isInventoryOpen: false,
useInventoryItem: asyncFalse,
equipInventoryItem: asyncFalse,
unequipItem: asyncFalse,
playerCurrency: 0,
currencyText: '0',
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
craftRecipe: asyncFalse,
dismantleItem: asyncFalse,
reforgeItem: asyncFalse,
},
battleRewardUi: {
isRewardModalOpen: false,
rewards: [],
reward: null,
dismiss: noop,
},
questUi: {
isQuestPanelOpen: false,
acknowledgeQuestCompletion: noop,
claimQuestReward: () => null,
},
npcChatQuestOfferUi: {
isOfferModalOpen: false,
pendingQuest: null,
replacePendingOffer: () => false,
abandonPendingOffer: () => false,
acceptPendingOffer: () => null,
},
goalUi: {
isGoalPanelOpen: false,
entries: [],
goalStack: {
northStarGoal: null,
activeGoal: null,
immediateStepGoal: null,
supportGoals: [],
},
pulse: null,
dismissPulse: noop,
},
},
entry: {