合并 master 并修复架构分支回归
合入 master 最新的认证、玩法契约与推荐页改动。 修复拼图草稿生成、推荐页下一关和公开详情访客试玩回归。 修复抓大鹅草稿试玩鉴权与首屏推荐详情测试入口。 补齐相关测试夹具、文档与团队记忆更新。
This commit is contained in:
@@ -115,6 +115,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
|
||||
import {
|
||||
buildPublicWorkStagePath,
|
||||
pushAppHistoryPath,
|
||||
resolvePathForSelectionStage,
|
||||
} from '../../routing/appPageRoutes';
|
||||
import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery';
|
||||
import {
|
||||
@@ -371,7 +372,6 @@ import {
|
||||
isPersistedBarkBattleDraftGenerating,
|
||||
} from '../custom-world-home/creationWorkShelf';
|
||||
import {
|
||||
buildPlatformRecommendFeedEntries,
|
||||
selectAdjacentPlatformRecommendEntry,
|
||||
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
|
||||
import {
|
||||
@@ -493,7 +493,9 @@ import {
|
||||
import {
|
||||
canExposePublicWork,
|
||||
EDUTAINMENT_HIDDEN_MESSAGE,
|
||||
filterGeneralPublicWorks,
|
||||
} from './platformEdutainmentVisibility';
|
||||
import { buildPlatformRecommendedEntries } from './platformRecommendation';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import {
|
||||
@@ -2872,10 +2874,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
publicGalleryFeeds;
|
||||
const recommendRuntimeEntries = useMemo(
|
||||
() =>
|
||||
buildPlatformRecommendFeedEntries(
|
||||
featuredGalleryEntries,
|
||||
latestGalleryEntries,
|
||||
),
|
||||
buildPlatformRecommendedEntries({
|
||||
featuredEntries: filterGeneralPublicWorks(featuredGalleryEntries),
|
||||
latestEntries: filterGeneralPublicWorks(latestGalleryEntries),
|
||||
}),
|
||||
[featuredGalleryEntries, latestGalleryEntries],
|
||||
);
|
||||
|
||||
@@ -4440,6 +4442,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
activePuzzleBackgroundCompileTask?.session ?? puzzleSession;
|
||||
const puzzleGenerationViewPayload =
|
||||
activePuzzleBackgroundCompileTask?.payload ?? puzzleFormDraftPayload;
|
||||
const puzzleGenerationViewStateRef = useRef(puzzleGenerationViewState);
|
||||
const puzzleGenerationViewPayloadRef = useRef(puzzleGenerationViewPayload);
|
||||
const setPuzzleSessionRef = useRef(puzzleFlow.setSession);
|
||||
useEffect(() => {
|
||||
puzzleGenerationViewStateRef.current = puzzleGenerationViewState;
|
||||
}, [puzzleGenerationViewState]);
|
||||
useEffect(() => {
|
||||
puzzleGenerationViewPayloadRef.current = puzzleGenerationViewPayload;
|
||||
}, [puzzleGenerationViewPayload]);
|
||||
useEffect(() => {
|
||||
setPuzzleSessionRef.current = puzzleFlow.setSession;
|
||||
}, [puzzleFlow.setSession]);
|
||||
const puzzleGenerationViewError =
|
||||
activePuzzleBackgroundCompileTask?.error ?? puzzleError;
|
||||
const isPuzzleGenerationViewBusy =
|
||||
@@ -4835,21 +4849,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
if (hasRecoverableGeneratedPuzzleDraft(latestSession)) {
|
||||
const payload =
|
||||
puzzleGenerationViewPayload ??
|
||||
puzzleGenerationViewPayloadRef.current ??
|
||||
buildPuzzleFormPayloadFromSession(latestSession);
|
||||
const generationState =
|
||||
puzzleGenerationViewState ??
|
||||
puzzleGenerationViewStateRef.current ??
|
||||
createPuzzleDraftGenerationStateFromPayload(payload, latestSession);
|
||||
await recoverCompletedPuzzleDraftGeneration({
|
||||
sessionId: latestSession.sessionId,
|
||||
payload,
|
||||
generationState,
|
||||
setSession: setPuzzleSession,
|
||||
setSession: setPuzzleSessionRef.current,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPuzzleSession(latestSession);
|
||||
setPuzzleSessionRef.current(latestSession);
|
||||
setPuzzleBackgroundCompileTasks((current) => {
|
||||
const task = current[activePuzzleGenerationSessionId];
|
||||
if (!task) {
|
||||
@@ -4892,11 +4906,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
};
|
||||
}, [
|
||||
activePuzzleGenerationSessionId,
|
||||
puzzleGenerationViewPayload,
|
||||
puzzleGenerationViewState,
|
||||
recoverCompletedPuzzleDraftGeneration,
|
||||
shouldPollPuzzleGenerationSession,
|
||||
setPuzzleSession,
|
||||
]);
|
||||
|
||||
const match3DGeneratingSessionId =
|
||||
@@ -5258,27 +5269,48 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
setPuzzleOperation(response.operation);
|
||||
const openResult = isViewingPuzzleGeneration(nextSession.sessionId);
|
||||
const readyGenerationState =
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'ready',
|
||||
{
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 1,
|
||||
},
|
||||
);
|
||||
const isCompileReady = isPuzzleCompileActionReady(response.session);
|
||||
const nextGenerationState = isCompileReady
|
||||
? resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'ready',
|
||||
{
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 1,
|
||||
},
|
||||
)
|
||||
: mergePuzzleSessionProgressIntoGenerationState(
|
||||
generationState,
|
||||
response.session,
|
||||
);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[nextSession.sessionId]: {
|
||||
session: response.session,
|
||||
payload,
|
||||
generationState: readyGenerationState,
|
||||
generationState: nextGenerationState,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
if (isViewingPuzzleGeneration(nextSession.sessionId)) {
|
||||
puzzleFlow.setSession(response.session);
|
||||
setPuzzleGenerationState(readyGenerationState);
|
||||
setPuzzleGenerationState(nextGenerationState);
|
||||
}
|
||||
|
||||
if (!isCompileReady) {
|
||||
markDraftGenerating('puzzle', [
|
||||
response.session.sessionId,
|
||||
buildPuzzleResultWorkId(response.session.sessionId),
|
||||
response.session.publishedProfileId,
|
||||
buildPuzzleResultProfileId(response.session.sessionId),
|
||||
]);
|
||||
markPendingDraftGenerating(
|
||||
'puzzle',
|
||||
response.session.sessionId,
|
||||
buildPendingPuzzleDraftMetadata(payload),
|
||||
);
|
||||
void refreshPuzzleShelf();
|
||||
return;
|
||||
}
|
||||
|
||||
const profileId =
|
||||
@@ -6870,10 +6902,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
profileId: targetProfileId,
|
||||
mode: 'play' as const,
|
||||
};
|
||||
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
|
||||
authUi,
|
||||
options.embedded,
|
||||
);
|
||||
const runtimeGuestOptions =
|
||||
options.embedded || workDetail.summary.publishStatus === 'draft'
|
||||
? await buildRecommendRuntimeAuthOptions(authUi, true)
|
||||
: {};
|
||||
const { run } = options.embedded
|
||||
? await startVisualNovelRun(
|
||||
targetProfileId,
|
||||
@@ -8818,7 +8850,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
profile: Match3DWorkProfile | Match3DWorkSummary,
|
||||
returnStage: 'match3d-result' | 'work-detail' = 'match3d-result',
|
||||
mirrorErrorToPublicDetail = false,
|
||||
options: { embedded?: boolean; itemTypeCountOverride?: number } = {},
|
||||
options: {
|
||||
embedded?: boolean;
|
||||
authMode?: PuzzleRuntimeAuthMode;
|
||||
itemTypeCountOverride?: number;
|
||||
} = {},
|
||||
) => {
|
||||
if (isMatch3DBusy) {
|
||||
return false;
|
||||
@@ -8857,10 +8893,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
runtimeProfile.generatedBackgroundAsset,
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
|
||||
authUi,
|
||||
options.embedded,
|
||||
);
|
||||
const runtimeGuestOptions =
|
||||
options.authMode === 'isolated'
|
||||
? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS
|
||||
: await buildRecommendRuntimeAuthOptions(authUi, options.embedded);
|
||||
const runtimeOptions = {
|
||||
...runtimeGuestOptions,
|
||||
...(typeof options.itemTypeCountOverride === 'number'
|
||||
@@ -9657,10 +9693,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
? await buildRecommendRuntimeGuestOptions()
|
||||
: {};
|
||||
const targetProfileId = _target?.profileId?.trim() ?? '';
|
||||
const preferSimilarWork =
|
||||
activeRecommendRuntimeKind === 'puzzle' &&
|
||||
puzzleRuntimeReturnStage === 'platform' &&
|
||||
puzzleRun.nextLevelMode === 'sameWork';
|
||||
if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) {
|
||||
const itemPromise =
|
||||
selectedPuzzleDetail?.profileId === targetProfileId
|
||||
@@ -9700,34 +9732,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
puzzleRuntimeAuthMode === 'isolated'
|
||||
? await advancePuzzleNextLevel(
|
||||
puzzleRun.runId,
|
||||
preferSimilarWork ? { preferSimilarWork: true } : {},
|
||||
{},
|
||||
runtimeGuestOptions,
|
||||
)
|
||||
: await advancePuzzleNextLevel(
|
||||
puzzleRun.runId,
|
||||
preferSimilarWork ? { preferSimilarWork: true } : {},
|
||||
);
|
||||
const nextProfileId = run.currentLevel?.profileId?.trim() ?? '';
|
||||
if (
|
||||
nextProfileId &&
|
||||
selectedPuzzleDetail?.profileId !== nextProfileId
|
||||
) {
|
||||
const item = await getPuzzleGalleryDetail(nextProfileId).then(
|
||||
(response) => response.item,
|
||||
);
|
||||
const nextRecommendEntry = mapPuzzleWorkToPlatformGalleryCard(item);
|
||||
setPuzzleGalleryEntries((current) => {
|
||||
const nextEntries = current.filter(
|
||||
(entry) => entry.profileId !== item.profileId,
|
||||
);
|
||||
nextEntries.push(item);
|
||||
return nextEntries;
|
||||
});
|
||||
setSelectedPuzzleDetail(item);
|
||||
setActiveRecommendEntryKey(
|
||||
getPlatformPublicGalleryEntryKey(nextRecommendEntry),
|
||||
);
|
||||
}
|
||||
: await advancePuzzleNextLevel(puzzleRun.runId, {});
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
||||
@@ -9739,12 +9747,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
isPuzzleBusy,
|
||||
isPuzzleLeaderboardBusy,
|
||||
activeRecommendRuntimeKind,
|
||||
puzzleRun,
|
||||
puzzleRuntimeReturnStage,
|
||||
puzzleRuntimeAuthMode,
|
||||
setActiveRecommendEntryKey,
|
||||
setPuzzleGalleryEntries,
|
||||
resolvePuzzleErrorMessage,
|
||||
selectedPuzzleDetail,
|
||||
setIsPuzzleBusy,
|
||||
@@ -10956,12 +10960,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await jumpHopClient.getWorkDetail(item.profileId);
|
||||
const detail = await jumpHopClient.getWorkDetail(item.profileId, {
|
||||
audience: 'creation',
|
||||
});
|
||||
setJumpHopSession(null);
|
||||
setJumpHopRun(null);
|
||||
setJumpHopWork(detail.item);
|
||||
setJumpHopRuntimeReturnStage('jump-hop-result');
|
||||
enterCreateTab();
|
||||
pushAppHistoryPath(resolvePathForSelectionStage('jump-hop-result'));
|
||||
writeCreationUrlState(
|
||||
buildJumpHopCreationUrlState({ work: detail.item }),
|
||||
);
|
||||
setSelectionStage('jump-hop-result');
|
||||
} catch (error) {
|
||||
setJumpHopError(
|
||||
@@ -11572,6 +11582,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
const started = await startMatch3DRunFromProfile(
|
||||
normalizedProfile,
|
||||
'match3d-result',
|
||||
false,
|
||||
{ authMode: 'isolated' },
|
||||
);
|
||||
if (!started) {
|
||||
setMatch3DProfile(normalizedProfile);
|
||||
@@ -12147,7 +12159,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
let work: JumpHopWorkProfileResponse | null = null;
|
||||
try {
|
||||
if (profileId) {
|
||||
work = (await jumpHopClient.getWorkDetail(profileId)).item;
|
||||
work = (
|
||||
await jumpHopClient.getWorkDetail(profileId, {
|
||||
audience: 'creation',
|
||||
})
|
||||
).item;
|
||||
}
|
||||
} catch {
|
||||
work = null;
|
||||
@@ -12525,6 +12541,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
setActiveRecommendEntryKey(entryKey);
|
||||
setActiveRecommendRuntimeKind(runtimeKind);
|
||||
setActiveRecommendRuntimeError(null);
|
||||
if (
|
||||
runtimeKind === 'puzzle' &&
|
||||
(isPuzzleBusy || puzzleStartInFlightKeyRef.current !== null)
|
||||
) {
|
||||
setIsStartingRecommendEntry(false);
|
||||
return;
|
||||
}
|
||||
setIsStartingRecommendEntry(true);
|
||||
|
||||
try {
|
||||
@@ -12659,6 +12682,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
activeRecommendEntryKey,
|
||||
barkBattleGalleryEntries,
|
||||
isPuzzleBusy,
|
||||
saveAndExitRecommendPuzzleRuntime,
|
||||
selectedPuzzleDetail,
|
||||
setBarkBattleError,
|
||||
@@ -12700,6 +12724,23 @@ export function PlatformEntryFlowShellImpl({
|
||||
selectRecommendRuntimeEntry,
|
||||
],
|
||||
);
|
||||
const resolveRecommendRuntimeEntryKeyByProfileId = useCallback(
|
||||
(profileId: string | null | undefined) => {
|
||||
const normalizedProfileId = profileId?.trim();
|
||||
if (!normalizedProfileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchedEntry =
|
||||
recommendRuntimeEntries.find(
|
||||
(entry) => entry.profileId === normalizedProfileId,
|
||||
) ?? null;
|
||||
return matchedEntry
|
||||
? getPlatformPublicGalleryEntryKey(matchedEntry)
|
||||
: null;
|
||||
},
|
||||
[recommendRuntimeEntries],
|
||||
);
|
||||
|
||||
const recommendRuntimeContent = useMemo(() => {
|
||||
if (
|
||||
@@ -12861,7 +12902,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
void dragPuzzlePiece(payload);
|
||||
}}
|
||||
onAdvanceNextLevel={(target) => {
|
||||
void advancePuzzleLevel(target);
|
||||
const targetEntryKey = resolveRecommendRuntimeEntryKeyByProfileId(
|
||||
target?.profileId,
|
||||
);
|
||||
void selectAdjacentRecommendRuntimeEntry(
|
||||
1,
|
||||
targetEntryKey ?? activeRecommendEntryKey,
|
||||
);
|
||||
}}
|
||||
onRestartLevel={() => {
|
||||
void restartPuzzleCurrentLevel();
|
||||
@@ -13115,9 +13162,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
squareHoleRun,
|
||||
submitBigFishInput,
|
||||
submitVisualNovelRuntimeAction,
|
||||
advancePuzzleLevel,
|
||||
dragPuzzlePiece,
|
||||
resolveRecommendRuntimeEntryKeyByProfileId,
|
||||
restartPuzzleCurrentLevel,
|
||||
selectAdjacentRecommendRuntimeEntry,
|
||||
setSquareHoleError,
|
||||
swapPuzzlePiecesInRun,
|
||||
syncPuzzleRuntimeTimeout,
|
||||
@@ -13166,7 +13214,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
isLoadingPlatform: platformBootstrap.isLoadingPlatform,
|
||||
entries: recommendRuntimeEntries,
|
||||
activeEntryKey: activeRecommendEntryKey,
|
||||
isStarting: isStartingRecommendEntry,
|
||||
isStarting: isStartingRecommendEntry || isPuzzleBusy,
|
||||
hasStartError: Boolean(activeRecommendRuntimeError),
|
||||
readyState: {
|
||||
activeKind: activeRecommendRuntimeKind,
|
||||
hasBabyObjectMatchDraft: Boolean(babyObjectMatchDraft),
|
||||
@@ -13198,10 +13247,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [
|
||||
activeRecommendEntryKey,
|
||||
activeRecommendRuntimeKind,
|
||||
activeRecommendRuntimeError,
|
||||
babyObjectMatchDraft,
|
||||
bigFishRun,
|
||||
jumpHopRun,
|
||||
isStartingRecommendEntry,
|
||||
isPuzzleBusy,
|
||||
match3dRun,
|
||||
platformBootstrap.isLoadingPlatform,
|
||||
platformBootstrap.platformTab,
|
||||
@@ -13952,8 +14003,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
canDeleteBigFish: isBigFishCreationVisible,
|
||||
canDeleteMatch3D: true,
|
||||
canDeleteSquareHole: isSquareHoleCreationVisible,
|
||||
canDeleteJumpHop: isJumpHopCreationVisible,
|
||||
canDeleteWoodenFish: true,
|
||||
canDeletePuzzle: true,
|
||||
canDeleteBabyObjectMatch: isBabyObjectMatchVisible,
|
||||
canDeleteBarkBattle: true,
|
||||
canDeleteVisualNovel: true,
|
||||
onOpenRpgDraft: (item) => {
|
||||
runProtectedAction(() => {
|
||||
@@ -13993,12 +14047,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onDeleteJumpHop: isJumpHopCreationVisible
|
||||
? handleDeleteJumpHopWork
|
||||
: undefined,
|
||||
onOpenWoodenFishDetail: (item) => {
|
||||
runProtectedAction(() => {
|
||||
markCreationFlowReturnToDraftShelf();
|
||||
void openWoodenFishDraft(item);
|
||||
});
|
||||
},
|
||||
onDeleteWoodenFish: handleDeleteWoodenFishWork,
|
||||
onOpenMatch3DDetail: (item) => {
|
||||
runProtectedAction(() => {
|
||||
markCreationFlowReturnToDraftShelf();
|
||||
@@ -14038,6 +14096,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
openBarkBattleDraft(item);
|
||||
});
|
||||
},
|
||||
onDeleteBarkBattle: handleDeleteBarkBattleWork,
|
||||
onOpenVisualNovelDetail: (item) => {
|
||||
runProtectedAction(() => {
|
||||
markCreationFlowReturnToDraftShelf();
|
||||
@@ -14057,11 +14116,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
handleClaimPuzzlePointIncentive,
|
||||
handleDeleteBabyObjectMatchWork,
|
||||
handleDeleteBigFishWork,
|
||||
handleDeleteBarkBattleWork,
|
||||
handleDeleteJumpHopWork,
|
||||
handleDeleteMatch3DWork,
|
||||
handleDeletePublishedWork,
|
||||
handleDeletePuzzleWork,
|
||||
handleDeleteSquareHoleWork,
|
||||
handleDeleteVisualNovelWork,
|
||||
handleDeleteWoodenFishWork,
|
||||
isBabyObjectMatchVisible,
|
||||
isBigFishCreationVisible,
|
||||
isJumpHopCreationVisible,
|
||||
@@ -14884,7 +14946,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
normalizedProfile,
|
||||
'match3d-result',
|
||||
false,
|
||||
options,
|
||||
{
|
||||
...options,
|
||||
authMode:
|
||||
normalizedProfile.publicationStatus === 'draft'
|
||||
? 'isolated'
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
isBarkBattleGalleryEntry,
|
||||
isCustomWorldGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
isPuzzleClearGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformPublicWorkCode,
|
||||
@@ -57,9 +61,18 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
|
||||
return '拼图';
|
||||
}
|
||||
if (isPuzzleClearGalleryEntry(entry)) {
|
||||
return '拼消消';
|
||||
}
|
||||
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
|
||||
return '大鱼吃小鱼';
|
||||
}
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return '跳一跳';
|
||||
}
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return '敲木鱼';
|
||||
}
|
||||
if ('sourceType' in entry && entry.sourceType === 'match3d') {
|
||||
return '抓大鹅';
|
||||
}
|
||||
@@ -75,7 +88,11 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.templateName;
|
||||
}
|
||||
return 'RPG';
|
||||
if (isCustomWorldGalleryEntry(entry)) {
|
||||
return 'RPG';
|
||||
}
|
||||
|
||||
throw new Error('未知公开作品类型。');
|
||||
}
|
||||
|
||||
function getAuthorAvatarLabel(authorDisplayName: string) {
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('platformCreationLaunchModel', () => {
|
||||
test('keeps unknown creation type as a prepared noop', () => {
|
||||
expect(
|
||||
resolvePlatformCreationLaunchIntent({
|
||||
type: 'unknown-template',
|
||||
type: 'unknown-template' as never,
|
||||
isBabyObjectMatchVisible: true,
|
||||
}),
|
||||
).toEqual({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
derivePlatformCreationTypes,
|
||||
groupVisiblePlatformCreationTypes,
|
||||
@@ -81,7 +82,7 @@ test('database entry config controls visibility open state and display order', (
|
||||
test('visible platform creation types hide invisible cards and put locked cards last', () => {
|
||||
const cards = derivePlatformCreationTypes([
|
||||
{
|
||||
id: 'hidden',
|
||||
id: 'airp',
|
||||
title: '隐藏',
|
||||
subtitle: '隐藏',
|
||||
badge: '隐藏',
|
||||
@@ -95,7 +96,7 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'locked',
|
||||
id: 'visual-novel',
|
||||
title: '锁定',
|
||||
subtitle: '锁定',
|
||||
badge: '即将开放',
|
||||
@@ -109,7 +110,7 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'open',
|
||||
id: 'rpg',
|
||||
title: '开放',
|
||||
subtitle: '开放',
|
||||
badge: '可创建',
|
||||
@@ -125,13 +126,13 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
]);
|
||||
|
||||
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual(
|
||||
['open', 'locked'],
|
||||
['rpg', 'visual-novel'],
|
||||
);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'hidden')).toBe(false);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'locked')).toBe(false);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'open')).toBe(true);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'airp')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'rpg')).toBe(true);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'airp')).toBe(false);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'visual-novel')).toBe(false);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'rpg')).toBe(true);
|
||||
expect(
|
||||
cards.every((item) =>
|
||||
item.imageSrc.startsWith('/creation-type-references/'),
|
||||
@@ -288,7 +289,7 @@ test('groups visible platform creation types by backend category metadata', () =
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
id: 'airp',
|
||||
title: '隐藏入口',
|
||||
subtitle: '隐藏',
|
||||
badge: '隐藏',
|
||||
@@ -319,7 +320,7 @@ test('groups visible platform creation types by backend category metadata', () =
|
||||
test('falls back when backend creation type category metadata is missing', () => {
|
||||
const cards = derivePlatformCreationTypes([
|
||||
{
|
||||
id: 'legacy-entry',
|
||||
id: 'creative-agent',
|
||||
title: '历史入口',
|
||||
subtitle: '旧数据缺少分类字段',
|
||||
badge: '可创建',
|
||||
@@ -336,7 +337,7 @@ test('falls back when backend creation type category metadata is missing', () =>
|
||||
|
||||
expect(cards[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'legacy-entry',
|
||||
id: 'creative-agent',
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
}),
|
||||
@@ -348,3 +349,24 @@ test('falls back when backend creation type category metadata is missing', () =>
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('throws when backend sends an unknown creation type id', () => {
|
||||
const unknownEntry = {
|
||||
id: 'unknown-play',
|
||||
title: '未知玩法',
|
||||
subtitle: '未知',
|
||||
badge: '未知',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
} as unknown as CreationEntryTypeConfig;
|
||||
|
||||
expect(() => derivePlatformCreationTypes([unknownEntry])).toThrow(
|
||||
'未知创作类型:unknown-play',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
assertPlatformCreationTypeId,
|
||||
type PlatformCreationTypeId,
|
||||
} from '../../../packages/shared/src/contracts/playTypes';
|
||||
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
|
||||
import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility';
|
||||
|
||||
export type PlatformCreationTypeId = string;
|
||||
export type { PlatformCreationTypeId };
|
||||
|
||||
export type PlatformCreationTypeCard = {
|
||||
id: PlatformCreationTypeId;
|
||||
@@ -117,21 +121,25 @@ export function derivePlatformCreationTypes(
|
||||
): PlatformCreationTypeCard[] {
|
||||
const orderedCards = [...creationTypes]
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
imageSrc: item.imageSrc,
|
||||
locked: !item.open,
|
||||
categoryId: normalizeCategoryId(item.categoryId),
|
||||
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
|
||||
categorySortOrder: item.categorySortOrder,
|
||||
sortOrder: item.sortOrder,
|
||||
hidden:
|
||||
!item.visible ||
|
||||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
|
||||
}));
|
||||
.map((item) => {
|
||||
const id = assertPlatformCreationTypeId(item.id);
|
||||
|
||||
return {
|
||||
id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
imageSrc: item.imageSrc,
|
||||
locked: !item.open,
|
||||
categoryId: normalizeCategoryId(item.categoryId),
|
||||
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
|
||||
categorySortOrder: item.categorySortOrder,
|
||||
sortOrder: item.sortOrder,
|
||||
hidden:
|
||||
!item.visible ||
|
||||
(id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...orderedCards.filter((item) => !item.hidden && !item.locked),
|
||||
|
||||
@@ -659,6 +659,13 @@ test('platform public gallery flow resolves recommend runtime auto-start gates',
|
||||
isStarting: true,
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
activeEntryKey: getPlatformPublicGalleryEntryKey(entry),
|
||||
hasStartError: true,
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime auto-start target', () => {
|
||||
@@ -695,6 +702,15 @@ test('platform public gallery flow resolves recommend runtime auto-start target'
|
||||
type: 'start',
|
||||
entry: activeEntry,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
readyState: { activeKind: null },
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'start',
|
||||
entry: activeEntry,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
|
||||
@@ -165,6 +165,7 @@ export type PlatformRecommendRuntimeAutoStartInput = {
|
||||
entries: readonly PlatformPublicGalleryCard[];
|
||||
activeEntryKey: string | null;
|
||||
isStarting: boolean;
|
||||
hasStartError?: boolean;
|
||||
readyState: PlatformRecommendRuntimeReadyState;
|
||||
};
|
||||
|
||||
@@ -496,7 +497,11 @@ export function resolvePlatformRecommendRuntimeAutoStartDecision(
|
||||
activeEntry !== null &&
|
||||
isPlatformRecommendRuntimeReadyForEntry(activeEntry, input.readyState);
|
||||
|
||||
if ((activeEntry !== null && isActiveRuntimeReady) || input.isStarting) {
|
||||
if (
|
||||
(activeEntry !== null && isActiveRuntimeReady) ||
|
||||
input.isStarting ||
|
||||
(activeEntry !== null && input.hasStartError)
|
||||
) {
|
||||
return { type: 'noop' };
|
||||
}
|
||||
|
||||
|
||||
@@ -599,7 +599,27 @@ test('platform public work detail flow resolves open strategy', () => {
|
||||
test('platform public work detail flow maps work summaries to detail entries', () => {
|
||||
const rpgEntry = buildRpgLibraryEntry();
|
||||
|
||||
expect(mapRpgGalleryCardToPublicWorkDetail(rpgEntry)).toBe(rpgEntry);
|
||||
expect(mapRpgGalleryCardToPublicWorkDetail(rpgEntry)).toEqual({
|
||||
ownerUserId: rpgEntry.ownerUserId,
|
||||
profileId: rpgEntry.profileId,
|
||||
publicWorkCode: rpgEntry.publicWorkCode,
|
||||
authorPublicUserCode: rpgEntry.authorPublicUserCode,
|
||||
visibility: rpgEntry.visibility,
|
||||
publishedAt: rpgEntry.publishedAt,
|
||||
updatedAt: rpgEntry.updatedAt,
|
||||
authorDisplayName: rpgEntry.authorDisplayName,
|
||||
worldName: rpgEntry.worldName,
|
||||
subtitle: rpgEntry.subtitle,
|
||||
summaryText: rpgEntry.summaryText,
|
||||
coverImageSrc: rpgEntry.coverImageSrc,
|
||||
themeMode: rpgEntry.themeMode,
|
||||
playableNpcCount: rpgEntry.playableNpcCount,
|
||||
landmarkCount: rpgEntry.landmarkCount,
|
||||
playCount: rpgEntry.playCount ?? 0,
|
||||
remixCount: rpgEntry.remixCount ?? 0,
|
||||
likeCount: rpgEntry.likeCount ?? 0,
|
||||
recentPlayCount7d: rpgEntry.recentPlayCount7d ?? 0,
|
||||
});
|
||||
expect(mapPuzzleWorkToPublicWorkDetail(buildPuzzleWork())).toMatchObject({
|
||||
sourceType: 'puzzle',
|
||||
workId: 'puzzle-work',
|
||||
@@ -838,9 +858,20 @@ test('platform public work detail flow resolves like intent', () => {
|
||||
expect(
|
||||
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('match3d')),
|
||||
).toEqual({
|
||||
type: 'like-rpg-gallery',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'match3d-profile',
|
||||
type: 'unsupported',
|
||||
errorMessage: '作品类型 match3d 暂不支持点赞。',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('jump-hop')),
|
||||
).toEqual({
|
||||
type: 'unsupported',
|
||||
errorMessage: '作品类型 jump-hop 暂不支持点赞。',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('wooden-fish')),
|
||||
).toEqual({
|
||||
type: 'unsupported',
|
||||
errorMessage: '作品类型 wooden-fish 暂不支持点赞。',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('edutainment')),
|
||||
|
||||
@@ -297,8 +297,28 @@ export function isRpgPublicWorkDetailEntry(
|
||||
|
||||
export function mapRpgGalleryCardToPublicWorkDetail(
|
||||
entry: PlatformRpgPublicWorkDetailEntry,
|
||||
): PlatformPublicGalleryCard {
|
||||
return entry;
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
publicWorkCode: entry.publicWorkCode,
|
||||
authorPublicUserCode: entry.authorPublicUserCode,
|
||||
visibility: entry.visibility,
|
||||
publishedAt: entry.publishedAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: entry.themeMode,
|
||||
playableNpcCount: entry.playableNpcCount,
|
||||
landmarkCount: entry.landmarkCount,
|
||||
playCount: entry.playCount ?? 0,
|
||||
remixCount: entry.remixCount ?? 0,
|
||||
likeCount: entry.likeCount ?? 0,
|
||||
recentPlayCount7d: entry.recentPlayCount7d ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function isRpgPublicWorkLibraryEntry(
|
||||
@@ -689,6 +709,27 @@ export function resolvePlatformPublicWorkLikeIntent(
|
||||
};
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
errorMessage: '作品类型 wooden-fish 暂不支持点赞。',
|
||||
};
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
errorMessage: '作品类型 jump-hop 暂不支持点赞。',
|
||||
};
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
errorMessage: '作品类型 match3d 暂不支持点赞。',
|
||||
};
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
|
||||
178
src/components/platform-entry/platformRecommendation.test.ts
Normal file
178
src/components/platform-entry/platformRecommendation.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { buildPlatformRecommendedEntries } from './platformRecommendation';
|
||||
|
||||
const NOW_MS = Date.parse('2026-06-07T12:00:00.000Z');
|
||||
|
||||
type PublicCardTestParams = {
|
||||
id: string;
|
||||
sourceType?: 'puzzle' | 'match3d' | 'jump-hop';
|
||||
subtitle?: string;
|
||||
summaryText?: string;
|
||||
coverImageSrc?: string | null;
|
||||
themeTags?: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
publishedAt?: string | null;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
function buildPublicCard(
|
||||
params: PublicCardTestParams,
|
||||
): PlatformPublicGalleryCard {
|
||||
const sourceType = params.sourceType ?? 'puzzle';
|
||||
|
||||
return {
|
||||
sourceType,
|
||||
workId: `${sourceType}-work-${params.id}`,
|
||||
profileId: `${sourceType}-profile-${params.id}`,
|
||||
publicWorkCode: `${sourceType.toUpperCase()}-${params.id}`,
|
||||
ownerUserId: `user-${params.id}`,
|
||||
authorDisplayName: `${params.id} 作者`,
|
||||
worldName: `${params.id} 作品`,
|
||||
subtitle: params.subtitle ?? '公开作品',
|
||||
summaryText: params.summaryText ?? '公开作品摘要。',
|
||||
coverImageSrc: params.coverImageSrc ?? `${params.id}.png`,
|
||||
themeTags: params.themeTags ?? ['推荐'],
|
||||
playCount: params.playCount ?? 0,
|
||||
remixCount: params.remixCount ?? 0,
|
||||
likeCount: params.likeCount ?? 0,
|
||||
recentPlayCount7d: params.recentPlayCount7d ?? 0,
|
||||
visibility: 'published',
|
||||
publishedAt: params.publishedAt ?? '2026-06-01T12:00:00.000Z',
|
||||
updatedAt:
|
||||
params.updatedAt ?? params.publishedAt ?? '2026-06-01T12:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
}
|
||||
|
||||
describe('buildPlatformRecommendedEntries', () => {
|
||||
test('combines heat, freshness and featured boost after de-duplicating works', () => {
|
||||
const coldEntry = buildPublicCard({
|
||||
id: 'cold',
|
||||
playCount: 1,
|
||||
publishedAt: '2026-04-01T12:00:00.000Z',
|
||||
});
|
||||
const hotRecentEntry = buildPublicCard({
|
||||
id: 'hot',
|
||||
playCount: 8,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 16,
|
||||
publishedAt: '2026-06-06T12:00:00.000Z',
|
||||
});
|
||||
const curatedEntry = buildPublicCard({
|
||||
id: 'curated',
|
||||
playCount: 0,
|
||||
likeCount: 0,
|
||||
publishedAt: '2026-05-10T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [curatedEntry],
|
||||
latestEntries: [coldEntry, hotRecentEntry, curatedEntry],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
hotRecentEntry.profileId,
|
||||
curatedEntry.profileId,
|
||||
coldEntry.profileId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('interleaves close-score works from different play types', () => {
|
||||
const firstPuzzle = buildPublicCard({
|
||||
id: 'puzzle-a',
|
||||
sourceType: 'puzzle',
|
||||
likeCount: 2,
|
||||
});
|
||||
const secondPuzzle = buildPublicCard({
|
||||
id: 'puzzle-b',
|
||||
sourceType: 'puzzle',
|
||||
likeCount: 2,
|
||||
});
|
||||
const match3d = buildPublicCard({
|
||||
id: 'match3d-a',
|
||||
sourceType: 'match3d',
|
||||
likeCount: 2,
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [],
|
||||
latestEntries: [firstPuzzle, secondPuzzle, match3d],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
firstPuzzle.profileId,
|
||||
match3d.profileId,
|
||||
secondPuzzle.profileId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('separates same-type candidates while alternatives remain', () => {
|
||||
const hotPuzzle = buildPublicCard({
|
||||
id: 'hot-puzzle',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 50,
|
||||
likeCount: 20,
|
||||
});
|
||||
const warmPuzzle = buildPublicCard({
|
||||
id: 'warm-puzzle',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 32,
|
||||
likeCount: 12,
|
||||
});
|
||||
const coldMatch3d = buildPublicCard({
|
||||
id: 'cold-match3d',
|
||||
sourceType: 'match3d',
|
||||
publishedAt: '2026-04-01T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [],
|
||||
latestEntries: [hotPuzzle, warmPuzzle, coldMatch3d],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
hotPuzzle.profileId,
|
||||
coldMatch3d.profileId,
|
||||
warmPuzzle.profileId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to same-type adjacency when no other type remains', () => {
|
||||
const firstPuzzle = buildPublicCard({
|
||||
id: 'only-puzzle-a',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 8,
|
||||
});
|
||||
const secondPuzzle = buildPublicCard({
|
||||
id: 'only-puzzle-b',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 4,
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [],
|
||||
latestEntries: [firstPuzzle, secondPuzzle],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
firstPuzzle.profileId,
|
||||
secondPuzzle.profileId,
|
||||
]);
|
||||
});
|
||||
});
|
||||
225
src/components/platform-entry/platformRecommendation.ts
Normal file
225
src/components/platform-entry/platformRecommendation.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
buildPlatformPublicGalleryCardKey,
|
||||
isEdutainmentGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkSourceType,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const FEATURED_BONUS = 14;
|
||||
const MAX_FRESHNESS_SCORE = 12;
|
||||
|
||||
export type PlatformRecommendationOptions = {
|
||||
nowMs?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type RecommendationCandidate = {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
key: string;
|
||||
sourceType: string;
|
||||
firstSeenIndex: number;
|
||||
isFeatured: boolean;
|
||||
timestampMs: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
type PlatformRecommendationMetricKey =
|
||||
| 'playCount'
|
||||
| 'remixCount'
|
||||
| 'likeCount'
|
||||
| 'recentPlayCount7d';
|
||||
|
||||
function parseRecommendationTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
|
||||
if (numericTimestamp?.[1]) {
|
||||
const rawTimestamp = Number(numericTimestamp[1]);
|
||||
if (Number.isFinite(rawTimestamp)) {
|
||||
const absoluteTimestamp = Math.abs(rawTimestamp);
|
||||
if (absoluteTimestamp >= 1_000_000_000_000_000) {
|
||||
return rawTimestamp / 1000;
|
||||
}
|
||||
if (absoluteTimestamp >= 1_000_000_000_000) {
|
||||
return rawTimestamp;
|
||||
}
|
||||
if (absoluteTimestamp >= 1_000_000_000) {
|
||||
return rawTimestamp * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = new Date(normalized).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
function getRecommendationTimestamp(entry: PlatformPublicGalleryCard) {
|
||||
return parseRecommendationTimestamp(entry.publishedAt ?? entry.updatedAt);
|
||||
}
|
||||
|
||||
function getRecommendationMetric(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
key: PlatformRecommendationMetricKey,
|
||||
) {
|
||||
const value = (
|
||||
entry as Partial<Record<PlatformRecommendationMetricKey, number>>
|
||||
)[key];
|
||||
return Math.max(0, Math.round(Number(value ?? 0) || 0));
|
||||
}
|
||||
|
||||
function getRecommendationSourceType(entry: PlatformPublicGalleryCard) {
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return `edutainment:${entry.templateId}`;
|
||||
}
|
||||
|
||||
return resolvePlatformPublicWorkSourceType(entry);
|
||||
}
|
||||
|
||||
function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) {
|
||||
return 'themeTags' in entry && Array.isArray(entry.themeTags)
|
||||
? entry.themeTags
|
||||
: [];
|
||||
}
|
||||
|
||||
function scoreRecommendationCandidate(
|
||||
candidate: Omit<RecommendationCandidate, 'score'>,
|
||||
nowMs: number,
|
||||
) {
|
||||
const entry = candidate.entry;
|
||||
const ageDays =
|
||||
candidate.timestampMs > 0
|
||||
? Math.max(0, (nowMs - candidate.timestampMs) / MS_PER_DAY)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const freshnessScore = Number.isFinite(ageDays)
|
||||
? MAX_FRESHNESS_SCORE / (1 + ageDays / 7)
|
||||
: 0;
|
||||
const coverScore = entry.coverImageSrc ? 1.5 : 0;
|
||||
const tagScore = Math.min(3, getRecommendationThemeTags(entry).length) * 0.6;
|
||||
const summaryScore = entry.summaryText.trim() ? 0.8 : 0;
|
||||
|
||||
return (
|
||||
(candidate.isFeatured ? FEATURED_BONUS : 0) +
|
||||
Math.log1p(getRecommendationMetric(entry, 'recentPlayCount7d')) * 8 +
|
||||
Math.log1p(getRecommendationMetric(entry, 'likeCount')) * 5 +
|
||||
Math.log1p(getRecommendationMetric(entry, 'remixCount')) * 3 +
|
||||
Math.log1p(getRecommendationMetric(entry, 'playCount')) * 2 +
|
||||
freshnessScore +
|
||||
coverScore +
|
||||
tagScore +
|
||||
summaryScore
|
||||
);
|
||||
}
|
||||
|
||||
function compareRecommendationCandidates(
|
||||
left: RecommendationCandidate,
|
||||
right: RecommendationCandidate,
|
||||
) {
|
||||
const scoreDiff = right.score - left.score;
|
||||
if (scoreDiff !== 0) {
|
||||
return scoreDiff;
|
||||
}
|
||||
|
||||
const timeDiff = right.timestampMs - left.timestampMs;
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff;
|
||||
}
|
||||
|
||||
if (left.firstSeenIndex !== right.firstSeenIndex) {
|
||||
return left.firstSeenIndex - right.firstSeenIndex;
|
||||
}
|
||||
|
||||
return left.key.localeCompare(right.key, 'zh-CN');
|
||||
}
|
||||
|
||||
function diversifyAdjacentSourceTypes(candidates: RecommendationCandidate[]) {
|
||||
const remaining = [...candidates];
|
||||
const result: RecommendationCandidate[] = [];
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const lastSourceType = result[result.length - 1]?.sourceType ?? null;
|
||||
let nextIndex = 0;
|
||||
|
||||
if (lastSourceType) {
|
||||
const alternativeIndex = remaining.findIndex(
|
||||
(candidate) => candidate.sourceType !== lastSourceType,
|
||||
);
|
||||
if (alternativeIndex > 0) {
|
||||
nextIndex = alternativeIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const [nextCandidate] = remaining.splice(nextIndex, 1);
|
||||
if (nextCandidate) {
|
||||
result.push(nextCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildPlatformRecommendedEntries(
|
||||
params: {
|
||||
featuredEntries: PlatformPublicGalleryCard[];
|
||||
latestEntries: PlatformPublicGalleryCard[];
|
||||
},
|
||||
options: PlatformRecommendationOptions = {},
|
||||
) {
|
||||
const candidateMap = new Map<
|
||||
string,
|
||||
Omit<RecommendationCandidate, 'score'>
|
||||
>();
|
||||
let firstSeenIndex = 0;
|
||||
|
||||
const collectEntries = (
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
source: 'featured' | 'latest',
|
||||
) => {
|
||||
entries.forEach((entry) => {
|
||||
const key = buildPlatformPublicGalleryCardKey(entry);
|
||||
const timestampMs = getRecommendationTimestamp(entry);
|
||||
const existing = candidateMap.get(key);
|
||||
if (existing) {
|
||||
existing.isFeatured = existing.isFeatured || source === 'featured';
|
||||
if (timestampMs >= existing.timestampMs) {
|
||||
existing.entry = entry;
|
||||
existing.timestampMs = timestampMs;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
candidateMap.set(key, {
|
||||
entry,
|
||||
key,
|
||||
sourceType: getRecommendationSourceType(entry),
|
||||
firstSeenIndex,
|
||||
isFeatured: source === 'featured',
|
||||
timestampMs,
|
||||
});
|
||||
firstSeenIndex += 1;
|
||||
});
|
||||
};
|
||||
|
||||
collectEntries(params.featuredEntries, 'featured');
|
||||
collectEntries(params.latestEntries, 'latest');
|
||||
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
const rankedCandidates = Array.from(candidateMap.values())
|
||||
.map((candidate) => ({
|
||||
...candidate,
|
||||
score: scoreRecommendationCandidate(candidate, nowMs),
|
||||
}))
|
||||
.sort(compareRecommendationCandidates);
|
||||
const diversifiedCandidates = diversifyAdjacentSourceTypes(rankedCandidates);
|
||||
const limit =
|
||||
typeof options.limit === 'number' && options.limit > 0
|
||||
? Math.floor(options.limit)
|
||||
: diversifiedCandidates.length;
|
||||
|
||||
return diversifiedCandidates
|
||||
.slice(0, limit)
|
||||
.map((candidate) => candidate.entry);
|
||||
}
|
||||
@@ -21,7 +21,24 @@ describe('isPuzzleCompileActionReady', () => {
|
||||
expect(isPuzzleCompileActionReady(session)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats compile action as ready after the selected cover exists', () => {
|
||||
it('keeps compile action generating when only the selected cover exists', () => {
|
||||
const session = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
draft: {
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
levels: [
|
||||
{
|
||||
generationStatus: 'generating',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as PuzzleAgentSessionSnapshot;
|
||||
|
||||
expect(isPuzzleCompileActionReady(session)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats compile action as ready after all runtime assets exist', () => {
|
||||
const session = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
draft: {
|
||||
@@ -30,6 +47,12 @@ describe('isPuzzleCompileActionReady', () => {
|
||||
{
|
||||
generationStatus: 'ready',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-background.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -4,6 +4,13 @@ function hasText(value: string | null | undefined) {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasAssetReference(
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
) {
|
||||
return hasText(imageSrc) || hasText(objectKey);
|
||||
}
|
||||
|
||||
export function isPuzzleCompileActionReady(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
@@ -11,10 +18,19 @@ export function isPuzzleCompileActionReady(
|
||||
if (!draft) {
|
||||
return false;
|
||||
}
|
||||
if (hasText(draft.coverImageSrc)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
draft.levels?.some((level) => hasText(level.coverImageSrc)) === true
|
||||
draft.levels?.some(
|
||||
(level) =>
|
||||
(hasText(draft.coverImageSrc) || hasText(level.coverImageSrc)) &&
|
||||
hasAssetReference(level.levelSceneImageSrc, level.levelSceneImageObjectKey) &&
|
||||
hasAssetReference(
|
||||
level.uiSpritesheetImageSrc,
|
||||
level.uiSpritesheetImageObjectKey,
|
||||
) &&
|
||||
hasAssetReference(
|
||||
level.levelBackgroundImageSrc,
|
||||
level.levelBackgroundImageObjectKey,
|
||||
),
|
||||
) === true
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user