统一推荐页游客运行态与切换队列

统一推荐页各玩法正式 runtime 的游客鉴权透传。

收口推荐页首页展示队列和嵌入运行态切换队列。

补齐未登录读档、签名资产和个人数据读取的游客态处理。

新增运行态 HUD 小尺寸 logo 资源并更新拼图与抓鹅展示。

补充推荐切换、runtime guest 启动和客户端请求回归测试。

更新玩法链路、后端契约和团队记忆文档。
This commit is contained in:
2026-06-10 22:00:19 +08:00
parent e29992cf01
commit 7dd53e95d8
41 changed files with 1372 additions and 376 deletions

View File

@@ -11,7 +11,7 @@ import {
useState,
} from 'react';
import jumpHopRuntimeLevelLogo from '../../../media/logo.png';
import jumpHopRuntimeLevelLogo from '../../../media/logo-runtime-hud.webp';
import type {
JumpHopRuntimeRunSnapshotResponse,
JumpHopTileFaceAsset,

View File

@@ -15,7 +15,7 @@ import {
useState,
} from 'react';
import match3DRuntimeLevelLogo from '../../../media/logo.png';
import match3DRuntimeLevelLogo from '../../../media/logo-runtime-hud.webp';
import type {
Match3DClickItemRequest,
Match3DClickItemResult,

View File

@@ -318,6 +318,7 @@ import {
submitRpgProfileFeedback,
} from '../../services/rpg-entry/rpgProfileClient';
import { requestRpgRuntimeJson } from '../../services/rpg-runtime/rpgRuntimeRequest';
import { type RuntimeGuestRequestOptions } from '../../services/runtimeGuestAuth';
import { squareHoleCreationClient } from '../../services/square-hole-creation';
import {
dropSquareHoleShape,
@@ -372,13 +373,16 @@ import {
type CreationWorkShelfItem,
isPersistedBarkBattleDraftGenerating,
} from '../custom-world-home/creationWorkShelf';
import { selectAdjacentPlatformRecommendEntry } from '../rpg-entry/rpgEntryPublicGalleryViewModel';
import {
buildPlatformRecommendFeedEntries,
selectAdjacentPlatformRecommendEntry,
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
import {
isBigFishGalleryEntry,
isEdutainmentGalleryEntry,
isJumpHopGalleryEntry,
isPuzzleGalleryEntry,
isPuzzleClearGalleryEntry,
isPuzzleGalleryEntry,
mapPuzzleClearWorkToPlatformGalleryCard,
mapPuzzleWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
@@ -492,7 +496,6 @@ import {
import {
canExposePublicWork,
EDUTAINMENT_HIDDEN_MESSAGE,
filterGeneralPublicWorks,
} from './platformEdutainmentVisibility';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
@@ -523,7 +526,6 @@ import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
import { PlatformErrorDialog } from './PlatformErrorDialog';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel';
import { buildPlatformRecommendedEntries } from './platformRecommendation';
import {
buildMatch3DProfileFromSession,
hasMatch3DRuntimeAsset,
@@ -1476,6 +1478,8 @@ export function PlatformEntryFlowShellImpl({
useState<MiniGameDraftGenerationState | null>(null);
const [jumpHopError, setJumpHopError] = useState<string | null>(null);
const [isJumpHopBusy, setIsJumpHopBusy] = useState(false);
const [barkBattleRuntimeRequestOptions, setBarkBattleRuntimeRequestOptions] =
useState<RuntimeGuestRequestOptions | null>(null);
const [puzzleClearSession, setPuzzleClearSession] =
useState<PuzzleClearSessionSnapshotResponse | null>(null);
const [puzzleClearRun, setPuzzleClearRun] = useState<
@@ -1579,6 +1583,34 @@ export function PlatformEntryFlowShellImpl({
useState<PuzzleRuntimeReturnStage>('puzzle-gallery-detail');
const [puzzleRuntimeAuthMode, setPuzzleRuntimeAuthMode] =
useState<PuzzleRuntimeAuthMode>('default');
const buildRecommendRuntimeRequestOptions = useCallback(
async (
input: {
kind?: RecommendRuntimeKind;
embedded?: boolean;
forcePublicRuntime?: boolean;
} = {},
) => {
const shouldUseRuntimeOptions = Boolean(
input.forcePublicRuntime ||
input.embedded ||
(input.kind && activeRecommendRuntimeKind === input.kind),
);
return shouldUseRuntimeOptions
? buildRecommendRuntimeAuthOptions(authUi, true)
: {};
},
[activeRecommendRuntimeKind, authUi],
);
const buildPuzzleRuntimeRequestOptions = useCallback(
() =>
buildRecommendRuntimeRequestOptions({
kind: 'puzzle',
forcePublicRuntime: puzzleRuntimeAuthMode === 'isolated',
}),
[buildRecommendRuntimeRequestOptions, puzzleRuntimeAuthMode],
);
const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false);
const submittedPuzzleLeaderboardKeysRef = useRef(new Set<string>());
const puzzleStartInFlightKeyRef = useRef<string | null>(null);
@@ -2910,10 +2942,10 @@ export function PlatformEntryFlowShellImpl({
} = publicGalleryFeeds;
const recommendRuntimeEntries = useMemo(
() =>
buildPlatformRecommendedEntries({
featuredEntries: filterGeneralPublicWorks(featuredGalleryEntries),
latestEntries: filterGeneralPublicWorks(latestGalleryEntries),
}),
buildPlatformRecommendFeedEntries(
featuredGalleryEntries,
latestGalleryEntries,
),
[featuredGalleryEntries, latestGalleryEntries],
);
@@ -6971,15 +7003,17 @@ export function PlatformEntryFlowShellImpl({
profileId: targetProfileId,
mode: 'play' as const,
};
const runtimeGuestOptions =
options.embedded || workDetail.summary.publishStatus === 'draft'
? await buildRecommendRuntimeAuthOptions(authUi, true)
: {};
const runtimeRequestOptions = await buildRecommendRuntimeRequestOptions(
{
kind: 'visual-novel',
embedded: options.embedded,
},
);
const { run } = options.embedded
? await startVisualNovelRun(
targetProfileId,
startRunPayload,
runtimeGuestOptions,
runtimeRequestOptions,
)
: await startVisualNovelRun(targetProfileId, startRunPayload);
setVisualNovelWork(workDetail);
@@ -7005,7 +7039,7 @@ export function PlatformEntryFlowShellImpl({
}
},
[
authUi,
buildRecommendRuntimeRequestOptions,
resolvePuzzleErrorMessage,
setIsVisualNovelBusy,
setSelectionStage,
@@ -7027,14 +7061,14 @@ export function PlatformEntryFlowShellImpl({
setVisualNovelError(null);
setIsVisualNovelBusy(true);
try {
const runtimeGuestOptions =
activeRecommendRuntimeKind === 'visual-novel'
? await buildRecommendRuntimeAuthOptions(authUi, true)
: {};
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'visual-novel',
});
const nextRun = await streamVisualNovelRuntimeAction(
visualNovelRun.runId,
payload,
runtimeGuestOptions,
runtimeRequestOptions,
);
setVisualNovelRun(nextRun);
} catch (error) {
@@ -7046,8 +7080,7 @@ export function PlatformEntryFlowShellImpl({
}
},
[
activeRecommendRuntimeKind,
authUi,
buildRecommendRuntimeRequestOptions,
isVisualNovelBusy,
resolvePuzzleErrorMessage,
setIsVisualNovelBusy,
@@ -7496,22 +7529,15 @@ export function PlatformEntryFlowShellImpl({
setJumpHopError(null);
setJumpHopRuntimeReturnStage(options.returnStage ?? 'work-detail');
try {
const runtimeGuestOptions =
options.embedded || shouldUseRecommendRuntimeGuestAuth(authUi)
? await buildRecommendRuntimeAuthOptions(authUi, true)
: RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'jump-hop',
embedded: options.embedded,
forcePublicRuntime: shouldUseRecommendRuntimeGuestAuth(authUi),
});
setJumpHopRuntimeRequestOptions(
runtimeGuestOptions.runtimeGuestToken?.trim()
? {
runtimeGuestToken: runtimeGuestOptions.runtimeGuestToken,
authImpact: runtimeGuestOptions.authImpact,
skipAuth: runtimeGuestOptions.skipAuth,
skipRefresh: runtimeGuestOptions.skipRefresh,
notifyAuthStateChange:
runtimeGuestOptions.notifyAuthStateChange,
clearAuthOnUnauthorized:
runtimeGuestOptions.clearAuthOnUnauthorized,
}
Object.keys(runtimeRequestOptions).length > 0
? runtimeRequestOptions
: null,
);
const [detail, runResponse] = await Promise.all([
@@ -7521,7 +7547,7 @@ export function PlatformEntryFlowShellImpl({
.getWorkDetail(normalizedProfileId)
.catch(() => null),
jumpHopClient.startRun(normalizedProfileId, {
...runtimeGuestOptions,
...runtimeRequestOptions,
runtimeMode: 'published',
}),
]);
@@ -7548,7 +7574,7 @@ export function PlatformEntryFlowShellImpl({
setIsJumpHopBusy(false);
}
},
[authUi, setSelectionStage],
[authUi, buildRecommendRuntimeRequestOptions, setSelectionStage],
);
useEffect(() => {
@@ -7989,15 +8015,16 @@ export function PlatformEntryFlowShellImpl({
setPuzzleClearError(null);
setPuzzleClearRuntimeReturnStage(options.returnStage ?? 'work-detail');
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'puzzle-clear',
embedded: options.embedded,
});
const [detail, runResponse] = await Promise.all([
puzzleClearClient
.getRuntimeWorkDetail(normalizedProfileId)
.catch(() => null),
puzzleClearClient.startRun(normalizedProfileId, runtimeGuestOptions),
puzzleClearClient.startRun(normalizedProfileId, runtimeRequestOptions),
]);
if (detail?.item) {
setPuzzleClearWork(detail.item);
@@ -8022,7 +8049,7 @@ export function PlatformEntryFlowShellImpl({
setIsPuzzleClearBusy(false);
}
},
[authUi, setSelectionStage],
[buildRecommendRuntimeRequestOptions, setSelectionStage],
);
const retryPuzzleClearLevelRun = useCallback(async () => {
@@ -8047,7 +8074,14 @@ export function PlatformEntryFlowShellImpl({
setIsPuzzleClearBusy(true);
setPuzzleClearError(null);
try {
const response = await puzzleClearClient.retryLevel(runId);
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'puzzle-clear',
});
const response = await puzzleClearClient.retryLevel(
runId,
runtimeRequestOptions,
);
setPuzzleClearRun(response.run);
} catch (error) {
setPuzzleClearError(
@@ -8059,6 +8093,7 @@ export function PlatformEntryFlowShellImpl({
}, [
puzzleClearRun,
puzzleClearWork,
buildRecommendRuntimeRequestOptions,
setSelectionStage,
startPuzzleClearTestRunFromProfile,
]);
@@ -8084,7 +8119,14 @@ export function PlatformEntryFlowShellImpl({
setIsPuzzleClearBusy(true);
setPuzzleClearError(null);
try {
const response = await puzzleClearClient.advanceNextLevel(runId);
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'puzzle-clear',
});
const response = await puzzleClearClient.advanceNextLevel(
runId,
runtimeRequestOptions,
);
setPuzzleClearRun(response.run);
} catch (error) {
setPuzzleClearError(
@@ -8093,7 +8135,12 @@ export function PlatformEntryFlowShellImpl({
} finally {
setIsPuzzleClearBusy(false);
}
}, [puzzleClearRun, puzzleClearWork, setSelectionStage]);
}, [
puzzleClearRun,
puzzleClearWork,
buildRecommendRuntimeRequestOptions,
setSelectionStage,
]);
const markPuzzleClearLevelTimeUp = useCallback(async () => {
const runId = puzzleClearRun?.runId;
@@ -8107,14 +8154,21 @@ export function PlatformEntryFlowShellImpl({
}
try {
const response = await puzzleClearClient.markTimeUp(runId);
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'puzzle-clear',
});
const response = await puzzleClearClient.markTimeUp(
runId,
runtimeRequestOptions,
);
setPuzzleClearRun(response.run);
} catch (error) {
setPuzzleClearError(
resolveRpgCreationErrorMessage(error, '同步拼消消倒计时失败。'),
);
}
}, [puzzleClearRun]);
}, [puzzleClearRun, buildRecommendRuntimeRequestOptions]);
const swapPuzzleClearCardsInRun = useCallback(
async (payload: {
@@ -8133,7 +8187,15 @@ export function PlatformEntryFlowShellImpl({
return;
}
try {
const response = await puzzleClearClient.swapCards(runId, payload);
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'puzzle-clear',
});
const response = await puzzleClearClient.swapCards(
runId,
payload,
runtimeRequestOptions,
);
setPuzzleClearRun(response.run);
} catch (error) {
setPuzzleClearError(
@@ -8141,7 +8203,7 @@ export function PlatformEntryFlowShellImpl({
);
}
},
[puzzleClearRun],
[puzzleClearRun, buildRecommendRuntimeRequestOptions],
);
const compileWoodenFishSession = useCallback(
@@ -8493,16 +8555,17 @@ export function PlatformEntryFlowShellImpl({
setWoodenFishError(null);
setWoodenFishRuntimeReturnStage(options.returnStage ?? 'work-detail');
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'wooden-fish',
embedded: options.embedded,
});
const [detail, runResponse] = await Promise.all([
woodenFishClient.getWorkDetail(normalizedProfileId).catch(() => null),
options.embedded
? woodenFishClient.startRun(
normalizedProfileId,
runtimeGuestOptions,
runtimeRequestOptions,
)
: woodenFishClient.startRun(normalizedProfileId),
]);
@@ -8529,7 +8592,7 @@ export function PlatformEntryFlowShellImpl({
setIsWoodenFishBusy(false);
}
},
[authUi, setSelectionStage],
[buildRecommendRuntimeRequestOptions, setSelectionStage],
);
const checkpointWoodenFishRuntimeRun = useCallback(
@@ -8541,10 +8604,18 @@ export function PlatformEntryFlowShellImpl({
if (!runId) {
return;
}
const response = await woodenFishClient.checkpointRun(runId, payload);
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'wooden-fish',
});
const response = await woodenFishClient.checkpointRun(
runId,
payload,
runtimeRequestOptions,
);
setWoodenFishRun(response.run);
},
[woodenFishRun?.runId],
[buildRecommendRuntimeRequestOptions, woodenFishRun?.runId],
);
const executePuzzleAction = puzzleFlow.executeAction;
@@ -9015,9 +9086,12 @@ export function PlatformEntryFlowShellImpl({
try {
let runtimeProfile: Match3DWorkProfile | Match3DWorkSummary = profile;
const canReadProtectedMatch3DDetail =
!options.embedded || !shouldUseRecommendRuntimeGuestAuth(authUi);
if (
!hasMatch3DRuntimeAsset(profile.generatedItemAssets) ||
!hasMatch3DRuntimeBackgroundAsset(profile)
canReadProtectedMatch3DDetail &&
(!hasMatch3DRuntimeAsset(profile.generatedItemAssets) ||
!hasMatch3DRuntimeBackgroundAsset(profile))
) {
try {
const { item } = await getMatch3DWorkDetail(profile.profileId);
@@ -9043,12 +9117,14 @@ export function PlatformEntryFlowShellImpl({
runtimeProfile.generatedBackgroundAsset,
{ expireSeconds: 300 },
);
const runtimeGuestOptions =
options.authMode === 'isolated'
? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS
: await buildRecommendRuntimeAuthOptions(authUi, options.embedded);
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'match3d',
embedded: options.embedded,
forcePublicRuntime: options.authMode === 'isolated',
});
const runtimeOptions = {
...runtimeGuestOptions,
...runtimeRequestOptions,
...(typeof options.itemTypeCountOverride === 'number'
? { itemTypeCountOverride: options.itemTypeCountOverride }
: {}),
@@ -9097,6 +9173,7 @@ export function PlatformEntryFlowShellImpl({
[
isMatch3DBusy,
authUi,
buildRecommendRuntimeRequestOptions,
match3dFlow,
resolveMatch3DErrorMessage,
resolveMatch3DRuntimeAdapter,
@@ -9120,12 +9197,13 @@ export function PlatformEntryFlowShellImpl({
setSquareHoleError(null);
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'square-hole',
embedded: options.embedded,
});
const { run } = options.embedded
? await startSquareHoleRun(profile.profileId, runtimeGuestOptions)
? await startSquareHoleRun(profile.profileId, runtimeRequestOptions)
: await startSquareHoleRun(profile.profileId);
setSquareHoleRun(run);
setSquareHoleRuntimeReturnStage(returnStage);
@@ -9157,7 +9235,7 @@ export function PlatformEntryFlowShellImpl({
},
[
isSquareHoleBusy,
authUi,
buildRecommendRuntimeRequestOptions,
resolveSquareHoleErrorMessage,
setSelectionStage,
setSquareHoleError,
@@ -9275,14 +9353,14 @@ export function PlatformEntryFlowShellImpl({
bigFishInputInFlightRef.current = true;
try {
const runtimeGuestOptions =
activeRecommendRuntimeKind === 'big-fish'
? await buildRecommendRuntimeAuthOptions(authUi, true)
: {};
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'big-fish',
});
const { run } = await submitBigFishRuntimeInput(
bigFishRun.runId,
payload,
runtimeGuestOptions,
runtimeRequestOptions,
);
setBigFishRun(run);
} catch (error) {
@@ -9294,8 +9372,7 @@ export function PlatformEntryFlowShellImpl({
}
},
[
activeRecommendRuntimeKind,
authUi,
buildRecommendRuntimeRequestOptions,
bigFishRun,
resolveBigFishErrorMessage,
setBigFishError,
@@ -9310,21 +9387,18 @@ export function PlatformEntryFlowShellImpl({
const elapsedMs = Math.max(1_000, Date.now() - bigFishRuntimeStartedAt);
setBigFishRuntimeStartedAt(null);
const reportPromise =
activeRecommendRuntimeKind === 'big-fish'
? buildRecommendRuntimeAuthOptions(authUi, true).then(
(runtimeAuthOptions) =>
recordBigFishPlay(sessionId, { elapsedMs }, runtimeAuthOptions),
)
: recordBigFishPlay(sessionId, { elapsedMs });
const reportPromise = buildRecommendRuntimeRequestOptions({
kind: 'big-fish',
}).then((runtimeRequestOptions) =>
recordBigFishPlay(sessionId, { elapsedMs }, runtimeRequestOptions),
);
void reportPromise.catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩时长失败。'),
);
});
}, [
activeRecommendRuntimeKind,
authUi,
buildRecommendRuntimeRequestOptions,
bigFishRun?.sessionId,
bigFishRuntimeStartedAt,
resolveBigFishErrorMessage,
@@ -9642,14 +9716,11 @@ export function PlatformEntryFlowShellImpl({
profileId: currentLevel.profileId,
levelId: resolvePuzzleRestartLevelId(currentRun, detailItem),
};
const runtimeGuestOptions =
puzzleRuntimeAuthMode === 'isolated'
? await buildRecommendRuntimeGuestOptions()
: {};
const { run } =
puzzleRuntimeAuthMode === 'isolated'
? await startPuzzleRun(startRunPayload, runtimeGuestOptions)
: await startPuzzleRun(startRunPayload);
const runtimeRequestOptions = await buildPuzzleRuntimeRequestOptions();
const { run } = await startPuzzleRun(
startRunPayload,
runtimeRequestOptions,
);
setSelectedPuzzleDetail(detailItem);
puzzleRunRef.current = run;
setPuzzleRun(run);
@@ -9663,7 +9734,7 @@ export function PlatformEntryFlowShellImpl({
}, [
isPuzzleBusy,
puzzleRun,
puzzleRuntimeAuthMode,
buildPuzzleRuntimeRequestOptions,
resolvePuzzleErrorMessage,
selectedPuzzleDetail,
setIsPuzzleBusy,
@@ -9775,16 +9846,10 @@ export function PlatformEntryFlowShellImpl({
return;
}
const submitLeaderboardPromise =
puzzleRuntimeAuthMode === 'isolated'
? buildRecommendRuntimeGuestOptions().then((runtimeGuestOptions) =>
submitPuzzleLeaderboard(
puzzleRun.runId,
payload,
runtimeGuestOptions,
),
)
: submitPuzzleLeaderboard(puzzleRun.runId, payload);
const submitLeaderboardPromise = buildPuzzleRuntimeRequestOptions().then(
(runtimeRequestOptions) =>
submitPuzzleLeaderboard(puzzleRun.runId, payload, runtimeRequestOptions),
);
void submitLeaderboardPromise
.then(({ run }) => {
@@ -9808,7 +9873,7 @@ export function PlatformEntryFlowShellImpl({
authUi?.user?.displayName,
platformBootstrap,
puzzleRun,
puzzleRuntimeAuthMode,
buildPuzzleRuntimeRequestOptions,
resolvePuzzleErrorMessage,
setPuzzleError,
]);
@@ -9838,10 +9903,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
const runtimeGuestOptions =
puzzleRuntimeAuthMode === 'isolated'
? await buildRecommendRuntimeGuestOptions()
: {};
const runtimeRequestOptions = await buildPuzzleRuntimeRequestOptions();
const targetProfileId = _target?.profileId?.trim() ?? '';
if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) {
const itemPromise =
@@ -9850,18 +9912,13 @@ export function PlatformEntryFlowShellImpl({
: getPuzzleGalleryDetail(targetProfileId).then(
(response) => response.item,
);
const advancePromise =
puzzleRuntimeAuthMode === 'isolated'
? advancePuzzleNextLevel(
puzzleRun.runId,
{
targetProfileId,
},
runtimeGuestOptions,
)
: advancePuzzleNextLevel(puzzleRun.runId, {
targetProfileId,
});
const advancePromise = advancePuzzleNextLevel(
puzzleRun.runId,
{
targetProfileId,
},
runtimeRequestOptions,
);
const [{ run }, item] = await Promise.all([
advancePromise,
itemPromise,
@@ -9878,14 +9935,11 @@ export function PlatformEntryFlowShellImpl({
return;
}
const { run } =
puzzleRuntimeAuthMode === 'isolated'
? await advancePuzzleNextLevel(
puzzleRun.runId,
{},
runtimeGuestOptions,
)
: await advancePuzzleNextLevel(puzzleRun.runId, {});
const { run } = await advancePuzzleNextLevel(
puzzleRun.runId,
{},
runtimeRequestOptions,
);
setPuzzleRun(run);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
@@ -9898,7 +9952,7 @@ export function PlatformEntryFlowShellImpl({
isPuzzleBusy,
isPuzzleLeaderboardBusy,
puzzleRun,
puzzleRuntimeAuthMode,
buildPuzzleRuntimeRequestOptions,
resolvePuzzleErrorMessage,
selectedPuzzleDetail,
setIsPuzzleBusy,
@@ -12436,12 +12490,13 @@ export function PlatformEntryFlowShellImpl({
setBigFishRuntimeReturnStage(returnStage);
setBigFishRun(null);
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'big-fish',
embedded: options.embedded,
});
const { run } = options.embedded
? await startBigFishRuntimeRun(sessionId, runtimeGuestOptions)
? await startBigFishRuntimeRun(sessionId, runtimeRequestOptions)
: await startBigFishRuntimeRun(sessionId);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRun(run);
@@ -12452,7 +12507,7 @@ export function PlatformEntryFlowShellImpl({
);
}
const recordPlayPromise = options.embedded
? recordBigFishPlay(sessionId, { elapsedMs: 0 }, runtimeGuestOptions)
? recordBigFishPlay(sessionId, { elapsedMs: 0 }, runtimeRequestOptions)
: recordBigFishPlay(sessionId, { elapsedMs: 0 });
void recordPlayPromise.catch((error) => {
setBigFishError(
@@ -12468,7 +12523,7 @@ export function PlatformEntryFlowShellImpl({
}
},
[
authUi,
buildRecommendRuntimeRequestOptions,
bigFishFlow,
resolveBigFishErrorMessage,
setBigFishError,
@@ -12495,12 +12550,18 @@ export function PlatformEntryFlowShellImpl({
);
setBarkBattleRuntimeReturnStage(returnStage);
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({
kind: 'bark-battle',
embedded: options.embedded,
});
setBarkBattleRuntimeRequestOptions(
Object.keys(runtimeRequestOptions).length > 0
? runtimeRequestOptions
: null,
);
const runResponse = options.embedded
? await startBarkBattleRun(item.workId, {}, runtimeGuestOptions)
? await startBarkBattleRun(item.workId, {}, runtimeRequestOptions)
: await startBarkBattleRun(item.workId);
void runResponse;
selectionStageRef.current = 'bark-battle-runtime';
@@ -12521,7 +12582,11 @@ export function PlatformEntryFlowShellImpl({
return false;
}
},
[authUi, resolveBarkBattleErrorMessage, setSelectionStage],
[
buildRecommendRuntimeRequestOptions,
resolveBarkBattleErrorMessage,
setSelectionStage,
],
);
const startSelectedPublicWork = useCallback(() => {
@@ -12975,10 +13040,12 @@ export function PlatformEntryFlowShellImpl({
match3dFlow.setIsBusy(true);
setMatch3DError(null);
void resolveMatch3DRuntimeAdapter(
activeMatch3DRuntimeProfile?.profileId,
)
.restartRun(match3dRun.runId)
void buildRecommendRuntimeRequestOptions({ kind: 'match3d' })
.then((runtimeRequestOptions) =>
resolveMatch3DRuntimeAdapter(
activeMatch3DRuntimeProfile?.profileId,
).restartRun(match3dRun.runId, runtimeRequestOptions),
)
.then(({ run }) => {
setMatch3DRun(run);
})
@@ -12992,24 +13059,28 @@ export function PlatformEntryFlowShellImpl({
});
}}
onOptimisticRunChange={setMatch3DRun}
onClickItem={(payload) => {
onClickItem={async (payload) => {
const runId = payload.runId ?? match3dRun?.runId;
if (!runId) {
return Promise.reject(new Error('抓大鹅运行态缺少 runId。'));
}
const runtimeRequestOptions =
await buildRecommendRuntimeRequestOptions({ kind: 'match3d' });
return resolveMatch3DRuntimeAdapter(
activeMatch3DRuntimeProfile?.profileId,
).clickItem(runId, payload);
).clickItem(runId, payload, runtimeRequestOptions);
}}
onTimeExpired={() => {
if (!match3dRun?.runId) {
return;
}
void resolveMatch3DRuntimeAdapter(
activeMatch3DRuntimeProfile?.profileId,
)
.finishTimeUp(match3dRun.runId)
void buildRecommendRuntimeRequestOptions({ kind: 'match3d' })
.then((runtimeRequestOptions) =>
resolveMatch3DRuntimeAdapter(
activeMatch3DRuntimeProfile?.profileId,
).finishTimeUp(match3dRun.runId, runtimeRequestOptions),
)
.then(({ run }) => {
setMatch3DRun(run);
})
@@ -13143,9 +13214,17 @@ export function PlatformEntryFlowShellImpl({
squareHoleRun?.runId &&
squareHoleRun.status.toLowerCase() === 'running'
) {
void stopSquareHoleRun(squareHoleRun.runId).catch(
() => undefined,
);
void buildRecommendRuntimeRequestOptions({
kind: 'square-hole',
})
.then((runtimeRequestOptions) =>
stopSquareHoleRun(
squareHoleRun.runId,
undefined,
runtimeRequestOptions,
),
)
.catch(() => undefined);
}
setActiveRecommendRuntimeKind(null);
}}
@@ -13156,7 +13235,12 @@ export function PlatformEntryFlowShellImpl({
squareHoleFlow.setIsBusy(true);
setSquareHoleError(null);
void restartSquareHoleRun(squareHoleRun.runId)
void buildRecommendRuntimeRequestOptions({
kind: 'square-hole',
})
.then((runtimeRequestOptions) =>
restartSquareHoleRun(squareHoleRun.runId, runtimeRequestOptions),
)
.then(({ run }) => {
setSquareHoleRun(run);
})
@@ -13178,14 +13262,26 @@ export function PlatformEntryFlowShellImpl({
if (!runId) {
return Promise.reject(new Error('方洞挑战运行态缺少 runId。'));
}
return dropSquareHoleShape(runId, payload);
return buildRecommendRuntimeRequestOptions({
kind: 'square-hole',
}).then((runtimeRequestOptions) =>
dropSquareHoleShape(runId, payload, runtimeRequestOptions),
);
}}
onTimeExpired={() => {
if (!squareHoleRun?.runId) {
return;
}
void finishSquareHoleTimeUp(squareHoleRun.runId)
void buildRecommendRuntimeRequestOptions({
kind: 'square-hole',
})
.then((runtimeRequestOptions) =>
finishSquareHoleTimeUp(
squareHoleRun.runId,
runtimeRequestOptions,
),
)
.then(({ run }) => {
setSquareHoleRun(run);
})
@@ -13230,6 +13326,7 @@ export function PlatformEntryFlowShellImpl({
workId={barkBattlePublishedConfig.workId}
publishedConfig={barkBattlePublishedConfig}
runtimeMode="published"
runtimeRequestOptions={barkBattleRuntimeRequestOptions ?? undefined}
onExit={() => {
setActiveRecommendRuntimeKind(null);
}}
@@ -13260,7 +13357,9 @@ export function PlatformEntryFlowShellImpl({
activeRecommendEntryKey,
activeRecommendRuntimeKind,
barkBattlePublishedConfig,
barkBattleRuntimeRequestOptions,
babyObjectMatchDraft,
buildRecommendRuntimeRequestOptions,
bigFishError,
bigFishRun,
bigFishRuntimeShare,
@@ -13297,6 +13396,7 @@ export function PlatformEntryFlowShellImpl({
recommendRuntimeEntries,
remodelCurrentPuzzleRuntimeWork,
resolveMatch3DErrorMessage,
resolveMatch3DRuntimeAdapter,
resolveSquareHoleErrorMessage,
reportBigFishObservedPlayTime,
restartBigFishRun,
@@ -16663,6 +16763,9 @@ export function PlatformEntryFlowShellImpl({
workId={barkBattlePublishedConfig.workId}
publishedConfig={barkBattlePublishedConfig}
runtimeMode={barkBattleRuntimeMode}
runtimeRequestOptions={
barkBattleRuntimeRequestOptions ?? undefined
}
onExit={() => {
if (
barkBattleRuntimeReturnStage === 'bark-battle-result' &&

View File

@@ -724,7 +724,7 @@ test('顶部不显示作者,关卡标题和倒计时使用游戏铭牌结构',
const levelLogo = screen.getByTestId(
'puzzle-runtime-level-logo',
) as HTMLImageElement;
expect(levelLogo.getAttribute('src')).toContain('logo.png');
expect(levelLogo.getAttribute('src')).toContain('logo-runtime-hud.webp');
expect(levelLogo.closest('.puzzle-runtime-level-logo')).toBeTruthy();
expect(document.querySelector('.puzzle-runtime-level-mascot')).toBeNull();
expect(timer.closest('.puzzle-runtime-timer-card')).toBeTruthy();

View File

@@ -18,7 +18,7 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import puzzleLevelLogo from '../../../media/logo.png';
import puzzleLevelLogo from '../../../media/logo-runtime-hud.webp';
import type {
DragPuzzlePieceRequest,
PuzzleBoardSnapshot,

View File

@@ -7810,6 +7810,113 @@ test('logged out home recommendation next starts the next puzzle work', async ()
});
});
test('home recommendation next follows the same scored queue shown in preview', async () => {
const user = userEvent.setup();
const quietWork = {
workId: 'puzzle-work-public-quiet',
profileId: 'puzzle-profile-public-quiet',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-public-quiet',
authorDisplayName: '拼图作者',
levelName: '安静拼图',
summary: '列表里排在前面但热度较低。',
themeTags: ['安静', '拼图'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T10:00:00.000Z',
publishedAt: '2026-04-25T10:00:00.000Z',
playCount: 40,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
const hotWork = {
...quietWork,
workId: 'puzzle-work-public-hot',
profileId: 'puzzle-profile-public-hot',
sourceSessionId: 'puzzle-session-public-hot',
levelName: '热门拼图',
summary: '推荐评分更高,应该先展示。',
playCount: 120,
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
} satisfies PuzzleWorkSummary;
const middleWork = {
...quietWork,
workId: 'puzzle-work-public-middle',
profileId: 'puzzle-profile-public-middle',
sourceSessionId: 'puzzle-session-public-middle',
levelName: '中间拼图',
summary: '推荐评分排在后面。',
playCount: 0,
updatedAt: '2026-04-25T08:00:00.000Z',
publishedAt: '2026-04-25T08:00:00.000Z',
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [quietWork, hotWork, middleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
item:
profileId === hotWork.profileId
? hotWork
: profileId === middleWork.profileId
? middleWork
: quietWork,
}));
vi.mocked(startPuzzleRun).mockImplementation(async (payload) => ({
run: buildMockPuzzleRun(
payload.profileId,
payload.profileId === hotWork.profileId
? hotWork.levelName
: payload.profileId === middleWork.profileId
? middleWork.levelName
: quietWork.levelName,
),
}));
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: hotWork.profileId,
levelId: null,
},
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
expect(
await screen.findByLabelText('热门拼图 作品信息', undefined, {
timeout: 3000,
}),
).toBeTruthy();
const nextPreview = document.querySelector(
'.platform-recommend-swipe-page--next',
);
expect(nextPreview).toBeTruthy();
expect(
within(nextPreview as HTMLElement).getByLabelText('安静拼图 作品信息'),
).toBeTruthy();
await user.click(await screen.findByRole('button', { name: '下一个' }));
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: quietWork.profileId,
levelId: null,
},
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
expect(
await screen.findByLabelText('安静拼图 作品信息', undefined, {
timeout: 3000,
}),
).toBeTruthy();
});
test('home recommendation keeps cover while switching during a pending puzzle start', async () => {
const user = userEvent.setup();
const firstWork = {
@@ -8142,6 +8249,54 @@ test('home recommendation Match3D runtime keeps profile generated models when ca
});
});
test('logged out home recommendation Match3D runtime skips protected detail and starts with guest auth', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-guest',
profileId: 'match3d-profile-card-guest',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-card-guest',
gameName: '游客抓大鹅',
themeText: '游客果园',
summary: '游客可直接游玩。',
tags: ['果园', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 3,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
generatedItemAssets: [],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: match3dCard,
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dCard.profileId),
});
render(<TestWrapper />);
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-card-guest',
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
skipRefresh: true,
}),
);
});
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-card-guest',
);
});
test('home recommendation Match3D runtime keeps image, music and UI assets without requiring models', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-image-only',
@@ -9357,6 +9512,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
elapsedMs: 18_000,
nickname: '测试玩家',
},
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
@@ -9377,6 +9533,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedFirstLevel.runId,
{},
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -9539,6 +9696,7 @@ test('formal puzzle similar work keeps current run level progression', async ()
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedThirdLevel.runId,
{ targetProfileId: 'puzzle-profile-similar-2' },
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
expect(startPuzzleRun).not.toHaveBeenCalled();

View File

@@ -189,6 +189,27 @@ test('public gallery ViewModel builds recommend feed from general public entries
).toEqual([latestPuzzle]);
});
test('public gallery ViewModel keeps recommend feed in scored runtime order', () => {
const quietPuzzle = buildPuzzleEntry({
profileId: 'quiet',
worldName: '安静拼图',
playCount: 0,
updatedAt: '2026-05-03T00:00:00.000Z',
publishedAt: '2026-05-03T00:00:00.000Z',
});
const hotPuzzle = buildPuzzleEntry({
profileId: 'hot',
worldName: '热门拼图',
playCount: 120,
updatedAt: '2026-05-02T00:00:00.000Z',
publishedAt: '2026-05-02T00:00:00.000Z',
});
expect(buildPlatformRecommendFeedEntries([], [quietPuzzle, hotPuzzle])).toEqual(
[hotPuzzle, quietPuzzle],
);
});
test('public gallery ViewModel selects recommend feed window with wraparound neighbors', () => {
const firstEntry = buildPuzzleEntry({ profileId: 'first' });
const secondEntry = buildJumpHopEntry({ profileId: 'second' });

View File

@@ -1,5 +1,6 @@
import { filterGeneralPublicWorks } from '../platform-entry/platformEdutainmentVisibility';
import { getPlatformPublicGalleryEntryKey } from '../platform-entry/platformPublicGalleryFlow';
import { buildPlatformRecommendedEntries } from '../platform-entry/platformRecommendation';
import {
buildPlatformWorldDisplayTags,
isBarkBattleGalleryEntry,
@@ -146,9 +147,10 @@ export function buildPlatformRecommendFeedEntries(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
return dedupePlatformPublicGalleryEntries(
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]),
);
return buildPlatformRecommendedEntries({
featuredEntries: filterGeneralPublicWorks(featuredEntries),
latestEntries: filterGeneralPublicWorks(latestEntries),
});
}
export function selectAdjacentPlatformRecommendEntry(

View File

@@ -8,7 +8,7 @@ import {
useState,
} from 'react';
import woodenFishRuntimeLogo from '../../../media/logo.png';
import woodenFishRuntimeLogo from '../../../media/logo-runtime-hud.webp';
import type {
WoodenFishRuntimeRunSnapshotResponse,
WoodenFishWordCounter,

View File

@@ -8,6 +8,7 @@ import type {
BarkBattleServerResult,
} from '../../../../packages/shared/src/contracts/barkBattle';
import {
type BarkBattleRuntimeRequestOptions,
finishBarkBattleRun,
startBarkBattleRun,
} from '../../../services/bark-battle-runtime';
@@ -31,6 +32,7 @@ type BarkBattleRuntimeShellProps = {
workId?: string;
publishedConfig?: BarkBattlePublishedConfig | null;
runtimeMode?: BarkBattleRuntimeMode;
runtimeRequestOptions?: BarkBattleRuntimeRequestOptions;
onExit?: () => void;
};
@@ -266,6 +268,7 @@ export function BarkBattleRuntimeShell({
workId,
publishedConfig,
runtimeMode = 'draft',
runtimeRequestOptions,
onExit,
}: BarkBattleRuntimeShellProps) {
const initialConfig = useMemo(
@@ -404,20 +407,24 @@ export function BarkBattleRuntimeShell({
0,
runtimeConfigRef.current.roundDurationMs - nextSnapshot.remainingMs,
);
void finishBarkBattleRun(activeRun.runId, {
runId: activeRun.runId,
runToken: activeRun.runToken,
workId: activeRun.workId,
configVersion: activeRun.configVersion,
rulesetVersion: activeRun.rulesetVersion,
difficultyPreset: activeRun.difficultyPreset,
clientStartedAt: startedAt,
clientFinishedAt: finishedAt,
durationMs,
derivedMetrics: buildDerivedMetrics(),
clientResult: resolveClientResult(nextSnapshot.winner),
clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION,
})
void finishBarkBattleRun(
activeRun.runId,
{
runId: activeRun.runId,
runToken: activeRun.runToken,
workId: activeRun.workId,
configVersion: activeRun.configVersion,
rulesetVersion: activeRun.rulesetVersion,
difficultyPreset: activeRun.difficultyPreset,
clientStartedAt: startedAt,
clientFinishedAt: finishedAt,
durationMs,
derivedMetrics: buildDerivedMetrics(),
clientResult: resolveClientResult(nextSnapshot.winner),
clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION,
},
runtimeRequestOptions,
)
.then(() => {
appendDebugEvent('正式成绩已提交');
})
@@ -433,6 +440,7 @@ export function BarkBattleRuntimeShell({
buildDerivedMetrics,
controller,
isPublishedRuntime,
runtimeRequestOptions,
],
);
@@ -447,14 +455,18 @@ export function BarkBattleRuntimeShell({
pendingRunStartRef.current = (async () => {
try {
setRuntimeError(null);
const started = await startBarkBattleRun(replacementConfig.workId, {
// 中文注释:公开广场摘要可能滞后于已发布运行配置;正式开局以服务端当前发布快照为准。
sourceRoute:
typeof window === 'undefined'
? 'bark-battle-runtime'
: window.location.pathname,
clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION,
});
const started = await startBarkBattleRun(
replacementConfig.workId,
{
// 中文注释:公开广场摘要可能滞后于已发布运行配置;正式开局以服务端当前发布快照为准。
sourceRoute:
typeof window === 'undefined'
? 'bark-battle-runtime'
: window.location.pathname,
clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION,
},
runtimeRequestOptions,
);
const serverRuntimeConfig = buildRuntimeConfigFromServerConfig(
started.runtimeConfig,
);
@@ -491,7 +503,13 @@ export function BarkBattleRuntimeShell({
})();
}
return pendingRunStartRef.current ?? Promise.resolve(true);
}, [appendDebugEvent, controller, isPublishedRuntime, replacementConfig]);
}, [
appendDebugEvent,
controller,
isPublishedRuntime,
replacementConfig,
runtimeRequestOptions,
]);
const syncSnapshot = useCallback(() => {
const nextSnapshot = controller.getSnapshot();

View File

@@ -58,10 +58,14 @@ export function startBigFishRun(
});
}
export function getBigFishRun(runId: string) {
export function getBigFishRun(
runId: string,
options: BigFishRuntimeRequestOptions = {},
) {
return requestRuntimeJson<BigFishRunResponse>({
url: buildRuntimeApiPath(BIG_FISH_RUNTIME_API_BASE, 'runs', runId),
fallbackMessage: '读取大鱼吃小鱼玩法失败',
requestOptions: options,
});
}

View File

@@ -139,4 +139,14 @@ test('jump hop work detail preserves flattened back button asset', async () => {
const response = await jumpHopClient.getWorkDetail('profile-1');
expect(response.item.backButtonAsset).toEqual(backButtonAsset);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/jump-hop/works/profile-1',
{ method: 'GET' },
'读取跳一跳作品详情失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
skipAuth: true,
skipRefresh: true,
}),
);
});

View File

@@ -19,6 +19,7 @@ import type {
JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
@@ -196,10 +197,19 @@ export async function getJumpHopWorkDetail(
options.audience === 'creation'
? JUMP_HOP_WORKS_API_BASE
: `${JUMP_HOP_RUNTIME_API_BASE}/works`;
const requestOptions: ApiRequestOptions =
options.audience === 'creation'
? {}
: {
retry: JUMP_HOP_RUNTIME_READ_RETRY,
skipAuth: true,
skipRefresh: true,
};
const response = await requestJson<JumpHopWorkDetailResponse>(
`${base}/${encodeURIComponent(profileId)}`,
{ method: 'GET' },
'读取跳一跳作品详情失败',
requestOptions,
);
return normalizeJumpHopWorkDetailResponse(response);
}

View File

@@ -64,30 +64,63 @@ test('server Match3D runtime adapter forwards the full runtime seam lazily', asy
stopRun: vi.fn().mockResolvedValue(stopResponse),
};
const adapter = createServerMatch3DRuntimeAdapter(dependencies);
const runtimeRequestOptions = {
runtimeGuestToken: 'runtime-guest-token',
skipRefresh: true,
};
expect(await adapter.startRun('server-profile-1', { skipRefresh: true })).toBe(
startResponse,
expect(
await adapter.startRun('server-profile-1', runtimeRequestOptions),
).toBe(startResponse);
expect(await adapter.getRun('server-run-start', runtimeRequestOptions)).toBe(
getResponse,
);
expect(await adapter.getRun('server-run-start')).toBe(getResponse);
expect(await adapter.clickItem('server-run-start', clickPayload)).toEqual({
expect(
await adapter.clickItem(
'server-run-start',
clickPayload,
runtimeRequestOptions,
),
).toEqual({
status: 'Accepted',
run: buildMockRun('server-run-click'),
});
expect(await adapter.restartRun('server-run-start')).toBe(restartResponse);
expect(await adapter.stopRun('server-run-restart')).toBe(stopResponse);
expect(await adapter.finishTimeUp('server-run-start')).toBe(finishResponse);
expect(
await adapter.restartRun('server-run-start', runtimeRequestOptions),
).toBe(restartResponse);
expect(await adapter.stopRun('server-run-restart', runtimeRequestOptions)).toBe(
stopResponse,
);
expect(
await adapter.finishTimeUp('server-run-start', runtimeRequestOptions),
).toBe(finishResponse);
expect(dependencies.startRun).toHaveBeenCalledWith('server-profile-1', {
skipRefresh: true,
});
expect(dependencies.getRun).toHaveBeenCalledWith('server-run-start');
expect(dependencies.startRun).toHaveBeenCalledWith(
'server-profile-1',
runtimeRequestOptions,
);
expect(dependencies.getRun).toHaveBeenCalledWith(
'server-run-start',
runtimeRequestOptions,
);
expect(dependencies.clickItem).toHaveBeenCalledWith(
'server-run-start',
clickPayload,
runtimeRequestOptions,
);
expect(dependencies.restartRun).toHaveBeenCalledWith(
'server-run-start',
runtimeRequestOptions,
);
expect(dependencies.stopRun).toHaveBeenCalledWith(
'server-run-restart',
undefined,
runtimeRequestOptions,
);
expect(dependencies.finishTimeUp).toHaveBeenCalledWith(
'server-run-start',
runtimeRequestOptions,
);
expect(dependencies.restartRun).toHaveBeenCalledWith('server-run-start');
expect(dependencies.stopRun).toHaveBeenCalledWith('server-run-restart');
expect(dependencies.finishTimeUp).toHaveBeenCalledWith('server-run-start');
});
test('local Match3D runtime adapter exposes the same runtime seam as the server client', async () => {

View File

@@ -24,14 +24,27 @@ export type Match3DRuntimeAdapter = {
profileId: string,
options?: Match3DRuntimeRequestOptions,
) => Promise<Match3DRunResponse>;
getRun: (runId: string) => Promise<Match3DRunResponse>;
getRun: (
runId: string,
options?: Match3DRuntimeRequestOptions,
) => Promise<Match3DRunResponse>;
clickItem: (
runId: string,
payload: Match3DClickItemRequest,
options?: Match3DRuntimeRequestOptions,
) => Promise<Match3DClickItemResult>;
restartRun: (runId: string) => Promise<Match3DRunResponse>;
stopRun: (runId: string) => Promise<Match3DRunResponse>;
finishTimeUp: (runId: string) => Promise<Match3DRunResponse>;
restartRun: (
runId: string,
options?: Match3DRuntimeRequestOptions,
) => Promise<Match3DRunResponse>;
stopRun: (
runId: string,
options?: Match3DRuntimeRequestOptions,
) => Promise<Match3DRunResponse>;
finishTimeUp: (
runId: string,
options?: Match3DRuntimeRequestOptions,
) => Promise<Match3DRunResponse>;
};
export type LocalMatch3DRuntimeAdapterOptions = {
@@ -63,12 +76,13 @@ export function createServerMatch3DRuntimeAdapter(
defaultServerMatch3DRuntimeAdapterDependencies,
): Match3DRuntimeAdapter {
return {
clickItem: (runId, payload) => dependencies.clickItem(runId, payload),
finishTimeUp: (runId) => dependencies.finishTimeUp(runId),
getRun: (runId) => dependencies.getRun(runId),
restartRun: (runId) => dependencies.restartRun(runId),
clickItem: (runId, payload, options) =>
dependencies.clickItem(runId, payload, options),
finishTimeUp: (runId, options) => dependencies.finishTimeUp(runId, options),
getRun: (runId, options) => dependencies.getRun(runId, options),
restartRun: (runId, options) => dependencies.restartRun(runId, options),
startRun: (profileId, options) => dependencies.startRun(profileId, options),
stopRun: (runId) => dependencies.stopRun(runId),
stopRun: (runId, options) => dependencies.stopRun(runId, undefined, options),
};
}

View File

@@ -89,11 +89,15 @@ export function startMatch3DRun(
/**
* 读取抓大鹅运行态快照。
*/
export function getMatch3DRun(runId: string) {
export function getMatch3DRun(
runId: string,
options: Match3DRuntimeRequestOptions = {},
) {
return requestRuntimeJson<Match3DRunResponse>({
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId),
fallbackMessage: '读取抓大鹅运行快照失败',
retry: MATCH3D_RUNTIME_READ_RETRY,
requestOptions: options,
});
}
@@ -103,6 +107,7 @@ export function getMatch3DRun(runId: string) {
export async function clickMatch3DItem(
runId: string,
payload: Match3DClickItemRequest,
options: Match3DRuntimeRequestOptions = {},
) {
const response = await requestRuntimeJson<Match3DClickResponse>({
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'click'),
@@ -113,6 +118,7 @@ export async function clickMatch3DItem(
},
fallbackMessage: '确认抓大鹅点击失败',
retry: MATCH3D_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
return mapClickConfirmation(payload, response.confirmation);
@@ -126,6 +132,7 @@ export function stopMatch3DRun(
payload: StopMatch3DRunRequest = {
clientActionId: `match3d-stop-${Date.now()}`,
},
options: Match3DRuntimeRequestOptions = {},
) {
return requestRuntimeJson<Match3DRunResponse>({
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'stop'),
@@ -133,30 +140,39 @@ export function stopMatch3DRun(
jsonBody: payload,
fallbackMessage: '停止抓大鹅玩法失败',
retry: MATCH3D_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}
/**
* 基于当前 run 重开一局。
*/
export function restartMatch3DRun(runId: string) {
export function restartMatch3DRun(
runId: string,
options: Match3DRuntimeRequestOptions = {},
) {
return requestRuntimeJson<Match3DRunResponse>({
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'restart'),
method: 'POST',
fallbackMessage: '重新开始抓大鹅玩法失败',
retry: MATCH3D_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}
/**
* 前端倒计时归零后通知后端确认失败状态。
*/
export function finishMatch3DTimeUp(runId: string) {
export function finishMatch3DTimeUp(
runId: string,
options: Match3DRuntimeRequestOptions = {},
) {
return requestRuntimeJson<Match3DRunResponse>({
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'time-up'),
method: 'POST',
fallbackMessage: '同步抓大鹅倒计时失败',
retry: MATCH3D_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}

View File

@@ -61,7 +61,7 @@ describe('puzzleRuntimeClient', () => {
);
});
it('keeps pause requests on account auth options instead of guest auth', async () => {
it('uses runtime guest auth for pause requests when provided', async () => {
await updatePuzzleRunPause(
'run/1',
{ paused: true },
@@ -76,17 +76,19 @@ describe('puzzleRuntimeClient', () => {
expect(init).toEqual(
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer runtime-guest-token',
},
body: JSON.stringify({ paused: true }),
}),
);
expect(init.headers).not.toHaveProperty('Authorization');
expect(options).toEqual(
expect.objectContaining({
authImpact: 'local',
skipAuth: true,
skipRefresh: true,
}),
);
expect(options).not.toMatchObject({ skipAuth: true });
});
});

View File

@@ -8,10 +8,7 @@ import type {
UpdatePuzzleRuntimePauseRequest,
UsePuzzleRuntimePropRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import {
type ApiRetryOptions,
requestJson,
} from '../apiClient';
import { type ApiRetryOptions } from '../apiClient';
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
@@ -52,11 +49,15 @@ export async function startPuzzleRun(
/**
* 读取拼图运行态快照。
*/
export async function getPuzzleRun(runId: string) {
export async function getPuzzleRun(
runId: string,
options: PuzzleRuntimeRequestOptions = {},
) {
return requestRuntimeJson<PuzzleRunResponse>({
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId),
fallbackMessage: '读取拼图运行快照失败',
retry: PUZZLE_RUNTIME_READ_RETRY,
requestOptions: options,
});
}
@@ -66,6 +67,7 @@ export async function getPuzzleRun(runId: string) {
export async function swapPuzzlePieces(
runId: string,
payload: SwapPuzzlePiecesRequest,
options: PuzzleRuntimeRequestOptions = {},
) {
return requestRuntimeJson<PuzzleRunResponse>({
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'swap'),
@@ -73,6 +75,7 @@ export async function swapPuzzlePieces(
jsonBody: payload,
fallbackMessage: '交换拼图块失败',
retry: PUZZLE_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}
@@ -82,6 +85,7 @@ export async function swapPuzzlePieces(
export async function dragPuzzlePieceOrGroup(
runId: string,
payload: DragPuzzlePieceRequest,
options: PuzzleRuntimeRequestOptions = {},
) {
return requestRuntimeJson<PuzzleRunResponse>({
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'drag'),
@@ -89,6 +93,7 @@ export async function dragPuzzlePieceOrGroup(
jsonBody: payload,
fallbackMessage: '拖动拼图块失败',
retry: PUZZLE_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}
@@ -141,22 +146,14 @@ export async function updatePuzzleRunPause(
payload: UpdatePuzzleRuntimePauseRequest,
options: PuzzleRuntimeRequestOptions = {},
) {
return requestJson<PuzzleRunResponse>(
buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'pause'),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'更新拼图计时状态失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
return requestRuntimeJson<PuzzleRunResponse>({
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'pause'),
method: 'POST',
jsonBody: payload,
fallbackMessage: '更新拼图计时状态失败',
retry: PUZZLE_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}
/**
@@ -167,22 +164,14 @@ export async function usePuzzleRuntimeProp(
payload: UsePuzzleRuntimePropRequest,
options: PuzzleRuntimeRequestOptions = {},
) {
return requestJson<PuzzleRunResponse>(
buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'props'),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'使用拼图道具失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
return requestRuntimeJson<PuzzleRunResponse>({
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'props'),
method: 'POST',
jsonBody: payload,
fallbackMessage: '使用拼图道具失败',
retry: PUZZLE_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}
export const puzzleRuntimeClient = {

View File

@@ -13,17 +13,56 @@ vi.mock('./apiClient', async () => {
};
});
import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient';
import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient';
import {
finishBarkBattleRun,
getBarkBattleRuntimeConfig,
startBarkBattleRun,
} from './bark-battle-runtime/barkBattleRuntimeClient';
import {
getBigFishRun,
recordBigFishPlay,
startBigFishRun,
submitBigFishInput,
} from './big-fish-runtime/bigFishRuntimeClient';
import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient';
import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient';
import {
clickMatch3DItem,
finishMatch3DTimeUp,
getMatch3DRun,
restartMatch3DRun,
startMatch3DRun,
stopMatch3DRun,
} from './match3d-runtime/match3dRuntimeClient';
import {
advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
getPuzzleRun,
startPuzzleRun,
submitPuzzleLeaderboard,
swapPuzzlePieces,
updatePuzzleRunPause,
usePuzzleRuntimeProp,
} from './puzzle-runtime/puzzleRuntimeClient';
import { startSquareHoleRun } from './square-hole-runtime/squareHoleRuntimeClient';
import { startVisualNovelRun } from './visual-novel-runtime/visualNovelRuntimeClient';
import { puzzleClearClient } from './puzzle-clear/puzzleClearClient';
import {
dropSquareHoleShape,
finishSquareHoleTimeUp,
getSquareHoleRun,
restartSquareHoleRun,
startSquareHoleRun,
stopSquareHoleRun,
} from './square-hole-runtime/squareHoleRuntimeClient';
import {
checkpointWoodenFishRun,
finishWoodenFishRun,
startWoodenFishRuntimeRun,
} from './wooden-fish/woodenFishClient';
import {
getVisualNovelHistory,
getVisualNovelRun,
regenerateVisualNovelRun,
startVisualNovelRun,
} from './visual-novel-runtime/visualNovelRuntimeClient';
describe('recommended runtime guest launch clients', () => {
beforeEach(() => {
@@ -31,6 +70,25 @@ describe('recommended runtime guest launch clients', () => {
apiClientMocks.requestJson.mockResolvedValue({ run: {} });
});
function expectRuntimeGuestRequest(expectedUrl: string, expectedMethod: string) {
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
expect(url).toBe(expectedUrl);
expect(init).toEqual(
expect.objectContaining({
method: expectedMethod,
headers: expect.objectContaining({
Authorization: 'Bearer runtime-guest-token',
}),
}),
);
expect(options).toEqual(
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
}
it.each([
{
name: 'jump-hop',
@@ -82,6 +140,14 @@ describe('recommended runtime guest launch clients', () => {
}),
expectedUrl: '/api/runtime/bark-battle/works/bark-battle-work-1/runs',
},
{
name: 'wooden-fish',
start: () =>
startWoodenFishRuntimeRun('wooden-fish-profile-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/wooden-fish/runs',
},
{
name: 'puzzle',
start: () =>
@@ -187,4 +253,397 @@ describe('recommended runtime guest launch clients', () => {
}),
);
});
it.each([
{
name: 'puzzle get run',
run: () =>
getPuzzleRun('run-puzzle-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1',
expectedMethod: 'GET',
},
{
name: 'puzzle swap',
run: () =>
swapPuzzlePieces(
'run-puzzle-1',
{ firstPieceId: 'piece-a', secondPieceId: 'piece-b' },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/swap',
expectedMethod: 'POST',
},
{
name: 'puzzle drag',
run: () =>
dragPuzzlePieceOrGroup(
'run-puzzle-1',
{ pieceId: 'piece-a', targetRow: 1, targetCol: 2 },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/drag',
expectedMethod: 'POST',
},
{
name: 'puzzle pause',
run: () =>
updatePuzzleRunPause(
'run-puzzle-1',
{ paused: true },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/pause',
expectedMethod: 'POST',
},
{
name: 'puzzle prop',
run: () =>
usePuzzleRuntimeProp(
'run-puzzle-1',
{ propKind: 'extendTime' },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/props',
expectedMethod: 'POST',
},
{
name: 'puzzle-clear get run',
run: () =>
puzzleClearClient.getRun('puzzle-clear-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/puzzle-clear/runs/puzzle-clear-run-1',
expectedMethod: 'GET',
},
{
name: 'puzzle-clear swap',
run: () =>
puzzleClearClient.swapCards(
'puzzle-clear-run-1',
{ fromRow: 0, fromCol: 0, toRow: 0, toCol: 1 },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/puzzle-clear/runs/puzzle-clear-run-1/swap',
expectedMethod: 'POST',
},
{
name: 'puzzle-clear retry',
run: () =>
puzzleClearClient.retryLevel('puzzle-clear-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl:
'/api/runtime/puzzle-clear/runs/puzzle-clear-run-1/retry-level',
expectedMethod: 'POST',
},
{
name: 'puzzle-clear next',
run: () =>
puzzleClearClient.advanceNextLevel('puzzle-clear-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl:
'/api/runtime/puzzle-clear/runs/puzzle-clear-run-1/next-level',
expectedMethod: 'POST',
},
{
name: 'puzzle-clear time up',
run: () =>
puzzleClearClient.markTimeUp('puzzle-clear-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/puzzle-clear/runs/puzzle-clear-run-1/time-up',
expectedMethod: 'POST',
},
{
name: 'square-hole get run',
run: () =>
getSquareHoleRun('square-hole-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1',
expectedMethod: 'GET',
},
{
name: 'square-hole drop',
run: () =>
dropSquareHoleShape(
'square-hole-run-1',
{
holeId: 'hole-1',
clientSnapshotVersion: 1,
clientEventId: 'event-1',
droppedAtMs: 1_700_000_000_000,
},
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1/drop',
expectedMethod: 'POST',
},
{
name: 'square-hole stop',
run: () =>
stopSquareHoleRun(
'square-hole-run-1',
{ clientActionId: 'stop-1' },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1/stop',
expectedMethod: 'POST',
},
{
name: 'square-hole restart',
run: () =>
restartSquareHoleRun('square-hole-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1/restart',
expectedMethod: 'POST',
},
{
name: 'square-hole time up',
run: () =>
finishSquareHoleTimeUp('square-hole-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1/time-up',
expectedMethod: 'POST',
},
{
name: 'big-fish get run',
run: () =>
getBigFishRun('big-fish-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/big-fish/runs/big-fish-run-1',
expectedMethod: 'GET',
},
{
name: 'big-fish input',
run: () =>
submitBigFishInput(
'big-fish-run-1',
{ x: 0.25, y: 0.75 },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/big-fish/runs/big-fish-run-1/input',
expectedMethod: 'POST',
},
{
name: 'big-fish play report',
run: () =>
recordBigFishPlay(
'big-fish-session-1',
{ elapsedMs: 1_000 },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl:
'/api/runtime/big-fish/sessions/big-fish-session-1/play',
expectedMethod: 'POST',
},
{
name: 'bark-battle config',
run: () =>
getBarkBattleRuntimeConfig('bark-battle-work-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl:
'/api/runtime/bark-battle/works/bark-battle-work-1/config',
expectedMethod: 'GET',
},
{
name: 'bark-battle finish',
run: () =>
finishBarkBattleRun(
'bark-battle-run-1',
{
runId: 'bark-battle-run-1',
runToken: 'run-token',
workId: 'bark-battle-work-1',
configVersion: 1,
rulesetVersion: 'v1',
difficultyPreset: 'normal',
clientStartedAt: '2026-06-10T00:00:00Z',
clientFinishedAt: '2026-06-10T00:00:10Z',
durationMs: 10_000,
derivedMetrics: {
triggerCount: 1,
maxVolume: 0.8,
averageVolume: 0.4,
finalEnergy: 10,
comboMax: 1,
},
},
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/bark-battle/runs/bark-battle-run-1/finish',
expectedMethod: 'POST',
},
{
name: 'wooden-fish checkpoint',
run: () =>
checkpointWoodenFishRun(
'wooden-fish-run-1',
{ totalTapCount: 8, wordCounters: [] },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl:
'/api/runtime/wooden-fish/runs/wooden-fish-run-1/checkpoint',
expectedMethod: 'POST',
},
{
name: 'wooden-fish finish',
run: () =>
finishWoodenFishRun(
'wooden-fish-run-1',
{ totalTapCount: 8, wordCounters: [] },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/wooden-fish/runs/wooden-fish-run-1/finish',
expectedMethod: 'POST',
},
{
name: 'visual-novel get run',
run: () =>
getVisualNovelRun('visual-novel-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/visual-novel/runs/visual-novel-run-1',
expectedMethod: 'GET',
},
{
name: 'visual-novel history',
run: () =>
getVisualNovelHistory('visual-novel-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl:
'/api/runtime/visual-novel/runs/visual-novel-run-1/history',
expectedMethod: 'GET',
},
{
name: 'visual-novel regenerate',
run: () =>
regenerateVisualNovelRun(
'visual-novel-run-1',
{ historyEntryId: 'history-1', clientEventId: 'event-1' },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl:
'/api/runtime/visual-novel/runs/visual-novel-run-1/regenerate',
expectedMethod: 'POST',
},
])(
'$name uses the shared runtime guest bearer token without touching login auth',
async ({ run, expectedUrl, expectedMethod }) => {
await run();
expectRuntimeGuestRequest(expectedUrl, expectedMethod);
},
);
it.each([
{
name: 'get run',
run: () =>
getMatch3DRun('match3d-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/match3d/runs/match3d-run-1',
expectedMethod: 'GET',
},
{
name: 'restart',
run: () =>
restartMatch3DRun('match3d-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/match3d/runs/match3d-run-1/restart',
expectedMethod: 'POST',
},
{
name: 'stop',
run: () =>
stopMatch3DRun(
'match3d-run-1',
{ clientActionId: 'stop-1' },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/match3d/runs/match3d-run-1/stop',
expectedMethod: 'POST',
},
{
name: 'time up',
run: () =>
finishMatch3DTimeUp('match3d-run-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/match3d/runs/match3d-run-1/time-up',
expectedMethod: 'POST',
},
])(
'match3d $name uses the runtime guest bearer token without touching login auth',
async ({ run, expectedUrl, expectedMethod }) => {
await run();
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
expect(url).toBe(expectedUrl);
expect(init).toEqual(
expect.objectContaining({
method: expectedMethod,
headers: expect.objectContaining({
Authorization: 'Bearer runtime-guest-token',
}),
}),
);
expect(options).toEqual(
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
},
);
it('match3d click uses the runtime guest bearer token without touching login auth', async () => {
apiClientMocks.requestJson.mockResolvedValueOnce({
confirmation: {
accepted: true,
run: {},
clearedItemInstanceIds: [],
},
});
await clickMatch3DItem(
'match3d-run-1',
{
runId: 'match3d-run-1',
itemInstanceId: 'item-1',
clientActionId: 'action-1',
clientEventId: 'event-1',
clickedAtMs: 1_700_000_000_000,
clientSnapshotVersion: 1,
},
{ runtimeGuestToken: 'runtime-guest-token' },
);
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
expect(url).toBe('/api/runtime/match3d/runs/match3d-run-1/click');
expect(init).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer runtime-guest-token',
}),
}),
);
expect(options).toEqual(
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
});
});

View File

@@ -49,11 +49,15 @@ export function startSquareHoleRun(
/**
* 读取方洞挑战运行态快照。
*/
export function getSquareHoleRun(runId: string) {
export function getSquareHoleRun(
runId: string,
options: SquareHoleRuntimeRequestOptions = {},
) {
return requestRuntimeJson<SquareHoleRunResponse>({
url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId),
fallbackMessage: '读取方洞挑战运行快照失败',
retry: SQUARE_HOLE_RUNTIME_READ_RETRY,
requestOptions: options,
});
}
@@ -63,6 +67,7 @@ export function getSquareHoleRun(runId: string) {
export function dropSquareHoleShape(
runId: string,
payload: DropSquareHoleShapeRequest,
options: SquareHoleRuntimeRequestOptions = {},
) {
return requestRuntimeJson<SquareHoleDropResponse>({
url: buildRuntimeApiPath(
@@ -78,6 +83,7 @@ export function dropSquareHoleShape(
},
fallbackMessage: '确认方洞挑战投入失败',
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}
@@ -89,6 +95,7 @@ export function stopSquareHoleRun(
payload: StopSquareHoleRunRequest = {
clientActionId: `square-hole-stop-${Date.now()}`,
},
options: SquareHoleRuntimeRequestOptions = {},
) {
return requestRuntimeJson<SquareHoleRunResponse>({
url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId, 'stop'),
@@ -96,13 +103,17 @@ export function stopSquareHoleRun(
jsonBody: payload,
fallbackMessage: '停止方洞挑战失败',
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}
/**
* 基于当前 run 重开一局。
*/
export function restartSquareHoleRun(runId: string) {
export function restartSquareHoleRun(
runId: string,
options: SquareHoleRuntimeRequestOptions = {},
) {
return requestRuntimeJson<SquareHoleRunResponse>({
url: buildRuntimeApiPath(
SQUARE_HOLE_RUNTIME_API_BASE,
@@ -113,13 +124,17 @@ export function restartSquareHoleRun(runId: string) {
method: 'POST',
fallbackMessage: '重新开始方洞挑战失败',
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}
/**
* 前端倒计时归零后通知后端确认失败状态。
*/
export function finishSquareHoleTimeUp(runId: string) {
export function finishSquareHoleTimeUp(
runId: string,
options: SquareHoleRuntimeRequestOptions = {},
) {
return requestRuntimeJson<SquareHoleRunResponse>({
url: buildRuntimeApiPath(
SQUARE_HOLE_RUNTIME_API_BASE,
@@ -130,6 +145,7 @@ export function finishSquareHoleTimeUp(runId: string) {
method: 'POST',
fallbackMessage: '同步方洞挑战倒计时失败',
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}

View File

@@ -135,15 +135,22 @@ export async function startVisualNovelRun(
);
}
export async function getVisualNovelRun(runId: string) {
export async function getVisualNovelRun(
runId: string,
options: VisualNovelRuntimeRequestOptions = {},
) {
return requestRuntimeJson<VisualNovelRunResponse>({
url: buildRuntimeApiPath(VISUAL_NOVEL_RUNTIME_API_BASE, 'runs', runId),
fallbackMessage: '读取视觉小说运行快照失败',
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
requestOptions: options,
});
}
export async function getVisualNovelHistory(runId: string) {
export async function getVisualNovelHistory(
runId: string,
options: VisualNovelRuntimeRequestOptions = {},
) {
return requestRuntimeJson<VisualNovelHistoryResponse>({
url: buildRuntimeApiPath(
VISUAL_NOVEL_RUNTIME_API_BASE,
@@ -153,6 +160,7 @@ export async function getVisualNovelHistory(runId: string) {
),
fallbackMessage: '读取视觉小说历史失败',
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
requestOptions: options,
});
}
@@ -185,6 +193,7 @@ export async function streamVisualNovelRuntimeAction(
export async function regenerateVisualNovelRun(
runId: string,
payload: VisualNovelRegenerateRequest,
options: VisualNovelRuntimeRequestOptions = {},
) {
return requestRuntimeJson<VisualNovelRunResponse>({
url: buildRuntimeApiPath(
@@ -197,6 +206,7 @@ export async function regenerateVisualNovelRun(
jsonBody: payload,
fallbackMessage: '重生成视觉小说历史失败',
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
requestOptions: options,
});
}

View File

@@ -55,6 +55,30 @@ test('wooden fish list works uses creation works endpoint', async () => {
);
});
test('wooden fish runtime work detail reads public profile without auth refresh coupling', async () => {
const { woodenFishClient } = await import('./woodenFishClient');
requestJsonMock.mockResolvedValueOnce({
item: {
summary: {
profileId: 'profile-1',
},
},
});
await woodenFishClient.getWorkDetail('profile-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/wooden-fish/works/profile-1',
{ method: 'GET' },
'读取敲木鱼作品详情失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
skipAuth: true,
skipRefresh: true,
}),
);
});
test('wooden fish start run uses runtime guest json skeleton', async () => {
const { woodenFishClient } = await import('./woodenFishClient');
requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } });

View File

@@ -17,7 +17,11 @@ import type {
WoodenFishWorksResponse,
WoodenFishWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import { type ApiRetryOptions, requestJson } from '../apiClient';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
import { createCreationAgentClient } from '../creation-agent';
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
@@ -176,11 +180,19 @@ export function executeWoodenFishCreationAction(
.then(normalizeWoodenFishActionResponse);
}
export async function getWoodenFishWorkDetail(profileId: string) {
export async function getWoodenFishWorkDetail(
profileId: string,
options: ApiRequestOptions = {
retry: WOODEN_FISH_RUNTIME_READ_RETRY,
skipAuth: true,
skipRefresh: true,
},
) {
const response = await requestJson<WoodenFishWorkDetailResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`,
{ method: 'GET' },
'读取敲木鱼作品详情失败',
options,
);
return normalizeWoodenFishWorkDetailResponse(response);
}