合并 master 并修复架构分支回归

合入 master 最新的认证、玩法契约与推荐页改动。

修复拼图草稿生成、推荐页下一关和公开详情访客试玩回归。

修复抓大鹅草稿试玩鉴权与首屏推荐详情测试入口。

补齐相关测试夹具、文档与团队记忆更新。
This commit is contained in:
2026-06-07 21:35:47 +08:00
80 changed files with 2627 additions and 511 deletions

View File

@@ -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,
},
);
}}
/>

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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',
);
});

View File

@@ -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),

View File

@@ -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,

View File

@@ -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' };
}

View File

@@ -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')),

View File

@@ -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',

View 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,
]);
});
});

View 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);
}

View File

@@ -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',
},
],
},

View File

@@ -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
);
}