合并 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

View File

@@ -8,9 +8,13 @@ import type {
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { buildJumpHopVisiblePlatforms } from '../../services/jump-hop/jumpHopRuntimeModel';
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
import { JumpHopRuntimeShell } from './JumpHopRuntimeShell';
import {
JUMP_HOP_THREE_CAMERA_UP_Y,
JumpHopRuntimeShell,
getJumpHopThreeProjectedY,
getJumpHopTileTextureSignature,
} from './JumpHopRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({
@@ -44,22 +48,10 @@ function dispatchPointerEvent(
target.dispatchEvent(event);
}
test('跳一跳运行态松手时提交向后拖动向量', async () => {
test('跳一跳运行态松手时提交长按蓄力值和后端方向向量', async () => {
vi.useFakeTimers();
const onJump = vi.fn().mockResolvedValue(undefined);
const run = buildRun();
const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []);
const current = visiblePlatforms[0]!;
const target = visiblePlatforms[1]!;
const stageSize = { width: 320, height: 568 };
const xPixelsPerWorldUnit =
Math.abs(
((target.screenX - current.screenX) / 100) * stageSize.width,
) / Math.abs(target.platform.x - current.platform.x);
const yPixelsPerWorldUnit =
Math.abs(
((target.screenY - current.screenY) / 100) * stageSize.height,
) / Math.abs(target.platform.y - current.platform.y);
render(
<JumpHopRuntimeShell
@@ -85,6 +77,9 @@ test('跳一跳运行态松手时提交向后拖动向量', async () => {
clientY: 478,
});
});
await act(async () => {
await vi.advanceTimersByTimeAsync(360);
});
await act(async () => {
dispatchPointerEvent(stage, 'pointerup', {
@@ -96,28 +91,19 @@ test('跳一跳运行态松手时提交向后拖动向量', async () => {
expect(onJump).toHaveBeenCalledTimes(1);
const jumpPayload = onJump.mock.calls[0]?.[0];
expect(jumpPayload?.dragVectorX).toBeCloseTo(-48 / xPixelsPerWorldUnit, 2);
expect(jumpPayload?.dragVectorY).toBeCloseTo(58 / yPixelsPerWorldUnit, 2);
expect(jumpPayload?.dragDistance).toBeGreaterThan(74);
expect(jumpPayload?.dragDistance).toBeLessThan(76);
expect(typeof jumpPayload?.dragVectorX).toBe('number');
expect(typeof jumpPayload?.dragVectorY).toBe('number');
expect(Number.isFinite(jumpPayload?.dragVectorX)).toBe(true);
expect(Number.isFinite(jumpPayload?.dragVectorY)).toBe(true);
expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(360);
expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(380);
vi.useRealTimers();
});
test('跳一跳运行态拖拽方向按手指起点到松手点计算', async () => {
test('跳一跳运行态手指移动不改变蓄力时长但仍提交方向向量', async () => {
vi.useFakeTimers();
const onJump = vi.fn().mockResolvedValue(undefined);
const run = buildRun();
const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []);
const current = visiblePlatforms[0]!;
const target = visiblePlatforms[1]!;
const stageSize = { width: 320, height: 568 };
const xPixelsPerWorldUnit =
Math.abs(
((target.screenX - current.screenX) / 100) * stageSize.width,
) / Math.abs(target.platform.x - current.platform.x);
const yPixelsPerWorldUnit =
Math.abs(
((target.screenY - current.screenY) / 100) * stageSize.height,
) / Math.abs(target.platform.y - current.platform.y);
render(
<JumpHopRuntimeShell
@@ -143,6 +129,9 @@ test('跳一跳运行态拖拽方向按手指起点到松手点计算', async ()
clientY: 20,
});
});
await act(async () => {
await vi.advanceTimersByTimeAsync(240);
});
await act(async () => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: 1,
@@ -152,15 +141,63 @@ test('跳一跳运行态拖拽方向按手指起点到松手点计算', async ()
});
const jumpPayload = onJump.mock.calls[0]?.[0];
expect(jumpPayload?.dragVectorX).toBeLessThan(0);
expect(jumpPayload?.dragVectorY).toBeLessThan(0);
expect(Math.abs(jumpPayload?.dragVectorX ?? 0)).toBeLessThan(30);
expect(Math.abs(jumpPayload?.dragVectorY ?? 0)).toBeLessThan(20);
expect(jumpPayload?.dragVectorX).toBeCloseTo(-30 / xPixelsPerWorldUnit, 2);
expect(jumpPayload?.dragVectorY).toBeCloseTo(-20 / yPixelsPerWorldUnit, 2);
expect(typeof jumpPayload?.dragVectorX).toBe('number');
expect(typeof jumpPayload?.dragVectorY).toBe('number');
expect(Number.isFinite(jumpPayload?.dragVectorX)).toBe(true);
expect(Number.isFinite(jumpPayload?.dragVectorY)).toBe(true);
expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(240);
expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(260);
vi.useRealTimers();
});
test('跳一跳运行态不再显示旧圆弧蓄力条而是显示弹弓拉线', async () => {
test('跳一跳运行态长按蓄力不会超过后端上限', async () => {
vi.useFakeTimers();
const onJump = vi.fn().mockResolvedValue(undefined);
const baseRun = buildRun();
const run: JumpHopRuntimeRunSnapshotResponse = {
...baseRun,
path: {
...baseRun.path,
scoring: {
...baseRun.path.scoring,
maxChargeMs: 300,
},
},
};
render(
<JumpHopRuntimeShell
profile={buildProfile()}
run={run}
onJump={onJump}
onRestart={() => {}}
/>,
);
const stage = screen.getByTestId('jump-hop-stage');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 40,
clientY: 40,
});
});
await act(async () => {
await vi.advanceTimersByTimeAsync(780);
});
await act(async () => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: 1,
clientX: 40,
clientY: 40,
});
});
expect(onJump.mock.calls[0]?.[0]?.dragDistance).toBe(300);
vi.useRealTimers();
});
test('跳一跳运行态不再显示旧圆弧蓄力条而是显示长按蓄力引导', async () => {
const onJump = vi.fn().mockResolvedValue(undefined);
render(
@@ -183,10 +220,12 @@ test('跳一跳运行态不再显示旧圆弧蓄力条而是显示弹弓拉线',
expect(screen.queryByText('起跳')).toBeNull();
expect(stage.querySelector('.jump-hop-runtime__charge-orbit')).toBeNull();
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
});
test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => {
test('跳一跳蓄力时角色只做垂直压缩', async () => {
vi.useFakeTimers();
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
@@ -205,11 +244,7 @@ test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => {
});
});
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 132,
clientY: 478,
});
await vi.advanceTimersByTimeAsync(180);
});
const character = screen.getByTestId('jump-hop-character-logo')
@@ -221,12 +256,20 @@ test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => {
.map((style) => style.textContent ?? '')
.join('\n');
expect(stretchTransform).toContain('matrix(');
expect(stretchTransform).not.toBe('matrix(1, 0, 0, 1, 0, 0)');
expect(stretchTransform).toMatch(/^scale\((?<x>[\d.]+), (?<y>[\d.]+)\)$/);
const scaleMatch = stretchTransform.match(
/^scale\((?<x>[\d.]+), (?<y>[\d.]+)\)$/,
);
const scaleX = Number(scaleMatch?.groups?.x ?? 1);
const scaleY = Number(scaleMatch?.groups?.y ?? 1);
expect(scaleX).toBeGreaterThan(1);
expect(scaleY).toBeLessThan(1);
expect(scaleY).toBeLessThan(scaleX);
expect(styleText).toContain('var(--jump-hop-character-stretch-transform)');
expect(styleText).not.toContain(
'scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))',
);
vi.useRealTimers();
});
test('跳一跳运行态游玩中只保留得分并隐藏常驻排行榜', () => {
@@ -379,7 +422,7 @@ test('跳一跳草稿运行失败后不请求公开排行榜', () => {
expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull();
});
test('跳一跳角色层永远压在地块层之上', () => {
test('跳一跳 Three.js 地板层位于 DOM 角色层下方', () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
@@ -393,13 +436,19 @@ test('跳一跳角色层永远压在地块层之上', () => {
const firstPlatform = screen.getAllByTestId('jump-hop-tile-image')[0]
?.parentElement?.parentElement as HTMLElement | undefined;
expect(threeScene.style.zIndex).toBe('100');
expect(threeScene.style.zIndex).toBe('42');
expect(Number(threeScene.style.zIndex)).toBeGreaterThan(
Number(firstPlatform?.style.zIndex ?? 0),
);
});
test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async () => {
test('跳一跳 Three.js 平台层和 DOM 角色层保持同向屏幕坐标', () => {
expect(JUMP_HOP_THREE_CAMERA_UP_Y).toBe(1);
expect(getJumpHopThreeProjectedY(360, 568)).toBeLessThan(284);
expect(getJumpHopThreeProjectedY(200, 568)).toBeGreaterThan(284);
});
test('跳一跳蓄力时隐藏落点辅助标识但保留蓄力引导', async () => {
const onJump = vi.fn().mockResolvedValue(undefined);
render(
@@ -429,7 +478,7 @@ test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async ()
});
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
@@ -440,10 +489,11 @@ test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async ()
});
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
});
test('跳一跳运行态直接渲染生成的地切片图片', () => {
test('跳一跳运行态直接渲染生成的地板贴图切片图片', () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
@@ -498,6 +548,71 @@ test('跳一跳运行态提前预加载下一屏地块且不在真实图片加
);
});
test('跳一跳新 UV 地板资源会解析六张面贴图而不是复用单张图', () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets({ withFaceAssets: true }) })}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image');
const faceImageSources = preloadImages
.map((image) => image.getAttribute('src') ?? '')
.filter((source) =>
source.includes('/generated-jump-hop-assets/jump-hop-profile-test/tile-'),
);
const firstTileMatch = faceImageSources[0]?.match(/tile-(\d{2})-/);
const firstTileNumber = firstTileMatch?.[1];
expect(firstTileNumber).toBeTruthy();
expect(faceImageSources).toEqual(
expect.arrayContaining([
expect.stringContaining(`/tile-${firstTileNumber}-top/image.png`),
expect.stringContaining(`/tile-${firstTileNumber}-front/image.png`),
expect.stringContaining(`/tile-${firstTileNumber}-right/image.png`),
expect.stringContaining(`/tile-${firstTileNumber}-back/image.png`),
expect.stringContaining(`/tile-${firstTileNumber}-left/image.png`),
expect.stringContaining(`/tile-${firstTileNumber}-bottom/image.png`),
]),
);
const frontSource = `/tile-${firstTileNumber}-front/image.png`;
const frontRefreshKey = `asset-object-${firstTileNumber}-front`;
expect(
vi
.mocked(useResolvedAssetReadUrl)
.mock.calls.some(
([source, options]) =>
source?.includes(frontSource) && options?.refreshKey === frontRefreshKey,
),
).toBe(true);
});
test('跳一跳 Three.js 地板贴图签名包含六面贴图 URL', () => {
const asset = buildTileAssets({ withFaceAssets: true })[0];
const signature = getJumpHopTileTextureSignature(
{
'p1::top': 'top-url',
'p1::front': 'front-url',
'p1::right': 'right-url',
'p1::back': 'back-url',
'p1::left': 'left-url',
'p1::bottom': 'bottom-url',
},
'p1',
asset,
);
expect(signature).toContain('top-url');
expect(signature).toContain('front-url');
expect(signature).toContain('right-url');
expect(signature).toContain('back-url');
expect(signature).toContain('left-url');
expect(signature).toContain('bottom-url');
});
test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => {
render(
<JumpHopRuntimeShell
@@ -513,9 +628,9 @@ test('跳一跳运行态首块地块落在中下方并且后续两块向中央
const first = tileImages[0]?.parentElement?.parentElement as HTMLElement | undefined;
const second = tileImages[1]?.parentElement?.parentElement as HTMLElement | undefined;
const third = tileImages[2]?.parentElement?.parentElement as HTMLElement | undefined;
expect(first?.style.top).toBe('78%');
expect(second?.style.top).toBe('50%');
expect(third?.style.top).toBe('22%');
expect(first?.style.top).toBe('64%');
expect(second?.style.top).toBe('47%');
expect(third?.style.top).toBe('30%');
});
test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸', () => {
@@ -604,11 +719,11 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
successfulJumpCount: 1,
score: 1,
lastJump: {
chargeMs: 150,
jumpDistance: 1.44,
chargeMs: 420,
jumpDistance: 1.68,
targetPlatformIndex: 1,
landedX: 0.8,
landedY: 1.2,
landedX: 0.93,
landedY: 1.4,
result: 'hit',
},
};
@@ -636,6 +751,9 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
clientY: 478,
});
});
await act(async () => {
await vi.advanceTimersByTimeAsync(420);
});
await act(async () => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: 1,
@@ -678,6 +796,63 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
vi.useRealTimers();
});
test('跳一跳成功落点偏移后下一跳视觉仍朝下一块地块方向', async () => {
vi.useFakeTimers();
const onJump = vi.fn().mockResolvedValue(undefined);
const run: JumpHopRuntimeRunSnapshotResponse = {
...buildRun(),
currentPlatformIndex: 1,
successfulJumpCount: 1,
score: 1,
lastJump: {
chargeMs: 300,
jumpDistance: 1.0,
targetPlatformIndex: 1,
landedX: 0,
landedY: 1.2,
result: 'hit',
},
};
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={run}
onJump={onJump}
onRestart={() => {}}
/>,
);
const character = screen.getByTestId('jump-hop-character-logo')
.parentElement as HTMLElement;
const initialLeft = Number.parseFloat(character.style.left);
const initialTop = Number.parseFloat(character.style.top);
const stage = screen.getByTestId('jump-hop-stage');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 180,
clientY: 420,
});
});
await act(async () => {
await vi.advanceTimersByTimeAsync(120);
});
await act(async () => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: 1,
clientX: 180,
clientY: 420,
});
});
expect(onJump).toHaveBeenCalledTimes(1);
expect(Number.parseFloat(character.style.left)).toBeLessThan(initialLeft);
expect(Number.parseFloat(character.style.top)).toBeLessThan(initialTop);
vi.useRealTimers();
});
test('跳一跳松手后先播放飞行动画再切换到下一块地块', async () => {
vi.useFakeTimers();
const onJump = vi.fn().mockResolvedValue(undefined);
@@ -688,11 +863,25 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
successfulJumpCount: 1,
score: 1,
lastJump: {
chargeMs: 150,
jumpDistance: 1.44,
chargeMs: 420,
jumpDistance: 1.68,
targetPlatformIndex: 1,
landedX: 0.8,
landedY: 1.2,
landedX: 0.93,
landedY: 1.4,
result: 'hit',
},
};
const runAfterSecondJump: JumpHopRuntimeRunSnapshotResponse = {
...buildRunWithExtraPreviewPlatform(),
currentPlatformIndex: 2,
successfulJumpCount: 2,
score: 2,
lastJump: {
chargeMs: 360,
jumpDistance: 1.44,
targetPlatformIndex: 2,
landedX: -0.2,
landedY: 2.4,
result: 'hit',
},
};
@@ -720,6 +909,9 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
clientY: 478,
});
});
await act(async () => {
await vi.advanceTimersByTimeAsync(420);
});
await act(async () => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: 1,
@@ -731,7 +923,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
expect(onJump).toHaveBeenCalledTimes(1);
expect(stage.getAttribute('data-jump-animating')).toBe('true');
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
'78%',
'64%',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
'true',
@@ -753,7 +945,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
'true',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
'78%',
'64%',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
'true',
@@ -775,6 +967,8 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
const landedCharacter = screen.getByTestId('jump-hop-character-logo')
.parentElement as HTMLElement;
expect(landedCharacter.getAttribute('data-landing-recoil')).toBe('true');
expect(Number.parseFloat(landedCharacter.style.left)).not.toBeCloseTo(50, 1);
expect(Number.parseFloat(landedCharacter.style.top)).not.toBeCloseTo(75, 1);
expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-x')).not.toBe(
'0px',
);
@@ -783,18 +977,23 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
);
const cameraLayer = screen.getByTestId('jump-hop-camera-layer');
expect(cameraLayer.getAttribute('data-platform-advancing')).toBe('true');
expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-zoom')).toBe(
'1.3',
);
expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe(
'-28%',
'-17%',
);
expect(
Number.parseFloat(
cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'),
),
).toBeCloseTo(12.29, 2);
).toBeCloseTo(8.96, 2);
const styleText = Array.from(document.querySelectorAll('style'))
.map((style) => style.textContent ?? '')
.join('\n');
expect(styleText).toContain('@keyframes jump-hop-character-recoil');
expect(styleText).not.toContain('@keyframes jump-hop-platform-exit-drift');
expect(styleText).toContain('scale(var(--jump-hop-camera-zoom, 1))');
expect(styleText).toMatch(
/data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/,
);
@@ -826,7 +1025,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
'p1',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe(
'78%',
'64%',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
'1.08',
@@ -835,7 +1034,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
'p2',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.style.top).toBe(
'50%',
'47%',
);
await act(async () => {
@@ -870,19 +1069,78 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
expect(screen.getByTestId('jump-hop-camera-layer').getAttribute('data-platform-advancing')).toBe(
'false',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
'78%',
const retainedOldPlatform = screen
.getByTestId('jump-hop-stage')
.querySelector("[data-platform-id='p0']") as HTMLElement | null;
expect(retainedOldPlatform?.getAttribute('data-advance-state')).toBe(
'exiting',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
expect(retainedOldPlatform?.style.top).toBe('81%');
const currentPlatform = screen
.getByTestId('jump-hop-stage')
.querySelector("[data-platform-id='p1']") as HTMLElement | null;
expect(currentPlatform?.style.top).toBe('64%');
expect(currentPlatform?.getAttribute('data-current')).toBe(
'true',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
expect(currentPlatform?.getAttribute('data-platform-id')).toBe(
'p1',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe(
'50%',
expect(
(
screen
.getByTestId('jump-hop-stage')
.querySelector("[data-platform-id='p2']") as HTMLElement | null
)?.style.top,
).toBe('47%');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 2,
clientX: 180,
clientY: 420,
});
});
await act(async () => {
await vi.advanceTimersByTimeAsync(160);
});
await act(async () => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: 2,
clientX: 180,
clientY: 420,
});
});
expect(onJump).toHaveBeenCalledTimes(2);
rerender(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={runAfterSecondJump}
onJump={onJump}
onRestart={() => {}}
/>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(580);
});
const movedOldPlatform = screen
.getByTestId('jump-hop-stage')
.querySelector("[data-platform-id='p0']") as HTMLElement | null;
if (movedOldPlatform) {
expect(Number.parseFloat(movedOldPlatform.style.top)).toBeGreaterThan(81);
}
expect(
(
screen
.getByTestId('jump-hop-stage')
.querySelector("[data-current='true']") as HTMLElement | null
)?.getAttribute('data-platform-id'),
).toBe('p2');
vi.useRealTimers();
});
@@ -994,22 +1252,51 @@ function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse {
};
}
function buildTileAssets() {
return Array.from({ length: 25 }, (_, index) => {
function buildTileAssets(options: { withFaceAssets?: boolean } = {}) {
return Array.from({ length: 18 }, (_, index) => {
const tileNumber = String(index + 1).padStart(2, '0');
const atlasRow = Math.floor(index / 3) + 1;
const atlasCol = (index % 3) + 1;
const buildFaceAsset = (
face: keyof NonNullable<
JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets']
>,
) => ({
face,
assetId: `asset-${tileNumber}-${face}`,
imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`,
imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`,
assetObjectId: `asset-object-${tileNumber}-${face}`,
generationProvider: 'vector-engine',
prompt: `tile ${tileNumber} ${face}`,
width: 256,
height: 256,
sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}/${face}`,
});
const faceAssets: NonNullable<
JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets']
> = {
top: buildFaceAsset('top'),
front: buildFaceAsset('front'),
right: buildFaceAsset('right'),
back: buildFaceAsset('back'),
left: buildFaceAsset('left'),
bottom: buildFaceAsset('bottom'),
};
return {
tileType: index === 0 ? 'start' : 'normal',
tileId: `tile-${tileNumber}`,
imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`,
imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`,
assetObjectId: `asset-object-${tileNumber}`,
sourceAtlasCell: `row-${Math.floor(index / 5) + 1}-col-${(index % 5) + 1}`,
atlasRow: Math.floor(index / 5) + 1,
atlasCol: (index % 5) + 1,
sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}`,
atlasRow,
atlasCol,
visualWidth: 256,
visualHeight: 192,
visualHeight: 256,
topSurfaceRadius: 42,
landingRadius: 34,
faceAssets: options.withFaceAssets ? faceAssets : undefined,
} satisfies JumpHopWorkProfileResponse['tileAssets'][number];
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -372,9 +372,7 @@ import {
type CreationWorkShelfItem,
isPersistedBarkBattleDraftGenerating,
} from '../custom-world-home/creationWorkShelf';
import {
selectAdjacentPlatformRecommendEntry,
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
import { selectAdjacentPlatformRecommendEntry } from '../rpg-entry/rpgEntryPublicGalleryViewModel';
import {
isBigFishGalleryEntry,
isEdutainmentGalleryEntry,
@@ -1328,7 +1326,9 @@ const PuzzleClearResultView = lazy(async () => {
});
const PuzzleClearRuntimeShell = lazy(async () => {
const module = await import('../puzzle-clear-runtime/PuzzleClearRuntimeShell');
const module = await import(
'../puzzle-clear-runtime/PuzzleClearRuntimeShell'
);
return {
default: module.PuzzleClearRuntimeShell,
};
@@ -1848,6 +1848,10 @@ export function PlatformEntryFlowShellImpl({
const entries = creationEntryConfig?.creationTypes ?? [];
return new Map(entries.map((entry) => [entry.id, entry]));
}, [creationEntryConfig]);
const publicWorkInteractions = useMemo(
() => creationEntryConfig?.publicWorkInteractions ?? [],
[creationEntryConfig],
);
const getUnifiedSpec = useCallback(
(playId: UnifiedCreationPlayId) =>
getUnifiedCreationSpec(playId, unifiedCreationConfigById.get(playId)),
@@ -1903,6 +1907,7 @@ export function PlatformEntryFlowShellImpl({
shouldRestoreCustomWorldAgentUiState(),
);
const handledInitialPublicWorkCodeRef = useRef<string | null>(null);
const handledJumpHopRuntimeRestoreRef = useRef<string | null>(null);
const selectionStageRef = useRef(selectionStage);
const creationFlowReturnTargetRef =
useRef<CreationFlowReturnTarget>('create');
@@ -3035,8 +3040,10 @@ export function PlatformEntryFlowShellImpl({
woodenFishGalleryEntries,
],
);
const { featuredEntries: featuredGalleryEntries, latestEntries: latestGalleryEntries } =
publicGalleryFeeds;
const {
featuredEntries: featuredGalleryEntries,
latestEntries: latestGalleryEntries,
} = publicGalleryFeeds;
const recommendRuntimeEntries = useMemo(
() =>
buildPlatformRecommendedEntries({
@@ -3219,23 +3226,22 @@ export function PlatformEntryFlowShellImpl({
]);
useEffect(() => {
const progressTickDecision =
resolvePlatformGenerationProgressTickDecision({
selectionStage,
miniGameStates: {
puzzle: puzzleGenerationState,
match3d: match3dGenerationState,
'big-fish': bigFishGenerationState,
'square-hole': squareHoleGenerationState,
'jump-hop': jumpHopGenerationState,
'wooden-fish': woodenFishGenerationState,
'baby-object-match': babyObjectMatchGenerationState,
},
visualNovel: {
startedAtMs: visualNovelGenerationStartedAtMs,
phase: visualNovelGenerationPhase,
},
});
const progressTickDecision = resolvePlatformGenerationProgressTickDecision({
selectionStage,
miniGameStates: {
puzzle: puzzleGenerationState,
match3d: match3dGenerationState,
'big-fish': bigFishGenerationState,
'square-hole': squareHoleGenerationState,
'jump-hop': jumpHopGenerationState,
'wooden-fish': woodenFishGenerationState,
'baby-object-match': babyObjectMatchGenerationState,
},
visualNovel: {
startedAtMs: visualNovelGenerationStartedAtMs,
phase: visualNovelGenerationPhase,
},
});
if (!progressTickDecision.shouldTick) {
return undefined;
@@ -3738,7 +3744,11 @@ export function PlatformEntryFlowShellImpl({
markPendingDraftFailed('match3d', session.sessionId);
markDraftFailed(
'match3d',
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
[
session.draft?.profileId,
session.publishedProfileId,
session.sessionId,
],
errorMessage,
);
try {
@@ -4020,7 +4030,11 @@ export function PlatformEntryFlowShellImpl({
markPendingDraftFailed('square-hole', session.sessionId);
markDraftFailed(
'square-hole',
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
[
session.draft?.profileId,
session.publishedProfileId,
session.sessionId,
],
errorMessage,
);
void refreshSquareHoleShelf().catch(() => undefined);
@@ -4100,15 +4114,18 @@ export function PlatformEntryFlowShellImpl({
if (!isPuzzleCompileActionReady(response.session)) {
const nextPayload =
formPayload ?? buildPuzzleFormPayloadFromSession(response.session);
const fallbackGenerationState = createPuzzleDraftGenerationStateFromPayload(
nextPayload,
response.session,
);
const nextGenerationState = mergePuzzleSessionProgressIntoGenerationState(
puzzleGenerationState ?? fallbackGenerationState,
response.session,
);
activePuzzleGenerationSessionIdRef.current = response.session.sessionId;
const fallbackGenerationState =
createPuzzleDraftGenerationStateFromPayload(
nextPayload,
response.session,
);
const nextGenerationState =
mergePuzzleSessionProgressIntoGenerationState(
puzzleGenerationState ?? fallbackGenerationState,
response.session,
);
activePuzzleGenerationSessionIdRef.current =
response.session.sessionId;
setSelectionStage('puzzle-generating');
markDraftGenerating('puzzle', [
response.session.sessionId,
@@ -7604,6 +7621,7 @@ export function PlatformEntryFlowShellImpl({
profileId: string,
options: {
embedded?: boolean;
preloadedWork?: JumpHopWorkProfileResponse | null;
returnStage?: 'work-detail' | 'platform';
} = {},
) => {
@@ -7636,7 +7654,11 @@ export function PlatformEntryFlowShellImpl({
: null,
);
const [detail, runResponse] = await Promise.all([
jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null),
options.preloadedWork
? Promise.resolve({ item: options.preloadedWork })
: jumpHopClient
.getWorkDetail(normalizedProfileId)
.catch(() => null),
jumpHopClient.startRun(normalizedProfileId, {
...runtimeGuestOptions,
runtimeMode: 'published',
@@ -7668,6 +7690,78 @@ export function PlatformEntryFlowShellImpl({
[authUi, setSelectionStage],
);
useEffect(() => {
if (selectionStage !== 'jump-hop-runtime' || jumpHopRun) {
return;
}
const publicWorkCode = initialPublicWorkCode?.trim() ?? '';
const restoreKey = publicWorkCode || '__jump-hop-runtime-empty__';
if (handledJumpHopRuntimeRestoreRef.current === restoreKey) {
return;
}
handledJumpHopRuntimeRestoreRef.current = restoreKey;
if (!publicWorkCode) {
setJumpHopError(null);
setJumpHopRuntimeRequestOptions(null);
setJumpHopRuntimeReturnStage('platform');
setSelectionStage('platform');
pushAppHistoryPath('/');
return;
}
let cancelled = false;
const restoreJumpHopRuntime = async () => {
setIsJumpHopBusy(true);
setJumpHopError(null);
try {
const detail = await jumpHopClient.getGalleryDetail(publicWorkCode);
if (cancelled) {
return;
}
const profileId = detail.item.summary.profileId;
const started = await startJumpHopRunFromProfile(profileId, {
preloadedWork: detail.item,
returnStage: 'work-detail',
});
if (!started && !cancelled) {
setSelectionStage('platform');
pushAppHistoryPath('/');
}
} catch (error) {
if (cancelled) {
return;
}
setJumpHopError(
resolveRpgCreationErrorMessage(error, '恢复跳一跳玩法失败。'),
);
setJumpHopRun(null);
setJumpHopRuntimeRequestOptions(null);
setJumpHopRuntimeReturnStage('platform');
setSelectionStage('platform');
pushAppHistoryPath('/');
} finally {
if (!cancelled) {
setIsJumpHopBusy(false);
}
}
};
void restoreJumpHopRuntime();
return () => {
cancelled = true;
};
}, [
initialPublicWorkCode,
jumpHopRun,
selectionStage,
setSelectionStage,
startJumpHopRunFromProfile,
]);
const restartJumpHopRuntimeRun = useCallback(async () => {
const runId = jumpHopRun?.runId;
if (!runId) {
@@ -7787,8 +7881,7 @@ export function PlatformEntryFlowShellImpl({
...current.filter(
(item) =>
item.workId !== response.work!.summary.workId &&
item.sourceSessionId !==
response.work!.summary.sourceSessionId,
item.sourceSessionId !== response.work!.summary.sourceSessionId,
),
]);
markPendingDraftReady(
@@ -7910,7 +8003,8 @@ export function PlatformEntryFlowShellImpl({
workTitle: puzzleClearSession.draft?.workTitle,
workDescription: puzzleClearSession.draft?.workDescription,
themePrompt: puzzleClearSession.draft?.themePrompt,
boardBackgroundPrompt: puzzleClearSession.draft?.boardBackgroundPrompt,
boardBackgroundPrompt:
puzzleClearSession.draft?.boardBackgroundPrompt,
generateBoardBackground:
puzzleClearSession.draft?.generateBoardBackground,
boardBackgroundAsset: puzzleClearSession.draft?.boardBackgroundAsset,
@@ -7935,11 +8029,9 @@ export function PlatformEntryFlowShellImpl({
);
setPuzzleClearError(errorMessage);
setPuzzleClearGenerationState(
resolveFinishedMiniGameDraftGenerationState(
generationState,
'failed',
{ error: errorMessage },
),
resolveFinishedMiniGameDraftGenerationState(generationState, 'failed', {
error: errorMessage,
}),
);
} finally {
setIsPuzzleClearBusy(false);
@@ -7966,7 +8058,9 @@ export function PlatformEntryFlowShellImpl({
setPuzzleClearWork(response.item);
setPuzzleClearWorks((current) => [
response.item.summary,
...current.filter((item) => item.workId !== response.item.summary.workId),
...current.filter(
(item) => item.workId !== response.item.summary.workId,
),
]);
void refreshPuzzleClearShelf();
void refreshPuzzleClearGallery();
@@ -7979,7 +8073,9 @@ export function PlatformEntryFlowShellImpl({
setPublicWorkDetailError(null);
selectionStageRef.current = 'work-detail';
setSelectionStage('work-detail');
pushAppHistoryPath(buildPublicWorkStagePath('work-detail', publicWorkCode));
pushAppHistoryPath(
buildPublicWorkStagePath('work-detail', publicWorkCode),
);
openPublishShareModal({
title: response.item.summary.workTitle || '拼消消',
publicWorkCode,
@@ -8081,7 +8177,9 @@ export function PlatformEntryFlowShellImpl({
return;
}
setPuzzleClearError(null);
setPuzzleClearRun(retryPuzzleClearLocalLevel(puzzleClearRun, puzzleClearWork));
setPuzzleClearRun(
retryPuzzleClearLocalLevel(puzzleClearRun, puzzleClearWork),
);
return;
}
@@ -10854,7 +10952,10 @@ export function PlatformEntryFlowShellImpl({
setIsPublicWorkDetailBusy(true);
setPublicWorkDetailError(null);
const intent = resolvePlatformPublicWorkLikeIntent(entry);
const intent = resolvePlatformPublicWorkLikeIntent(
entry,
publicWorkInteractions,
);
if (intent.type === 'like-big-fish') {
void likeBigFishGalleryWork(intent.profileId)
@@ -10964,6 +11065,7 @@ export function PlatformEntryFlowShellImpl({
[
isPublicWorkDetailBusy,
platformBootstrap,
publicWorkInteractions,
resolveBigFishErrorMessage,
resolvePuzzleErrorMessage,
runProtectedAction,
@@ -11250,7 +11352,8 @@ export function PlatformEntryFlowShellImpl({
notices: draftGenerationNotices,
generation: {
activeSessionId: jumpHopSession?.sessionId,
hasActiveGenerationFailure: jumpHopGenerationState?.phase === 'failed',
hasActiveGenerationFailure:
jumpHopGenerationState?.phase === 'failed',
},
});
markDraftNoticeSeen(openIntent.noticeKeys);
@@ -11334,7 +11437,9 @@ export function PlatformEntryFlowShellImpl({
try {
const detail = await puzzleClearClient.getRuntimeWorkDetail(profileId);
setPuzzleClearWork(detail.item);
openPublicWorkDetail(mapPuzzleClearWorkToPlatformGalleryCard(detail.item));
openPublicWorkDetail(
mapPuzzleClearWorkToPlatformGalleryCard(detail.item),
);
} catch (error) {
setPublicWorkDetailError(
resolveRpgCreationErrorMessage(error, '读取拼消消详情失败。'),
@@ -11636,8 +11741,7 @@ export function PlatformEntryFlowShellImpl({
notices: draftGenerationNotices,
generation: {
activeSessionId: puzzleSession?.sessionId,
hasActiveGenerationFailure:
activeGenerationState?.phase === 'failed',
hasActiveGenerationFailure: activeGenerationState?.phase === 'failed',
hasActiveGenerationRunning: isMiniGameDraftGenerating(
activeGenerationState ?? null,
),
@@ -11684,9 +11788,8 @@ export function PlatformEntryFlowShellImpl({
const failedError = backgroundTask?.error ?? openIntent.errorMessage;
if (!failedSession) {
try {
const { session: latestSession } = await getPuzzleAgentSession(
sourceSessionId,
);
const { session: latestSession } =
await getPuzzleAgentSession(sourceSessionId);
failedSession = latestSession;
failedPayload = buildPuzzleFormPayloadFromSession(latestSession);
} catch {
@@ -11769,9 +11872,8 @@ export function PlatformEntryFlowShellImpl({
if (openIntent.type === 'restore-generating') {
try {
const { session: latestSession } = await getPuzzleAgentSession(
sourceSessionId,
);
const { session: latestSession } =
await getPuzzleAgentSession(sourceSessionId);
const payload = buildPuzzleFormPayloadFromSession(latestSession);
const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs(
latestSession.updatedAt,
@@ -11820,9 +11922,7 @@ export function PlatformEntryFlowShellImpl({
markDraftNoticeSeen(noticeKeys);
const restoredSession = await puzzleFlow.restoreDraft(
sourceSessionId,
);
const restoredSession = await puzzleFlow.restoreDraft(sourceSessionId);
if (!restoredSession) {
await refreshPuzzleShelf().catch(() => undefined);
return;
@@ -11870,8 +11970,7 @@ export function PlatformEntryFlowShellImpl({
forceDraft: options.forceDraft,
generation: {
activeSessionId: match3dSession?.sessionId,
hasActiveGenerationFailure:
activeGenerationState?.phase === 'failed',
hasActiveGenerationFailure: activeGenerationState?.phase === 'failed',
hasActiveGenerationRunning: isMiniGameDraftGenerating(
activeGenerationState ?? null,
),
@@ -12066,9 +12165,7 @@ export function PlatformEntryFlowShellImpl({
markDraftNoticeSeen(noticeKeys);
const restoredSession = await match3dFlow.restoreDraft(
sourceSessionId,
);
const restoredSession = await match3dFlow.restoreDraft(sourceSessionId);
if (!restoredSession) {
await refreshMatch3DShelf().catch(() => undefined);
return;
@@ -13517,8 +13614,7 @@ export function PlatformEntryFlowShellImpl({
activeRecommendEntryKey && !isDesktopLayout
? (recommendRuntimeEntries.find(
(entry) =>
getPlatformPublicGalleryEntryKey(entry) ===
activeRecommendEntryKey,
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
) ?? null)
: null;
const isActiveRecommendRuntimeReady =
@@ -13534,7 +13630,8 @@ export function PlatformEntryFlowShellImpl({
hasVisualNovelRun: Boolean(visualNovelRun),
hasWoodenFishRun: Boolean(woodenFishRun),
puzzleRunEntryProfileId: puzzleRun?.entryProfileId ?? null,
puzzleRunCurrentLevelProfileId: puzzleRun?.currentLevel?.profileId ?? null,
puzzleRunCurrentLevelProfileId:
puzzleRun?.currentLevel?.profileId ?? null,
});
useEffect(() => {
@@ -13608,7 +13705,10 @@ export function PlatformEntryFlowShellImpl({
setIsPublicWorkDetailBusy(true);
setPublicWorkDetailError(null);
const intent = resolvePlatformPublicWorkRemixIntent(entry);
const intent = resolvePlatformPublicWorkRemixIntent(
entry,
publicWorkInteractions,
);
if (intent.type === 'remix-big-fish') {
void remixBigFishGalleryWork(intent.profileId)
@@ -13683,6 +13783,7 @@ export function PlatformEntryFlowShellImpl({
isPublicWorkDetailBusy,
platformBootstrap,
puzzleFlow,
publicWorkInteractions,
resetRecommendRuntimeSelection,
resolveBigFishErrorMessage,
resolvePuzzleErrorMessage,
@@ -13899,10 +14000,7 @@ export function PlatformEntryFlowShellImpl({
const detailEntry = mapPuzzleClearWorkToPlatformGalleryCard(entry);
return (
canExposePublicWork(detailEntry) &&
isSamePuzzleClearPublicWorkCode(
normalizedKeyword,
entry.profileId,
)
isSamePuzzleClearPublicWorkCode(normalizedKeyword, entry.profileId)
);
});
@@ -14202,16 +14300,20 @@ export function PlatformEntryFlowShellImpl({
useEffect(() => {
const publicWorkCode = initialPublicWorkCode?.trim();
if (
!publicWorkCode ||
handledInitialPublicWorkCodeRef.current === publicWorkCode
) {
if (!publicWorkCode) {
return;
}
if (selectionStage === 'jump-hop-runtime') {
handledInitialPublicWorkCodeRef.current = publicWorkCode;
return;
}
if (handledInitialPublicWorkCodeRef.current === publicWorkCode) {
return;
}
handledInitialPublicWorkCodeRef.current = publicWorkCode;
void handlePublicCodeSearch(publicWorkCode);
}, [handlePublicCodeSearch, initialPublicWorkCode]);
}, [handlePublicCodeSearch, initialPublicWorkCode, selectionStage]);
useEffect(() => {
if (selectionStage === 'platform') {
@@ -14323,7 +14425,9 @@ export function PlatformEntryFlowShellImpl({
jumpHopItems: isJumpHopCreationVisible ? jumpHopShelfItems : [],
woodenFishItems: woodenFishShelfItems,
match3dItems: match3dShelfItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleShelfItems : [],
squareHoleItems: isSquareHoleCreationVisible
? squareHoleShelfItems
: [],
puzzleItems: puzzleShelfItems,
babyObjectMatchItems: isBabyObjectMatchVisible
? babyObjectMatchDrafts
@@ -14555,7 +14659,7 @@ export function PlatformEntryFlowShellImpl({
puzzleShelfError ??
puzzleError ??
(isVisualNovelCreationOpen ? visualNovelError : null) ??
babyObjectMatchError ??
babyObjectMatchError ??
puzzleClearError ??
barkBattleError)
}
@@ -15963,7 +16067,9 @@ export function PlatformEntryFlowShellImpl({
profile={jumpHopWork}
isBusy={isJumpHopBusy}
error={jumpHopError}
runtimeRequestOptions={jumpHopRuntimeRequestOptions ?? undefined}
runtimeRequestOptions={
jumpHopRuntimeRequestOptions ?? undefined
}
onBack={() => {
setSelectionStage(jumpHopRuntimeReturnStage);
}}
@@ -16423,7 +16529,9 @@ export function PlatformEntryFlowShellImpl({
<UnifiedCreationPage
spec={getUnifiedSpec('visual-novel')}
onBack={leaveVisualNovelFlow}
isBackDisabled={isVisualNovelBusy || isVisualNovelStreamingReply}
isBackDisabled={
isVisualNovelBusy || isVisualNovelStreamingReply
}
>
<VisualNovelAgentWorkspace
session={visualNovelSession}

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ function buildJumpHopWorkspacePayload(
difficulty: 'standard',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt: `${themeText}主题的正面30度视角主题物体图集物体本身作为跳跃落点`,
tilePrompt: `${themeText}主题的3D立方体主题身份方块包装图集`,
endMoodPrompt: null,
};
}