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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
85
src/services/creationUrlState.test.ts
Normal file
85
src/services/creationUrlState.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
220
src/services/creationUrlState.ts
Normal file
220
src/services/creationUrlState.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
24
src/services/puzzle-works/puzzleAssetClient.test.ts
Normal file
24
src/services/puzzle-works/puzzleAssetClient.test.ts
Normal 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)。',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)。',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
114
src/services/puzzleRuntimeUrlState.test.ts
Normal file
114
src/services/puzzleRuntimeUrlState.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
155
src/services/puzzleRuntimeUrlState.ts
Normal file
155
src/services/puzzleRuntimeUrlState.ts
Normal 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);
|
||||
}
|
||||
113
src/services/recommendedRuntimeGuestLaunch.test.ts
Normal file
113
src/services/recommendedRuntimeGuestLaunch.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
40
src/services/runtimeGuestAuth.ts
Normal file
40
src/services/runtimeGuestAuth.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user