This commit is contained in:
2026-05-25 23:09:07 +08:00
70 changed files with 4093 additions and 2115 deletions

View File

@@ -53,6 +53,7 @@ export type CreativeImageInputPanelProps = {
aiRedraw: boolean;
promptReferenceImages: CreativeImageInputReferenceImage[];
promptReferenceLimit?: number;
imageLimitHint?: string | null;
imageModelPicker?: ReactNode;
error?: string | null;
inputError?: string | null;
@@ -96,6 +97,7 @@ export function CreativeImageInputPanel({
aiRedraw,
promptReferenceImages,
promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT,
imageLimitHint = null,
imageModelPicker = null,
error = null,
inputError = null,
@@ -276,6 +278,11 @@ export function CreativeImageInputPanel({
</div>
</div>
{mainImageMeta ? <div className="mt-3 shrink-0">{mainImageMeta}</div> : null}
{imageLimitHint ? (
<div className="mt-2 shrink-0 text-center text-[11px] font-semibold text-[var(--platform-text-soft)]">
{imageLimitHint}
</div>
) : null}
</div>
{showPrompt ? (

View File

@@ -6,6 +6,7 @@ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contra
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
@@ -61,6 +62,9 @@ type CustomWorldCreationHubProps = {
squareHoleItems?: SquareHoleWorkSummary[];
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null;
jumpHopItems?: JumpHopWorkSummaryResponse[];
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
@@ -169,6 +173,9 @@ export function CustomWorldCreationHub({
squareHoleItems = [],
onOpenSquareHoleDetail,
onDeleteSquareHole = null,
jumpHopItems = [],
onOpenJumpHopDetail,
onDeleteJumpHop = null,
puzzleItems = [],
onOpenPuzzleDetail,
onDeletePuzzle = null,
@@ -201,6 +208,7 @@ export function CustomWorldCreationHub({
bigFishItems,
match3dItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
jumpHopItems,
puzzleItems,
babyObjectMatchItems,
barkBattleItems,
@@ -210,6 +218,7 @@ export function CustomWorldCreationHub({
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeleteSquareHole:
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeleteJumpHop: Boolean(onDeleteJumpHop),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
@@ -223,6 +232,8 @@ export function CustomWorldCreationHub({
onDeleteMatch3D: onDeleteMatch3D ?? undefined,
onOpenSquareHoleDetail,
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined,
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
onOpenPuzzleDetail,
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
@@ -249,6 +260,7 @@ export function CustomWorldCreationHub({
onDeleteBabyObjectMatch,
onDeleteBarkBattle,
onDeleteVisualNovel,
onDeleteJumpHop,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
onOpenDraft,
@@ -262,7 +274,9 @@ export function CustomWorldCreationHub({
getWorkState,
puzzleItems,
rpgLibraryEntries,
squareHoleItems,
onOpenSquareHoleDetail,
onOpenJumpHopDetail,
jumpHopItems,
visualNovelItems,
],
);
@@ -310,6 +324,9 @@ export function CustomWorldCreationHub({
case 'square-hole':
onOpenSquareHoleDetail?.(item.source.item);
return;
case 'jump-hop':
onOpenJumpHopDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);

View File

@@ -59,6 +59,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
'big-fish': '/creation-type-references/big-fish.webp',
match3d: '/creation-type-references/match3d.webp',
'square-hole': '/creation-type-references/square-hole.webp',
'jump-hop': '/creation-type-references/jump-hop.webp',
puzzle: '/creation-type-references/puzzle.webp',
'baby-object-match': '/creation-type-references/creative-agent.webp',
'bark-battle': '/creation-type-references/bark-battle.webp',

View File

@@ -7,12 +7,14 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBabyObjectMatchPublicWorkCode,
buildCustomWorldPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode,
buildJumpHopPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
buildSquareHolePublicWorkCode,
@@ -31,6 +33,7 @@ export type CreationWorkShelfKind =
| 'big-fish'
| 'match3d'
| 'square-hole'
| 'jump-hop'
| 'puzzle'
| 'baby-object-match'
| 'bark-battle'
@@ -83,6 +86,10 @@ export type CreationWorkShelfSource =
kind: 'square-hole';
item: SquareHoleWorkSummary;
}
| {
kind: 'jump-hop';
item: JumpHopWorkSummaryResponse;
}
| {
kind: 'puzzle';
item: PuzzleWorkSummary;
@@ -137,6 +144,7 @@ export function buildCreationWorkShelfItems(params: {
bigFishItems: BigFishWorkSummary[];
match3dItems?: Match3DWorkSummary[];
squareHoleItems?: SquareHoleWorkSummary[];
jumpHopItems?: JumpHopWorkSummaryResponse[];
puzzleItems: PuzzleWorkSummary[];
babyObjectMatchItems?: BabyObjectMatchDraft[];
barkBattleItems?: BarkBattleWorkSummary[];
@@ -145,6 +153,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteBigFish?: boolean;
canDeleteMatch3D?: boolean;
canDeleteSquareHole?: boolean;
canDeleteJumpHop?: boolean;
canDeletePuzzle?: boolean;
canDeleteBabyObjectMatch?: boolean;
canDeleteBarkBattle?: boolean;
@@ -158,6 +167,8 @@ export function buildCreationWorkShelfItems(params: {
onDeleteMatch3D?: (item: Match3DWorkSummary) => void;
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void;
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void;
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
@@ -177,6 +188,7 @@ export function buildCreationWorkShelfItems(params: {
bigFishItems,
match3dItems = [],
squareHoleItems = [],
jumpHopItems = [],
puzzleItems,
babyObjectMatchItems = [],
barkBattleItems = [],
@@ -185,6 +197,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteBigFish = false,
canDeleteMatch3D = false,
canDeleteSquareHole = false,
canDeleteJumpHop = false,
canDeletePuzzle = false,
canDeleteBabyObjectMatch = false,
canDeleteBarkBattle = false,
@@ -198,6 +211,8 @@ export function buildCreationWorkShelfItems(params: {
onDeleteMatch3D,
onOpenSquareHoleDetail,
onDeleteSquareHole,
onOpenJumpHopDetail,
onDeleteJumpHop,
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
@@ -236,6 +251,12 @@ export function buildCreationWorkShelfItems(params: {
onDelete: onDeleteSquareHole,
}),
),
...jumpHopItems.map((item) =>
mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, {
onOpen: onOpenJumpHopDetail,
onDelete: onDeleteJumpHop,
}),
),
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
onOpen: onOpenPuzzleDetail,
@@ -749,6 +770,51 @@ function mapSquareHoleWorkToShelfItem(
};
}
function mapJumpHopWorkToShelfItem(
item: JumpHopWorkSummaryResponse,
canDelete: boolean,
adapter: WorkShelfAdapter<JumpHopWorkSummaryResponse>,
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published' ? buildJumpHopPublicWorkCode(item.profileId) : null;
const coverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
return {
id: item.workId,
kind: 'jump-hop',
status,
title: item.workTitle,
summary: item.workDescription,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
sharePath:
publicWorkCode && status === 'published'
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '跳一跳', tone: 'neutral' },
],
metrics:
status === 'published'
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: 0,
likeCount: 0,
})
: [],
actions: buildWorkShelfActions(item, adapter),
source: { kind: 'jump-hop', item },
};
}
function resolveAuthorDisplayName(
...sources: Array<unknown>

View File

@@ -115,8 +115,10 @@ import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFound
import {
ApiClientError,
BACKGROUND_AUTH_REQUEST_OPTIONS,
getStoredAccessToken,
} from '../../services/apiClient';
import {
ensureRuntimeGuestToken,
getPublicAuthUserByCode,
getPublicAuthUserById,
} from '../../services/authService';
@@ -127,6 +129,7 @@ import {
publishBarkBattleWork,
updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation';
import { startBarkBattleRun } from '../../services/bark-battle-runtime';
import {
createBigFishCreationSession,
executeBigFishCreationAction,
@@ -177,9 +180,10 @@ import {
type JumpHopRunResponse,
type JumpHopSessionResponse,
type JumpHopSessionSnapshotResponse,
type JumpHopWorkProfileResponse,
type JumpHopWorkspaceCreateRequest,
JumpHopWorkProfileResponse,
JumpHopWorkspaceCreateRequest,
} from '../../services/jump-hop/jumpHopClient';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { match3dCreationClient } from '../../services/match3d-creation';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import {
@@ -407,6 +411,7 @@ import { PlatformFeedbackView } from './PlatformFeedbackView';
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
import { usePlatformDesktopLayout } from './platformEntryResponsive';
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
import { usePlatformEntryNavigation } from './usePlatformEntryNavigation';
@@ -549,8 +554,34 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
]);
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS =
BACKGROUND_AUTH_REQUEST_OPTIONS;
const PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS =
const RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS =
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
async function buildRecommendRuntimeGuestOptions() {
const { token } = await ensureRuntimeGuestToken();
return {
...RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
runtimeGuestToken: token,
};
}
function shouldUseRecommendRuntimeGuestAuth(
authUi: { user?: { id?: string } | null } | null | undefined,
) {
return !authUi?.user?.id?.trim() && !getStoredAccessToken();
}
async function buildRecommendRuntimeAuthOptions(
authUi: { user?: { id?: string } | null } | null | undefined,
embedded?: boolean,
) {
if (!embedded) {
return {};
}
if (shouldUseRecommendRuntimeGuestAuth(authUi)) {
return buildRecommendRuntimeGuestOptions();
}
return RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
}
const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
const MATCH3D_DRAFT_GENERATION_POINT_COST = 10;
const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3;
@@ -1943,7 +1974,7 @@ function hasRecoverableGeneratedPuzzleDraft(
);
}
function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) {
function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] {
switch (item.source.kind) {
case 'rpg':
return collectDraftNoticeKeys('rpg', [
@@ -1972,6 +2003,13 @@ function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) {
item.source.item.profileId,
item.source.item.sourceSessionId,
]);
case 'jump-hop':
return collectDraftNoticeKeys('jump-hop', [
item.id,
item.source.item.workId,
item.source.item.profileId,
item.source.item.sourceSessionId,
]);
case 'puzzle':
return collectDraftNoticeKeys('puzzle', [
item.id,
@@ -2057,6 +2095,39 @@ function buildPendingBigFishWorks(
}));
}
function buildPendingJumpHopWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly JumpHopWorkSummaryResponse[],
): JumpHopWorkSummaryResponse[] {
if (!pending) {
return [];
}
return Object.entries(pending)
.filter(([sessionId]) =>
existingItems.every((item) => item.sourceSessionId !== sessionId),
)
.map(([sessionId, state]) => ({
runtimeKind: 'jump-hop',
workId: `jump-hop-work-${sessionId}`,
profileId: `jump-hop-profile-${sessionId}`,
ownerUserId: '',
sourceSessionId: sessionId,
workTitle: '跳一跳草稿',
workDescription: '正在生成跳一跳玩法草稿。',
themeTags: [],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: state.updatedAt,
publishedAt: null,
publishReady: false,
generationStatus: state.status === 'generating' ? 'generating' : 'ready',
}));
}
function buildPendingMatch3DWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly Match3DWorkSummary[],
@@ -2666,7 +2737,12 @@ export function PlatformEntryFlowShellImpl({
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
const isDesktopLayout = usePlatformDesktopLayout();
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
const [draftGenerationPointNotice, setDraftGenerationPointNotice] = useState<{
title: string;
message: string;
} | null>(null);
const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] =
@@ -2727,6 +2803,9 @@ export function PlatformEntryFlowShellImpl({
const [jumpHopGalleryEntries, setJumpHopGalleryEntries] = useState<
JumpHopGalleryCardResponse[]
>([]);
const [jumpHopWorks, setJumpHopWorks] = useState<
JumpHopWorkSummaryResponse[]
>([]);
const [jumpHopRuntimeReturnStage, setJumpHopRuntimeReturnStage] =
useState<JumpHopRuntimeReturnStage>('jump-hop-result');
const [jumpHopGenerationState, setJumpHopGenerationState] =
@@ -2943,6 +3022,10 @@ export function PlatformEntryFlowShellImpl({
creationEntryTypes,
'big-fish',
);
const isJumpHopCreationVisible = isPlatformCreationTypeVisible(
creationEntryTypes,
'jump-hop',
);
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
creationEntryTypes,
'square-hole',
@@ -3244,7 +3327,7 @@ export function PlatformEntryFlowShellImpl({
[draftGenerationNotices],
);
const ensureEnoughDraftGenerationPointsFromServer = useCallback(
async (pointsCost: number, setError: (message: string | null) => void) => {
async (pointsCost: number) => {
try {
const latestDashboard = await getPlatformProfileDashboard(
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
@@ -3252,25 +3335,26 @@ export function PlatformEntryFlowShellImpl({
platformBootstrap.setProfileDashboard(latestDashboard);
const walletBalance = resolveProfileWalletBalance(latestDashboard);
if (walletBalance >= pointsCost) {
setDraftGenerationPointNotice(null);
return true;
}
setError(
`泥点不足,本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`,
setDraftGenerationPointNotice(
{
title: '泥点不足',
message: `本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`,
},
);
enterCreateTab();
selectionStageRef.current = 'platform';
setSelectionStage('platform');
return false;
} catch {
setError('读取泥点余额失败,请稍后重试。');
enterCreateTab();
selectionStageRef.current = 'platform';
setSelectionStage('platform');
setDraftGenerationPointNotice({
title: '读取泥点余额失败',
message: '请稍后重试。',
});
return false;
}
},
[enterCreateTab, platformBootstrap, setSelectionStage],
[platformBootstrap],
);
const resolveBigFishErrorMessage = useCallback(
@@ -3293,6 +3377,11 @@ export function PlatformEntryFlowShellImpl({
resolveRpgCreationErrorMessage(error, fallback),
[],
);
const resolveBarkBattleErrorMessage = useCallback(
(error: unknown, fallback: string) =>
resolveRpgCreationErrorMessage(error, fallback),
[],
);
const refreshBigFishShelf = useCallback(async () => {
setIsBigFishLoadingLibrary(true);
@@ -3392,6 +3481,22 @@ export function PlatformEntryFlowShellImpl({
}
}, []);
const refreshJumpHopShelf = useCallback(async () => {
if (!isJumpHopCreationVisible) {
setJumpHopWorks([]);
return [];
}
try {
const worksResponse = await jumpHopClient.listWorks();
setJumpHopWorks(worksResponse.items);
return worksResponse.items;
} catch {
setJumpHopWorks([]);
return [];
}
}, [isJumpHopCreationVisible]);
const refreshWoodenFishGallery = useCallback(async () => {
try {
const galleryResponse = await woodenFishClient.listGallery();
@@ -3601,6 +3706,22 @@ export function PlatformEntryFlowShellImpl({
selectionStage,
]);
useEffect(() => {
if (!platformBootstrap.canReadProtectedData) {
setJumpHopWorks([]);
return;
}
if (platformBootstrap.platformTab === 'create' || selectionStage === 'platform') {
void refreshJumpHopShelf();
}
}, [
platformBootstrap.canReadProtectedData,
platformBootstrap.platformTab,
refreshJumpHopShelf,
selectionStage,
]);
const sessionController = useRpgCreationSessionController({
userId: authUi?.user?.id,
openLoginModal: authUi?.openLoginModal,
@@ -3948,6 +4069,16 @@ export function PlatformEntryFlowShellImpl({
],
[bigFishWorks, pendingDraftShelfItems],
);
const jumpHopShelfItems = useMemo(
() => [
...buildPendingJumpHopWorks(
pendingDraftShelfItems['jump-hop'],
jumpHopWorks,
),
...jumpHopWorks,
],
[jumpHopWorks, pendingDraftShelfItems],
);
const match3dShelfItems = useMemo(
() => [
...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks),
@@ -4023,6 +4154,13 @@ export function PlatformEntryFlowShellImpl({
...bigFishShelfItems.flatMap((item) =>
collectDraftNoticeKeys('big-fish', [item.workId, item.sourceSessionId]),
),
...jumpHopShelfItems.flatMap((item) =>
collectDraftNoticeKeys('jump-hop', [
item.workId,
item.profileId,
item.sourceSessionId,
]),
),
...match3dShelfItems.flatMap((item) =>
collectDraftNoticeKeys('match3d', [
item.workId,
@@ -4065,6 +4203,7 @@ export function PlatformEntryFlowShellImpl({
babyObjectMatchDrafts,
barkBattleShelfItems,
bigFishShelfItems,
jumpHopShelfItems,
creationHubItems,
isSquareHoleCreationVisible,
match3dShelfItems,
@@ -5164,30 +5303,27 @@ export function PlatformEntryFlowShellImpl({
setPuzzleError(null);
return ensureEnoughDraftGenerationPointsFromServer(
PUZZLE_DRAFT_GENERATION_POINT_COST,
(message) => {
setPuzzleCreationError(message);
setPuzzleError(message);
},
);
}, [
ensureEnoughDraftGenerationPointsFromServer,
setPuzzleCreationError,
setPuzzleError,
]);
const preflightMatch3DDraftGeneration = useCallback(async () => {
setMatch3DError(null);
return ensureEnoughDraftGenerationPointsFromServer(
MATCH3D_DRAFT_GENERATION_POINT_COST,
setMatch3DError,
);
}, [ensureEnoughDraftGenerationPointsFromServer, setMatch3DError]);
}, [ensureEnoughDraftGenerationPointsFromServer]);
const preflightBarkBattleDraftGeneration = useCallback(async () => {
setBarkBattleError(null);
return ensureEnoughDraftGenerationPointsFromServer(
BARK_BATTLE_DRAFT_GENERATION_POINT_COST,
setBarkBattleError,
);
}, [ensureEnoughDraftGenerationPointsFromServer]);
const draftGenerationPointNoticeDescription = draftGenerationPointNotice
? draftGenerationPointNotice.title === '读取泥点余额失败'
? '当前表单不会丢失,关闭后可继续编辑,稍后再试。'
: '当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。'
: undefined;
const recoverCompletedPuzzleDraftGeneration = useCallback(
async ({
sessionId,
@@ -7276,11 +7412,15 @@ export function PlatformEntryFlowShellImpl({
profileId: targetProfileId,
mode: 'play' as const,
};
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const { run } = options.embedded
? await startVisualNovelRun(
targetProfileId,
startRunPayload,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
runtimeGuestOptions,
)
: await startVisualNovelRun(targetProfileId, startRunPayload);
setVisualNovelWork(workDetail);
@@ -7306,6 +7446,7 @@ export function PlatformEntryFlowShellImpl({
}
},
[
authUi,
resolvePuzzleErrorMessage,
setIsVisualNovelBusy,
setSelectionStage,
@@ -7327,9 +7468,14 @@ export function PlatformEntryFlowShellImpl({
setVisualNovelError(null);
setIsVisualNovelBusy(true);
try {
const runtimeGuestOptions =
activeRecommendRuntimeKind === 'visual-novel'
? await buildRecommendRuntimeAuthOptions(authUi, true)
: {};
const nextRun = await streamVisualNovelRuntimeAction(
visualNovelRun.runId,
payload,
runtimeGuestOptions,
);
setVisualNovelRun(nextRun);
} catch (error) {
@@ -7341,6 +7487,8 @@ export function PlatformEntryFlowShellImpl({
}
},
[
activeRecommendRuntimeKind,
authUi,
isVisualNovelBusy,
resolvePuzzleErrorMessage,
setIsVisualNovelBusy,
@@ -7551,6 +7699,22 @@ export function PlatformEntryFlowShellImpl({
setJumpHopSession(response.session);
setJumpHopWork(response.work ?? null);
setJumpHopGenerationState(readyState);
if (response.work) {
setJumpHopWorks((current) =>
[response.work!.summary, ...current.filter((item) => item.workId !== response.work!.summary.workId)],
);
markPendingDraftReady('jump-hop', created.session.sessionId, false);
markDraftReady(
'jump-hop',
[
created.session.sessionId,
response.work.summary.workId,
response.work.summary.profileId,
],
false,
);
void refreshJumpHopShelf().catch(() => undefined);
}
setSelectionStage('jump-hop-result');
} catch (error) {
const errorMessage = resolveRpgCreationErrorMessage(
@@ -7665,6 +7829,10 @@ export function PlatformEntryFlowShellImpl({
try {
const response = await jumpHopClient.publishWork(profileId);
setJumpHopWork(response.item);
setJumpHopWorks((current) =>
[response.item.summary, ...current.filter((item) => item.workId !== response.item.summary.workId)],
);
void refreshJumpHopShelf().catch(() => undefined);
openPublishShareModal({
title: response.item.summary.workTitle || '跳一跳',
publicWorkCode: buildJumpHopPublicWorkCode(
@@ -7729,12 +7897,13 @@ export function PlatformEntryFlowShellImpl({
setJumpHopError(null);
setJumpHopRuntimeReturnStage(options.returnStage ?? 'work-detail');
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const [detail, runResponse] = await Promise.all([
jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null),
jumpHopClient.startRun(
normalizedProfileId,
options.embedded ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS : {},
),
jumpHopClient.startRun(normalizedProfileId, runtimeGuestOptions),
]);
if (detail?.item) {
setJumpHopWork(detail.item);
@@ -7759,7 +7928,7 @@ export function PlatformEntryFlowShellImpl({
setIsJumpHopBusy(false);
}
},
[setSelectionStage],
[authUi, setSelectionStage],
);
const restartJumpHopRuntimeRun = useCallback(async () => {
@@ -8066,9 +8235,15 @@ export function PlatformEntryFlowShellImpl({
setWoodenFishError(null);
setWoodenFishRuntimeReturnStage(options.returnStage ?? 'work-detail');
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const [detail, runResponse] = await Promise.all([
woodenFishClient.getWorkDetail(normalizedProfileId).catch(() => null),
woodenFishClient.startRun(normalizedProfileId),
options.embedded
? woodenFishClient.startRun(normalizedProfileId, runtimeGuestOptions)
: woodenFishClient.startRun(normalizedProfileId),
]);
if (detail?.item) {
setWoodenFishWork(detail.item);
@@ -8093,7 +8268,7 @@ export function PlatformEntryFlowShellImpl({
setIsWoodenFishBusy(false);
}
},
[setSelectionStage],
[authUi, setSelectionStage],
);
const checkpointWoodenFishRuntimeRun = useCallback(
@@ -8496,16 +8671,23 @@ export function PlatformEntryFlowShellImpl({
profileId: item.profileId,
levelId: levelId ?? null,
};
const authMode = options.embedded
? 'isolated'
: (options.authMode ?? 'default');
const canUseRuntimeGuestAuth =
options.embedded || options.authMode === 'isolated';
const useRuntimeGuestAuth =
canUseRuntimeGuestAuth && shouldUseRecommendRuntimeGuestAuth(authUi);
const runtimeGuestOptions = useRuntimeGuestAuth
? await buildRecommendRuntimeGuestOptions()
: {};
const authMode = useRuntimeGuestAuth ? 'isolated' : 'default';
const runtimeAuthOptions = useRuntimeGuestAuth
? runtimeGuestOptions
: canUseRuntimeGuestAuth
? RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS
: {};
const { run } =
authMode === 'isolated'
? await startPuzzleRun(
startRunPayload,
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
)
: await startPuzzleRun(startRunPayload);
? await startPuzzleRun(startRunPayload, runtimeGuestOptions)
: await startPuzzleRun(startRunPayload, runtimeAuthOptions);
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
setPuzzleRuntimeAuthMode(authMode);
@@ -8548,6 +8730,7 @@ export function PlatformEntryFlowShellImpl({
},
[
isPuzzleBusy,
authUi,
resolvePuzzleErrorMessage,
setIsPuzzleBusy,
setPuzzleError,
@@ -8600,10 +8783,12 @@ export function PlatformEntryFlowShellImpl({
runtimeProfile.generatedBackgroundAsset,
{ expireSeconds: 300 },
);
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const runtimeOptions = {
...(options.embedded
? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS
: {}),
...runtimeGuestOptions,
...(typeof options.itemTypeCountOverride === 'number'
? { itemTypeCountOverride: options.itemTypeCountOverride }
: {}),
@@ -8648,6 +8833,7 @@ export function PlatformEntryFlowShellImpl({
},
[
isMatch3DBusy,
authUi,
match3dFlow,
match3dRuntimeAdapter,
resolveMatch3DErrorMessage,
@@ -8671,11 +8857,12 @@ export function PlatformEntryFlowShellImpl({
setSquareHoleError(null);
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const { run } = options.embedded
? await startSquareHoleRun(
profile.profileId,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
? await startSquareHoleRun(profile.profileId, runtimeGuestOptions)
: await startSquareHoleRun(profile.profileId);
setSquareHoleRun(run);
setSquareHoleRuntimeReturnStage(returnStage);
@@ -8707,6 +8894,7 @@ export function PlatformEntryFlowShellImpl({
},
[
isSquareHoleBusy,
authUi,
resolveSquareHoleErrorMessage,
setSelectionStage,
setSquareHoleError,
@@ -8827,9 +9015,14 @@ export function PlatformEntryFlowShellImpl({
bigFishInputInFlightRef.current = true;
try {
const runtimeGuestOptions =
activeRecommendRuntimeKind === 'big-fish'
? await buildRecommendRuntimeAuthOptions(authUi, true)
: {};
const { run } = await submitBigFishRuntimeInput(
bigFishRun.runId,
payload,
runtimeGuestOptions,
);
setBigFishRun(run);
} catch (error) {
@@ -8840,7 +9033,13 @@ export function PlatformEntryFlowShellImpl({
bigFishInputInFlightRef.current = false;
}
},
[bigFishRun, resolveBigFishErrorMessage, setBigFishError],
[
activeRecommendRuntimeKind,
authUi,
bigFishRun,
resolveBigFishErrorMessage,
setBigFishError,
],
);
const reportBigFishObservedPlayTime = useCallback(() => {
@@ -8853,10 +9052,9 @@ export function PlatformEntryFlowShellImpl({
setBigFishRuntimeStartedAt(null);
const reportPromise =
activeRecommendRuntimeKind === 'big-fish'
? recordBigFishPlay(
sessionId,
{ elapsedMs },
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
? buildRecommendRuntimeAuthOptions(authUi, true).then(
(runtimeAuthOptions) =>
recordBigFishPlay(sessionId, { elapsedMs }, runtimeAuthOptions),
)
: recordBigFishPlay(sessionId, { elapsedMs });
void reportPromise.catch((error) => {
@@ -8866,6 +9064,7 @@ export function PlatformEntryFlowShellImpl({
});
}, [
activeRecommendRuntimeKind,
authUi,
bigFishRun?.sessionId,
bigFishRuntimeStartedAt,
resolveBigFishErrorMessage,
@@ -9041,12 +9240,13 @@ export function PlatformEntryFlowShellImpl({
profileId: currentLevel.profileId,
levelId: resolvePuzzleRestartLevelId(currentRun, detailItem),
};
const runtimeGuestOptions =
puzzleRuntimeAuthMode === 'isolated'
? await buildRecommendRuntimeGuestOptions()
: {};
const { run } =
puzzleRuntimeAuthMode === 'isolated'
? await startPuzzleRun(
startRunPayload,
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
)
? await startPuzzleRun(startRunPayload, runtimeGuestOptions)
: await startPuzzleRun(startRunPayload);
setSelectedPuzzleDetail(detailItem);
puzzleRunRef.current = run;
@@ -9169,10 +9369,8 @@ export function PlatformEntryFlowShellImpl({
const submitLeaderboardPromise =
puzzleRuntimeAuthMode === 'isolated'
? submitPuzzleLeaderboard(
puzzleRun.runId,
payload,
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
? buildRecommendRuntimeGuestOptions().then((runtimeGuestOptions) =>
submitPuzzleLeaderboard(puzzleRun.runId, payload, runtimeGuestOptions),
)
: submitPuzzleLeaderboard(puzzleRun.runId, payload);
@@ -9229,6 +9427,10 @@ export function PlatformEntryFlowShellImpl({
return;
}
const runtimeGuestOptions =
puzzleRuntimeAuthMode === 'isolated'
? await buildRecommendRuntimeGuestOptions()
: {};
const targetProfileId = _target?.profileId?.trim() ?? '';
if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) {
const itemPromise =
@@ -9244,7 +9446,7 @@ export function PlatformEntryFlowShellImpl({
{
targetProfileId,
},
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
runtimeGuestOptions,
)
: advancePuzzleNextLevel(puzzleRun.runId, {
targetProfileId,
@@ -9269,7 +9471,7 @@ export function PlatformEntryFlowShellImpl({
? await advancePuzzleNextLevel(
puzzleRun.runId,
{},
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
runtimeGuestOptions,
)
: await advancePuzzleNextLevel(puzzleRun.runId);
setPuzzleRun(run);
@@ -10354,6 +10556,43 @@ export function PlatformEntryFlowShellImpl({
[openPublicWorkDetail, setJumpHopError, setSelectionStage],
);
const openJumpHopDraft = useCallback(
async (item: JumpHopWorkSummaryResponse) => {
markDraftNoticeSeen(
collectDraftNoticeKeys('jump-hop', [
item.workId,
item.profileId,
item.sourceSessionId,
]),
);
if (item.publicationStatus === 'published') {
void openJumpHopPublicWorkDetail(item.profileId);
return;
}
setJumpHopError(null);
setPublicWorkDetailError(null);
setIsJumpHopBusy(true);
try {
const detail = await jumpHopClient.getWorkDetail(item.profileId);
setJumpHopSession(null);
setJumpHopRun(null);
setJumpHopWork(detail.item);
setJumpHopRuntimeReturnStage('jump-hop-result');
enterCreateTab();
setSelectionStage('jump-hop-result');
} catch (error) {
setJumpHopError(
resolveRpgCreationErrorMessage(error, '读取跳一跳草稿失败。'),
);
} finally {
setIsJumpHopBusy(false);
}
},
[enterCreateTab, markDraftNoticeSeen, openPublicWorkDetail, setSelectionStage],
);
const openWoodenFishPublicWorkDetail = useCallback(
async (profileId: string) => {
setIsPublicWorkDetailBusy(true);
@@ -11120,11 +11359,12 @@ export function PlatformEntryFlowShellImpl({
setBigFishRuntimeReturnStage(returnStage);
setBigFishRun(null);
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const { run } = options.embedded
? await startBigFishRuntimeRun(
sessionId,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
? await startBigFishRuntimeRun(sessionId, runtimeGuestOptions)
: await startBigFishRuntimeRun(sessionId);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRun(run);
@@ -11135,11 +11375,7 @@ export function PlatformEntryFlowShellImpl({
);
}
const recordPlayPromise = options.embedded
? recordBigFishPlay(
sessionId,
{ elapsedMs: 0 },
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
? recordBigFishPlay(sessionId, { elapsedMs: 0 }, runtimeGuestOptions)
: recordBigFishPlay(sessionId, { elapsedMs: 0 });
void recordPlayPromise.catch((error) => {
setBigFishError(
@@ -11154,13 +11390,14 @@ export function PlatformEntryFlowShellImpl({
return false;
}
},
[bigFishFlow, resolveBigFishErrorMessage, setBigFishError, setSelectionStage],
[authUi, bigFishFlow, resolveBigFishErrorMessage, setBigFishError, setSelectionStage],
);
const startBarkBattleRunFromWork = useCallback(
(
async (
item: BarkBattleWorkSummary,
returnStage: BarkBattleRuntimeReturnStage = 'work-detail',
options: { embedded?: boolean } = {},
) => {
if (item.status !== 'published') {
setBarkBattleError('汪汪声浪作品发布后才能进入正式玩法。');
@@ -11172,17 +11409,34 @@ export function PlatformEntryFlowShellImpl({
setBarkBattleRuntimeMode('published');
setBarkBattlePublishedConfig(mapBarkBattleWorkToPublishedConfig(item));
setBarkBattleRuntimeReturnStage(returnStage);
selectionStageRef.current = 'bark-battle-runtime';
setSelectionStage('bark-battle-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath(
'bark-battle-runtime',
buildBarkBattlePublicWorkCode(item.workId),
),
);
return true;
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const runResponse = options.embedded
? await startBarkBattleRun(item.workId, {}, runtimeGuestOptions)
: await startBarkBattleRun(item.workId);
void runResponse;
selectionStageRef.current = 'bark-battle-runtime';
if (!options.embedded) {
setSelectionStage('bark-battle-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath(
'bark-battle-runtime',
buildBarkBattlePublicWorkCode(item.workId),
),
);
}
return true;
} catch (error) {
setBarkBattleError(
resolveBarkBattleErrorMessage(error, '启动汪汪声浪玩法失败。'),
);
return false;
}
},
[setSelectionStage],
[authUi, resolveBarkBattleErrorMessage, setSelectionStage],
);
const startSelectedPublicWork = useCallback(() => {
@@ -11454,7 +11708,9 @@ export function PlatformEntryFlowShellImpl({
'当前汪汪声浪作品信息不完整,暂时无法进入玩法。',
);
} else {
started = startBarkBattleRunFromWork(work, 'platform');
started = await startBarkBattleRunFromWork(work, 'platform', {
embedded: true,
});
}
} else if (isEdutainmentGalleryEntry(entry)) {
started = await startBabyObjectMatchRuntimeFromEntry(
@@ -11548,6 +11804,7 @@ export function PlatformEntryFlowShellImpl({
const recommendRuntimeContent = useMemo(() => {
if (
isDesktopLayout ||
selectionStage !== 'platform' ||
platformBootstrap.platformTab !== 'home' ||
!activeRecommendRuntimeKind
@@ -11954,10 +12211,12 @@ export function PlatformEntryFlowShellImpl({
visualNovelSession,
visualNovelWork,
checkpointWoodenFishRuntimeRun,
isDesktopLayout,
]);
useEffect(() => {
if (
isDesktopLayout ||
selectionStage !== 'platform' ||
platformBootstrap.platformTab !== 'home' ||
platformBootstrap.isLoadingPlatform
@@ -12013,6 +12272,7 @@ export function PlatformEntryFlowShellImpl({
match3dRun,
platformBootstrap.isLoadingPlatform,
platformBootstrap.platformTab,
isDesktopLayout,
puzzleRun,
recommendRuntimeEntries,
selectRecommendRuntimeEntry,
@@ -13119,6 +13379,7 @@ export function PlatformEntryFlowShellImpl({
deletingWorkId={deletingCreationWorkId}
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []}
jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []}
onOpenBigFishDetail={
isBigFishCreationVisible
? (item) => {
@@ -13128,6 +13389,15 @@ export function PlatformEntryFlowShellImpl({
}
: undefined
}
onOpenJumpHopDetail={
isJumpHopCreationVisible
? (item) => {
runProtectedAction(() => {
void openJumpHopDraft(item);
});
}
: undefined
}
onDeleteBigFish={
isBigFishCreationVisible
? (item) => {
@@ -13135,6 +13405,7 @@ export function PlatformEntryFlowShellImpl({
}
: null
}
onDeleteJumpHop={null}
match3dItems={match3dShelfItems}
onOpenMatch3DDetail={(item) => {
runProtectedAction(() => {
@@ -13241,6 +13512,7 @@ export function PlatformEntryFlowShellImpl({
isLoadingPlatform={platformBootstrap.isLoadingPlatform}
isLoadingDashboard={platformBootstrap.isLoadingDashboard}
hasUnreadDraftUpdate={hasUnreadDraftUpdates}
isDesktopLayout={isDesktopLayout}
isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey}
platformError={
platformBootstrap.isLoadingPlatform
@@ -15466,6 +15738,29 @@ export function PlatformEntryFlowShellImpl({
}}
/>
) : null}
<UnifiedModal
open={Boolean(draftGenerationPointNotice)}
title={draftGenerationPointNotice?.title ?? '泥点提示'}
description={draftGenerationPointNoticeDescription}
onClose={() => setDraftGenerationPointNotice(null)}
closeOnBackdrop
size="sm"
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
panelClassName="platform-remap-surface rounded-[1.75rem]"
footer={
<button
type="button"
onClick={() => setDraftGenerationPointNotice(null)}
className="platform-button platform-button--primary min-h-0 rounded-full px-4 py-2 text-sm"
>
</button>
}
>
<div className="text-sm leading-6 text-[var(--platform-text-base)]">
{draftGenerationPointNotice?.message}
</div>
</UnifiedModal>
<PublishShareModal
open={Boolean(publishSharePayload)}
payload={publishSharePayload}

View File

@@ -315,3 +315,36 @@ test('groups visible platform creation types by backend category metadata', () =
]);
expect(groups[1]?.items.map((item) => item.id)).toEqual(['visual-novel']);
});
test('falls back when backend creation type category metadata is missing', () => {
const cards = derivePlatformCreationTypes([
{
id: 'legacy-entry',
title: '历史入口',
subtitle: '旧数据缺少分类字段',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 10,
categoryId: undefined as unknown as string,
categoryLabel: undefined as unknown as string,
categorySortOrder: 0,
updatedAtMicros: 1,
},
]);
expect(cards[0]).toEqual(
expect.objectContaining({
id: 'legacy-entry',
categoryId: 'recent',
categoryLabel: '最近创作',
}),
);
expect(groupVisiblePlatformCreationTypes(cards)).toEqual([
expect.objectContaining({
id: 'recent',
label: '最近创作',
}),
]);
});

View File

@@ -55,13 +55,13 @@ export function isPlatformCreationTypeOpen(
);
}
function normalizeCategoryId(value: string) {
const normalized = value.trim();
function normalizeCategoryId(value: string | null | undefined) {
const normalized = typeof value === 'string' ? value.trim() : '';
return normalized || FALLBACK_CREATION_CATEGORY_ID;
}
function normalizeCategoryLabel(value: string) {
const normalized = value.trim();
function normalizeCategoryLabel(value: string | null | undefined) {
const normalized = typeof value === 'string' ? value.trim() : '';
return normalized || FALLBACK_CREATION_CATEGORY_LABEL;
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
export const PLATFORM_DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
export function getInitialPlatformDesktopLayout() {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(PLATFORM_DESKTOP_LAYOUT_QUERY).matches;
}
export function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(
getInitialPlatformDesktopLayout,
);
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return;
}
const mediaQuery = window.matchMedia(PLATFORM_DESKTOP_LAYOUT_QUERY);
const updateLayout = (event?: MediaQueryListEvent) => {
setIsDesktopLayout(event?.matches ?? mediaQuery.matches);
};
updateLayout();
// 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', updateLayout);
return () => mediaQuery.removeEventListener('change', updateLayout);
}
mediaQuery.addListener(updateLayout);
return () => mediaQuery.removeListener(updateLayout);
}, []);
return isDesktopLayout;
}

View File

@@ -551,9 +551,9 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: 'first-level.png',
pictureDescription: 'first-level.png',
referenceImageSrc: '/generated-puzzle-assets/reference/first-level.png',
referenceImageSrc: 'data:image/png;base64,uploaded-square',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-reference-first-level.png',
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: false,
@@ -616,22 +616,10 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
});
});
test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => {
test('puzzle workspace submits uploaded reference image as data URL when AI redraw is on', async () => {
const onCreateFromForm = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockResolvedValue({
assetObjectId: 'asset-reference-main-1',
assetKind: 'puzzle_cover_image',
objectKey: 'generated-puzzle-assets/reference/main-1.png',
imageSrc: '/generated-puzzle-assets/reference/main-1.png',
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
});
render(
<PuzzleAgentWorkspace
@@ -651,9 +639,7 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
await waitFor(() => {
expect(screen.getByAltText('拼图图片')).toBeTruthy();
});
expect(puzzleAssetClient.uploadReferenceImage).toHaveBeenCalledWith({
file: expect.any(File),
});
expect(puzzleAssetClient.uploadReferenceImage).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('画面AI重绘要求提示词'), {
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
});
@@ -663,9 +649,9 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
referenceImageSrc: null,
referenceImageSrc: 'data:image/png;base64,uploaded-square',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-reference-main-1',
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
@@ -754,12 +740,12 @@ test('puzzle workspace uploads prompt references as asset object ids', async ()
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [
'asset-reference-prompt-1',
'asset-reference-prompt-2',
referenceImageSrcs: [
'data:image/png;base64,reference-1',
'data:image/png;base64,reference-2',
],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -842,15 +828,15 @@ test('puzzle workspace uploads prompt reference images from the description box'
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [
'asset-reference-reference-1.png',
'asset-reference-reference-2.png',
'asset-reference-reference-3.png',
'asset-reference-reference-4.png',
'asset-reference-reference-5.png',
referenceImageSrcs: [
'data:image/png;base64,reference-1',
'data:image/png;base64,reference-2',
'data:image/png;base64,reference-3',
'data:image/png;base64,reference-4',
'data:image/png;base64,reference-5',
],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});

View File

@@ -16,11 +16,9 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works
import {
cropPuzzleReferenceImageDataUrl,
isPuzzleReferenceImageSquare,
puzzleReferenceImageDataUrlToFile,
readPuzzleReferenceImageAsDataUrl,
readPuzzleReferenceImageForUpload,
} from '../../services/puzzleReferenceImage';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import {
CreativeImageInputPanel,
type CreativeImageInputReferenceImage,
@@ -409,11 +407,10 @@ export function PuzzleAgentWorkspace({
return;
}
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
setFormState((current) => ({
...current,
referenceImageSrc: asset.imageSrc || uploadImage.dataUrl,
referenceImageAssetObjectId: asset.assetObjectId,
referenceImageSrc: uploadImage.dataUrl,
referenceImageAssetObjectId: '',
referenceImageLabel: file.name.trim() || '本地拼图图片',
}));
setReferenceImageError(null);
@@ -441,18 +438,12 @@ export function PuzzleAgentWorkspace({
try {
const images = await Promise.all(
files.slice(0, remainingSlots).map(async (file, index) => {
const [imageSrc, asset] = await Promise.all([
readPuzzleReferenceImageAsDataUrl(file),
puzzleAssetClient.uploadReferenceImage({ file }),
]);
return {
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
label: file.name.trim() || `参考图 ${index + 1}`,
imageSrc: asset.imageSrc || imageSrc,
assetObjectId: asset.assetObjectId,
};
}),
files.slice(0, remainingSlots).map(async (file, index) => ({
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
label: file.name.trim() || `参考图 ${index + 1}`,
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
assetObjectId: null,
})),
);
setFormState((current) => ({
...current,
@@ -515,15 +506,10 @@ export function PuzzleAgentWorkspace({
cropY: currentCropState.cropRect.y,
cropSize: currentCropState.cropRect.size,
});
const file = puzzleReferenceImageDataUrlToFile(
dataUrl,
currentCropState.fileName,
);
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
setFormState((current) => ({
...current,
referenceImageSrc: asset.imageSrc || dataUrl,
referenceImageAssetObjectId: asset.assetObjectId,
referenceImageSrc: dataUrl,
referenceImageAssetObjectId: '',
referenceImageLabel: currentCropState.label,
}));
setCropState(null);
@@ -651,6 +637,7 @@ export function PuzzleAgentWorkspace({
aiRedraw={formState.aiRedraw}
promptReferenceImages={formState.referenceImageSrcs}
promptReferenceLimit={PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT}
imageLimitHint="图片≤6MB"
imageModelPicker={
<PuzzleImageModelPicker
value={formState.imageModel}

View File

@@ -840,6 +840,7 @@ function PuzzleLevelDetailDialog({
aiRedraw={aiRedraw}
promptReferenceImages={promptReferenceImages}
promptReferenceLimit={PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT}
imageLimitHint="图片≤6MB"
imageModelPicker={
<PuzzleImageModelPicker
value={imageModel}

View File

@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
@@ -178,6 +179,35 @@ import {
type SelectionStage,
} from './RpgEntryFlowShell';
const authServiceMocks = vi.hoisted(() => ({
ensureRuntimeGuestToken: vi.fn(async () => ({
token: 'runtime-guest-token',
expiresAt: '2099-01-01T00:00:00.000Z',
})),
getPublicAuthUserByCode: vi.fn(
async (publicUserCode: string): Promise<PublicUserSummary> => ({
id: `public-user-${publicUserCode}`,
publicUserCode,
displayName: '公开作者',
avatarUrl: null,
}),
),
getPublicAuthUserById: vi.fn(
async (userId: string): Promise<PublicUserSummary> => ({
id: userId,
publicUserCode: `code-${userId}`,
displayName: '公开作者',
avatarUrl: null,
}),
),
}));
vi.mock('../../services/authService', () => ({
ensureRuntimeGuestToken: authServiceMocks.ensureRuntimeGuestToken,
getPublicAuthUserByCode: authServiceMocks.getPublicAuthUserByCode,
getPublicAuthUserById: authServiceMocks.getPublicAuthUserById,
}));
async function clickFirstButtonByName(
user: ReturnType<typeof userEvent.setup>,
name: string | RegExp,
@@ -276,6 +306,11 @@ const ISOLATED_RUNTIME_AUTH_OPTIONS = {
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
};
const RECOMMEND_RUNTIME_AUTH_OPTIONS = {
...ISOLATED_RUNTIME_AUTH_OPTIONS,
runtimeGuestToken: 'runtime-guest-token',
};
const LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS = ISOLATED_RUNTIME_AUTH_OPTIONS;
function getPlatformTabPanel(tab: string) {
const panel = document.getElementById(`platform-tab-panel-${tab}`);
@@ -1085,6 +1120,10 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
}) => (
<div className="bark-battle-config-editor-mock">
<div></div>
<label>
<input aria-label="汪汪作品标题" defaultValue="汪汪测试杯" />
</label>
<div data-testid="bark-battle-editor-back-state">
{showBackButton ? 'back-visible' : 'back-hidden'}
</div>
@@ -2235,6 +2274,10 @@ function TestWrapper({
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(authServiceMocks.ensureRuntimeGuestToken).mockResolvedValue({
token: 'runtime-guest-token',
expiresAt: '2099-01-01T00:00:00.000Z',
});
vi.mocked(
match3dGeneratedModelCache.hasMatch3DGeneratedImageAsset,
).mockImplementation((assets) =>
@@ -3581,11 +3624,20 @@ test('bark battle form checks mud points before creating image assets', async ()
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('汪汪声浪'));
const titleInput = await screen.findByLabelText('汪汪作品标题');
await user.clear(titleInput);
await user.type(titleInput, '自定义声浪杯');
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
await screen.findByText('泥点不足,本次需要 3 泥点,当前 2 泥点。'),
within(noticeDialog).getByText('本次需要 3 泥点,当前 2 泥点。'),
).toBeTruthy();
expect(screen.getByText('汪汪声浪配置表单')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect((screen.getByLabelText('汪汪作品标题') as HTMLInputElement).value).toBe(
'自定义声浪杯',
);
expect(createBarkBattleDraft).not.toHaveBeenCalled();
expect(generateAllBarkBattleImageAssets).not.toHaveBeenCalled();
});
@@ -4302,11 +4354,15 @@ test('puzzle form checks mud points before creating a draft', async () => {
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
await screen.findByText('泥点不足,本次需要 2 泥点,当前 1 泥点。'),
within(noticeDialog).getByText('本次需要 2 泥点,当前 1 泥点。'),
).toBeTruthy();
expect(screen.getByText('拼图工作区missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(executePuzzleAgentAction).not.toHaveBeenCalled();
});
@@ -4323,14 +4379,17 @@ test('match3d form checks mud points before creating a draft', async () => {
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
await screen.findByText('泥点不足,本次需要 10 泥点,当前 9 泥点。'),
within(noticeDialog).getByText('本次需要 10 泥点,当前 9 泥点。'),
).toBeTruthy();
expect(screen.getByText('抓大鹅工作区missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(match3dCreationClient.executeAction).not.toHaveBeenCalled();
});
@@ -6121,11 +6180,59 @@ test('home recommendation starts embedded puzzle without global auth reset on lo
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
});
test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => {
const publishedPuzzleWork = {
workId: 'puzzle-work-public-2',
profileId: 'puzzle-profile-public-2',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-public-2',
authorDisplayName: '拼图作者',
levelName: '星桥机关',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [publishedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: 'puzzle-profile-public-2',
levelId: null,
},
expect.objectContaining({
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
}),
);
});
expect(vi.mocked(startPuzzleRun).mock.calls[0]?.[1]).not.toHaveProperty(
'runtimeGuestToken',
);
});
test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-1',
@@ -6184,7 +6291,7 @@ test('home recommendation Match3D runtime keeps profile generated models when ca
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-card-1',
ISOLATED_RUNTIME_AUTH_OPTIONS,
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
await waitFor(() => {
@@ -6503,7 +6610,13 @@ test('home recommendation surfaces start failure instead of staying in loading s
expect(
await screen.findByText('作品暂时无法进入,请稍后再试。'),
).toBeTruthy();
expect(screen.queryByText('加载中...')).toBeNull();
await waitFor(() => {
expect(
within(getPlatformTabPanel('home'))
.queryByText('加载中...')
?.closest('.platform-recommend-runtime-panel'),
).toBeFalsy();
});
});
test('published big fish works stay hidden from platform home and game category channel', async () => {
@@ -7135,7 +7248,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
vi.mocked(listProfileSaveArchives).mockClear();
vi.mocked(listProfileSaveArchives).mockRejectedValueOnce(
@@ -7159,7 +7271,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
elapsedMs: 18_000,
nickname: '测试玩家',
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
@@ -7180,7 +7291,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedFirstLevel.runId,
{},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -7343,7 +7453,6 @@ test('formal puzzle similar work keeps current run level progression', async ()
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedThirdLevel.runId,
{ targetProfileId: 'puzzle-profile-similar-2' },
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(startPuzzleRun).not.toHaveBeenCalled();
@@ -7527,7 +7636,6 @@ test('recommend puzzle remix return restarts recommendation instead of stale loa
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(screen.queryByText('正在进入拼图关卡')).toBeNull();

View File

@@ -804,6 +804,7 @@ function renderLoggedOutHomeView(
>
> = {},
activeTab: RpgEntryHomeViewProps['activeTab'] = 'home',
isDesktopLayout = false,
) {
return render(
<AuthUiContext.Provider
@@ -827,6 +828,7 @@ function renderLoggedOutHomeView(
>
<RpgEntryHomeView
activeTab={activeTab}
isDesktopLayout={isDesktopLayout}
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
@@ -954,6 +956,7 @@ function renderStatefulLoggedOutHomeView(
| 'onSelectPreviousRecommendEntry'
>
> = {},
isDesktopLayout = false,
) {
const authSpies = {
openLoginModal: vi.fn(),
@@ -985,6 +988,7 @@ function renderStatefulLoggedOutHomeView(
>
<RpgEntryHomeView
activeTab={activeTab}
isDesktopLayout={isDesktopLayout}
onTabChange={setActiveTab}
hasSavedGame={false}
savedSnapshot={null}
@@ -2803,7 +2807,7 @@ test('logged out mobile shell defaults to discover tab', () => {
).toBeNull();
});
test('logged out recommend tab opens recommend runtime directly', async () => {
test('logged out recommend tab opens embedded runtime without login modal', async () => {
const user = userEvent.setup();
const { container, openLoginModal } = renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],
@@ -2818,10 +2822,8 @@ test('logged out recommend tab opens recommend runtime directly', async () => {
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
);
expect(openLoginModal).toHaveBeenCalledTimes(1);
expect(
container.querySelector('.platform-recommend-cover-only'),
).toBeNull();
expect(openLoginModal).not.toHaveBeenCalled();
expect(container.querySelector('.platform-recommend-cover-only')).toBeNull();
expect(container.querySelector('.platform-mobile-topbar')).toBeNull();
expect(
container.querySelector('.platform-mobile-entry-shell--recommend'),
@@ -2831,7 +2833,7 @@ test('logged out recommend tab opens recommend runtime directly', async () => {
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
});
test('logged out recommend meta keeps gallery detail gated', async () => {
test('logged out recommend runtime keeps detail callback idle', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();
const { openLoginModal } = renderStatefulLoggedOutHomeView({
@@ -2847,9 +2849,9 @@ test('logged out recommend meta keeps gallery detail gated', async () => {
await user.click(
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
);
await user.click(screen.getByLabelText('奇幻拼图 作品信息'));
expect(openLoginModal).toHaveBeenCalledTimes(1);
expect(openLoginModal).not.toHaveBeenCalled();
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
@@ -2858,16 +2860,15 @@ test('logged out desktop recommend page renders runtime directly', () => {
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
});
}, 'home', true);
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
expect(screen.queryByText('今日游戏')).toBeNull();
expect(screen.queryByText('作品分类')).toBeNull();
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
expect(screen.getByText('今日游戏')).toBeTruthy();
expect(screen.getByText('作品分类')).toBeTruthy();
});
test('logged out recommend page can enter runtime without login gate', () => {
mockDesktopLayout();
const openLoginModal = vi.fn();
const onOpenGalleryDetail = vi.fn();
renderLoggedOutHomeView(openLoginModal, {
@@ -2883,6 +2884,35 @@ test('logged out recommend page can enter runtime without login gate', () => {
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
test('logged out desktop recommend rail enters runtime without login modal', async () => {
mockDesktopLayout();
const user = userEvent.setup();
const openLoginModal = vi.fn();
const { container } = renderLoggedOutHomeView(
openLoginModal,
{
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
},
'category',
true,
);
const desktopRail = container.querySelector('.platform-desktop-rail');
if (!desktopRail) {
throw new Error('缺少桌面侧边栏');
}
await user.click(
within(desktopRail as HTMLElement).getByRole('button', { name: '推荐' }),
);
expect(openLoginModal).not.toHaveBeenCalled();
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
expect(container.querySelector('.platform-desktop-shell')).toBeTruthy();
});
test('logged in recommend page uses gated recommend detail callback', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();

View File

@@ -131,6 +131,7 @@ import {
findPublicWorkForHistoryEntry,
isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
@@ -165,6 +166,7 @@ export type PlatformHomeTab =
| 'profile';
export interface RpgEntryHomeViewProps {
activeTab: PlatformHomeTab;
isDesktopLayout?: boolean;
onTabChange: (tab: PlatformHomeTab) => void;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
@@ -234,7 +236,6 @@ const DESKTOP_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4';
const DESKTOP_DISCOVER_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-5 pb-4';
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'home',
'category',
@@ -416,46 +417,6 @@ const PLATFORM_RANKING_TABS: Array<{
emptyText: '公开广场暂时还没有点赞作品。',
},
];
function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(DESKTOP_LAYOUT_QUERY).matches;
});
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return;
}
const mediaQuery = window.matchMedia(DESKTOP_LAYOUT_QUERY);
const updateLayout = (event?: MediaQueryListEvent) => {
setIsDesktopLayout(event?.matches ?? mediaQuery.matches);
};
updateLayout();
// 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', updateLayout);
return () => mediaQuery.removeEventListener('change', updateLayout);
}
mediaQuery.addListener(updateLayout);
return () => mediaQuery.removeListener(updateLayout);
}, []);
return isDesktopLayout;
}
function ResolvedAssetBackdrop({
src,
fallbackSrc,
@@ -3978,6 +3939,7 @@ function ProfilePlayedWorksModal({
export function RpgEntryHomeView({
activeTab,
isDesktopLayout: isDesktopLayoutProp,
onTabChange,
saveEntries,
saveError,
@@ -4137,7 +4099,8 @@ export function RpgEntryHomeView({
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const isAuthenticated = Boolean(authUi?.user);
const edutainmentEntryEnabled = isEdutainmentEntryEnabled();
const isDesktopLayout = usePlatformDesktopLayout();
const [fallbackDesktopLayout] = useState(getInitialPlatformDesktopLayout);
const isDesktopLayout = isDesktopLayoutProp ?? fallbackDesktopLayout;
const openRecommendGalleryDetail =
onOpenRecommendGalleryDetail ?? onOpenGalleryDetail;
const generalFeaturedEntries = useMemo(
@@ -5494,7 +5457,7 @@ export function RpgEntryHomeView({
{recommendRuntimeError}
</button>
</section>
) : isStartingRecommendEntry || !recommendRuntimeContent ? (
) : isStartingRecommendEntry ? (
<section className="platform-recommend-runtime-panel">
<div className="platform-recommend-runtime-state">...</div>
</section>
@@ -6670,10 +6633,7 @@ export function RpgEntryHomeView({
);
const tabContentById = {
home:
!isAuthenticated || !isDesktopLayout
? mobileRecommendContent
: desktopHomeContent,
home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent,
category: categoryContent,
create: createContent,
saves: savesContent,
@@ -6885,12 +6845,6 @@ export function RpgEntryHomeView({
return;
}
if (!isAuthenticated && tab === 'home') {
onTabChange(tab);
authUi?.openLoginModal();
return;
}
onTabChange(tab);
}}
/>
@@ -7049,12 +7003,6 @@ export function RpgEntryHomeView({
emphasized={tab === 'create'}
showDot={tab === 'saves' && hasUnreadDraftUpdate}
onClick={() => {
if (!isAuthenticated && tab === 'home') {
onTabChange(tab);
authUi?.openLoginModal();
return;
}
onTabChange(tab);
}}
/>

View File

@@ -351,7 +351,7 @@ export function useRpgEntryBootstrap(
!hasInitialAgentSession &&
!hasExplicitPlatformTabSelectionRef.current
) {
// 中文注释:新用户先进入发现页;推荐页只在用户主动点击后作为登录门禁入口
// 中文注释:新用户先进入发现页;推荐页可直接进入,真正受保护的动作再单独做登录门禁。
setPlatformTabState(isAuthenticated ? 'home' : 'category');
}
} finally {

View File

@@ -25,6 +25,7 @@ import type {
AuthWechatStartResponse,
LogoutResponse,
PublicUserSearchResponse,
RuntimeGuestTokenResponse,
} from '../../packages/shared/src/contracts/auth';
import type { RedeemProfileReferralInviteCodeResponse } from '../../packages/shared/src/contracts/runtime';
import {
@@ -61,6 +62,42 @@ const PUBLIC_AUTH_REQUEST_OPTIONS = {
skipRefresh: true,
} satisfies ApiRequestOptions;
const runtimeGuestTokenCache: {
value: RuntimeGuestTokenResponse | null;
} = {
value: null,
};
function isRuntimeGuestTokenFresh(response: RuntimeGuestTokenResponse | null) {
if (!response?.expiresAt) {
return false;
}
const expiresAtMs = Date.parse(response.expiresAt);
return Number.isFinite(expiresAtMs) && expiresAtMs - Date.now() > 15_000;
}
export function clearRuntimeGuestTokenCache() {
runtimeGuestTokenCache.value = null;
}
export async function ensureRuntimeGuestToken() {
if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) {
return runtimeGuestTokenCache.value!;
}
const response = await requestJson<RuntimeGuestTokenResponse>(
'/api/auth/runtime-guest-token',
{
method: 'POST',
},
'获取匿名运行态身份失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
runtimeGuestTokenCache.value = response;
return response;
}
const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone';
export function normalizePhoneInput(phoneInput: string) {

View File

@@ -6,10 +6,14 @@ import type {
BarkBattleRuntimeConfig,
} from '../../../packages/shared/src/contracts/barkBattle';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const BARK_BATTLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
@@ -24,28 +28,20 @@ const BARK_BATTLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
retryUnsafeMethods: true,
};
export type BarkBattleRuntimeRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
export type BarkBattleRuntimeRequestOptions = RuntimeGuestRequestOptions;
export function getBarkBattleRuntimeConfig(
workId: string,
options: BarkBattleRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<BarkBattleRuntimeConfig>(
`/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/config`,
{ method: 'GET' },
{ method: 'GET', headers: buildRuntimeGuestHeaders(options) },
'读取汪汪声浪大作战配置失败',
{
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
...requestOptions,
},
);
}
@@ -55,11 +51,12 @@ export function startBarkBattleRun(
payload: Partial<BarkBattleRunStartRequest> = {},
options: BarkBattleRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<BarkBattleRunStartResponse>(
`/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/runs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json' }),
body: JSON.stringify({
...payload,
workId: payload.workId ?? workId,
@@ -68,10 +65,7 @@ export function startBarkBattleRun(
'启动汪汪声浪大作战正式局失败',
{
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
...requestOptions,
},
);
}
@@ -80,16 +74,14 @@ export function getBarkBattleRun(
runId: string,
options: BarkBattleRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<unknown>(
`/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}`,
{ method: 'GET' },
{ method: 'GET', headers: buildRuntimeGuestHeaders(options) },
'读取汪汪声浪大作战单局失败',
{
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
...requestOptions,
},
);
}
@@ -99,11 +91,12 @@ export function finishBarkBattleRun(
payload: BarkBattleRunFinishRequest,
options: BarkBattleRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<BarkBattleFinishResponse>(
`/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}/finish`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json' }),
body: JSON.stringify({
...payload,
runId: payload.runId ?? runId,
@@ -112,10 +105,7 @@ export function finishBarkBattleRun(
'提交汪汪声浪大作战成绩失败',
{
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
...requestOptions,
},
);
}

View File

@@ -5,10 +5,14 @@ import type {
} from '../../../packages/shared/src/contracts/bigFish';
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
@@ -16,13 +20,7 @@ const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxDelayMs: 360,
retryUnsafeMethods: true,
};
type BigFishRuntimeRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
type BigFishRuntimeRequestOptions = RuntimeGuestRequestOptions;
/**
* 上报大鱼吃小鱼正式游玩。elapsedMs 为 0 时仅标记玩过作品。
@@ -32,20 +30,20 @@ export function recordBigFishPlay(
payload: RecordBigFishPlayRequest,
options: BigFishRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<BigFishWorksResponse>(
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify(payload),
},
'记录大鱼吃小鱼游玩失败',
{
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
...requestOptions,
},
);
}
@@ -54,18 +52,17 @@ export function startBigFishRun(
sessionId: string,
options: BigFishRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<BigFishRunResponse>(
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`,
{
method: 'POST',
headers: buildRuntimeGuestHeaders(options),
},
'启动大鱼吃小鱼玩法失败',
{
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
...requestOptions,
},
);
}
@@ -83,17 +80,22 @@ export function getBigFishRun(runId: string) {
export function submitBigFishInput(
runId: string,
payload: SubmitBigFishInputRequest,
options: BigFishRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<BigFishRunResponse>(
`/api/runtime/big-fish/runs/${encodeURIComponent(runId)}/input`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify(payload),
},
'同步大鱼吃小鱼输入失败',
{
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
...requestOptions,
},
);
}

View File

@@ -12,15 +12,20 @@ import type {
JumpHopWorkDetailResponse,
JumpHopWorkMutationResponse,
JumpHopWorkProfileResponse,
JumpHopWorksResponse,
JumpHopWorkspaceCreateRequest,
JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
import { createCreationAgentClient } from '../creation-agent';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions';
const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works';
@@ -30,14 +35,7 @@ const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = {
baseDelayMs: 120,
maxDelayMs: 360,
};
type JumpHopRuntimeRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipAuth'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
type JumpHopRuntimeRequestOptions = RuntimeGuestRequestOptions;
export type {
JumpHopActionRequest,
@@ -53,6 +51,7 @@ export type {
JumpHopWorkDetailResponse,
JumpHopWorkMutationResponse,
JumpHopWorkProfileResponse,
JumpHopWorksResponse,
JumpHopWorkspaceCreateRequest,
};
export type CreateJumpHopSessionRequest = {
@@ -211,6 +210,17 @@ export async function getJumpHopGalleryDetail(publicWorkCode: string) {
return normalizeJumpHopWorkDetailResponse(response);
}
export async function listJumpHopWorks() {
return requestJson<JumpHopWorksResponse>(
JUMP_HOP_WORKS_API_BASE,
{ method: 'GET' },
'读取跳一跳作品列表失败',
{
retry: JUMP_HOP_RUNTIME_READ_RETRY,
},
);
}
export async function publishJumpHopWork(profileId: string) {
const response = await requestJson<JumpHopWorkMutationResponse>(
`${JUMP_HOP_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
@@ -224,22 +234,20 @@ export async function startJumpHopRuntimeRun(
profileId: string,
options: JumpHopRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<JumpHopRunResponse>(
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify({ profileId }),
},
'启动跳一跳运行态失败',
{
authImpact: options.authImpact,
skipAuth: options.skipAuth,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
...requestOptions,
},
);
}
@@ -247,7 +255,9 @@ export async function startJumpHopRuntimeRun(
export async function submitJumpHopJump(
runId: string,
payload: { chargeMs: number },
options: JumpHopRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const requestPayload = {
chargeMs: payload.chargeMs,
clientEventId: `jump-${runId}-${Date.now()}`,
@@ -259,26 +269,34 @@ export async function submitJumpHopJump(
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify(requestPayload),
},
'提交跳一跳起跳失败',
requestOptions,
);
}
export async function restartJumpHopRuntimeRun(runId: string) {
export async function restartJumpHopRuntimeRun(
runId: string,
options: JumpHopRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<JumpHopRunResponse>(
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/restart`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify({
clientActionId: `restart-${runId}-${Date.now()}`,
}),
},
'重新开始跳一跳失败',
requestOptions,
);
}
@@ -289,6 +307,7 @@ export const jumpHopClient = {
getGalleryDetail: getJumpHopGalleryDetail,
getWorkDetail: getJumpHopWorkDetail,
listGallery: listJumpHopGallery,
listWorks: listJumpHopWorks,
publishWork: publishJumpHopWork,
restartRun: restartJumpHopRuntimeRun,
startRun: startJumpHopRuntimeRun,

View File

@@ -9,10 +9,14 @@ import type {
StopMatch3DRunRequest,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
@@ -25,13 +29,7 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxDelayMs: 360,
retryUnsafeMethods: true,
};
export type Match3DRuntimeRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
> & {
export type Match3DRuntimeRequestOptions = RuntimeGuestRequestOptions & {
itemTypeCountOverride?: number | null;
};
@@ -76,6 +74,7 @@ export function startMatch3DRun(
profileId: string,
options: Match3DRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const payload: StartMatch3DRunRequest = {
profileId,
itemTypeCountOverride: options.itemTypeCountOverride ?? null,
@@ -85,16 +84,15 @@ export function startMatch3DRun(
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify(payload),
},
'启动抓大鹅玩法失败',
{
retry: MATCH3D_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
...requestOptions,
},
);
}

View File

@@ -9,10 +9,14 @@ import type {
UsePuzzleRuntimePropRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs';
const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
@@ -26,13 +30,7 @@ const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxDelayMs: 360,
retryUnsafeMethods: true,
};
type PuzzleRuntimeRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
type PuzzleRuntimeRequestOptions = RuntimeGuestRequestOptions;
/**
* 从某个已发布拼图作品开始一次 run。
@@ -41,20 +39,20 @@ export async function startPuzzleRun(
payload: StartPuzzleRunRequest,
options: PuzzleRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<PuzzleRunResponse>(
PUZZLE_RUNTIME_API_BASE,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: buildRuntimeGuestHeaders(options, {
'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,
...requestOptions,
},
);
}
@@ -125,6 +123,7 @@ export async function advancePuzzleNextLevel(
payload: AdvancePuzzleNextLevelRequest = {},
options: PuzzleRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const targetProfileId = payload.targetProfileId?.trim() ?? '';
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
@@ -132,18 +131,19 @@ export async function advancePuzzleNextLevel(
method: 'POST',
...(targetProfileId
? {
headers: { 'Content-Type': 'application/json' },
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify({ targetProfileId }),
}
: {}),
: {
headers: buildRuntimeGuestHeaders(options),
}),
},
'进入下一关失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
...requestOptions,
},
);
}

View File

@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { describe, expect, test } from 'vitest';
import {
PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
validatePuzzleReferenceImageFile,
} from './puzzleAssetClient';
describe('puzzle reference image upload validation', () => {
test('limits uploads to 6MB', () => {
expect(PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES).toBe(6 * 1024 * 1024);
});
test('rejects files that exceed the upload limit with a precise message', () => {
const file = new File([
'x'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES + 1),
], 'too-large.png', { type: 'image/png' });
expect(() => validatePuzzleReferenceImageFile(file)).toThrow(
'参考图过大,请压缩后再上传(当前 6.0MB,最多 6MB。',
);
});
});

View File

@@ -1,5 +1,9 @@
import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
import { requestJson } from '../apiClient';
import {
PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
validatePuzzleReferenceImageFile,
} from '../puzzleReferenceImage';
export type PuzzleHistoryAsset = {
assetObjectId: string;
@@ -40,8 +44,6 @@ type ConfirmAssetObjectResponse = {
};
};
const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
const MIME_BY_EXTENSION: Record<string, string> = {
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
@@ -58,14 +60,9 @@ function resolvePuzzleImageContentType(file: File) {
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
}
function validatePuzzleReferenceImageFile(file: File) {
function validatePuzzleReferenceImageUploadFile(file: File) {
const contentType = resolvePuzzleImageContentType(file);
if (file.size <= 0) {
throw new Error('参考图文件为空,请重新选择。');
}
if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) {
throw new Error('参考图过大,请压缩后再上传。');
}
validatePuzzleReferenceImageFile(file);
if (!contentType.startsWith('image/')) {
throw new Error('参考图必须是图片文件。');
}
@@ -96,7 +93,7 @@ async function postDirectUploadFile(
export async function uploadPuzzleReferenceImage(payload: {
file: File;
}): Promise<PuzzleReferenceAsset> {
validatePuzzleReferenceImageFile(payload.file);
validatePuzzleReferenceImageUploadFile(payload.file);
const contentType = resolvePuzzleImageContentType(payload.file);
const uploadedAt = Date.now();
const ticket = await requestJson<DirectUploadTicketResponse>(
@@ -157,7 +154,12 @@ export async function uploadPuzzleReferenceImage(payload: {
export const puzzleReferenceAssetTestUtils = {
maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
validateFile: validatePuzzleReferenceImageFile,
validateFile: validatePuzzleReferenceImageUploadFile,
};
export {
PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
validatePuzzleReferenceImageUploadFile as validatePuzzleReferenceImageFile,
};
/**

View File

@@ -92,7 +92,7 @@ describe('readPuzzleReferenceImageAsDataUrl', () => {
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
expect(dataUrl).toBe(`data:image/jpeg;base64,${'C'.repeat(1000)}`);
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1536, 1152);
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1024, 768);
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.84);
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.76);
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.68);
@@ -114,7 +114,7 @@ describe('readPuzzleReferenceImageAsDataUrl', () => {
});
await expect(readPuzzleReferenceImageAsDataUrl(file)).rejects.toThrow(
'参考图过大,请换一张尺寸更小的图片。',
'参考图过大,请压缩后再上传(当前 10.0MB,最多 6MB。',
);
});
});

View File

@@ -1,8 +1,29 @@
const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1024;
const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024;
export const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 6 * 1024 * 1024;
export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
const PUZZLE_REFERENCE_IMAGE_SQUARE_TOLERANCE = 1;
export function formatPuzzleReferenceImageUploadBytes(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
export function buildPuzzleReferenceImageTooLargeMessage(actualBytes: number) {
return `参考图过大,请压缩后再上传(当前 ${formatPuzzleReferenceImageUploadBytes(actualBytes)},最多 6MB`;
}
export function validatePuzzleReferenceImageFile(file: File) {
if (file.size <= 0) {
throw new Error('参考图文件为空,请重新选择。');
}
if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) {
throw new Error(buildPuzzleReferenceImageTooLargeMessage(file.size));
}
if (file.type.trim() && !file.type.trim().startsWith('image/')) {
throw new Error('参考图必须是图片文件。');
}
}
type PuzzleReferenceImageSize = {
width: number;
height: number;
@@ -36,7 +57,7 @@ function readFileAsDataUrl(file: File) {
function ensureReferenceImageWithinLimit(dataUrl: string) {
if (dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
throw new Error('参考图过大,请换一张尺寸更小的图片。');
throw new Error(buildPuzzleReferenceImageTooLargeMessage(dataUrl.length));
}
return dataUrl;
}
@@ -130,6 +151,7 @@ async function compressReferenceImageDataUrl(file: File, dataUrl: string) {
}
export async function readPuzzleReferenceImageAsDataUrl(file: File) {
validatePuzzleReferenceImageFile(file);
const dataUrl = await readFileAsDataUrl(file);
try {
const compressedDataUrl = await compressReferenceImageDataUrl(
@@ -150,6 +172,7 @@ export async function readPuzzleReferenceImageAsDataUrl(file: File) {
export async function readPuzzleReferenceImageForUpload(
file: File,
): Promise<PuzzleReferenceImageReadResult> {
validatePuzzleReferenceImageFile(file);
const dataUrl = await readFileAsDataUrl(file);
const image = await loadReferenceImage(dataUrl);
const size = resolveReferenceImageNaturalSize(image);

View File

@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const apiClientMocks = vi.hoisted(() => ({
requestJson: vi.fn(),
}));
vi.mock('./apiClient', async () => {
const actual =
await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
requestJson: apiClientMocks.requestJson,
};
});
import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient';
import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient';
import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient';
import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient';
import { startPuzzleRun } from './puzzle-runtime/puzzleRuntimeClient';
import { startSquareHoleRun } from './square-hole-runtime/squareHoleRuntimeClient';
import { startVisualNovelRun } from './visual-novel-runtime/visualNovelRuntimeClient';
describe('recommended runtime guest launch clients', () => {
beforeEach(() => {
vi.clearAllMocks();
apiClientMocks.requestJson.mockResolvedValue({ run: {} });
});
it.each([
{
name: 'jump-hop',
start: () =>
startJumpHopRuntimeRun('jump-hop-profile-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/jump-hop/runs',
},
{
name: 'visual-novel',
start: () =>
startVisualNovelRun(
'visual-novel-profile-1',
{ profileId: 'visual-novel-profile-1', mode: 'play' },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/visual-novel/works/visual-novel-profile-1/runs',
},
{
name: 'match3d',
start: () =>
startMatch3DRun('match3d-profile-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/match3d/works/match3d-profile-1/runs',
},
{
name: 'square-hole',
start: () =>
startSquareHoleRun('square-hole-profile-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/square-hole/works/square-hole-profile-1/runs',
},
{
name: 'big-fish',
start: () =>
startBigFishRun('big-fish-session-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/big-fish/sessions/big-fish-session-1/runs',
},
{
name: 'bark-battle',
start: () =>
startBarkBattleRun('bark-battle-work-1', {}, {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/bark-battle/works/bark-battle-work-1/runs',
},
{
name: 'puzzle',
start: () =>
startPuzzleRun(
{ profileId: 'puzzle-profile-1', levelId: 'level-1' },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/puzzle/runs',
},
])(
'$name start request uses the runtime guest bearer token without touching login auth',
async ({ start, expectedUrl }) => {
await start();
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
expect(url).toBe(expectedUrl);
expect(init).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer runtime-guest-token',
}),
}),
);
expect(options).toEqual(
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
},
);
});

View File

@@ -0,0 +1,40 @@
import type { ApiRequestOptions } from './apiClient';
export type RuntimeGuestRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipAuth'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
> & {
runtimeGuestToken?: string;
};
export function buildRuntimeGuestHeaders(
options: Pick<RuntimeGuestRequestOptions, 'runtimeGuestToken'>,
headers: Record<string, string> = {},
) {
const runtimeGuestToken = options.runtimeGuestToken?.trim();
if (!runtimeGuestToken) {
return headers;
}
return {
...headers,
Authorization: `Bearer ${runtimeGuestToken}`,
};
}
export function buildRuntimeGuestAuthOptions<
TOptions extends RuntimeGuestRequestOptions,
>(options: TOptions) {
const runtimeGuestToken = options.runtimeGuestToken?.trim();
return {
authImpact: options.authImpact,
skipAuth: runtimeGuestToken ? true : options.skipAuth,
skipRefresh: runtimeGuestToken ? true : options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
} satisfies ApiRequestOptions;
}

View File

@@ -5,10 +5,14 @@ import type {
StopSquareHoleRunRequest,
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const SQUARE_HOLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
@@ -21,13 +25,7 @@ const SQUARE_HOLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxDelayMs: 360,
retryUnsafeMethods: true,
};
type SquareHoleRuntimeRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
type SquareHoleRuntimeRequestOptions = RuntimeGuestRequestOptions;
/**
* 基于作品启动一局方洞挑战正式 run。
@@ -36,20 +34,20 @@ export function startSquareHoleRun(
profileId: string,
options: SquareHoleRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<SquareHoleRunResponse>(
`/api/runtime/square-hole/works/${encodeURIComponent(profileId)}/runs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify({ profileId }),
},
'启动方洞挑战失败',
{
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
...requestOptions,
},
);
}

View File

@@ -19,12 +19,16 @@ import type {
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
import type { TextStreamOptions } from '../aiTypes';
import {
type ApiRequestOptions,
type ApiRetryOptions,
fetchWithApiAuth,
requestJson,
} from '../apiClient';
import { readVisualNovelRuntimeRunFromSse } from './visualNovelRuntimeSse';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const VISUAL_NOVEL_RUNTIME_API_BASE = '/api/runtime/visual-novel';
const VISUAL_NOVEL_RUNTIME_READ_RETRY: ApiRetryOptions = {
@@ -39,16 +43,11 @@ const VISUAL_NOVEL_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
retryUnsafeMethods: true,
};
export type VisualNovelRuntimeStreamOptions = TextStreamOptions & {
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
};
type VisualNovelRuntimeRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
export type VisualNovelRuntimeStreamOptions = TextStreamOptions &
RuntimeGuestRequestOptions & {
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
};
type VisualNovelRuntimeRequestOptions = RuntimeGuestRequestOptions;
export type VisualNovelSaveArchiveResumeResponse =
ProfileSaveArchiveResumeResponse<
@@ -84,11 +83,20 @@ async function openVisualNovelRuntimeSsePost(
payload: unknown,
fallbackMessage: string,
signal?: AbortSignal,
options: RuntimeGuestRequestOptions = {},
) {
const response = await fetchWithApiAuth(url, {
...buildJsonInit('POST', payload),
signal,
});
const requestOptions = buildRuntimeGuestAuthOptions(options);
const response = await fetchWithApiAuth(
url,
{
...buildJsonInit('POST', payload),
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
signal,
},
requestOptions,
);
if (!response.ok) {
const responseText = await response.text();
@@ -107,17 +115,20 @@ export async function startVisualNovelRun(
payload: VisualNovelStartRunRequest,
options: VisualNovelRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<VisualNovelRunResponse>(
`${VISUAL_NOVEL_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/runs`,
buildJsonInit('POST', payload),
{
...buildJsonInit('POST', payload),
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
},
'启动视觉小说运行失败',
{
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
timeoutMs: 15000,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
...requestOptions,
},
);
}
@@ -154,6 +165,7 @@ export async function streamVisualNovelRuntimeAction(
payload,
'推进视觉小说失败',
options.signal,
options,
);
return readVisualNovelRuntimeRunFromSse(response, {

View File

@@ -18,6 +18,11 @@ import type {
} from '../../../packages/shared/src/contracts/woodenFish';
import { type ApiRetryOptions, requestJson } from '../apiClient';
import { createCreationAgentClient } from '../creation-agent';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const WOODEN_FISH_API_BASE = '/api/creation/wooden-fish/sessions';
const WOODEN_FISH_WORKS_API_BASE = '/api/creation/wooden-fish/works';
@@ -29,6 +34,13 @@ const WOODEN_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = {
baseDelayMs: 120,
maxDelayMs: 360,
};
const WOODEN_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
type WoodenFishRuntimeRequestOptions = RuntimeGuestRequestOptions;
export type {
WoodenFishActionRequest,
@@ -210,24 +222,35 @@ export async function publishWoodenFishWork(profileId: string) {
return normalizeWoodenFishWorkMutationResponse(response);
}
export async function startWoodenFishRuntimeRun(profileId: string) {
export async function startWoodenFishRuntimeRun(
profileId: string,
options: WoodenFishRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<WoodenFishRunResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/runs`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify({ profileId }),
},
'启动敲木鱼运行态失败',
{
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
...requestOptions,
},
);
}
export async function checkpointWoodenFishRun(
runId: string,
payload: Omit<WoodenFishCheckpointRunRequest, 'clientEventId'>,
options: WoodenFishRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const requestPayload: WoodenFishCheckpointRunRequest = {
...payload,
clientEventId: `checkpoint-${runId}-${Date.now()}`,
@@ -239,17 +262,24 @@ export async function checkpointWoodenFishRun(
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify(requestPayload),
},
'保存敲木鱼进度失败',
{
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
...requestOptions,
},
);
}
export async function finishWoodenFishRun(
runId: string,
payload: Omit<WoodenFishFinishRunRequest, 'clientEventId'>,
options: WoodenFishRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const requestPayload: WoodenFishFinishRunRequest = {
...payload,
clientEventId: `finish-${runId}-${Date.now()}`,
@@ -261,10 +291,15 @@ export async function finishWoodenFishRun(
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify(requestPayload),
},
'结束敲木鱼运行失败',
{
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
...requestOptions,
},
);
}