Merge remote-tracking branch 'origin/master' into codex/wooden-fish-template

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	.hermes/shared-memory/pitfalls.md
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
2026-05-26 02:00:11 +08:00
140 changed files with 12176 additions and 3526 deletions

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

@@ -9,6 +9,9 @@ export type CreationEntryTypeConfig = {
visible: boolean;
open: boolean;
sortOrder: number;
categoryId: string;
categoryLabel: string;
categorySortOrder: number;
updatedAtMicros: number;
};
@@ -23,6 +26,14 @@ export type CreationEntryConfig = {
title: string;
description: string;
};
eventBanner: {
title: string;
description: string;
coverImageSrc: string;
prizePoolMudPoints: number;
startsAtText: string;
endsAtText: string;
};
creationTypes: CreationEntryTypeConfig[];
};

View File

@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import {
clearCreationUrlState,
readCreationUrlState,
writeCreationUrlState,
} from './creationUrlState';
describe('creationUrlState', () => {
it('writes and reads restore state on creation restore paths', () => {
const replaceState = vi.fn();
const env = {
location: {
pathname: '/creation/puzzle/result',
search: '?clientRuntime=wechat_mini_program',
},
history: { replaceState },
};
writeCreationUrlState(
{
sessionId: ' session-1 ',
profileId: 'profile-1',
draftId: 'draft-1',
workId: 'work-1',
},
env,
);
expect(replaceState).toHaveBeenCalledWith(
null,
'',
'/creation/puzzle/result?clientRuntime=wechat_mini_program&sessionId=session-1&profileId=profile-1&draftId=draft-1&workId=work-1',
);
expect(
readCreationUrlState({
location: {
pathname: '/creation/puzzle/result',
search:
'?sessionId=session-1&profileId=profile-1&draftId=draft-1&workId=work-1',
},
}),
).toEqual({
sessionId: 'session-1',
profileId: 'profile-1',
draftId: 'draft-1',
workId: 'work-1',
});
});
it('ignores writes and clears outside creation restore paths', () => {
const replaceState = vi.fn();
const env = {
location: {
pathname: '/works/detail',
search: '?work=PZ-123&sessionId=session-1',
},
history: { replaceState },
};
writeCreationUrlState({ sessionId: 'session-2' }, env);
clearCreationUrlState(env);
expect(replaceState).not.toHaveBeenCalled();
});
it('clears only private restore params on creation restore paths', () => {
const replaceState = vi.fn();
const env = {
location: {
pathname: '/creation/bark-battle/result',
search: '?draftId=draft-1&workId=work-1&clientRuntime=wechat',
},
history: { replaceState },
};
clearCreationUrlState(env);
expect(replaceState).toHaveBeenCalledWith(
null,
'',
'/creation/bark-battle/result?clientRuntime=wechat',
);
});
});

View File

@@ -0,0 +1,220 @@
import {
CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY,
CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY,
CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY,
} from './customWorldAgentUiState';
export const CREATION_URL_SESSION_QUERY_KEY = 'sessionId';
export const CREATION_URL_PROFILE_QUERY_KEY = 'profileId';
export const CREATION_URL_DRAFT_QUERY_KEY = 'draftId';
export const CREATION_URL_WORK_QUERY_KEY = 'workId';
export const CREATION_URL_RESTORE_QUERY_KEYS = [
CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY,
CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY,
CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY,
CREATION_URL_SESSION_QUERY_KEY,
CREATION_URL_PROFILE_QUERY_KEY,
CREATION_URL_DRAFT_QUERY_KEY,
CREATION_URL_WORK_QUERY_KEY,
] as const;
export type CreationUrlState = {
sessionId?: string | null;
profileId?: string | null;
draftId?: string | null;
workId?: string | null;
};
type CreationUrlEnvironment = {
location?: {
pathname: string;
search: string;
} | null;
history?: {
replaceState: (
data: unknown,
unused: string,
url?: string | URL | null,
) => void;
} | null;
};
const CREATION_PATH_PREFIXES = [
'/creation/rpg',
'/creation/big-fish',
'/creation/match3d',
'/creation/square-hole',
'/creation/jump-hop',
'/creation/wooden-fish',
'/creation/bark-battle',
'/creation/visual-novel',
'/creation/baby-object-match',
'/creation/puzzle',
] as const;
function resolveEnvironment(
env?: CreationUrlEnvironment,
): Required<CreationUrlEnvironment> {
if (env) {
return {
location: env.location ?? null,
history: env.history ?? null,
};
}
if (typeof window === 'undefined') {
return {
location: null,
history: null,
};
}
return {
location: window.location,
history: window.history,
};
}
function normalizeValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function normalizePathname(value: string | undefined) {
const pathname = value?.trim().toLowerCase() ?? '';
if (!pathname || pathname === '/') {
return '/';
}
return pathname.replace(/\/+$/u, '');
}
export function isCreationRestorePath(pathname: string | undefined) {
const normalizedPathname = normalizePathname(pathname);
return CREATION_PATH_PREFIXES.some(
(pathPrefix) =>
normalizedPathname === pathPrefix ||
normalizedPathname.startsWith(`${pathPrefix}/`),
);
}
export function isSameCreationFlowPath(
currentPathname: string | undefined,
nextPathname: string | undefined,
) {
const normalizedCurrentPath = normalizePathname(currentPathname);
const normalizedNextPath = normalizePathname(nextPathname);
if (
!normalizedCurrentPath ||
!normalizedNextPath ||
normalizedCurrentPath === '/' ||
normalizedNextPath === '/'
) {
return false;
}
const currentCreationPrefix = CREATION_PATH_PREFIXES.find((pathPrefix) =>
normalizedCurrentPath === pathPrefix ||
normalizedCurrentPath.startsWith(`${pathPrefix}/`),
);
const nextCreationPrefix = CREATION_PATH_PREFIXES.find((pathPrefix) =>
normalizedNextPath === pathPrefix ||
normalizedNextPath.startsWith(`${pathPrefix}/`),
);
return Boolean(
currentCreationPrefix &&
nextCreationPrefix &&
currentCreationPrefix === nextCreationPrefix,
);
}
export function buildCreationUrlSearchFromParams(search: string) {
const params = new URLSearchParams(search);
const preservedParams = new URLSearchParams();
CREATION_URL_RESTORE_QUERY_KEYS.forEach((key) => {
const value = normalizeValue(params.get(key));
if (value) {
preservedParams.set(key, value);
}
});
const queryString = preservedParams.toString();
return queryString ? `?${queryString}` : '';
}
export function readCreationUrlState(
env?: CreationUrlEnvironment,
): CreationUrlState {
const resolved = resolveEnvironment(env);
const params = new URLSearchParams(resolved.location?.search ?? '');
return {
sessionId: normalizeValue(params.get(CREATION_URL_SESSION_QUERY_KEY)),
profileId: normalizeValue(params.get(CREATION_URL_PROFILE_QUERY_KEY)),
draftId: normalizeValue(params.get(CREATION_URL_DRAFT_QUERY_KEY)),
workId: normalizeValue(params.get(CREATION_URL_WORK_QUERY_KEY)),
};
}
export function writeCreationUrlState(
state: CreationUrlState,
env?: CreationUrlEnvironment,
) {
const resolved = resolveEnvironment(env);
if (
!resolved.location ||
!resolved.history?.replaceState ||
!isCreationRestorePath(resolved.location.pathname)
) {
return;
}
const params = new URLSearchParams(resolved.location.search);
const entries = [
[CREATION_URL_SESSION_QUERY_KEY, state.sessionId],
[CREATION_URL_PROFILE_QUERY_KEY, state.profileId],
[CREATION_URL_DRAFT_QUERY_KEY, state.draftId],
[CREATION_URL_WORK_QUERY_KEY, state.workId],
] as const;
entries.forEach(([key, rawValue]) => {
const value = normalizeValue(rawValue);
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const search = params.toString();
const nextUrl = search
? `${resolved.location.pathname}?${search}`
: resolved.location.pathname;
resolved.history.replaceState(null, '', nextUrl);
}
export function clearCreationUrlState(env?: CreationUrlEnvironment) {
const resolved = resolveEnvironment(env);
if (
!resolved.location ||
!resolved.history?.replaceState ||
!isCreationRestorePath(resolved.location.pathname)
) {
return;
}
const params = new URLSearchParams(resolved.location.search);
[
CREATION_URL_SESSION_QUERY_KEY,
CREATION_URL_PROFILE_QUERY_KEY,
CREATION_URL_DRAFT_QUERY_KEY,
CREATION_URL_WORK_QUERY_KEY,
].forEach((key) => params.delete(key));
const search = params.toString();
const nextUrl = search
? `${resolved.location.pathname}?${search}`
: resolved.location.pathname;
resolved.history.replaceState(null, '', nextUrl);
}

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

@@ -36,12 +36,15 @@ describe('miniGameDraftGenerationProgress', () => {
expect(progress?.steps[0]?.detail).toBe(
'建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
);
expect(progress?.estimatedRemainingMs).toBe(296_500);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.steps[2]?.detail).toBe(
'调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
);
expect(progress?.estimatedRemainingMs).toBe(446_500);
expect(progress?.overallProgress).toBe(0);
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
});
test('puzzle draft generation advances steps across the current asset pipeline', () => {
test('puzzle draft generation starts total progress from zero', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
@@ -51,42 +54,81 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 206_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(
state,
296_000,
);
const progress = buildMiniGameDraftGenerationProgress(state, 1000);
expect(imageProgress?.phaseId).toBe('puzzle-cover-image');
expect(imageProgress?.estimatedRemainingMs).toBe(273_000);
expect(imageProgress?.steps[1]?.status).toBe('completed');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
expect(uiProgress?.phaseId).toBe('puzzle-ui-assets');
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
expect(writeBackProgress?.steps[5]?.status).toBe('active');
expect(progress?.overallProgress).toBe(0);
expect(progress?.completedWeight).toBe(0);
expect(progress?.estimatedRemainingMs).toBe(448_000);
expect(progress?.steps[0]?.completed).toBe(0);
});
test('puzzle write-back step turns completed once rounded progress reaches 100%', () => {
test('puzzle draft generation total progress advances after startup', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 7000);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.overallProgress).toBeLessThan(88);
expect(progress?.phaseId).toBe('compile');
});
test('puzzle draft generation keeps current step until real progress advances it', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const longRunningProgress = buildMiniGameDraftGenerationProgress(
state,
296_000,
);
const progressedState: MiniGameDraftGenerationState = {
...state,
phase: 'puzzle-cover-image',
};
const realProgress = buildMiniGameDraftGenerationProgress(
progressedState,
296_000,
);
expect(longRunningProgress?.phaseId).toBe('compile');
expect(longRunningProgress?.steps[0]?.status).toBe('active');
expect(longRunningProgress?.steps[1]?.status).toBe('pending');
expect(longRunningProgress?.overallProgress).toBeLessThanOrEqual(98);
expect(longRunningProgress?.overallProgress).toBeGreaterThan(40);
expect(realProgress?.phaseId).toBe('puzzle-cover-image');
expect(realProgress?.steps[1]?.status).toBe('completed');
expect(realProgress?.steps[2]?.status).toBe('active');
});
test('puzzle write-back step stays active until the generation action finishes', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'puzzle-select-image',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 298_950);
const progress = buildMiniGameDraftGenerationProgress(state, 448_950);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.estimatedRemainingMs).toBe(50);
expect(progress?.steps[5]?.completed).toBe(1);
expect(progress?.steps[5]?.status).toBe('completed');
expect(progress?.steps[5]?.completed).toBe(0.98);
expect(progress?.steps[5]?.status).toBe('active');
});
test('puzzle direct upload generation skips the first image generation step', () => {
@@ -112,14 +154,14 @@ describe('miniGameDraftGenerationProgress', () => {
'生成UI与背景',
'写入正式草稿',
]);
expect(progress?.phaseId).toBe('puzzle-level-scene');
expect(progress?.phaseId).toBe('compile');
expect(progress?.steps[2]?.detail).toContain('直接使用上传图作为参考');
expect(progress?.estimatedRemainingMs).toBe(189_000);
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.phaseId).toBe('compile');
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
});
test('puzzle draft generation keeps moving without claiming completion before response', () => {
test('puzzle draft generation does not advance or claim completion before response', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
@@ -129,18 +171,88 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 480_000);
const progress = buildMiniGameDraftGenerationProgress(state, 630_000);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.phaseId).toBe('compile');
expect(progress?.overallProgress).toBeLessThan(88);
expect(progress?.overallProgress).toBeGreaterThan(80);
expect(progress?.estimatedRemainingMs).toBe(0);
expect(progress?.steps[5]?.completed).toBe(1);
expect(progress?.steps[5]?.status).toBe('completed');
expect(progress?.steps.every((step) => step.status === 'completed')).toBe(
expect(progress?.steps[0]?.status).toBe('active');
expect(progress?.steps[0]?.completed).toBe(0.98);
expect(progress?.steps.slice(1).every((step) => step.status === 'pending')).toBe(
true,
);
});
test('puzzle draft generation advances steps from backend progress percent only', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 88,
} as MiniGameDraftGenerationState['metadata'],
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 120_000);
const uiProgress = buildMiniGameDraftGenerationProgress(
{
...state,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 94,
} as MiniGameDraftGenerationState['metadata'],
},
230_000,
);
const writeProgress = buildMiniGameDraftGenerationProgress(
{
...state,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 96,
} as MiniGameDraftGenerationState['metadata'],
},
260_000,
);
expect(imageProgress?.phaseId).toBe('puzzle-cover-image');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[3]?.status).toBe('pending');
expect(uiProgress?.phaseId).toBe('puzzle-ui-assets');
expect(uiProgress?.steps[4]?.status).toBe('active');
expect(uiProgress?.steps[5]?.status).toBe('pending');
expect(writeProgress?.phaseId).toBe('puzzle-select-image');
expect(writeProgress?.steps[5]?.status).toBe('active');
});
test('puzzle backend milestone starts fake progress from the current step entry time', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 88,
puzzleActivePhaseId: 'puzzle-cover-image',
puzzleActiveStepStartedAtMs: 120_000,
} as MiniGameDraftGenerationState['metadata'],
};
const progress = buildMiniGameDraftGenerationProgress(state, 121_000);
expect(progress?.phaseId).toBe('puzzle-cover-image');
expect(progress?.steps[2]?.status).toBe('active');
expect(progress?.steps[2]?.completed).toBeLessThan(0.02);
});
test('puzzle ready copy points to result page work info completion', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
@@ -268,6 +380,19 @@ describe('miniGameDraftGenerationProgress', () => {
);
});
test('match3d draft generation starts total progress from zero', () => {
const state = createMiniGameDraftGenerationState('match3d');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs,
);
expect(progress?.overallProgress).toBe(0);
expect(progress?.completedWeight).toBe(0);
expect(progress?.steps[0]?.completed).toBe(0);
});
test('match3d draft generation keeps backend observed asset phase', () => {
const state = {
...createMiniGameDraftGenerationState('match3d'),

View File

@@ -91,6 +91,9 @@ export type MiniGameDraftGenerationState = {
error: string | null;
metadata?: {
puzzleAiRedraw?: boolean;
puzzleActivePhaseId?: MiniGameDraftGenerationPhase;
puzzleActiveStepStartedAtMs?: number;
puzzleProgressPercent?: number;
};
};
@@ -111,10 +114,14 @@ type MiniGameAnchorSource = {
value: string;
};
const PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS = 240_000;
const PUZZLE_IMAGE_GENERATION_EXPECTED_MS = 90_000;
const PUZZLE_COMPILE_EXPECTED_MS = 8_000;
const PUZZLE_LEVEL_NAME_EXPECTED_MS = 10_000;
const PUZZLE_WRITE_DRAFT_EXPECTED_MS = 10_000;
const PUZZLE_COMPILE_MILESTONE_PROGRESS = 88;
const PUZZLE_IMAGE_MILESTONE_PROGRESS = 94;
const PUZZLE_UI_MILESTONE_PROGRESS = 96;
function shouldSkipPuzzleCoverGeneration(state: MiniGameDraftGenerationState) {
return state.metadata?.puzzleAiRedraw === false;
@@ -160,8 +167,8 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
steps.push({
id: 'puzzle-cover-image',
label: '生成拼图首图',
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
durationMs: PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS,
});
}
@@ -204,30 +211,40 @@ function resolvePuzzleEstimatedWaitMs(state: MiniGameDraftGenerationState) {
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000;
function buildPuzzlePhaseTimeline(state: MiniGameDraftGenerationState): Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
| 'compile'
| 'puzzle-level-name'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| 'puzzle-select-image'
>;
durationMs: number;
}> {
return buildPuzzleTimedSteps(state).map((step) => ({
phase: step.id as Extract<
MiniGameDraftGenerationPhase,
| 'compile'
| 'puzzle-level-name'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| 'puzzle-select-image'
>,
durationMs: step.durationMs,
}));
function resolvePuzzleBackendProgressPercent(
state: MiniGameDraftGenerationState,
) {
const progressPercent = state.metadata?.puzzleProgressPercent;
if (typeof progressPercent !== 'number' || !Number.isFinite(progressPercent)) {
return null;
}
return Math.max(0, Math.min(100, Math.round(progressPercent)));
}
function resolvePuzzlePhaseByBackendProgress(
state: MiniGameDraftGenerationState,
): MiniGameDraftGenerationPhase | null {
const progressPercent = resolvePuzzleBackendProgressPercent(state);
if (progressPercent == null) {
return null;
}
// 中文注释:拼图生成页的跨步骤只跟随后端会话真实里程碑;
// 每步内部的等待反馈仍由本地假进度补足。
if (progressPercent >= 96) {
return 'puzzle-select-image';
}
if (progressPercent >= 94) {
return 'puzzle-ui-assets';
}
if (progressPercent >= 88) {
return shouldSkipPuzzleCoverGeneration(state)
? 'puzzle-level-scene'
: 'puzzle-cover-image';
}
return null;
}
const BIG_FISH_STEPS = [
@@ -685,32 +702,141 @@ function resolveWoodenFishTimelineByElapsedMs(elapsedMs: number) {
};
}
function resolvePuzzleTimelineByElapsedMs(
function resolvePuzzleActiveStepProgressRatio(
steps: ReadonlyArray<TimedMiniGameStepDefinition>,
activeStepIndex: number,
elapsedMs: number,
state: MiniGameDraftGenerationState,
) {
let elapsedBeforePhase = 0;
for (const item of buildPuzzlePhaseTimeline(state)) {
const elapsedInPhase = elapsedMs - elapsedBeforePhase;
if (elapsedInPhase < item.durationMs) {
return {
phase: item.phase,
activeStepProgressRatio: Math.max(
0,
Math.min(1, elapsedInPhase / item.durationMs),
),
};
}
elapsedBeforePhase += item.durationMs;
const activeStep = steps[activeStepIndex];
if (!activeStep) {
return 0;
}
return {
phase: 'puzzle-select-image' as const,
activeStepProgressRatio: 1,
};
const elapsedBeforeActiveStep = steps
.slice(0, activeStepIndex)
.reduce((sum, step) => sum + step.durationMs, 0);
const elapsedInActiveStep = Math.max(0, elapsedMs - elapsedBeforeActiveStep);
return Math.max(
0,
Math.min(0.98, elapsedInActiveStep / Math.max(1, activeStep.durationMs)),
);
}
function resolvePuzzleActiveStepElapsedProgressRatio(
state: MiniGameDraftGenerationState,
steps: ReadonlyArray<TimedMiniGameStepDefinition>,
activeStepIndex: number,
elapsedMs: number,
effectiveNowMs: number,
) {
if (resolvePuzzleBackendProgressPercent(state) != null) {
const stepStartedAtMs = state.metadata?.puzzleActiveStepStartedAtMs;
if (
state.metadata?.puzzleActivePhaseId === state.phase &&
typeof stepStartedAtMs === 'number' &&
Number.isFinite(stepStartedAtMs)
) {
const activeStep = steps[activeStepIndex];
if (!activeStep) {
return 0;
}
return Math.max(
0,
Math.min(
0.98,
(effectiveNowMs - stepStartedAtMs) /
Math.max(1, activeStep.durationMs),
),
);
}
return resolvePuzzleActiveStepProgressRatio(
steps,
activeStepIndex,
elapsedMs,
);
}
const activeStep = steps[activeStepIndex];
if (!activeStep) {
return 0;
}
// 中文注释:未收到后端真实里程碑时,跨步骤必须卡住;
// 但当前步骤内的假进度要按整段等待时间继续向前走,避免短步骤几秒后停死。
const fallbackDurationMs = Math.max(1, resolvePuzzleEstimatedWaitMs(state));
return Math.max(
0,
Math.min(0.98, elapsedMs / fallbackDurationMs),
);
}
function resolveElapsedActiveStepProgressRatio(
kind: MiniGameDraftGenerationKind,
elapsedMs: number,
) {
const estimatedWaitMs =
kind === 'big-fish'
? 7_000
: kind === 'square-hole'
? 12_000
: kind === 'match3d'
? MATCH3D_ESTIMATED_WAIT_MS
: kind === 'baby-object-match'
? BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS
: kind === 'jump-hop'
? JUMP_HOP_ESTIMATED_WAIT_MS
: kind === 'wooden-fish'
? WOODEN_FISH_ESTIMATED_WAIT_MS
: 1;
return Math.max(
0,
Math.min(0.98, elapsedMs / Math.max(1, estimatedWaitMs)),
);
}
function resolvePuzzleOverallProgress(
state: MiniGameDraftGenerationState,
activeStepProgressRatio: number,
) {
const backendProgressPercent = resolvePuzzleBackendProgressPercent(state);
// 中文注释88 以下的后端进度只保留为会话事实,不参与首帧总进度抬升。
// 生成页恢复时必须先从 0% 起步,再由当前步骤内的假进度平滑推进。
const backendProgressFloor =
backendProgressPercent != null &&
backendProgressPercent >= PUZZLE_COMPILE_MILESTONE_PROGRESS
? backendProgressPercent
: 0;
const range =
state.phase === 'puzzle-select-image'
? {
start: PUZZLE_UI_MILESTONE_PROGRESS,
end: PUZZLE_NON_READY_MAX_PROGRESS,
}
: state.phase === 'puzzle-ui-assets'
? {
start: PUZZLE_IMAGE_MILESTONE_PROGRESS,
end: PUZZLE_UI_MILESTONE_PROGRESS,
}
: state.phase === 'puzzle-cover-image' ||
state.phase === 'puzzle-level-scene'
? {
start: PUZZLE_COMPILE_MILESTONE_PROGRESS,
end: PUZZLE_IMAGE_MILESTONE_PROGRESS,
}
: {
start: 0,
end: PUZZLE_COMPILE_MILESTONE_PROGRESS,
};
const fakeProgress =
range.start + (range.end - range.start) * activeStepProgressRatio;
const nextProgress = Math.min(
PUZZLE_NON_READY_MAX_PROGRESS,
Math.max(range.start, backendProgressFloor, fakeProgress),
);
return nextProgress;
}
export function buildMiniGameDraftGenerationProgress(
@@ -726,11 +852,11 @@ export function buildMiniGameDraftGenerationProgress(
? state.finishedAtMs
: nowMs;
const elapsedMs = Math.max(0, effectiveNowMs - state.startedAtMs);
const puzzleTimeline =
const puzzleBackendPhase =
state.kind === 'puzzle' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? resolvePuzzleTimelineByElapsedMs(elapsedMs, state)
? resolvePuzzlePhaseByBackendProgress(state)
: null;
const woodenFishTimeline =
state.kind === 'wooden-fish' &&
@@ -739,10 +865,10 @@ export function buildMiniGameDraftGenerationProgress(
? resolveWoodenFishTimelineByElapsedMs(elapsedMs)
: null;
const normalizedState =
puzzleTimeline != null
puzzleBackendPhase != null
? {
...state,
phase: puzzleTimeline.phase,
phase: puzzleBackendPhase,
}
: woodenFishTimeline != null
? {
@@ -786,47 +912,83 @@ export function buildMiniGameDraftGenerationProgress(
}
: state;
const puzzleTimedSteps =
normalizedState.kind === 'puzzle'
? buildPuzzleTimedSteps(normalizedState)
: null;
const steps =
normalizedState.kind === 'puzzle'
? buildPuzzleSteps(normalizedState)
? buildWeightedPuzzleSteps(
puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState),
)
: getStepDefinitions(normalizedState.kind);
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
const activeStep = steps[activeStepIndex] ?? steps[0];
const activeStepProgressRatio =
normalizedState.kind === 'puzzle'
? normalizedState.phase === 'ready'
? 1
: normalizedState.phase === 'failed'
? 0
: resolvePuzzleActiveStepElapsedProgressRatio(
normalizedState,
puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState),
activeStepIndex,
elapsedMs,
effectiveNowMs,
)
: normalizedState.totalAssetCount > 0
? Math.min(
1,
normalizedState.completedAssetCount / normalizedState.totalAssetCount,
)
: normalizedState.phase === 'ready'
? 1
: normalizedState.kind === 'big-fish'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'square-hole'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'match3d'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'baby-object-match'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'jump-hop'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'wooden-fish'
? (woodenFishTimeline?.activeStepProgressRatio ?? 0)
: 0;
const completedWeight = steps
.slice(
0,
normalizedState.phase === 'ready' ? steps.length : activeStepIndex,
)
.reduce((sum, step) => sum + step.weight, 0);
const activeStep = steps[activeStepIndex] ?? steps[0];
const assetRatio =
normalizedState.totalAssetCount > 0
? Math.min(
1,
normalizedState.completedAssetCount / normalizedState.totalAssetCount,
)
: normalizedState.phase === 'ready'
? 1
: normalizedState.kind === 'puzzle'
? (puzzleTimeline?.activeStepProgressRatio ?? 0)
: normalizedState.kind === 'big-fish'
? 0.55
: normalizedState.kind === 'square-hole'
? 0.42
: normalizedState.kind === 'match3d'
? 0.5
: normalizedState.kind === 'baby-object-match'
? 0.52
: normalizedState.kind === 'jump-hop'
? 0.5
: normalizedState.kind === 'wooden-fish'
? (woodenFishTimeline?.activeStepProgressRatio ?? 0)
: 0;
const overallProgress =
normalizedState.phase === 'failed'
? Math.max(1, completedWeight)
: normalizedState.phase === 'ready'
? 100
: completedWeight + activeStep.weight * assetRatio;
: normalizedState.kind === 'puzzle'
? resolvePuzzleOverallProgress(
normalizedState,
activeStepProgressRatio,
)
: completedWeight + activeStep.weight * activeStepProgressRatio;
const cappedOverallProgress =
normalizedState.phase === 'ready' || normalizedState.phase === 'failed'
? overallProgress
@@ -890,7 +1052,7 @@ export function buildMiniGameDraftGenerationProgress(
steps,
activeStepIndex,
normalizedState,
assetRatio,
activeStepProgressRatio,
),
};
}

View File

@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
buildCustomWorldPublicWorkCode,
buildJumpHopPublicWorkCode,
buildWoodenFishPublicWorkCode,
isSameCustomWorldPublicWorkCode,
isSameJumpHopPublicWorkCode,
isSameWoodenFishPublicWorkCode,
} from './publicWorkCode';
@@ -32,6 +34,16 @@ describe('publicWorkCode', () => {
);
});
it('builds and matches custom world public work codes from profile ids', () => {
expect(buildCustomWorldPublicWorkCode('world-public-1')).toBe('CW-00000001');
expect(isSameCustomWorldPublicWorkCode('cw-00000001', 'world-public-1')).toBe(
true,
);
expect(
isSameCustomWorldPublicWorkCode('world-public-1', 'world-public-1'),
).toBe(true);
});
it('matches wooden fish public work codes and raw profile ids', () => {
expect(
isSameWoodenFishPublicWorkCode(

View File

@@ -53,6 +53,28 @@ export function buildBabyObjectMatchPublicWorkCode(profileId: string) {
return `BO-${suffix}`;
}
function normalizeCustomWorldPublicWorkCodeSuffix(profileId: string) {
const digits = profileId
.split('')
.filter((character) => character >= '0' && character <= '9')
.join('');
if (digits.length === 0) {
const bytes = new TextEncoder().encode(profileId);
const checksum = bytes.reduce((accumulator, value) => {
return (accumulator * 131 + value) >>> 0;
}, 0);
return String(checksum % 100_000_000).padStart(8, '0');
}
return digits.slice(-8).padStart(8, '0');
}
export function buildCustomWorldPublicWorkCode(profileId: string) {
return `CW-${normalizeCustomWorldPublicWorkCodeSuffix(profileId)}`;
}
function normalizeBarkBattlePublicWorkCodeSuffix(workId: string) {
const normalized = normalizePublicCodeText(workId);
const withoutPrefix = normalized.startsWith('BB')
@@ -155,6 +177,19 @@ export function isSameBabyObjectMatchPublicWorkCode(
);
}
export function isSameCustomWorldPublicWorkCode(
keyword: string,
profileId: string,
) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildCustomWorldPublicWorkCode(profileId)) ||
normalizedKeyword === normalizePublicCodeText(profileId)
);
}
export function isSameBarkBattlePublicWorkCode(keyword: string, workId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);

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,114 @@
import { describe, expect, it, vi } from 'vitest';
import {
clearPuzzleRuntimeUrlState,
readPuzzleRuntimeUrlState,
writePuzzleRuntimeUrlState,
} from './puzzleRuntimeUrlState';
describe('puzzleRuntimeUrlState', () => {
it('writes puzzle runtime identity on runtime paths', () => {
const replaceState = vi.fn();
const env = {
location: {
pathname: '/runtime/puzzle',
search: '?clientRuntime=wechat_mini_program',
},
history: { replaceState },
};
writePuzzleRuntimeUrlState(
{
runtimeSessionId: 'puzzle-session-1',
runtimeProfileId: 'puzzle-profile-1',
runtimeLevelId: 'puzzle-level-2',
publicWorkCode: 'PZ-12345678',
mode: 'draft',
},
env,
);
expect(replaceState).toHaveBeenCalledWith(
null,
'',
'/runtime/puzzle?clientRuntime=wechat_mini_program&runtimeProfileId=puzzle-profile-1&runtimeSessionId=puzzle-session-1&runtimeLevelId=puzzle-level-2&work=PZ-12345678&mode=draft',
);
expect(
readPuzzleRuntimeUrlState({
location: {
pathname: '/runtime/puzzle',
search:
'?runtimeProfileId=puzzle-profile-1&runtimeSessionId=puzzle-session-1&runtimeLevelId=puzzle-level-2&work=PZ-12345678&mode=draft',
},
}),
).toEqual({
runtimeProfileId: 'puzzle-profile-1',
runtimeSessionId: 'puzzle-session-1',
runtimeLevelId: 'puzzle-level-2',
publicWorkCode: 'PZ-12345678',
mode: 'draft',
});
});
it('ignores writes outside puzzle runtime paths', () => {
const replaceState = vi.fn();
const env = {
location: {
pathname: '/creation/puzzle/result',
search: '?sessionId=puzzle-session-1',
},
history: { replaceState },
};
writePuzzleRuntimeUrlState({ runtimeSessionId: 'puzzle-session-2' }, env);
expect(replaceState).not.toHaveBeenCalled();
});
it('can write runtime state to an explicit puzzle runtime pathname', () => {
const replaceState = vi.fn();
const env = {
location: {
pathname: '/creation/puzzle/result',
search: '?clientRuntime=wechat_mini_program',
},
history: { replaceState },
};
writePuzzleRuntimeUrlState(
{
runtimeProfileId: 'puzzle-profile-1',
mode: 'published',
publicWorkCode: 'PZ-12345678',
},
env,
{ pathname: '/runtime/puzzle' },
);
expect(replaceState).toHaveBeenCalledWith(
null,
'',
'/runtime/puzzle?clientRuntime=wechat_mini_program&runtimeProfileId=puzzle-profile-1&work=PZ-12345678&mode=published',
);
});
it('clears only puzzle runtime restore params on runtime paths', () => {
const replaceState = vi.fn();
const env = {
location: {
pathname: '/runtime/puzzle',
search:
'?runtimeSessionId=puzzle-session-1&runtimeProfileId=puzzle-profile-1&runtimeLevelId=puzzle-level-1&work=PZ-12345678&mode=draft&clientRuntime=wechat',
},
history: { replaceState },
};
clearPuzzleRuntimeUrlState(env);
expect(replaceState).toHaveBeenCalledWith(
null,
'',
'/runtime/puzzle?clientRuntime=wechat',
);
});
});

View File

@@ -0,0 +1,155 @@
export const PUZZLE_RUNTIME_WORK_QUERY_KEY = 'work';
export const PUZZLE_RUNTIME_PROFILE_QUERY_KEY = 'runtimeProfileId';
export const PUZZLE_RUNTIME_SESSION_QUERY_KEY = 'runtimeSessionId';
export const PUZZLE_RUNTIME_LEVEL_QUERY_KEY = 'runtimeLevelId';
export const PUZZLE_RUNTIME_MODE_QUERY_KEY = 'mode';
export type PuzzleRuntimeUrlMode = 'draft' | 'published';
export type PuzzleRuntimeUrlState = {
runtimeProfileId?: string | null;
runtimeSessionId?: string | null;
runtimeLevelId?: string | null;
publicWorkCode?: string | null;
mode?: PuzzleRuntimeUrlMode | null;
};
type PuzzleRuntimeUrlEnvironment = {
location?: {
pathname: string;
search: string;
} | null;
history?: {
replaceState: (
data: unknown,
unused: string,
url?: string | URL | null,
) => void;
} | null;
};
type WritePuzzleRuntimeUrlOptions = {
pathname?: string;
};
function resolveEnvironment(
env?: PuzzleRuntimeUrlEnvironment,
): Required<PuzzleRuntimeUrlEnvironment> {
if (env) {
return {
location: env.location ?? null,
history: env.history ?? null,
};
}
if (typeof window === 'undefined') {
return {
location: null,
history: null,
};
}
return {
location: window.location,
history: window.history,
};
}
function normalizeValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function normalizeRuntimeMode(value: unknown): PuzzleRuntimeUrlMode | null {
const normalized = normalizeValue(value);
return normalized === 'draft' || normalized === 'published'
? normalized
: null;
}
function isPuzzleRuntimePath(pathname: string | undefined) {
return pathname?.trim().toLowerCase().replace(/\/+$/u, '') === '/runtime/puzzle';
}
export function readPuzzleRuntimeUrlState(
env?: PuzzleRuntimeUrlEnvironment,
): PuzzleRuntimeUrlState {
const resolved = resolveEnvironment(env);
const params = new URLSearchParams(resolved.location?.search ?? '');
return {
runtimeProfileId: normalizeValue(
params.get(PUZZLE_RUNTIME_PROFILE_QUERY_KEY),
),
runtimeSessionId: normalizeValue(
params.get(PUZZLE_RUNTIME_SESSION_QUERY_KEY),
),
runtimeLevelId: normalizeValue(params.get(PUZZLE_RUNTIME_LEVEL_QUERY_KEY)),
publicWorkCode: normalizeValue(params.get(PUZZLE_RUNTIME_WORK_QUERY_KEY)),
mode: normalizeRuntimeMode(params.get(PUZZLE_RUNTIME_MODE_QUERY_KEY)),
};
}
export function writePuzzleRuntimeUrlState(
state: PuzzleRuntimeUrlState,
env?: PuzzleRuntimeUrlEnvironment,
options: WritePuzzleRuntimeUrlOptions = {},
) {
const resolved = resolveEnvironment(env);
const pathname = options.pathname ?? resolved.location?.pathname;
if (
!resolved.location ||
!resolved.history?.replaceState ||
!isPuzzleRuntimePath(pathname)
) {
return;
}
const params = new URLSearchParams(resolved.location.search);
const entries = [
[PUZZLE_RUNTIME_PROFILE_QUERY_KEY, state.runtimeProfileId],
[PUZZLE_RUNTIME_SESSION_QUERY_KEY, state.runtimeSessionId],
[PUZZLE_RUNTIME_LEVEL_QUERY_KEY, state.runtimeLevelId],
[PUZZLE_RUNTIME_WORK_QUERY_KEY, state.publicWorkCode],
[PUZZLE_RUNTIME_MODE_QUERY_KEY, state.mode],
] as const;
entries.forEach(([key, rawValue]) => {
const value = normalizeValue(rawValue);
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const search = params.toString();
const nextPathname = pathname ?? resolved.location.pathname;
const nextUrl = search ? `${nextPathname}?${search}` : nextPathname;
resolved.history.replaceState(null, '', nextUrl);
}
export function clearPuzzleRuntimeUrlState(env?: PuzzleRuntimeUrlEnvironment) {
const resolved = resolveEnvironment(env);
if (
!resolved.location ||
!resolved.history?.replaceState ||
!isPuzzleRuntimePath(resolved.location.pathname)
) {
return;
}
const params = new URLSearchParams(resolved.location.search);
[
PUZZLE_RUNTIME_PROFILE_QUERY_KEY,
PUZZLE_RUNTIME_SESSION_QUERY_KEY,
PUZZLE_RUNTIME_LEVEL_QUERY_KEY,
PUZZLE_RUNTIME_WORK_QUERY_KEY,
PUZZLE_RUNTIME_MODE_QUERY_KEY,
].forEach((key) => params.delete(key));
const search = params.toString();
const nextUrl = search
? `${resolved.location.pathname}?${search}`
: resolved.location.pathname;
resolved.history.replaceState(null, '', nextUrl);
}

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