合并 master 并保留外部生成 worker 模式

合入 master 的生产健康巡检、JumpHop 和 SpacetimeDB 更新
保留外部生成 worker、队列/内联模式与 lease guard 口径
合并 Server-Provision 工具复用、health patrol 和外部生成 worker systemd 配置
补齐 SpacetimeDB 生成绑定并通过本地检查
This commit is contained in:
2026-06-10 21:26:53 +08:00
93 changed files with 7872 additions and 2244 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -899,6 +899,23 @@ test('platform public work detail flow resolves like intent', () => {
});
});
test('platform public work detail flow respects configured like disable', () => {
expect(
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('puzzle'), [
{
sourceType: 'puzzle',
likeEnabled: false,
remixEnabled: true,
likeDisabledMessage: '拼图点赞维护中。',
remixDisabledMessage: '拼图改造维护中。',
},
]),
).toEqual({
type: 'unsupported',
errorMessage: '拼图点赞维护中。',
});
});
test('platform public work detail flow resolves remix intent', () => {
expect(
resolvePlatformPublicWorkRemixIntent(buildTypedEntry('big-fish')),
@@ -969,13 +986,31 @@ test('platform public work detail flow resolves remix intent', () => {
});
});
test('platform public work detail flow respects configured remix disable', () => {
expect(
resolvePlatformPublicWorkRemixIntent(buildRpgEntry(), [
{
sourceType: 'custom-world',
likeEnabled: true,
remixEnabled: false,
likeDisabledMessage: 'RPG 点赞维护中。',
remixDisabledMessage: 'RPG 改造维护中。',
},
]),
).toEqual({
type: 'unsupported',
errorMessage: 'RPG 改造维护中。',
});
});
test('platform public work detail flow resolves edit intent for draft-backed works', () => {
const bigFishEntry = buildTypedEntry('big-fish');
expect(resolvePlatformPublicWorkEditIntent(bigFishEntry, buildEditIntentDeps()))
.toEqual({
type: 'edit-big-fish',
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
});
expect(
resolvePlatformPublicWorkEditIntent(bigFishEntry, buildEditIntentDeps()),
).toEqual({
type: 'edit-big-fish',
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
});
const selectedPuzzleDetail = buildPuzzleWork({
profileId: 'puzzle-profile',
@@ -1153,7 +1188,10 @@ test('platform public work detail flow resolves edit intent for unsupported and
const edutainmentEntry = buildTypedEntry('edutainment');
expect(
resolvePlatformPublicWorkEditIntent(edutainmentEntry, buildEditIntentDeps()),
resolvePlatformPublicWorkEditIntent(
edutainmentEntry,
buildEditIntentDeps(),
),
).toEqual({
type: 'resolve-edutainment-draft',
entry: edutainmentEntry,
@@ -97,6 +97,14 @@ export type PlatformPublicWorkDetailOpenStrategy =
export type PlatformPublicWorkActionMode = 'edit' | 'remix';
export type PlatformPublicWorkInteractionConfig = {
sourceType: string;
likeEnabled: boolean;
remixEnabled: boolean;
likeDisabledMessage: string;
remixDisabledMessage: string;
};
export type PlatformPublicWorkLikeIntent =
| {
type: 'like-big-fish';
@@ -678,9 +686,55 @@ export function resolvePlatformPublicWorkActionMode(
: 'remix';
}
export function getPlatformPublicWorkInteractionSourceType(
entry: PlatformPublicGalleryCard,
) {
return 'sourceType' in entry ? entry.sourceType : 'custom-world';
}
function resolveConfiguredPublicWorkInteractionBlock(
entry: PlatformPublicGalleryCard,
configs: readonly PlatformPublicWorkInteractionConfig[] | null | undefined,
action: 'like' | 'remix',
): PlatformPublicWorkLikeIntent | PlatformPublicWorkRemixIntent | null {
const sourceType = getPlatformPublicWorkInteractionSourceType(entry);
const config = configs?.find((item) => item.sourceType === sourceType);
if (!config) {
return null;
}
if (action === 'like' && !config.likeEnabled) {
return {
type: 'unsupported',
errorMessage:
config.likeDisabledMessage.trim() || '该作品类型暂不支持点赞。',
};
}
if (action === 'remix' && !config.remixEnabled) {
return {
type: 'unsupported',
errorMessage:
config.remixDisabledMessage.trim() || '该作品类型暂不支持改造。',
};
}
return null;
}
export function resolvePlatformPublicWorkLikeIntent(
entry: PlatformPublicGalleryCard,
configs?: readonly PlatformPublicWorkInteractionConfig[] | null,
): PlatformPublicWorkLikeIntent {
const configuredBlock = resolveConfiguredPublicWorkInteractionBlock(
entry,
configs,
'like',
);
if (configuredBlock) {
return configuredBlock as PlatformPublicWorkLikeIntent;
}
if (isBigFishGalleryEntry(entry)) {
return {
type: 'like-big-fish',
@@ -760,7 +814,17 @@ export function resolvePlatformPublicWorkLikeIntent(
export function resolvePlatformPublicWorkRemixIntent(
entry: PlatformPublicGalleryCard,
configs?: readonly PlatformPublicWorkInteractionConfig[] | null,
): PlatformPublicWorkRemixIntent {
const configuredBlock = resolveConfiguredPublicWorkInteractionBlock(
entry,
configs,
'remix',
);
if (configuredBlock) {
return configuredBlock as PlatformPublicWorkRemixIntent;
}
if (isBigFishGalleryEntry(entry)) {
return {
type: 'remix-big-fish',
@@ -933,8 +997,9 @@ export function resolvePlatformPublicWorkEditIntent(
if (isVisualNovelGalleryEntry(entry)) {
const work =
deps.visualNovelWorks?.find((item) => item.profileId === entry.profileId) ??
null;
deps.visualNovelWorks?.find(
(item) => item.profileId === entry.profileId,
) ?? null;
if (!work) {
return {
type: 'blocked',
@@ -1695,6 +1695,28 @@ function buildMockJumpHopWork(
};
}
function buildMockJumpHopRuntimeRun(
work: JumpHopWorkProfileResponse,
overrides: Partial<JumpHopRuntimeRunSnapshotResponse> = {},
): JumpHopRuntimeRunSnapshotResponse {
return {
runId: 'jump-hop-run-1',
profileId: work.summary.profileId,
ownerUserId: work.summary.ownerUserId,
status: 'playing',
currentPlatformIndex: 0,
successfulJumpCount: 0,
durationMs: 0,
score: 0,
combo: 0,
path: work.path,
lastJump: null,
startedAtMs: 1_779_999_000_000,
finishedAtMs: null,
...overrides,
};
}
function buildMockBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
@@ -6991,6 +7013,89 @@ test('logged out public jump-hop detail starts runtime without requireAuth', asy
expect(requireAuth).not.toHaveBeenCalled();
});
test('direct jump hop runtime route without work code returns platform home', async () => {
window.history.replaceState(null, '', '/runtime/jump-hop');
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(window.location.pathname).toBe('/');
});
expect(window.location.search).toBe('');
expect(jumpHopClient.getGalleryDetail).not.toHaveBeenCalled();
expect(jumpHopClient.startRun).not.toHaveBeenCalled();
expect(await screen.findByRole('button', { name: '创作' })).toBeTruthy();
});
test('direct jump hop runtime route with public work code starts published run', async () => {
const publishedJumpHopWork = buildMockJumpHopWork({
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-direct-1',
profileId: 'jump-hop-profile-direct-12345678',
ownerUserId: 'user-2',
sourceSessionId: 'jump-hop-session-direct-1',
themeText: '星星果园',
workTitle: '星星果园跳一跳',
workDescription: '沿着水果一路弹跳。',
themeTags: ['果园', '星星'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: null,
publicationStatus: 'published',
playCount: 8,
updatedAt: '2026-06-07T10:00:00.000Z',
publishedAt: '2026-06-07T10:00:00.000Z',
publishReady: true,
generationStatus: 'ready',
},
});
const publishedJumpHopRun = buildMockJumpHopRuntimeRun(publishedJumpHopWork, {
runId: 'jump-hop-run-direct-1',
ownerUserId: '',
});
window.history.replaceState(null, '', '/runtime/jump-hop?work=JH-12345678');
vi.mocked(jumpHopClient.getGalleryDetail).mockResolvedValue({
item: publishedJumpHopWork,
});
vi.mocked(jumpHopClient.startRun).mockResolvedValue({
run: publishedJumpHopRun,
});
vi.mocked(jumpHopClient.getLeaderboard).mockResolvedValue({
profileId: publishedJumpHopWork.summary.profileId,
items: [],
viewerBest: null,
});
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: vi.fn(),
})}
/>,
);
await waitFor(() => {
expect(jumpHopClient.getGalleryDetail).toHaveBeenCalledWith('JH-12345678');
});
await waitFor(() => {
expect(jumpHopClient.startRun).toHaveBeenCalledWith(
publishedJumpHopWork.summary.profileId,
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
runtimeMode: 'published',
}),
);
});
expect(jumpHopClient.getWorkDetail).not.toHaveBeenCalled();
expect(window.location.pathname).toBe('/runtime/jump-hop');
expect(window.location.search).toContain('work=JH-12345678');
});
test('owned public puzzle detail edits original draft instead of remixing', async () => {
const user = userEvent.setup();
const ownedPuzzleWork = {
@@ -65,7 +65,7 @@ test('jump hop workspace submits theme payload after required field is filled',
difficulty: 'standard',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt: '云朵跳台主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
tilePrompt: '云朵跳台主题的3D立方体主题身份方块包装图集',
endMoodPrompt: null,
});
});
@@ -40,7 +40,7 @@ function buildJumpHopWorkspacePayload(
difficulty: 'standard',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt: `${themeText}主题的正面30度视角主题物体图集,物体本身作为跳跃落点`,
tilePrompt: `${themeText}主题的3D立方体主题身份方块包装图集`,
endMoodPrompt: null,
};
}
+61 -6
View File
@@ -81,7 +81,6 @@ describe('apiClient', () => {
body: JSON.stringify({
ok: true,
data: {
ok: true,
token: 'fresh-token',
},
error: null,
@@ -156,7 +155,6 @@ describe('apiClient', () => {
body: JSON.stringify({
ok: true,
data: {
ok: true,
token: 'fresh-token',
},
error: null,
@@ -334,6 +332,64 @@ describe('apiClient', () => {
expect(getStoredAccessToken()).toBe('usable-local-token');
});
it('keeps local token when refresh fails with transient server unavailable', async () => {
setStoredAccessToken('usable-local-token', { emit: false });
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 503 }));
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
status: 503,
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/refresh',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
}),
);
expect(dispatchEventMock).not.toHaveBeenCalled();
expect(getStoredAccessToken()).toBe('usable-local-token');
});
it('keeps local token when refresh cannot reach the restarting server', async () => {
setStoredAccessToken('usable-local-token', { emit: false });
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await expect(refreshStoredAccessToken()).rejects.toBeInstanceOf(TypeError);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(dispatchEventMock).not.toHaveBeenCalled();
expect(getStoredAccessToken()).toBe('usable-local-token');
});
it('clears local token when refresh confirms the session is unauthorized', async () => {
setStoredAccessToken('expired-local-token', { emit: false });
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
status: 401,
});
expect(dispatchEventMock).not.toHaveBeenCalled();
expect(getStoredAccessToken()).toBe('');
});
it('does not clear auth when protected request refresh fails transiently', async () => {
setStoredAccessToken('expired-token-during-restart', { emit: false });
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(createResponseMock({ status: 503 }));
const response = await fetchWithApiAuth('/api/runtime/protected', {
method: 'GET',
});
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(dispatchEventMock).not.toHaveBeenCalled();
expect(getStoredAccessToken()).toBe('expired-token-during-restart');
});
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
setStoredAccessToken('expired-token', { emit: false });
fetchMock
@@ -344,7 +400,6 @@ describe('apiClient', () => {
body: JSON.stringify({
ok: true,
data: {
ok: true,
token: 'fresh-token',
},
error: null,
@@ -366,7 +421,7 @@ describe('apiClient', () => {
expect(dispatchEventMock).not.toHaveBeenCalled();
});
it('rejects refresh responses that do not return a renewed bearer token', async () => {
it('rejects malformed refresh responses without treating them as logout', async () => {
setStoredAccessToken('expired-token', { emit: false });
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
@@ -397,8 +452,8 @@ describe('apiClient', () => {
message: '读取受保护数据失败',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(getStoredAccessToken()).toBe('');
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
expect(getStoredAccessToken()).toBe('expired-token');
expect(dispatchEventMock).not.toHaveBeenCalled();
});
it('keeps the current access token when a public request explicitly skips auth', async () => {
+20 -6
View File
@@ -497,6 +497,13 @@ function withAuthorizationHeaders(
let refreshAccessTokenPromise: Promise<string> | null = null;
function shouldClearAuthAfterRefreshFailure(error: unknown) {
return (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
);
}
async function refreshAccessToken() {
if (refreshAccessTokenPromise) {
return refreshAccessTokenPromise;
@@ -522,11 +529,11 @@ async function refreshAccessToken() {
)
: null;
if (payload?.ok !== true || !payload.token?.trim()) {
const nextToken = payload?.token?.trim();
if (!nextToken) {
throw new Error('刷新登录状态失败');
}
const nextToken = payload.token.trim();
setStoredAccessToken(nextToken, { emit: false });
return nextToken;
})();
@@ -556,7 +563,10 @@ export async function refreshStoredAccessToken(
try {
return await refreshAccessToken();
} catch (error) {
if (options.clearOnFailure !== false) {
if (
options.clearOnFailure !== false &&
shouldClearAuthAfterRefreshFailure(error)
) {
clearStoredAccessToken({ emit: false });
}
throw error;
@@ -629,11 +639,15 @@ export async function fetchWithApiAuth(
// 不能把当前业务请求的首次 401 直接放大成全局鉴权变更,
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
continue;
} catch {
if (hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized) {
} catch (refreshError) {
const shouldClearAuth =
hasAuthHeader &&
authFailurePolicy.clearAuthOnUnauthorized &&
shouldClearAuthAfterRefreshFailure(refreshError);
if (shouldClearAuth) {
clearStoredAccessToken({ emit: false });
}
if (authFailurePolicy.notifyAuthStateChange) {
if (shouldClearAuth && authFailurePolicy.notifyAuthStateChange) {
emitAuthStateChange();
}
}
@@ -51,6 +51,15 @@ export type CreationEntryEventBannerConfig = {
htmlCode?: string | null;
};
/** 公开作品详情页互动能力配置,前端只据此关闭已接入动作。 */
export type PublicWorkInteractionConfig = {
sourceType: string;
likeEnabled: boolean;
remixEnabled: boolean;
likeDisabledMessage: string;
remixDisabledMessage: string;
};
/** 创作入口页完整配置;前端只展示后端事实源,不内置入口默认值。 */
export type CreationEntryConfig = {
startCard: {
@@ -67,6 +76,8 @@ export type CreationEntryConfig = {
eventBanner: CreationEntryEventBannerConfig;
/** 底部加号创作入口页的多公告轮播配置。 */
eventBanners?: CreationEntryEventBannerConfig[];
/** 公开作品详情页点赞 / 改造能力矩阵。 */
publicWorkInteractions?: PublicWorkInteractionConfig[];
creationTypes: CreationEntryTypeConfig[];
};
@@ -6,28 +6,28 @@ import type {
} from '../../../packages/shared/src/contracts/jumpHop';
import {
buildJumpHopVisiblePlatforms,
getJumpHopBackendDragVector,
getJumpHopCharacterVisualPosition,
getJumpHopJumpFeedbackLabel,
getJumpHopLandingAssistVisualPosition,
getJumpHopPlatformVisualSize,
getJumpHopStatusLabel,
isJumpHopLandingInsidePlatformFootprint,
resolveJumpHopCharacterCanvasPosition,
selectJumpHopTileAsset,
} from './jumpHopRuntimeModel';
test('跳一跳地块池按平台编号从 25 个素材中抽取而不是按类型压扁', () => {
const tileAssets = Array.from({ length: 25 }, (_, index) => ({
test('跳一跳地块池按平台编号从 18 个素材中抽取而不是按类型压扁', () => {
const tileAssets = Array.from({ length: 18 }, (_, index) => ({
tileType: 'normal',
tileId: `tile-${String(index + 1).padStart(2, '0')}`,
imageSrc: `asset-${index + 1}`,
imageObjectKey: `key-${index + 1}`,
assetObjectId: `object-${index + 1}`,
sourceAtlasCell: `row-1-col-${index + 1}`,
atlasRow: 1,
atlasCol: index + 1,
sourceAtlasCell: `row-${Math.floor(index / 3) + 1}-col-${(index % 3) + 1}`,
atlasRow: Math.floor(index / 3) + 1,
atlasCol: (index % 3) + 1,
visualWidth: 256,
visualHeight: 192,
visualHeight: 256,
topSurfaceRadius: 42,
landingRadius: 34,
})) satisfies JumpHopTileAsset[];
@@ -59,15 +59,17 @@ test('跳一跳可见平台窗口固定为 3 个并携带选中的地块素材',
platform(0.8, 5.1, 'normal'),
],
};
const tileAssets = Array.from({ length: 25 }, (_, index) => ({
const tileAssets = Array.from({ length: 18 }, (_, index) => ({
tileType: 'normal',
tileId: `tile-${String(index + 1).padStart(2, '0')}`,
imageSrc: `asset-${index + 1}`,
imageObjectKey: `key-${index + 1}`,
assetObjectId: `object-${index + 1}`,
sourceAtlasCell: `row-1-col-${index + 1}`,
sourceAtlasCell: `row-${Math.floor(index / 3) + 1}-col-${(index % 3) + 1}`,
atlasRow: Math.floor(index / 3) + 1,
atlasCol: (index % 3) + 1,
visualWidth: 256,
visualHeight: 192,
visualHeight: 256,
topSurfaceRadius: 42,
landingRadius: 34,
})) satisfies JumpHopTileAsset[];
@@ -119,12 +121,12 @@ test('跳一跳三块可见地块按下方中部上方展开且角色落在当
visible,
);
expect(visible[0]?.screenY).toBeGreaterThanOrEqual(68);
expect(visible[0]?.screenY).toBeLessThanOrEqual(80);
expect(visible[0]?.screenY).toBeGreaterThanOrEqual(60);
expect(visible[0]?.screenY).toBeLessThanOrEqual(66);
expect(visible[1]?.screenY).toBeGreaterThanOrEqual(40);
expect(visible[1]?.screenY).toBeLessThan(visible[0]?.screenY ?? 0);
expect(visible[2]?.screenY).toBeLessThan(visible[1]?.screenY ?? 0);
expect(visible[2]?.screenY).toBeLessThanOrEqual(26);
expect(visible[2]?.screenY).toBeLessThanOrEqual(32);
expect(Math.abs((visible[1]?.screenX ?? 0) - (visible[0]?.screenX ?? 0))).toBeGreaterThan(5);
expect(Math.abs((visible[2]?.screenX ?? 0) - (visible[1]?.screenX ?? 0))).toBeGreaterThan(5);
expect(character?.screenX).toBeCloseTo(visible[0]?.screenX ?? 0, 1);
@@ -216,8 +218,8 @@ test('跳一跳三维角色画布坐标与屏幕坐标同向映射到下方起
expect(canvasPosition?.x).toBeGreaterThan(140);
expect(canvasPosition?.x).toBeLessThan(180);
expect(canvasPosition?.y).toBeGreaterThan(380);
expect(canvasPosition?.y).toBeLessThan(450);
expect(canvasPosition?.y).toBeGreaterThan(330);
expect(canvasPosition?.y).toBeLessThan(370);
});
test('跳一跳运行态当前地块视觉尺寸按原调参结果放大一倍', () => {
@@ -227,7 +229,7 @@ test('跳一跳运行态当前地块视觉尺寸按原调参结果放大一倍',
expect(size.height).toBeCloseTo(103.68, 2);
});
test('跳一跳落点辅助标识按后端落点规则随拖拽方向和距离投影', () => {
test('跳一跳落点预测按蓄力值沿下一地块中心方向投影', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -265,22 +267,12 @@ test('跳一跳落点辅助标识按后端落点规则随拖拽方向和距离
const current = visible[0]!;
const target = visible[1]!;
const stageSize = { width: 320, height: 568 };
const currentCanvasPosition = {
x: (current.screenX / 100) * stageSize.width,
y: (current.screenY / 100) * stageSize.height,
};
const targetCanvasPosition = {
x: (target.screenX / 100) * stageSize.width,
y: (target.screenY / 100) * stageSize.height,
};
const targetWorldDistance = Math.hypot(
target.platform.x - current.platform.x,
target.platform.y - current.platform.y,
);
const fullDragDistance =
targetWorldDistance / path.scoring.chargeToDistanceRatio;
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
const dragVectorY = -(targetCanvasPosition.y - currentCanvasPosition.y);
const fullAssist = getJumpHopLandingAssistVisualPosition(
run,
@@ -288,8 +280,6 @@ test('跳一跳落点辅助标识按后端落点规则随拖拽方向和距离
character,
stageSize,
fullDragDistance,
dragVectorX,
dragVectorY,
);
const halfAssist = getJumpHopLandingAssistVisualPosition(
run,
@@ -297,23 +287,21 @@ test('跳一跳落点辅助标识按后端落点规则随拖拽方向和距离
character,
stageSize,
fullDragDistance / 2,
dragVectorX,
dragVectorY,
);
expect(fullAssist?.screenX).toBeCloseTo(target.screenX, 1);
expect(fullAssist?.screenY).toBeCloseTo(target.screenY, 1);
expect(fullAssist?.screenY).toBeCloseTo(target.screenY - 3, 1);
expect(halfAssist?.screenX).toBeCloseTo(
current.screenX + (target.screenX - current.screenX) / 2,
1,
);
expect(halfAssist?.screenY).toBeCloseTo(
current.screenY + (target.screenY - current.screenY) / 2,
current.screenY + (target.screenY - current.screenY) / 2 - 3,
1,
);
});
test('跳一跳落点辅助标识使用屏幕坐标向后拖拽并投向上方目标地块', () => {
test('跳一跳落点预测忽略旧客户端拖拽方向', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -351,16 +339,6 @@ test('跳一跳落点辅助标识使用屏幕坐标向后拖拽并投向上方
const current = visible[0]!;
const target = visible[1]!;
const stageSize = { width: 320, height: 568 };
const currentCanvasPosition = {
x: (current.screenX / 100) * stageSize.width,
y: (current.screenY / 100) * stageSize.height,
};
const targetCanvasPosition = {
x: (target.screenX / 100) * stageSize.width,
y: (target.screenY / 100) * stageSize.height,
};
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
const dragVectorY = currentCanvasPosition.y - targetCanvasPosition.y;
const targetWorldDistance = Math.hypot(
target.platform.x - current.platform.x,
target.platform.y - current.platform.y,
@@ -374,16 +352,29 @@ test('跳一跳落点辅助标识使用屏幕坐标向后拖拽并投向上方
character,
stageSize,
fullDragDistance,
dragVectorX,
dragVectorY,
-999,
-999,
);
expect(dragVectorY).toBeGreaterThan(0);
expect(assist?.screenX).toBeCloseTo(target.screenX, 1);
expect(assist?.screenY).toBeCloseTo(target.screenY, 1);
expect(assist?.screenY).toBeCloseTo(target.screenY - 3, 1);
expect(assist?.isOnTargetPlatform).toBe(true);
});
test('跳一跳后端落点向量会把屏幕拖拽换算为世界尺度一致的反向弹射', () => {
test('跳一跳落点预测用收缩后的视觉顶面 footprint 判断命中', () => {
const target = {
...platform(1, 0, 'normal'),
width: 2,
height: 0.6,
landingRadius: 0.2,
};
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.6, 0)).toBe(true);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.8, 0)).toBe(false);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1, 0.18)).toBe(false);
});
test('跳一跳成功落地后保留真实落点偏移而不是吸附到地块中心', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -406,41 +397,34 @@ test('跳一跳后端落点向量会把屏幕拖拽换算为世界尺度一致
profileId: 'profile-1',
ownerUserId: 'user-1',
status: 'playing',
currentPlatformIndex: 0,
successfulJumpCount: 0,
currentPlatformIndex: 1,
successfulJumpCount: 1,
durationMs: 0,
score: 0,
score: 1,
combo: 0,
path,
lastJump: null,
lastJump: {
chargeMs: 300,
jumpDistance: 1.0,
targetPlatformIndex: 1,
landedX: 0.52,
landedY: 0.78,
result: 'hit',
},
startedAtMs: 1000,
finishedAtMs: null,
} as const;
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
const current = visible[0]!;
const target = visible[1]!;
const stageSize = { width: 320, height: 568 };
const currentCanvasPosition = {
x: (current.screenX / 100) * stageSize.width,
y: (current.screenY / 100) * stageSize.height,
};
const targetCanvasPosition = {
x: (target.screenX / 100) * stageSize.width,
y: (target.screenY / 100) * stageSize.height,
};
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
const dragVectorY = currentCanvasPosition.y - targetCanvasPosition.y;
const backendVector = getJumpHopBackendDragVector(
run,
visible,
stageSize,
dragVectorX,
dragVectorY,
);
const visible = buildJumpHopVisiblePlatforms(path, 1, []);
const character = getJumpHopCharacterVisualPosition(run, visible, {
width: 320,
height: 568,
});
const currentCenter = visible[0]!;
expect(backendVector.dragVectorX).toBeLessThan(0);
expect(backendVector.dragVectorY).toBeGreaterThan(0);
expect(Math.abs(backendVector.dragVectorY)).toBeLessThan(Math.abs(dragVectorY));
expect(character?.screenX).not.toBeCloseTo(currentCenter.screenX, 1);
expect(character?.screenY).not.toBeCloseTo(currentCenter.screenY - 3, 1);
expect(character?.screenX).toBeLessThan(currentCenter.screenX);
expect(character?.screenY).toBeGreaterThan(currentCenter.screenY - 3);
});
test('跳一跳运行态公开反馈不再展示旧 perfect 和通关语义', () => {
+240 -40
View File
@@ -42,6 +42,7 @@ export type JumpHopLandingAssistVisualPosition = {
screenX: number;
screenY: number;
targetPlatformIndex: number;
isOnTargetPlatform: boolean;
};
export type JumpHopBackendDragVector = {
@@ -49,12 +50,19 @@ export type JumpHopBackendDragVector = {
dragVectorY: number;
};
const JUMP_HOP_DEFAULT_CHARGE_TO_DISTANCE_RATIO = 0.004;
const JUMP_HOP_DEFAULT_STAGE_SIZE: JumpHopCanvasSize = {
width: 320,
height: 568,
};
const VISIBLE_PLATFORM_COUNT = 3;
const JUMP_HOP_STAGE_WORLD_SCALE = 4.2;
const JUMP_HOP_STAGE_FORWARD_SCALE = 3;
const JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y = [78, 50, 22] as const;
const JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y = [64, 47, 30] as const;
const JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER = 2;
const JUMP_HOP_SCREEN_X_WORLD_PERCENT = 16 * 0.96;
const JUMP_HOP_SCREEN_X_WORLD_PERCENT = 11.2;
const JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO = 0.72;
const JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO = 0.52;
const tileToneByType: Record<JumpHopTileType, string> = {
accent: '#e0f2fe',
@@ -128,7 +136,7 @@ export function buildJumpHopVisiblePlatforms(
: depth === 1
? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[1]
: JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[2];
const screenX = clamp(50 + dx * 16 * worldScale, 14, 86);
const screenX = clamp(50 + dx * JUMP_HOP_SCREEN_X_WORLD_PERCENT, 14, 86);
return {
platform,
@@ -198,6 +206,31 @@ function getJumpHopCanvasPosition(
};
}
function getJumpHopCharacterVisualPositionFromPlatform(
platform: JumpHopVisiblePlatform,
isMiss = false,
): JumpHopCharacterVisualPosition {
if (isMiss) {
return {
screenX: platform.screenX + 8,
screenY: platform.screenY - 2,
sceneX: platform.sceneX + 0.7,
sceneY: platform.sceneY + 0.48,
sceneZ: platform.sceneZ - 0.4,
isMiss: true,
};
}
return {
screenX: platform.screenX,
screenY: platform.screenY - 3,
sceneX: platform.sceneX,
sceneY: platform.sceneY + 0.84,
sceneZ: platform.sceneZ,
isMiss: false,
};
}
function getJumpHopScreenWorldScales(
currentPlatform: JumpHopVisiblePlatform,
targetPlatform: JumpHopVisiblePlatform,
@@ -257,6 +290,155 @@ function getJumpHopScreenWorldScales(
};
}
export function getJumpHopWorldLandingVisualPosition(
originPlatform: JumpHopVisiblePlatform | null | undefined,
scalePlatform: JumpHopVisiblePlatform | null | undefined,
stageSize: JumpHopCanvasSize,
landedX: number,
landedY: number,
isMiss = false,
): JumpHopCharacterVisualPosition | null {
if (
!originPlatform ||
!scalePlatform ||
stageSize.width <= 0 ||
stageSize.height <= 0 ||
!Number.isFinite(landedX) ||
!Number.isFinite(landedY)
) {
return null;
}
const scales = getJumpHopScreenWorldScales(
originPlatform,
scalePlatform,
stageSize,
);
const worldDeltaX = landedX - originPlatform.platform.x;
const worldDeltaY = landedY - originPlatform.platform.y;
const landedPixelX =
scales.currentCanvasPosition.x +
worldDeltaX * scales.signedXScreenPerWorld;
const landedPixelY =
scales.currentCanvasPosition.y +
worldDeltaY * scales.signedYScreenPerWorld;
const sceneDeltaX =
(landedX - originPlatform.platform.x) * JUMP_HOP_STAGE_WORLD_SCALE;
const sceneDeltaZ =
(landedY - originPlatform.platform.y) * JUMP_HOP_STAGE_FORWARD_SCALE;
return {
screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94),
screenY: clamp((landedPixelY / stageSize.height) * 100 - 3, 10, 92),
sceneX: originPlatform.sceneX + sceneDeltaX,
sceneY: originPlatform.sceneY + (isMiss ? 0.48 : 0.84),
sceneZ: originPlatform.sceneZ + sceneDeltaZ,
isMiss,
};
}
export function isJumpHopLandingInsidePlatformFootprint(
platform: JumpHopPlatform | null | undefined,
landedX: number,
landedY: number,
) {
if (
!platform ||
!Number.isFinite(landedX) ||
!Number.isFinite(landedY)
) {
return false;
}
const halfWidth = Math.max(
0,
platform.width * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO,
);
const halfHeight = Math.max(
0,
platform.height * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO,
);
return (
Math.abs(landedX - platform.x) <= halfWidth &&
Math.abs(landedY - platform.y) <= halfHeight
);
}
function getJumpHopSuccessfulLandingVisualPosition(
run: JumpHopRuntimeRunSnapshotResponse,
platforms: JumpHopVisiblePlatform[],
stageSize: JumpHopCanvasSize,
) {
const lastJump = run.lastJump;
if (!lastJump) {
return null;
}
const landedPlatform =
platforms.find((item) => item.index === run.currentPlatformIndex) ??
platforms.find((item) => item.index === lastJump.targetPlatformIndex) ??
null;
if (!landedPlatform) {
return null;
}
const previousPlatformIndex = Math.max(0, lastJump.targetPlatformIndex - 1);
const previousWindowPlatforms = buildJumpHopVisiblePlatforms(
run.path,
previousPlatformIndex,
[],
);
const previousPlatform =
previousWindowPlatforms.find(
(item) => item.index === previousPlatformIndex,
) ?? null;
const targetPlatformInPreviousWindow =
previousWindowPlatforms.find(
(item) => item.index === lastJump.targetPlatformIndex,
) ?? null;
const landingInPreviousWindow = getJumpHopWorldLandingVisualPosition(
previousPlatform,
targetPlatformInPreviousWindow,
stageSize,
lastJump.landedX,
lastJump.landedY,
false,
);
if (!landingInPreviousWindow || !targetPlatformInPreviousWindow) {
return null;
}
const targetCenterInPreviousWindow =
getJumpHopCharacterVisualPositionFromPlatform(
targetPlatformInPreviousWindow,
);
const landedPlatformCenter =
getJumpHopCharacterVisualPositionFromPlatform(landedPlatform);
const worldDeltaX = lastJump.landedX - landedPlatform.platform.x;
const worldDeltaY = lastJump.landedY - landedPlatform.platform.y;
return {
screenX: clamp(
landedPlatformCenter.screenX +
landingInPreviousWindow.screenX -
targetCenterInPreviousWindow.screenX,
6,
94,
),
screenY: clamp(
landedPlatformCenter.screenY +
landingInPreviousWindow.screenY -
targetCenterInPreviousWindow.screenY,
10,
92,
),
sceneX: landedPlatform.sceneX + worldDeltaX * JUMP_HOP_STAGE_WORLD_SCALE,
sceneY: landedPlatform.sceneY + 0.84,
sceneZ: landedPlatform.sceneZ + worldDeltaY * JUMP_HOP_STAGE_FORWARD_SCALE,
isMiss: false,
};
}
export function getJumpHopBackendDragVector(
run: JumpHopRuntimeRunSnapshotResponse | null,
platforms: JumpHopVisiblePlatform[],
@@ -290,8 +472,8 @@ export function getJumpHopLandingAssistVisualPosition(
characterPosition: JumpHopCharacterVisualPosition | null,
stageSize: JumpHopCanvasSize,
dragDistance: number,
dragVectorX: number | null,
dragVectorY: number | null,
_dragVectorX?: number | null,
_dragVectorY?: number | null,
) {
if (
!run ||
@@ -310,27 +492,13 @@ export function getJumpHopLandingAssistVisualPosition(
}
const { currentPlatform, targetPlatform } = pair;
const dragX = dragVectorX ?? 0;
const dragY = dragVectorY ?? 0;
const dragLength = Math.hypot(dragX, dragY);
if (dragLength < 0.0001) {
return null;
}
const scales = getJumpHopScreenWorldScales(
currentPlatform,
targetPlatform,
stageSize,
);
const backendDragVector = getJumpHopBackendDragVector(
run,
platforms,
stageSize,
dragX,
dragY,
);
const jumpWorldX = -backendDragVector.dragVectorX;
const jumpWorldY = backendDragVector.dragVectorY;
const jumpWorldX = targetPlatform.platform.x - currentPlatform.platform.x;
const jumpWorldY = targetPlatform.platform.y - currentPlatform.platform.y;
const jumpWorldLength = Math.hypot(jumpWorldX, jumpWorldY);
if (jumpWorldLength < 0.0001) {
return null;
@@ -341,13 +509,15 @@ export function getJumpHopLandingAssistVisualPosition(
const chargeToDistanceRatio =
run.path.scoring.chargeToDistanceRatio > 0
? run.path.scoring.chargeToDistanceRatio
: 0.008;
: JUMP_HOP_DEFAULT_CHARGE_TO_DISTANCE_RATIO;
const projectedWorldDistance =
clamp(dragDistance, 0, maxDragDistance) * chargeToDistanceRatio;
const landedWorldDeltaX =
(jumpWorldX / jumpWorldLength) * projectedWorldDistance;
const landedWorldDeltaY =
(jumpWorldY / jumpWorldLength) * projectedWorldDistance;
const landedWorldX = currentPlatform.platform.x + landedWorldDeltaX;
const landedWorldY = currentPlatform.platform.y + landedWorldDeltaY;
const landedPixelX =
scales.currentCanvasPosition.x +
landedWorldDeltaX * scales.signedXScreenPerWorld;
@@ -357,8 +527,13 @@ export function getJumpHopLandingAssistVisualPosition(
return {
screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94),
screenY: clamp((landedPixelY / stageSize.height) * 100, 10, 92),
screenY: clamp((landedPixelY / stageSize.height) * 100 - 3, 10, 92),
targetPlatformIndex: targetPlatform.index,
isOnTargetPlatform: isJumpHopLandingInsidePlatformFootprint(
targetPlatform.platform,
landedWorldX,
landedWorldY,
),
};
}
@@ -379,39 +554,64 @@ export function resolveJumpHopCharacterCanvasPosition(
export function getJumpHopCharacterVisualPosition(
run: JumpHopRuntimeRunSnapshotResponse | null,
platforms: JumpHopVisiblePlatform[],
stageSize: JumpHopCanvasSize = JUMP_HOP_DEFAULT_STAGE_SIZE,
) {
if (!run) {
return null;
}
const lastJump = run.lastJump;
if (lastJump) {
const isMiss = lastJump.result === 'miss';
if (!isMiss) {
const landedPosition = getJumpHopSuccessfulLandingVisualPosition(
run,
platforms,
stageSize,
);
if (landedPosition) {
return landedPosition;
}
}
const originPlatform =
platforms.find((item) => item.index === run.currentPlatformIndex) ??
platforms[0] ??
null;
const scalePlatform =
platforms.find((item) =>
isMiss
? item.index === lastJump.targetPlatformIndex
: item.index === run.currentPlatformIndex + 1,
) ??
platforms.find((item) => item.index === lastJump.targetPlatformIndex) ??
originPlatform;
const landedPosition = getJumpHopWorldLandingVisualPosition(
originPlatform,
scalePlatform,
stageSize,
lastJump.landedX,
lastJump.landedY,
isMiss,
);
if (landedPosition) {
return landedPosition;
}
}
const landedPlatform = platforms.find(
(item) => item.index === run.currentPlatformIndex,
);
if (landedPlatform) {
return {
screenX: landedPlatform.screenX,
screenY: landedPlatform.screenY - 3,
sceneX: landedPlatform.sceneX,
sceneY: landedPlatform.sceneY + 0.84,
sceneZ: landedPlatform.sceneZ,
isMiss: false,
};
return getJumpHopCharacterVisualPositionFromPlatform(landedPlatform);
}
const lastJump = run.lastJump;
if (lastJump && run.status === 'failed') {
const targetPlatform = platforms.find(
(item) => item.index === lastJump.targetPlatformIndex,
);
if (targetPlatform) {
return {
screenX: targetPlatform.screenX + 8,
screenY: targetPlatform.screenY - 2,
sceneX: targetPlatform.sceneX + 0.7,
sceneY: targetPlatform.sceneY + 0.48,
sceneZ: targetPlatform.sceneZ - 0.4,
isMiss: true,
};
return getJumpHopCharacterVisualPositionFromPlatform(targetPlatform, true);
}
}
@@ -505,7 +505,7 @@ describe('miniGameDraftGenerationProgress', () => {
'jump-hop-write-draft',
]);
expect(progress?.phaseId).toBe('jump-hop-tile-atlas');
expect(progress?.phaseLabel).toBe('生成 5x5 地块图集');
expect(progress?.phaseLabel).toBe('生成 UV 贴图图集');
expect(progress?.estimatedRemainingMs).toBe(265_000);
});
@@ -513,7 +513,7 @@ describe('miniGameDraftGenerationProgress', () => {
const entries = buildJumpHopGenerationAnchorEntries(null, {
themeText: '云端糖果塔',
templateId: 'jump-hop',
tilePrompt: '云端糖果塔主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
tilePrompt: '云端糖果塔主题的3D立方体主题身份方块包装图集',
});
expect(entries).toEqual([
@@ -524,8 +524,8 @@ describe('miniGameDraftGenerationProgress', () => {
},
{
id: 'jump-hop-tile-style',
label: '地块图集',
value: '云端糖果塔主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
label: '地块贴图',
value: '云端糖果塔主题的3D立方体主题身份方块包装图集',
},
]);
});
@@ -408,20 +408,20 @@ const JUMP_HOP_STEPS = [
},
{
id: 'jump-hop-tile-atlas',
label: '生成 5x5 地块图集',
detail: '调用 image2 生成 25 个主题地块素材。',
label: '生成 UV 贴图图集',
detail: '调用 image2 一次生成 18 个立方体六面展开包装。',
weight: 54,
},
{
id: 'jump-hop-slice-tiles',
label: '切分 25 个地块',
detail: '按 5 行 5 列切分透明地块 PNG。',
label: '切分六面贴图',
detail: '按 3 列 6 行与 4x3 UV 网切分 108 张面贴图 PNG。',
weight: 24,
},
{
id: 'jump-hop-write-draft',
label: '写入正式草稿',
detail: '保存地块池、无限路径缓冲和运行态配置。',
detail: '保存地板贴图池、无限路径缓冲和运行态配置。',
weight: 10,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
@@ -1183,7 +1183,7 @@ export function buildJumpHopGenerationAnchorEntries(
},
{
key: 'jump-hop-tile-style',
label: '地块图集',
label: '地块贴图',
value:
formPayload?.tilePrompt?.trim() ||
config?.tilePrompt?.trim() ||