合入最新 master
合并 origin/master 并保留平台入口运行态与推荐链路语义 修正合并后基于 tab 语义变化的前端断言
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import jumpHopRuntimeLevelLogo from '../../../media/logo.png';
|
||||
import jumpHopRuntimeLevelLogo from '../../../media/logo-runtime-hud.webp';
|
||||
import type {
|
||||
JumpHopRunStatus,
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import match3DRuntimeLevelLogo from '../../../media/logo.png';
|
||||
import match3DRuntimeLevelLogo from '../../../media/logo-runtime-hud.webp';
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DClickItemResult,
|
||||
|
||||
@@ -317,6 +317,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,
|
||||
@@ -377,11 +378,15 @@ 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,
|
||||
isPuzzleClearGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
mapPuzzleClearWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
@@ -495,7 +500,6 @@ import {
|
||||
import {
|
||||
canExposePublicWork,
|
||||
EDUTAINMENT_HIDDEN_MESSAGE,
|
||||
filterGeneralPublicWorks,
|
||||
} from './platformEdutainmentVisibility';
|
||||
import {
|
||||
PlatformDraftGenerationPointNoticeDialog,
|
||||
@@ -1496,6 +1500,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<
|
||||
@@ -1599,6 +1605,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);
|
||||
@@ -1734,6 +1768,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
const entries = creationEntryConfig?.creationTypes ?? [];
|
||||
return new Map(entries.map((entry) => [entry.id, entry]));
|
||||
}, [creationEntryConfig]);
|
||||
const publicWorkInteractions = useMemo(
|
||||
() => creationEntryConfig?.publicWorkInteractions ?? [],
|
||||
[creationEntryConfig],
|
||||
);
|
||||
const getUnifiedSpec = useCallback(
|
||||
(playId: UnifiedCreationPlayId) =>
|
||||
getUnifiedCreationSpec(playId, unifiedCreationConfigById.get(playId)),
|
||||
@@ -2938,10 +2976,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
} = publicGalleryFeeds;
|
||||
const recommendRuntimeEntries = useMemo(
|
||||
() =>
|
||||
buildPlatformRecommendedEntries({
|
||||
featuredEntries: filterGeneralPublicWorks(featuredGalleryEntries),
|
||||
latestEntries: filterGeneralPublicWorks(latestGalleryEntries),
|
||||
}),
|
||||
buildPlatformRecommendFeedEntries(
|
||||
featuredGalleryEntries,
|
||||
latestGalleryEntries,
|
||||
),
|
||||
[featuredGalleryEntries, latestGalleryEntries],
|
||||
);
|
||||
|
||||
@@ -6994,15 +7032,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);
|
||||
@@ -7028,7 +7068,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
},
|
||||
[
|
||||
authUi,
|
||||
buildRecommendRuntimeRequestOptions,
|
||||
resolvePuzzleErrorMessage,
|
||||
setIsVisualNovelBusy,
|
||||
setSelectionStage,
|
||||
@@ -7050,14 +7090,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) {
|
||||
@@ -7069,8 +7109,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
},
|
||||
[
|
||||
activeRecommendRuntimeKind,
|
||||
authUi,
|
||||
buildRecommendRuntimeRequestOptions,
|
||||
isVisualNovelBusy,
|
||||
resolvePuzzleErrorMessage,
|
||||
setIsVisualNovelBusy,
|
||||
@@ -7519,22 +7558,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([
|
||||
@@ -7544,7 +7576,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
.getWorkDetail(normalizedProfileId)
|
||||
.catch(() => null),
|
||||
jumpHopClient.startRun(normalizedProfileId, {
|
||||
...runtimeGuestOptions,
|
||||
...runtimeRequestOptions,
|
||||
runtimeMode: 'published',
|
||||
}),
|
||||
]);
|
||||
@@ -7571,7 +7603,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsJumpHopBusy(false);
|
||||
}
|
||||
},
|
||||
[authUi, setSelectionStage],
|
||||
[authUi, buildRecommendRuntimeRequestOptions, setSelectionStage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -8012,15 +8044,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);
|
||||
@@ -8045,7 +8078,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPuzzleClearBusy(false);
|
||||
}
|
||||
},
|
||||
[authUi, setSelectionStage],
|
||||
[buildRecommendRuntimeRequestOptions, setSelectionStage],
|
||||
);
|
||||
|
||||
const retryPuzzleClearLevelRun = useCallback(async () => {
|
||||
@@ -8070,7 +8103,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(
|
||||
@@ -8082,6 +8122,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [
|
||||
puzzleClearRun,
|
||||
puzzleClearWork,
|
||||
buildRecommendRuntimeRequestOptions,
|
||||
setSelectionStage,
|
||||
startPuzzleClearTestRunFromProfile,
|
||||
]);
|
||||
@@ -8107,7 +8148,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(
|
||||
@@ -8116,7 +8164,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
} finally {
|
||||
setIsPuzzleClearBusy(false);
|
||||
}
|
||||
}, [puzzleClearRun, puzzleClearWork, setSelectionStage]);
|
||||
}, [
|
||||
puzzleClearRun,
|
||||
puzzleClearWork,
|
||||
buildRecommendRuntimeRequestOptions,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const markPuzzleClearLevelTimeUp = useCallback(async () => {
|
||||
const runId = puzzleClearRun?.runId;
|
||||
@@ -8130,14 +8183,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: {
|
||||
@@ -8156,7 +8216,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(
|
||||
@@ -8164,7 +8232,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
}
|
||||
},
|
||||
[puzzleClearRun],
|
||||
[puzzleClearRun, buildRecommendRuntimeRequestOptions],
|
||||
);
|
||||
|
||||
const compileWoodenFishSession = useCallback(
|
||||
@@ -8516,16 +8584,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),
|
||||
]);
|
||||
@@ -8552,7 +8621,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsWoodenFishBusy(false);
|
||||
}
|
||||
},
|
||||
[authUi, setSelectionStage],
|
||||
[buildRecommendRuntimeRequestOptions, setSelectionStage],
|
||||
);
|
||||
|
||||
const checkpointWoodenFishRuntimeRun = useCallback(
|
||||
@@ -8564,10 +8633,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;
|
||||
@@ -9038,9 +9115,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);
|
||||
@@ -9066,12 +9146,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 }
|
||||
: {}),
|
||||
@@ -9120,6 +9202,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
isMatch3DBusy,
|
||||
authUi,
|
||||
buildRecommendRuntimeRequestOptions,
|
||||
match3dFlow,
|
||||
resolveMatch3DErrorMessage,
|
||||
resolveMatch3DRuntimeAdapter,
|
||||
@@ -9143,12 +9226,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);
|
||||
@@ -9180,7 +9264,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
[
|
||||
isSquareHoleBusy,
|
||||
authUi,
|
||||
buildRecommendRuntimeRequestOptions,
|
||||
resolveSquareHoleErrorMessage,
|
||||
setSelectionStage,
|
||||
setSquareHoleError,
|
||||
@@ -9298,14 +9382,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) {
|
||||
@@ -9317,8 +9401,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
},
|
||||
[
|
||||
activeRecommendRuntimeKind,
|
||||
authUi,
|
||||
buildRecommendRuntimeRequestOptions,
|
||||
bigFishRun,
|
||||
resolveBigFishErrorMessage,
|
||||
setBigFishError,
|
||||
@@ -9333,21 +9416,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,
|
||||
@@ -9665,14 +9745,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);
|
||||
@@ -9686,7 +9763,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [
|
||||
isPuzzleBusy,
|
||||
puzzleRun,
|
||||
puzzleRuntimeAuthMode,
|
||||
buildPuzzleRuntimeRequestOptions,
|
||||
resolvePuzzleErrorMessage,
|
||||
selectedPuzzleDetail,
|
||||
setIsPuzzleBusy,
|
||||
@@ -9798,16 +9875,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 }) => {
|
||||
@@ -9831,7 +9902,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
authUi?.user?.displayName,
|
||||
platformBootstrap,
|
||||
puzzleRun,
|
||||
puzzleRuntimeAuthMode,
|
||||
buildPuzzleRuntimeRequestOptions,
|
||||
resolvePuzzleErrorMessage,
|
||||
setPuzzleError,
|
||||
]);
|
||||
@@ -9861,10 +9932,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 =
|
||||
@@ -9873,18 +9941,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,
|
||||
@@ -9901,14 +9964,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, '准备下一关失败。'));
|
||||
@@ -9921,7 +9981,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isPuzzleBusy,
|
||||
isPuzzleLeaderboardBusy,
|
||||
puzzleRun,
|
||||
puzzleRuntimeAuthMode,
|
||||
buildPuzzleRuntimeRequestOptions,
|
||||
resolvePuzzleErrorMessage,
|
||||
selectedPuzzleDetail,
|
||||
setIsPuzzleBusy,
|
||||
@@ -10696,7 +10756,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPublicWorkDetailBusy(true);
|
||||
setPublicWorkDetailError(null);
|
||||
|
||||
const intent = resolvePlatformPublicWorkLikeIntent(entry);
|
||||
const intent = resolvePlatformPublicWorkLikeIntent(
|
||||
entry,
|
||||
publicWorkInteractions,
|
||||
);
|
||||
|
||||
if (intent.type === 'like-big-fish') {
|
||||
void likeBigFishGalleryWork(intent.profileId)
|
||||
@@ -10806,6 +10869,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
isPublicWorkDetailBusy,
|
||||
platformBootstrap,
|
||||
publicWorkInteractions,
|
||||
resolveBigFishErrorMessage,
|
||||
resolvePuzzleErrorMessage,
|
||||
runProtectedAction,
|
||||
@@ -12452,12 +12516,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);
|
||||
@@ -12468,7 +12533,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(
|
||||
@@ -12484,7 +12549,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
},
|
||||
[
|
||||
authUi,
|
||||
buildRecommendRuntimeRequestOptions,
|
||||
bigFishFlow,
|
||||
resolveBigFishErrorMessage,
|
||||
setBigFishError,
|
||||
@@ -12511,12 +12576,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';
|
||||
@@ -12537,7 +12608,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[authUi, resolveBarkBattleErrorMessage, setSelectionStage],
|
||||
[
|
||||
buildRecommendRuntimeRequestOptions,
|
||||
resolveBarkBattleErrorMessage,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const startSelectedPublicWork = useCallback(() => {
|
||||
@@ -12991,10 +13066,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);
|
||||
})
|
||||
@@ -13008,24 +13085,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);
|
||||
})
|
||||
@@ -13159,9 +13240,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);
|
||||
}}
|
||||
@@ -13172,7 +13261,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);
|
||||
})
|
||||
@@ -13194,14 +13288,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);
|
||||
})
|
||||
@@ -13246,6 +13352,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
workId={barkBattlePublishedConfig.workId}
|
||||
publishedConfig={barkBattlePublishedConfig}
|
||||
runtimeMode="published"
|
||||
runtimeRequestOptions={barkBattleRuntimeRequestOptions ?? undefined}
|
||||
onExit={() => {
|
||||
setActiveRecommendRuntimeKind(null);
|
||||
}}
|
||||
@@ -13276,7 +13383,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeRecommendEntryKey,
|
||||
activeRecommendRuntimeKind,
|
||||
barkBattlePublishedConfig,
|
||||
barkBattleRuntimeRequestOptions,
|
||||
babyObjectMatchDraft,
|
||||
buildRecommendRuntimeRequestOptions,
|
||||
bigFishError,
|
||||
bigFishRun,
|
||||
bigFishRuntimeShare,
|
||||
@@ -13313,6 +13422,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
recommendRuntimeEntries,
|
||||
remodelCurrentPuzzleRuntimeWork,
|
||||
resolveMatch3DErrorMessage,
|
||||
resolveMatch3DRuntimeAdapter,
|
||||
resolveSquareHoleErrorMessage,
|
||||
reportBigFishObservedPlayTime,
|
||||
restartBigFishRun,
|
||||
@@ -13442,7 +13552,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPublicWorkDetailBusy(true);
|
||||
setPublicWorkDetailError(null);
|
||||
|
||||
const intent = resolvePlatformPublicWorkRemixIntent(entry);
|
||||
const intent = resolvePlatformPublicWorkRemixIntent(
|
||||
entry,
|
||||
publicWorkInteractions,
|
||||
);
|
||||
|
||||
if (intent.type === 'remix-big-fish') {
|
||||
void remixBigFishGalleryWork(intent.profileId)
|
||||
@@ -13517,6 +13630,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isPublicWorkDetailBusy,
|
||||
platformBootstrap,
|
||||
puzzleFlow,
|
||||
publicWorkInteractions,
|
||||
resetRecommendRuntimeSelection,
|
||||
resolveBigFishErrorMessage,
|
||||
resolvePuzzleErrorMessage,
|
||||
@@ -16702,6 +16816,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
workId={barkBattlePublishedConfig.workId}
|
||||
publishedConfig={barkBattlePublishedConfig}
|
||||
runtimeMode={barkBattleRuntimeMode}
|
||||
runtimeRequestOptions={
|
||||
barkBattleRuntimeRequestOptions ?? undefined
|
||||
}
|
||||
onExit={() => {
|
||||
if (
|
||||
barkBattleRuntimeReturnStage === 'bark-battle-result' &&
|
||||
|
||||
@@ -899,6 +899,23 @@ test('platform public work detail flow resolves like intent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public work detail flow respects configured like disable', () => {
|
||||
expect(
|
||||
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('puzzle'), [
|
||||
{
|
||||
sourceType: 'puzzle',
|
||||
likeEnabled: false,
|
||||
remixEnabled: true,
|
||||
likeDisabledMessage: '拼图点赞维护中。',
|
||||
remixDisabledMessage: '拼图改造维护中。',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
type: 'unsupported',
|
||||
errorMessage: '拼图点赞维护中。',
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public work detail flow resolves remix intent', () => {
|
||||
expect(
|
||||
resolvePlatformPublicWorkRemixIntent(buildTypedEntry('big-fish')),
|
||||
@@ -969,13 +986,31 @@ test('platform public work detail flow resolves remix intent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public work detail flow respects configured remix disable', () => {
|
||||
expect(
|
||||
resolvePlatformPublicWorkRemixIntent(buildRpgEntry(), [
|
||||
{
|
||||
sourceType: 'custom-world',
|
||||
likeEnabled: true,
|
||||
remixEnabled: false,
|
||||
likeDisabledMessage: 'RPG 点赞维护中。',
|
||||
remixDisabledMessage: 'RPG 改造维护中。',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
type: 'unsupported',
|
||||
errorMessage: 'RPG 改造维护中。',
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public work detail flow resolves edit intent for draft-backed works', () => {
|
||||
const bigFishEntry = buildTypedEntry('big-fish');
|
||||
expect(resolvePlatformPublicWorkEditIntent(bigFishEntry, buildEditIntentDeps()))
|
||||
.toEqual({
|
||||
type: 'edit-big-fish',
|
||||
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkEditIntent(bigFishEntry, buildEditIntentDeps()),
|
||||
).toEqual({
|
||||
type: 'edit-big-fish',
|
||||
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
|
||||
});
|
||||
|
||||
const selectedPuzzleDetail = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile',
|
||||
@@ -1153,7 +1188,10 @@ test('platform public work detail flow resolves edit intent for unsupported and
|
||||
|
||||
const edutainmentEntry = buildTypedEntry('edutainment');
|
||||
expect(
|
||||
resolvePlatformPublicWorkEditIntent(edutainmentEntry, buildEditIntentDeps()),
|
||||
resolvePlatformPublicWorkEditIntent(
|
||||
edutainmentEntry,
|
||||
buildEditIntentDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'resolve-edutainment-draft',
|
||||
entry: edutainmentEntry,
|
||||
|
||||
@@ -97,6 +97,14 @@ export type PlatformPublicWorkDetailOpenStrategy =
|
||||
|
||||
export type PlatformPublicWorkActionMode = 'edit' | 'remix';
|
||||
|
||||
export type PlatformPublicWorkInteractionConfig = {
|
||||
sourceType: string;
|
||||
likeEnabled: boolean;
|
||||
remixEnabled: boolean;
|
||||
likeDisabledMessage: string;
|
||||
remixDisabledMessage: string;
|
||||
};
|
||||
|
||||
export type PlatformPublicWorkLikeIntent =
|
||||
| {
|
||||
type: 'like-big-fish';
|
||||
@@ -678,9 +686,55 @@ export function resolvePlatformPublicWorkActionMode(
|
||||
: 'remix';
|
||||
}
|
||||
|
||||
export function getPlatformPublicWorkInteractionSourceType(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
return 'sourceType' in entry ? entry.sourceType : 'custom-world';
|
||||
}
|
||||
|
||||
function resolveConfiguredPublicWorkInteractionBlock(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
configs: readonly PlatformPublicWorkInteractionConfig[] | null | undefined,
|
||||
action: 'like' | 'remix',
|
||||
): PlatformPublicWorkLikeIntent | PlatformPublicWorkRemixIntent | null {
|
||||
const sourceType = getPlatformPublicWorkInteractionSourceType(entry);
|
||||
const config = configs?.find((item) => item.sourceType === sourceType);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (action === 'like' && !config.likeEnabled) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
errorMessage:
|
||||
config.likeDisabledMessage.trim() || '该作品类型暂不支持点赞。',
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'remix' && !config.remixEnabled) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
errorMessage:
|
||||
config.remixDisabledMessage.trim() || '该作品类型暂不支持改造。',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolvePlatformPublicWorkLikeIntent(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
configs?: readonly PlatformPublicWorkInteractionConfig[] | null,
|
||||
): PlatformPublicWorkLikeIntent {
|
||||
const configuredBlock = resolveConfiguredPublicWorkInteractionBlock(
|
||||
entry,
|
||||
configs,
|
||||
'like',
|
||||
);
|
||||
if (configuredBlock) {
|
||||
return configuredBlock as PlatformPublicWorkLikeIntent;
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'like-big-fish',
|
||||
@@ -760,7 +814,17 @@ export function resolvePlatformPublicWorkLikeIntent(
|
||||
|
||||
export function resolvePlatformPublicWorkRemixIntent(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
configs?: readonly PlatformPublicWorkInteractionConfig[] | null,
|
||||
): PlatformPublicWorkRemixIntent {
|
||||
const configuredBlock = resolveConfiguredPublicWorkInteractionBlock(
|
||||
entry,
|
||||
configs,
|
||||
'remix',
|
||||
);
|
||||
if (configuredBlock) {
|
||||
return configuredBlock as PlatformPublicWorkRemixIntent;
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'remix-big-fish',
|
||||
@@ -933,8 +997,9 @@ export function resolvePlatformPublicWorkEditIntent(
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
const work =
|
||||
deps.visualNovelWorks?.find((item) => item.profileId === entry.profileId) ??
|
||||
null;
|
||||
deps.visualNovelWorks?.find(
|
||||
(item) => item.profileId === entry.profileId,
|
||||
) ?? null;
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
|
||||
@@ -748,7 +748,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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3801,11 +3801,6 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
||||
expect(await findCreationTypeButton('汪汪声浪')).toBeTruthy();
|
||||
expect(await findCreationTypeButton('宝贝识物')).toBeTruthy();
|
||||
expect(queryCreationTypeButton('智能创作')).toBeNull();
|
||||
expect(
|
||||
screen
|
||||
.getByRole('tab', { name: '热门推荐' })
|
||||
.querySelector('[class*="bg-[#d9793f]"]'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
|
||||
expect(screen.queryByPlaceholderText('问一问陶泥儿')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||
@@ -7855,6 +7850,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 = {
|
||||
@@ -8184,6 +8286,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',
|
||||
@@ -9400,6 +9550,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
|
||||
elapsedMs: 18_000,
|
||||
nickname: '测试玩家',
|
||||
},
|
||||
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9420,6 +9571,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(
|
||||
@@ -9582,6 +9734,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();
|
||||
@@ -10326,7 +10479,7 @@ test('existing draft sessions open result page refinement instead of agent dialo
|
||||
expect(screen.getByText(/基本设定/u)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /新增场景角色/u })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景角色/u }));
|
||||
await user.click(screen.getByRole('tab', { name: /场景角色/u }));
|
||||
expect(screen.getByRole('button', { name: /顾潮音/u })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: /顾潮音/u }));
|
||||
expect(await screen.findByText(/编辑场景角色:顾潮音/u)).toBeTruthy();
|
||||
@@ -12256,7 +12409,7 @@ test('creation hub published work edit keeps loaded detail profile assets instea
|
||||
document.querySelector('video[src="/assets/custom-world/opening.mp4"]'),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景\s+2/u }));
|
||||
await user.click(screen.getByRole('tab', { name: /场景\s+2/u }));
|
||||
expect((await screen.findByAltText('废都营地')).getAttribute('src')).toBe(
|
||||
'/assets/custom-world/star-waste-camp.png',
|
||||
);
|
||||
@@ -12264,12 +12417,12 @@ test('creation hub published work edit keeps loaded detail profile assets instea
|
||||
'/assets/custom-world/act-stardust-opening-1.png',
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /可扮演角色\s+1/u }));
|
||||
await user.click(screen.getByRole('tab', { name: /可扮演角色\s+1/u }));
|
||||
expect((await screen.findByAltText('砂眠')).getAttribute('src')).toBe(
|
||||
'/assets/custom-world/playable-stardust-1.png',
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景角色\s+1/u }));
|
||||
await user.click(screen.getByRole('tab', { name: /场景角色\s+1/u }));
|
||||
expect((await screen.findByAltText('钟守')).getAttribute('src')).toBe(
|
||||
'/assets/custom-world/story-clock-keeper-1.png',
|
||||
);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -66,6 +66,9 @@ describe('apiClient', () => {
|
||||
dispatchEvent: dispatchEventMock,
|
||||
localStorage: createLocalStorageMock(),
|
||||
});
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: () => '11111111-2222-3333-4444-555555555555',
|
||||
});
|
||||
fetchMock.mockReset();
|
||||
dispatchEventMock.mockReset();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
@@ -81,7 +84,6 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -122,6 +124,7 @@ describe('apiClient', () => {
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer expired-token',
|
||||
'x-request-id': 'web-11111111-2222-3333-4444-555555555555',
|
||||
'x-genarrative-response-envelope': 'v1',
|
||||
}),
|
||||
}),
|
||||
@@ -141,6 +144,7 @@ describe('apiClient', () => {
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer fresh-token',
|
||||
'x-request-id': 'web-11111111-2222-3333-4444-555555555555',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -156,7 +160,6 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -334,6 +337,64 @@ describe('apiClient', () => {
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('keeps local token when refresh fails with transient server unavailable', async () => {
|
||||
setStoredAccessToken('usable-local-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 503 }));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('keeps local token when refresh cannot reach the restarting server', async () => {
|
||||
setStoredAccessToken('usable-local-token', { emit: false });
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toBeInstanceOf(TypeError);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('clears local token when refresh confirms the session is unauthorized', async () => {
|
||||
setStoredAccessToken('expired-local-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
|
||||
status: 401,
|
||||
});
|
||||
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
it('does not clear auth when protected request refresh fails transiently', async () => {
|
||||
setStoredAccessToken('expired-token-during-restart', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 503 }));
|
||||
|
||||
const response = await fetchWithApiAuth('/api/runtime/protected', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('expired-token-during-restart');
|
||||
});
|
||||
|
||||
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
@@ -344,7 +405,6 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -366,7 +426,7 @@ describe('apiClient', () => {
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects refresh responses that do not return a renewed bearer token', async () => {
|
||||
it('rejects malformed refresh responses without treating them as logout', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
@@ -397,8 +457,8 @@ describe('apiClient', () => {
|
||||
message: '读取受保护数据失败',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(getStoredAccessToken()).toBe('expired-token');
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the current access token when a public request explicitly skips auth', async () => {
|
||||
|
||||
@@ -40,6 +40,8 @@ export type ApiRequestOptions = {
|
||||
notifyAuthStateChange?: boolean;
|
||||
// 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。
|
||||
clearAuthOnUnauthorized?: boolean;
|
||||
// 同一次业务请求在客户端重试时复用 request id,后端据此做计费幂等。
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export const BACKGROUND_AUTH_REQUEST_OPTIONS = {
|
||||
@@ -99,6 +101,22 @@ function normalizeHeaders(headers?: HeadersInit) {
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
function buildClientRequestId() {
|
||||
const randomId =
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
return `web-${randomId}`;
|
||||
}
|
||||
|
||||
function resolveRequestIdHeader(headers: Record<string, string>, options: ApiRequestOptions) {
|
||||
const explicitRequestId = options.requestId?.trim();
|
||||
const existingRequestId = Object.entries(headers).find(
|
||||
([key, value]) => key.toLowerCase() === REQUEST_ID_HEADER && value.trim(),
|
||||
)?.[1];
|
||||
return explicitRequestId || existingRequestId || buildClientRequestId();
|
||||
}
|
||||
|
||||
function coerceMeta(value: unknown): Partial<ApiMeta> {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
@@ -497,6 +515,13 @@ function withAuthorizationHeaders(
|
||||
|
||||
let refreshAccessTokenPromise: Promise<string> | null = null;
|
||||
|
||||
function shouldClearAuthAfterRefreshFailure(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
(error.status === 401 || error.status === 403)
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (refreshAccessTokenPromise) {
|
||||
return refreshAccessTokenPromise;
|
||||
@@ -522,11 +547,11 @@ async function refreshAccessToken() {
|
||||
)
|
||||
: null;
|
||||
|
||||
if (payload?.ok !== true || !payload.token?.trim()) {
|
||||
const nextToken = payload?.token?.trim();
|
||||
if (!nextToken) {
|
||||
throw new Error('刷新登录状态失败');
|
||||
}
|
||||
|
||||
const nextToken = payload.token.trim();
|
||||
setStoredAccessToken(nextToken, { emit: false });
|
||||
return nextToken;
|
||||
})();
|
||||
@@ -556,7 +581,10 @@ export async function refreshStoredAccessToken(
|
||||
try {
|
||||
return await refreshAccessToken();
|
||||
} catch (error) {
|
||||
if (options.clearOnFailure !== false) {
|
||||
if (
|
||||
options.clearOnFailure !== false &&
|
||||
shouldClearAuthAfterRefreshFailure(error)
|
||||
) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
throw error;
|
||||
@@ -572,12 +600,14 @@ export async function fetchWithApiAuth(
|
||||
const retry = resolveRetryOptions(method, options.retry);
|
||||
const authFailurePolicy = resolveAuthFailurePolicy(options);
|
||||
const requestSignal = init.signal ?? undefined;
|
||||
const requestId = resolveRequestIdHeader(normalizeHeaders(init.headers), options);
|
||||
let attempt = 0;
|
||||
let refreshAttempted = false;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
let requestHeaders = withAuthorizationHeaders(init.headers, options);
|
||||
requestHeaders[REQUEST_ID_HEADER] = requestId;
|
||||
let hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
@@ -593,6 +623,7 @@ export async function fetchWithApiAuth(
|
||||
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
|
||||
await ensureStoredAccessToken();
|
||||
requestHeaders = withAuthorizationHeaders(init.headers, options);
|
||||
requestHeaders[REQUEST_ID_HEADER] = requestId;
|
||||
hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
@@ -629,11 +660,15 @@ export async function fetchWithApiAuth(
|
||||
// 不能把当前业务请求的首次 401 直接放大成全局鉴权变更,
|
||||
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
||||
continue;
|
||||
} catch {
|
||||
if (hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized) {
|
||||
} catch (refreshError) {
|
||||
const shouldClearAuth =
|
||||
hasAuthHeader &&
|
||||
authFailurePolicy.clearAuthOnUnauthorized &&
|
||||
shouldClearAuthAfterRefreshFailure(refreshError);
|
||||
if (shouldClearAuth) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
if (authFailurePolicy.notifyAuthStateChange) {
|
||||
if (shouldClearAuth && authFailurePolicy.notifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,15 @@ export type CreationEntryEventBannerConfig = {
|
||||
htmlCode?: string | null;
|
||||
};
|
||||
|
||||
/** 公开作品详情页互动能力配置,前端只据此关闭已接入动作。 */
|
||||
export type PublicWorkInteractionConfig = {
|
||||
sourceType: string;
|
||||
likeEnabled: boolean;
|
||||
remixEnabled: boolean;
|
||||
likeDisabledMessage: string;
|
||||
remixDisabledMessage: string;
|
||||
};
|
||||
|
||||
/** 创作入口页完整配置;前端只展示后端事实源,不内置入口默认值。 */
|
||||
export type CreationEntryConfig = {
|
||||
startCard: {
|
||||
@@ -67,6 +76,8 @@ export type CreationEntryConfig = {
|
||||
eventBanner: CreationEntryEventBannerConfig;
|
||||
/** 底部加号创作入口页的多公告轮播配置。 */
|
||||
eventBanners?: CreationEntryEventBannerConfig[];
|
||||
/** 公开作品详情页点赞 / 改造能力矩阵。 */
|
||||
publicWorkInteractions?: PublicWorkInteractionConfig[];
|
||||
creationTypes: CreationEntryTypeConfig[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user