1
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-28 20:52:08 +08:00
82 changed files with 3844 additions and 4125 deletions
@@ -200,6 +200,7 @@ function mapBigFishWorkToShelfItem(
id: 'level-motion-ready-count',
label: `动作 ${item.levelMotionReadyCount}`,
},
{ id: 'play-count', label: `游玩 ${item.playCount ?? 0}` },
...(item.backgroundReady
? [
{
@@ -36,6 +36,8 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
} from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import {
@@ -55,6 +57,7 @@ import {
import { listBigFishGallery } from '../../services/big-fish-gallery';
import {
advanceLocalBigFishRuntimeRun,
recordBigFishPlay,
startLocalBigFishRuntimeRun,
} from '../../services/big-fish-runtime';
import {
@@ -91,6 +94,7 @@ import {
} from '../../services/puzzle-gallery';
import {
advanceLocalPuzzleNextLevel,
startPuzzleRun,
submitPuzzleLeaderboard,
} from '../../services/puzzle-runtime';
import {
@@ -107,6 +111,7 @@ import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetailByCode,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import {
@@ -154,6 +159,8 @@ type AgentResultBlockerView = {
message: string;
};
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
'publish_missing_world_hook',
'publish_missing_player_premise',
@@ -442,6 +449,13 @@ export function PlatformEntryFlowShellImpl({
title: string;
publicWorkCode: string;
} | null>(null);
const [bigFishRuntimeWork, setBigFishRuntimeWork] =
useState<BigFishWorkSummary | null>(null);
const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState<
number | null
>(null);
const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] =
useState<BigFishRuntimeSessionSource>(null);
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
const [bigFishGenerationState, setBigFishGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
@@ -475,6 +489,14 @@ export function PlatformEntryFlowShellImpl({
string | null
>(null);
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish');
const [profilePlayStats, setProfilePlayStats] =
useState<ProfilePlayStatsResponse | null>(null);
const [profilePlayStatsError, setProfilePlayStatsError] = useState<
string | null
>(null);
const [isProfilePlayStatsLoading, setIsProfilePlayStatsLoading] =
useState(false);
const [isProfilePlayStatsOpen, setIsProfilePlayStatsOpen] = useState(false);
const hadReadableProtectedDataRef = useRef(false);
const hasInitialAgentSession = Boolean(
readCustomWorldAgentUiState().activeSessionId &&
@@ -1041,6 +1063,9 @@ export function PlatformEntryFlowShellImpl({
setBigFishWorks([]);
setBigFishRun(null);
setBigFishRuntimeShare(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(null);
setBigFishRuntimeSessionSource(null);
setBigFishGenerationState(null);
setBigFishError(null);
setPuzzleOperation(null);
@@ -1052,6 +1077,9 @@ export function PlatformEntryFlowShellImpl({
setIsPuzzleNextLevelGenerating(false);
setPuzzleError(null);
setDeletingCreationWorkId(null);
setProfilePlayStats(null);
setProfilePlayStatsError(null);
setIsProfilePlayStatsOpen(false);
resetRpgSessionViewState();
setRpgGeneratedCustomWorldProfile(null);
setRpgCustomWorldError(null);
@@ -1120,6 +1148,9 @@ export function PlatformEntryFlowShellImpl({
const leaveBigFishFlow = useCallback(() => {
setBigFishRun(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(null);
setBigFishRuntimeSessionSource(null);
setBigFishGenerationState(null);
bigFishFlow.leaveFlow();
}, [bigFishFlow]);
@@ -1156,22 +1187,62 @@ export function PlatformEntryFlowShellImpl({
return;
}
const sessionId = bigFishSession.sessionId;
setBigFishError(null);
setBigFishRuntimeShare(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource('draft');
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
}, [bigFishSession, setSelectionStage]);
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
);
});
void refreshBigFishShelf();
}, [
bigFishSession,
refreshBigFishShelf,
resolveBigFishErrorMessage,
setSelectionStage,
]);
const restartBigFishRun = useCallback(() => {
if (!bigFishSession && !bigFishRun) {
return;
}
const sessionId = bigFishSession?.sessionId ?? bigFishRun?.sessionId;
if (!sessionId) {
return;
}
setBigFishError(null);
setBigFishRuntimeShare(null);
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
if (bigFishSession) {
setBigFishRuntimeShare(null);
}
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work');
setBigFishRun(
startLocalBigFishRuntimeRun({
session: bigFishSession,
work: bigFishRuntimeWork,
}),
);
setSelectionStage('big-fish-runtime');
}, [bigFishRun, bigFishSession, setSelectionStage]);
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
);
});
}, [
bigFishRun,
bigFishRuntimeWork,
bigFishSession,
resolveBigFishErrorMessage,
setSelectionStage,
]);
const startPuzzleRunFromProfile = useCallback(
async (profileId: string) => {
@@ -1184,8 +1255,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(
@@ -1269,6 +1341,25 @@ export function PlatformEntryFlowShellImpl({
[bigFishRun],
);
const reportBigFishObservedPlayTime = useCallback(() => {
const sessionId = bigFishRun?.sessionId?.trim();
if (!sessionId || !bigFishRuntimeStartedAt) {
return;
}
const elapsedMs = Math.max(1_000, Date.now() - bigFishRuntimeStartedAt);
setBigFishRuntimeStartedAt(null);
void recordBigFishPlay(sessionId, { elapsedMs }).catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩时长失败。'),
);
});
}, [
bigFishRun?.sessionId,
bigFishRuntimeStartedAt,
resolveBigFishErrorMessage,
]);
const swapPuzzlePiecesInRun = useCallback(
(payload: { firstPieceId: string; secondPieceId: string }) => {
if (!puzzleRun || isPuzzleBusy) {
@@ -1712,17 +1803,25 @@ export function PlatformEntryFlowShellImpl({
const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId);
setBigFishError(null);
bigFishFlow.setSession(null);
setBigFishRuntimeWork(item);
setBigFishRuntimeShare({
title: item.title,
publicWorkCode,
});
setBigFishRuntimeStartedAt(Date.now());
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, '记录大鱼吃小鱼游玩失败。'),
);
});
},
[bigFishFlow, setSelectionStage],
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
);
const handlePublicCodeSearch = useCallback(
@@ -1871,6 +1970,118 @@ export function PlatformEntryFlowShellImpl({
],
);
const openProfilePlayedWorks = useCallback(() => {
setIsProfilePlayStatsOpen(true);
setIsProfilePlayStatsLoading(true);
setProfilePlayStatsError(null);
void getRpgProfilePlayStats()
.then(setProfilePlayStats)
.catch((error) => {
setProfilePlayStats(null);
setProfilePlayStatsError(
resolveRpgCreationErrorMessage(error, '读取玩过作品失败。'),
);
})
.finally(() => {
setIsProfilePlayStatsLoading(false);
});
}, []);
const openPlayedWork = useCallback(
(work: ProfilePlayedWorkSummary) => {
const worldType = (work.worldType ?? '').toLowerCase();
setIsProfilePlayStatsOpen(false);
if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) {
const profileId =
work.profileId ?? work.worldKey.replace(/^puzzle:/u, '');
if (profileId) {
void openPuzzleDetail(profileId, { tab: 'profile' });
}
return;
}
if (
worldType === 'big_fish' ||
worldType === 'big-fish' ||
work.worldKey.startsWith('big-fish:')
) {
const sessionId =
work.profileId ?? work.worldKey.replace(/^big-fish:/u, '');
if (!sessionId) {
return;
}
void refreshBigFishGallery()
.then((entries) => {
const matchedEntry = entries.find(
(entry) => entry.sourceSessionId === sessionId,
);
if (matchedEntry) {
startBigFishRunFromWork(matchedEntry);
return;
}
startBigFishRunFromWork({
workId: `big-fish:${sessionId}`,
sourceSessionId: sessionId,
ownerUserId: work.ownerUserId ?? '',
title: work.worldTitle,
subtitle: work.worldSubtitle,
summary: work.worldSubtitle,
coverImageSrc: null,
status: 'published',
updatedAt: work.lastPlayedAt,
publishReady: true,
levelCount: 0,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
});
})
.catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '进入大鱼吃小鱼作品失败。'),
);
});
return;
}
const profileId = work.profileId ?? work.worldKey;
const ownerUserId = work.ownerUserId;
if (!ownerUserId || !profileId) {
return;
}
runProtectedAction(() => {
void detailNavigation.openGalleryDetail({
ownerUserId,
profileId,
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: work.firstPlayedAt,
updatedAt: work.lastPlayedAt,
authorDisplayName: work.worldSubtitle,
worldName: work.worldTitle,
subtitle: work.worldSubtitle,
summaryText: '',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
});
});
},
[
detailNavigation,
openPuzzleDetail,
refreshBigFishGallery,
resolveBigFishErrorMessage,
runProtectedAction,
startBigFishRunFromWork,
],
);
useEffect(() => {
const publicWorkCode = initialPublicWorkCode?.trim();
if (
@@ -2149,7 +2360,19 @@ export function PlatformEntryFlowShellImpl({
void handlePublicCodeSearch(keyword);
}}
isSearchingPublicCode={isSearchingPublicCode}
onOpenProfileDashboardCard={() => {
profilePlayStats={profilePlayStats}
isProfilePlayStatsOpen={isProfilePlayStatsOpen}
isProfilePlayStatsLoading={isProfilePlayStatsLoading}
profilePlayStatsError={profilePlayStatsError}
onCloseProfilePlayStats={() => {
setIsProfilePlayStatsOpen(false);
}}
onOpenPlayedWork={openPlayedWork}
onOpenProfileDashboardCard={(cardKey) => {
if (cardKey === 'playedWorks') {
openProfilePlayedWorks();
return;
}
if (platformBootstrap.dashboardError) {
void platformBootstrap.refreshProfileDashboard();
}
@@ -2402,11 +2625,15 @@ export function PlatformEntryFlowShellImpl({
isBusy={isBigFishBusy}
error={bigFishError}
onBack={() => {
reportBigFishObservedPlayTime();
setSelectionStage(
bigFishSession ? 'big-fish-result' : 'platform',
bigFishRuntimeSessionSource === 'draft'
? 'big-fish-result'
: 'platform',
);
}}
onRestart={() => {
reportBigFishObservedPlayTime();
void restartBigFishRun();
}}
onSubmitInput={submitBigFishInput}
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
@@ -11,7 +11,29 @@ import {
} from './RpgEntryHomeView';
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
const { mockGetRpgProfileWalletLedger } = vi.hoisted(() => ({
mockGetRpgProfileWalletLedger: vi.fn(async () => ({
entries: [
{
id: 'ledger-1',
amountDelta: -1,
balanceAfter: 29,
sourceType: 'asset_operation_consume',
createdAt: '2026-04-28T10:00:00Z',
},
{
id: 'ledger-2',
amountDelta: 30,
balanceAfter: 30,
sourceType: 'invite_invitee_reward',
createdAt: '2026-04-28T09:00:00Z',
},
],
})),
}));
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
getRpgProfileRechargeCenter: vi.fn(async () => ({
walletBalance: 0,
membership: {
@@ -269,20 +291,34 @@ afterEach(() => {
});
});
test('opens recharge modal and submits points product', async () => {
test('opens wallet ledger modal from narrative coin card', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
await user.click(screen.getByText('会员充值'));
renderProfileView();
await user.click(screen.getByText('剩余叙世币'));
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(await screen.findByText('叙世币充值')).toBeTruthy();
expect(await screen.findByText('60叙世币')).toBeTruthy();
expect(await screen.findByText('叙世币账单')).toBeTruthy();
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
expect(screen.getByText('资产操作消耗')).toBeTruthy();
expect(screen.getByText('-1')).toBeTruthy();
expect(screen.getByText('填写邀请码奖励')).toBeTruthy();
expect(screen.getByText('+30')).toBeTruthy();
});
await user.click(screen.getByText('首充送60叙世币'));
test('wallet ledger modal shows empty and error states', async () => {
const user = userEvent.setup();
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
renderProfileView();
await user.click(screen.getByText('剩余叙世币'));
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
await user.click(screen.getByLabelText('关闭叙世币账单'));
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
await user.click(screen.getByText('剩余叙世币'));
expect(await screen.findByText('加载失败')).toBeTruthy();
expect(screen.getByText('重新加载')).toBeTruthy();
});
test('shows a reachable login entry in logged out mobile shell', async () => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
import type {
BigFishSessionResponse,
RecordBigFishPlayRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
/**
* 上报大鱼吃小鱼正式游玩。elapsedMs 为 0 时仅标记玩过作品。
*/
export function recordBigFishPlay(
sessionId: string,
payload: RecordBigFishPlayRequest,
) {
return requestJson<BigFishSessionResponse>(
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'记录大鱼吃小鱼游玩失败',
{
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
},
);
}
+1
View File
@@ -2,3 +2,4 @@ export {
advanceLocalBigFishRuntimeRun,
startLocalBigFishRuntimeRun,
} from './bigFishLocalRuntime';
export { recordBigFishPlay } from './bigFishRuntimeClient';
@@ -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,
};
+1
View File
@@ -2,4 +2,5 @@ export {
bigFishWorksClient,
deleteBigFishWork,
listBigFishWorks,
recordBigFishWorkPlay,
} from './bigFishWorksClient';
@@ -11,6 +11,7 @@ import type {
ProfileSaveArchiveResumeResponse,
ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
RuntimeSettings,
} from '../../../packages/shared/src/contracts/runtime';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
@@ -125,6 +126,22 @@ export function redeemRpgProfileReferralInviteCode(
);
}
export function redeemRpgProfileRewardCode(
code: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<RedeemProfileRewardCodeResponse>(
'/profile/redeem-codes/redeem',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
},
'兑换失败',
options,
);
}
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
'/profile/play-stats',