Merge remote-tracking branch 'origin/master' into feat/game-fork
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s
# Conflicts: # apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs # apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx # apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx # docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md # package.json # packages/shared/src/contracts/gameDistribution.ts # scripts/check-game-distribution-dto-parity.mjs # server-rs/crates/api-server/src/modules/game_distribution.rs # server-rs/crates/module-game-distribution/src/errors.rs # server-rs/crates/module-game-distribution/src/lib.rs # server-rs/crates/shared-contracts/src/game_distribution.rs # server-rs/crates/spacetime-client/src/active.rs # server-rs/crates/spacetime-client/src/active/mapper.rs # server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs # server-rs/crates/spacetime-client/src/game_distribution.rs # server-rs/crates/spacetime-client/src/module_bindings.rs # server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs # server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs # server-rs/crates/spacetime-module/src/game_distribution.rs # server-rs/crates/spacetime-module/src/migration.rs # src/components/game-distribution/GameDetailPage.tsx # src/components/game-distribution/GameDistributionPages.test.tsx # src/components/game-distribution/GamePlayPage.tsx # src/components/platform-entry/PlatformEntryActiveFlowShell.tsx # src/services/gameDistributionClient.test.ts # src/services/gameDistributionClient.ts
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export type { PlatformGamePricingFieldProps } from '@genarrative/shared/components';
|
||||
export { PlatformGamePricingField } from '@genarrative/shared/components';
|
||||
@@ -138,10 +138,15 @@ it('自己的粉丝支持回关和取消回关,移除须确认且只移除该
|
||||
total: 1,
|
||||
});
|
||||
render(<CreatorConnectionsPage id="a" tab="followers" {...callbacks} />);
|
||||
await screen.findByRole('button', { name: '回关' });
|
||||
// 关系 store 的 seed 晚于行首帧,此时按钮仍是 disabled;React 不会对 disabled
|
||||
// 元素派发 onClick,必须先等到按钮可用再点,否则这次点击会被直接吞掉。
|
||||
const back = (await screen.findByRole('button', {
|
||||
name: '回关',
|
||||
})) as HTMLButtonElement;
|
||||
await waitFor(() => expect(back.disabled).toBe(false));
|
||||
api.write.mockResolvedValue({ relationship: following });
|
||||
api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 });
|
||||
fireEvent.click(screen.getByRole('button', { name: '回关' }));
|
||||
fireEvent.click(back);
|
||||
await screen.findByRole('button', { name: '取消关注' });
|
||||
expect(api.write).toHaveBeenLastCalledWith('b', 'follow');
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除粉丝' }));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,11 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution';
|
||||
import type {
|
||||
GameDistributionPlaySessionResponse,
|
||||
GameDistributionVersionStatus,
|
||||
} from '../../../packages/shared/src/contracts/gameDistribution';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type {
|
||||
GameDistributionGame,
|
||||
GameDistributionGameWithCollection,
|
||||
@@ -43,6 +47,8 @@ const recordGamePlayMock = vi.hoisted(() => vi.fn());
|
||||
const resolvedAssetUrlMock = vi.hoisted(() => vi.fn());
|
||||
const listGameReviewsMock = vi.hoisted(() => vi.fn());
|
||||
const getMyGameReviewMock = vi.hoisted(() => vi.fn());
|
||||
const purchaseGameMock = vi.hoisted(() => vi.fn());
|
||||
const createGamePlaySessionMock = vi.hoisted(() => vi.fn());
|
||||
const getPublicAuthUserByIdMock = vi.hoisted(() => vi.fn());
|
||||
const getCreatorRelationshipMock = vi.hoisted(() => vi.fn());
|
||||
const changeCreatorRelationshipMock = vi.hoisted(() => vi.fn());
|
||||
@@ -69,6 +75,21 @@ vi.mock('../../services/gameDistributionClient', () => ({
|
||||
recordGamePlay: recordGamePlayMock,
|
||||
listGameReviews: listGameReviewsMock,
|
||||
getMyGameReview: getMyGameReviewMock,
|
||||
purchaseGame: purchaseGameMock,
|
||||
createGamePlaySession: createGamePlaySessionMock,
|
||||
}));
|
||||
|
||||
// 详情页购买后要刷新平台钱包;这里只提供选择器需要的状态与刷新钩子,避免真实取余额请求。
|
||||
const walletMocks = vi.hoisted(() => ({
|
||||
ownerUserId: 'author-1',
|
||||
mudPointBalance: { totalPoints: 500, permanentPoints: 500, limitedPoints: 0 },
|
||||
legacyWalletBalance: 500,
|
||||
onWalletBalanceMayHaveChanged: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/usePlatformWalletStore', () => ({
|
||||
usePlatformWalletStore: <T,>(selector: (state: typeof walletMocks) => T) =>
|
||||
selector(walletMocks),
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -153,6 +174,8 @@ function createGame(
|
||||
},
|
||||
playCount: 12,
|
||||
createdAt: '2026-09-18T00:00:00.000Z',
|
||||
priceMudPoints: 0,
|
||||
purchased: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -216,6 +239,9 @@ beforeEach(() => {
|
||||
});
|
||||
getMyGameReviewMock.mockResolvedValue({ review: null });
|
||||
recordGamePlayMock.mockResolvedValue(undefined);
|
||||
purchaseGameMock.mockReset();
|
||||
createGamePlaySessionMock.mockReset();
|
||||
walletMocks.onWalletBalanceMayHaveChanged.mockClear();
|
||||
resolvedAssetUrlMock.mockImplementation((objectKey: string) =>
|
||||
objectKey ? `https://assets.example.test/${objectKey}` : '',
|
||||
);
|
||||
@@ -286,6 +312,7 @@ describe('GameDetailPage', () => {
|
||||
expect(link.querySelector('strong')?.textContent).toBe('');
|
||||
// 补查由 effect 发起、提交后一拍才发出:这里等它发生,避免断言与 React 提交竞态
|
||||
// (此前偶发 0 次调用,会让整份文件的门禁随机变红)。
|
||||
// 占位昵称的补查发生在渲染后的 effect 里:并发负载下首帧可能还没触发,必须等。
|
||||
await waitFor(() =>
|
||||
expect(getPublicAuthUserByIdMock).toHaveBeenCalledWith('author-2'),
|
||||
);
|
||||
@@ -440,6 +467,207 @@ describe('GameDetailPage', () => {
|
||||
// 「由 测试作者 制作」是作品自身署名,不在断言范围内。
|
||||
expect(document.querySelector('.game-fork-lineage__author')).toBeNull();
|
||||
});
|
||||
|
||||
it('付费已购买时显示立即玩并可进入游玩页', async () => {
|
||||
const onPlay = vi.fn();
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({ priceMudPoints: 120, purchased: true }),
|
||||
);
|
||||
render(<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={onPlay} />);
|
||||
|
||||
const playButton = await screen.findByRole('button', { name: /立即玩/u });
|
||||
expect((playButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByRole('button', { name: /泥点购买/u })).toBeNull();
|
||||
expect(screen.getByText('120 泥点')).toBeTruthy();
|
||||
|
||||
fireEvent.click(playButton);
|
||||
expect(onPlay).toHaveBeenCalledWith('game-1');
|
||||
});
|
||||
|
||||
it('付费未购买但服务端已下发公开入口(作者/管理员)时显示立即玩而不是购买', async () => {
|
||||
const onPlay = vi.fn();
|
||||
// 作者本人与管理员不会被记为已购买(purchased=false),但后端仍授予可玩入口。
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({ priceMudPoints: 120, purchased: false }),
|
||||
);
|
||||
render(<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={onPlay} />);
|
||||
|
||||
const playButton = await screen.findByRole('button', { name: /立即玩/u });
|
||||
expect((playButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByRole('button', { name: /泥点购买/u })).toBeNull();
|
||||
expect(screen.getByText('120 泥点')).toBeTruthy();
|
||||
|
||||
fireEvent.click(playButton);
|
||||
expect(onPlay).toHaveBeenCalledWith('game-1');
|
||||
});
|
||||
|
||||
function createLockedPaidGame(): GameDistributionGame {
|
||||
return createGame({
|
||||
priceMudPoints: 120,
|
||||
purchased: false,
|
||||
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
|
||||
});
|
||||
}
|
||||
|
||||
it('付费未购买时展示价格与购买主动作,不再给出游玩入口', async () => {
|
||||
getGameMock.mockResolvedValueOnce(createLockedPaidGame());
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
const buyButton = await screen.findByRole('button', {
|
||||
name: /120 泥点购买/u,
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull();
|
||||
// 定价资料卡展示价格;入口为空是服务端口径,不是「入口地址无效」。
|
||||
expect(screen.getByText('120 泥点')).toBeTruthy();
|
||||
expect(screen.queryByText('游戏入口地址无效')).toBeNull();
|
||||
expect((buyButton as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('购买走二次确认并展示余额,成功后刷新钱包并回到可玩状态', async () => {
|
||||
getGameMock
|
||||
.mockResolvedValueOnce(createLockedPaidGame())
|
||||
.mockResolvedValueOnce(
|
||||
createGame({ priceMudPoints: 120, purchased: true }),
|
||||
);
|
||||
purchaseGameMock.mockResolvedValue({
|
||||
purchase: {
|
||||
purchaseId: 'gdpurchase_1',
|
||||
gameId: 'game-1',
|
||||
priceMudPoints: 120,
|
||||
createdAt: '2026-10-05T00:00:00Z',
|
||||
},
|
||||
walletBalance: 380,
|
||||
replayed: false,
|
||||
});
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /120 泥点购买/u }),
|
||||
);
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '确认消耗泥点',
|
||||
});
|
||||
expect(within(dialog).getByText('消耗 120 泥点')).toBeTruthy();
|
||||
expect(within(dialog).getByText('当前泥点余额:500 泥点')).toBeTruthy();
|
||||
|
||||
const confirmButton = within(dialog).getByRole('button', {
|
||||
name: '确认购买',
|
||||
});
|
||||
// 双击必须只扣一次:第二次点击落在同一次购买的请求窗口内,不得再发一次购买请求。
|
||||
fireEvent.click(confirmButton);
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => expect(purchaseGameMock).toHaveBeenCalledTimes(1));
|
||||
expect(purchaseGameMock.mock.calls[0]?.[0]).toBe('game-1');
|
||||
expect(purchaseGameMock.mock.calls[0]?.[1]).toEqual({
|
||||
expectedPriceMudPoints: 120,
|
||||
});
|
||||
// 同一次购买只用一个幂等键:键为空或每次点击换键都意味着服务端会当新购买扣费。
|
||||
const purchaseKeys = purchaseGameMock.mock.calls.map((call) =>
|
||||
String(call[2] ?? ''),
|
||||
);
|
||||
expect(purchaseKeys).toEqual([
|
||||
expect.stringMatching(/^web-game-purchase-[0-9a-f-]{36}$/u),
|
||||
]);
|
||||
await waitFor(() =>
|
||||
expect(walletMocks.onWalletBalanceMayHaveChanged).toHaveBeenCalled(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText('购买成功,现在可以直接开始游玩。'),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByRole('button', { name: /立即玩/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('余额不足时失败关闭并给出充值入口', async () => {
|
||||
const onOpenRecharge = vi.fn();
|
||||
getGameMock.mockResolvedValue(createLockedPaidGame());
|
||||
purchaseGameMock.mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '泥点余额不足',
|
||||
status: 400,
|
||||
code: 'INSUFFICIENT_MUD_POINTS',
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GameDetailPage
|
||||
gameId="game-1"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenRecharge={onOpenRecharge}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /120 泥点购买/u }),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '确认购买' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('泥点不足,充值后即可购买这款游戏。'),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '去充值' }));
|
||||
expect(onOpenRecharge).toHaveBeenCalledTimes(1);
|
||||
// 失败关闭:既没有游玩入口,也没有伪造成功。
|
||||
expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull();
|
||||
expect(screen.queryByText('购买成功,现在可以直接开始游玩。')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /120 泥点购买/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('服务端返回 409 价格已变化时提示已刷新并保留购买入口', async () => {
|
||||
getGameMock.mockResolvedValue(createLockedPaidGame());
|
||||
purchaseGameMock.mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '价格已变化,请按最新价格重新确认',
|
||||
status: 409,
|
||||
code: 'CONFLICT',
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /120 泥点购买/u }),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '确认购买' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'价格已更新,已为你刷新最新价格,请重新确认购买。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
// CAS 失败必须回读详情拿最新价,但绝不按旧价扣费,也不伪造购买成功。
|
||||
await waitFor(() => expect(getGameMock).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByText('购买成功,现在可以直接开始游玩。')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /120 泥点购买/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('旧响应缺价格且没有可玩入口时说明「立即玩」为何不可用', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
// 旧响应没有买断价:按免费兜底会被判成可玩,但服务端并没有下发入口。
|
||||
priceMudPoints: undefined,
|
||||
purchased: false,
|
||||
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
const playButton = await screen.findByRole('button', { name: /立即玩/u });
|
||||
await waitFor(() =>
|
||||
expect((playButton as HTMLButtonElement).disabled).toBe(true),
|
||||
);
|
||||
// 禁用按钮必须带可操作说明,否则用户只看到一个点不动的按钮。
|
||||
const reason = screen.getByText('暂时拿不到可玩入口,请刷新页面后重试。');
|
||||
expect(reason.id).toBe('game-entry-missing-warning');
|
||||
expect(playButton.getAttribute('aria-describedby')).toBe(reason.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameDetailPage 作者视角', () => {
|
||||
@@ -694,6 +922,138 @@ describe('GamePlayPage', () => {
|
||||
expect(await screen.findByText('游戏入口地址无效')).toBeTruthy();
|
||||
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
|
||||
});
|
||||
|
||||
it('免费游戏不请求播放会话', async () => {
|
||||
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
|
||||
await screen.findByTitle('星轨防线 在线游玩');
|
||||
expect(createGamePlaySessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** 付费未购买:服务端不下发公开入口,只能靠播放会话判定能否游玩。 */
|
||||
function createLockedPlayGame(): GameDistributionGame {
|
||||
return createGame({
|
||||
priceMudPoints: 120,
|
||||
purchased: false,
|
||||
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
|
||||
});
|
||||
}
|
||||
|
||||
it('付费游戏先签发播放会话再挂载 iframe 并计数', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
priceMudPoints: 120,
|
||||
purchased: true,
|
||||
// 已购买时公开入口也在,但付费作品的发行网关只认会话,必须走播放会话。
|
||||
currentVersion: { ...createGame().currentVersion! },
|
||||
}),
|
||||
);
|
||||
createGamePlaySessionMock.mockResolvedValue({
|
||||
playUrl: '/api/game-distribution/play-sessions/token-1/',
|
||||
expiresAt: '2026-10-05T00:05:00Z',
|
||||
});
|
||||
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
|
||||
|
||||
const iframe = await screen.findByTitle('星轨防线 在线游玩');
|
||||
expect(iframe.getAttribute('src')).toBe(
|
||||
new URL(
|
||||
'/api/game-distribution/play-sessions/token-1/',
|
||||
window.location.origin,
|
||||
).href,
|
||||
);
|
||||
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
|
||||
expect(createGamePlaySessionMock).toHaveBeenCalledWith('game-1');
|
||||
expect(recordGamePlayMock).toHaveBeenCalledWith('game-1');
|
||||
});
|
||||
|
||||
it('未登录直接进游玩页时提示登录且不挂载 iframe', async () => {
|
||||
getGameMock.mockResolvedValueOnce(createLockedPlayGame());
|
||||
createGamePlaySessionMock.mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '播放付费作品需要先登录',
|
||||
status: 401,
|
||||
code: 'UNAUTHORIZED',
|
||||
}),
|
||||
);
|
||||
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'这款游戏需要先登录才能游玩,请返回详情页登录后再试。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
|
||||
expect(recordGamePlayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('未购买时播放会话被拒,不挂载 iframe 也不上报游玩', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
priceMudPoints: 120,
|
||||
purchased: false,
|
||||
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
|
||||
}),
|
||||
);
|
||||
createGamePlaySessionMock.mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '尚未购买这款游戏',
|
||||
status: 403,
|
||||
code: 'FORBIDDEN',
|
||||
}),
|
||||
);
|
||||
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'这款游戏需要先购买才能游玩,请返回详情页完成购买。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
|
||||
expect(recordGamePlayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('付费作品在会话签发期间显示共享加载面,签发完成后才挂载 iframe', async () => {
|
||||
const pendingSession: {
|
||||
resolve: (session: GameDistributionPlaySessionResponse) => void;
|
||||
} = { resolve: () => {} };
|
||||
getGameMock.mockResolvedValueOnce(createLockedPlayGame());
|
||||
createGamePlaySessionMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<GameDistributionPlaySessionResponse>((resolve) => {
|
||||
pendingSession.resolve = resolve;
|
||||
}),
|
||||
);
|
||||
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
|
||||
|
||||
// 会话签发期间不能只留空白:这是付费作品唯一的“正在准备”反馈。
|
||||
const loadingSurface = await screen.findByRole('status');
|
||||
expect(
|
||||
loadingSurface.classList.contains('platform-game-loading--player'),
|
||||
).toBe(true);
|
||||
expect(loadingSurface.textContent).toContain('正在准备游戏…');
|
||||
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
|
||||
expect(recordGamePlayMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
pendingSession.resolve({
|
||||
playUrl: '/api/game-distribution/play-sessions/token-9/',
|
||||
expiresAt: '2026-10-05T00:05:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
const iframe = await screen.findByTitle('星轨防线 在线游玩');
|
||||
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
|
||||
expect(recordGamePlayMock).toHaveBeenCalledWith('game-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameGalleryPage 筛选与返回上下文', () => {
|
||||
@@ -2263,7 +2623,9 @@ describe('GameLineagePage 可视化与交互', () => {
|
||||
).toBeTruthy();
|
||||
// 卡片上只保留「打开作品 / Fork」两个动作按钮(第三个次要动作已按复验意见移除),
|
||||
// 封面+标题本身也是一个打开详情的按钮。
|
||||
expect(within(leafCard).getByRole('button', { name: '打开作品' })).toBeTruthy();
|
||||
expect(
|
||||
within(leafCard).getByRole('button', { name: '打开作品' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(leafCard).getByRole('button', { name: 'Fork 叶节点' }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import {
|
||||
createGamePlaySession,
|
||||
type GameDistributionGame,
|
||||
getGame,
|
||||
recordGamePlay,
|
||||
@@ -31,7 +33,7 @@ import { GamePlayFrame } from './GamePlayFrame';
|
||||
|
||||
type GamePlayPageProps = { gameId: string | null; onBack: () => void };
|
||||
|
||||
type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout';
|
||||
type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout' | 'failed';
|
||||
|
||||
export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
@@ -40,6 +42,10 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
|
||||
const [startupState, setStartupState] = useState<GameStartupState>('idle');
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
const [frameRunId, setFrameRunId] = useState(0);
|
||||
/** 实际交给 iframe 的发行入口:免费游戏来自详情,付费游戏来自播放会话。 */
|
||||
const [playUrl, setPlayUrl] = useState<string | null>(null);
|
||||
const [isPreparingPlayback, setIsPreparingPlayback] = useState(false);
|
||||
const [playError, setPlayError] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const requestSerialRef = useRef(0);
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
@@ -51,6 +57,8 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
|
||||
setGame(null);
|
||||
setHasStarted(false);
|
||||
setStartupState('idle');
|
||||
setPlayUrl(null);
|
||||
setPlayError('');
|
||||
setError('');
|
||||
if (!normalizedGameId) {
|
||||
setError('缺少游戏编号');
|
||||
@@ -61,14 +69,28 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
|
||||
void getGame(normalizedGameId)
|
||||
.then((value) => {
|
||||
if (requestSerial !== requestSerialRef.current) return;
|
||||
if (!value?.currentVersion?.entryUrl) {
|
||||
const currentVersion = value?.currentVersion;
|
||||
if (!value || !currentVersion) {
|
||||
throw new Error('这款游戏暂时没有可玩的版本');
|
||||
}
|
||||
const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl);
|
||||
const priceMudPoints = value.priceMudPoints ?? 0;
|
||||
const rawEntryUrl = currentVersion.entryUrl;
|
||||
if (!rawEntryUrl) {
|
||||
// 付费作品的入口由播放会话签发;免费游戏没有入口说明服务端确实没有可玩版本。
|
||||
if (priceMudPoints <= 0) {
|
||||
throw new Error('这款游戏暂时没有可玩的版本');
|
||||
}
|
||||
setGame({
|
||||
...value,
|
||||
currentVersion: { ...currentVersion, entryUrl: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entryUrl = normalizeGameEntryUrl(rawEntryUrl);
|
||||
if (!entryUrl) throw new Error('游戏入口地址无效');
|
||||
setGame({
|
||||
...value,
|
||||
currentVersion: { ...value.currentVersion, entryUrl },
|
||||
currentVersion: { ...currentVersion, entryUrl },
|
||||
});
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
@@ -106,9 +128,8 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
|
||||
const mobilePlaybackBlocked = isMobileViewport && !game?.deviceSupport.mobile;
|
||||
const showRotationHint =
|
||||
Boolean(game) && game?.orientation === 'landscape' && isPortraitViewport;
|
||||
const startGame = () => {
|
||||
if (mobilePlaybackBlocked) return;
|
||||
// 计数是 fire-and-forget:点击即视为一次游玩,上报失败也不阻断进入游戏。
|
||||
/** 进入游戏:计数是 fire-and-forget,失败也不阻断;付费作品在会话签发成功后才计数。 */
|
||||
const startFrame = () => {
|
||||
const playGameId = game?.id ?? normalizeGameId(gameId);
|
||||
if (playGameId) {
|
||||
void recordGamePlay(playGameId).catch(() => {});
|
||||
@@ -118,8 +139,57 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
|
||||
setFrameRunId((runId) => runId + 1);
|
||||
};
|
||||
|
||||
/** 重试只重挂载发行页,不改变已开始的会话,也不会在退出后自动重启。 */
|
||||
const startGame = () => {
|
||||
if (mobilePlaybackBlocked || !game || isPreparingPlayback) return;
|
||||
setPlayError('');
|
||||
const priceMudPoints = game.priceMudPoints ?? 0;
|
||||
if (priceMudPoints <= 0) {
|
||||
// 免费游戏维持原来的公开入口链路,不经过播放会话。
|
||||
setPlayUrl(game.currentVersion?.entryUrl ?? null);
|
||||
startFrame();
|
||||
return;
|
||||
}
|
||||
setIsPreparingPlayback(true);
|
||||
void createGamePlaySession(game.id)
|
||||
.then((session) => {
|
||||
const sessionUrl = normalizeGameEntryUrl(session.playUrl);
|
||||
if (!sessionUrl) throw new Error('播放地址无效');
|
||||
setPlayUrl(sessionUrl);
|
||||
startFrame();
|
||||
})
|
||||
.catch((sessionError: unknown) => {
|
||||
setHasStarted(true);
|
||||
setStartupState('failed');
|
||||
if (
|
||||
sessionError instanceof ApiClientError &&
|
||||
sessionError.status === 403
|
||||
) {
|
||||
setPlayError('这款游戏需要先购买才能游玩,请返回详情页完成购买。');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
sessionError instanceof ApiClientError &&
|
||||
sessionError.status === 401
|
||||
) {
|
||||
setPlayError('这款游戏需要先登录才能游玩,请返回详情页登录后再试。');
|
||||
return;
|
||||
}
|
||||
setPlayError(
|
||||
sessionError instanceof Error && sessionError.message.trim()
|
||||
? sessionError.message
|
||||
: '创建播放会话失败,请稍后重试。',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsPreparingPlayback(false));
|
||||
};
|
||||
|
||||
/** 重试:付费作品在会话缺失时重新签发,其余情况只重挂载发行页。 */
|
||||
const retryGame = () => {
|
||||
setPlayError('');
|
||||
if ((game?.priceMudPoints ?? 0) > 0 && !playUrl) {
|
||||
startGame();
|
||||
return;
|
||||
}
|
||||
setStartupState('loading');
|
||||
setFrameRunId((runId) => runId + 1);
|
||||
};
|
||||
@@ -184,7 +254,7 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
|
||||
</p>
|
||||
) : null}
|
||||
<div className="game-player-shell">
|
||||
{!hasStarted ? (
|
||||
{!hasStarted && !isPreparingPlayback ? (
|
||||
<div className="game-player-launch">
|
||||
<GameCover game={game} compact />
|
||||
<div className="game-player-launch__copy">
|
||||
@@ -213,14 +283,31 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasStarted && startupState === 'loading' ? (
|
||||
{isPreparingPlayback ||
|
||||
(hasStarted && startupState === 'loading' && !playError) ? (
|
||||
<PlatformGameLoadingSurface
|
||||
key={`game-loading-${frameRunId}`}
|
||||
tone="player"
|
||||
title={game.title}
|
||||
cover={<GameCover game={game} compact />}
|
||||
message={isPreparingPlayback ? '正在准备游戏…' : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{playError ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
className="game-status-message game-player-timeout"
|
||||
>
|
||||
<span>{playError}</span>
|
||||
<button type="button" onClick={retryGame}>
|
||||
重试
|
||||
</button>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{hasStarted && startupState === 'timeout' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
@@ -236,12 +323,12 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
|
||||
</button>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{hasStarted ? (
|
||||
{hasStarted && playUrl ? (
|
||||
<GamePlayFrame
|
||||
key={`${game.currentVersion.id}:${frameRunId}`}
|
||||
frameRef={frameRef}
|
||||
key={`${game.currentVersion?.id ?? game.id}:${frameRunId}`}
|
||||
title={`${game.title} 在线游玩`}
|
||||
entryUrl={game.currentVersion.entryUrl}
|
||||
entryUrl={playUrl}
|
||||
ready={startupState === 'ready'}
|
||||
onLoad={() => setStartupState('ready')}
|
||||
/>
|
||||
|
||||
@@ -229,6 +229,169 @@ test('元数据校验与服务端口径一致', () => {
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('草稿恢复按版本冻结价预填并锁住定价,提示写明将沿用的价格', async () => {
|
||||
writeDraft('user-1');
|
||||
vi.mocked(getGameVersion).mockResolvedValue({
|
||||
game: {
|
||||
id: 'game-1',
|
||||
title: '上次的游戏',
|
||||
summary: '上次的简介',
|
||||
description: '上次的详细介绍',
|
||||
publicationRevision: 2,
|
||||
// 游戏行现价与版本冻结价刻意取不同值:恢复必须取版本冻结价。
|
||||
priceMudPoints: 999,
|
||||
},
|
||||
version: {
|
||||
versionId: 'gamever-1',
|
||||
versionNumber: 1,
|
||||
status: 'uploaded',
|
||||
recoveryAction: 'upload',
|
||||
priceMudPoints: 240,
|
||||
frozenMetadata: { coverAssetId: 'asset-cover-old' },
|
||||
},
|
||||
} as never);
|
||||
|
||||
await renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '继续上传' }));
|
||||
|
||||
// 恢复态定价字段被禁用,所以提示必须直接写出将要沿用的冻结价,作者不必回表单里找。
|
||||
expect(
|
||||
await screen.findByText(/本次将沿用版本冻结价 240 泥点/u),
|
||||
).toBeTruthy();
|
||||
const paidRadio = screen.getByLabelText('买断制') as HTMLInputElement;
|
||||
expect(paidRadio.checked).toBe(true);
|
||||
expect(paidRadio.disabled).toBe(true);
|
||||
const priceInput = screen.getByLabelText(/买断价/u) as HTMLInputElement;
|
||||
expect(priceInput.value).toBe('240');
|
||||
expect(priceInput.disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('越界价格在网页元数据兜底里被拦下', () => {
|
||||
// 纯函数解析口径已由共享组件用例覆盖;这里只保留网页独有的元数据兜底。
|
||||
const desktop = { desktop: true, mobile: false, touch: false };
|
||||
expect(
|
||||
resolvePublishMetadataError({
|
||||
title: '游戏',
|
||||
summary: '简介',
|
||||
deviceSupport: desktop,
|
||||
coverAssetId: 'asset-cover',
|
||||
priceMudPoints: 1000001,
|
||||
}),
|
||||
).toBe('买断价必须是 0 到 1000000 之间的整数泥点');
|
||||
expect(
|
||||
resolvePublishMetadataError({
|
||||
title: '游戏',
|
||||
summary: '简介',
|
||||
deviceSupport: desktop,
|
||||
coverAssetId: 'asset-cover',
|
||||
priceMudPoints: 0,
|
||||
}),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('买断制发布把价格随版本请求提交,免费模式提交 0', async () => {
|
||||
vi.mocked(createGame).mockResolvedValue({
|
||||
id: 'game-1',
|
||||
publicationRevision: 0,
|
||||
} as never);
|
||||
vi.mocked(createGameVersion).mockResolvedValue({
|
||||
gameId: 'game-1',
|
||||
versionId: 'gamever-1',
|
||||
versionNumber: 1,
|
||||
status: 'awaiting_upload',
|
||||
});
|
||||
vi.mocked(uploadGamePackage).mockResolvedValue({
|
||||
versionId: 'gamever-1',
|
||||
status: 'uploaded',
|
||||
});
|
||||
vi.mocked(submitGameVersion).mockResolvedValue({
|
||||
version: { status: 'pending_review' },
|
||||
});
|
||||
|
||||
await renderPage();
|
||||
const file = await buildZipFile();
|
||||
fireEvent.change(screen.getByLabelText('游戏名称'), {
|
||||
target: { value: '付费游戏' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('一句话简介'), {
|
||||
target: { value: '测试简介' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await selectCover();
|
||||
|
||||
// 买断制下非法价格在本地先拦截,不发任何写请求。
|
||||
fireEvent.click(screen.getByLabelText('买断制'));
|
||||
fireEvent.change(screen.getByLabelText(/买断价/u), {
|
||||
target: { value: '0' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
|
||||
expect(
|
||||
await screen.findByText('买断价必须是 1 到 1000000 之间的整数泥点'),
|
||||
).toBeTruthy();
|
||||
expect(createGame).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/买断价/u), {
|
||||
target: { value: '120' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
|
||||
|
||||
await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({ priceMudPoints: 120 }),
|
||||
);
|
||||
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
gameMetadata: expect.objectContaining({ title: '付费游戏' }),
|
||||
}),
|
||||
);
|
||||
// 价格属于版本冻结资料,不进创建游戏请求的元数据。
|
||||
expect(vi.mocked(createGame).mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
'priceMudPoints',
|
||||
);
|
||||
});
|
||||
|
||||
test('免费模式提交 0 泥点并保持免费链路', async () => {
|
||||
vi.mocked(createGame).mockResolvedValue({
|
||||
id: 'game-1',
|
||||
publicationRevision: 0,
|
||||
} as never);
|
||||
vi.mocked(createGameVersion).mockResolvedValue({
|
||||
gameId: 'game-1',
|
||||
versionId: 'gamever-1',
|
||||
versionNumber: 1,
|
||||
status: 'awaiting_upload',
|
||||
});
|
||||
vi.mocked(uploadGamePackage).mockResolvedValue({
|
||||
versionId: 'gamever-1',
|
||||
status: 'uploaded',
|
||||
});
|
||||
vi.mocked(submitGameVersion).mockResolvedValue({
|
||||
version: { status: 'pending_review' },
|
||||
});
|
||||
|
||||
await renderPage();
|
||||
const file = await buildZipFile();
|
||||
fireEvent.change(screen.getByLabelText('游戏名称'), {
|
||||
target: { value: '免费游戏' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('一句话简介'), {
|
||||
target: { value: '测试简介' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await selectCover();
|
||||
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
|
||||
|
||||
await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({ priceMudPoints: 0 }),
|
||||
);
|
||||
expect(screen.queryByLabelText(/买断价/u)).toBeNull();
|
||||
});
|
||||
|
||||
test('未登录时不提交且提示登录', async () => {
|
||||
await renderPage(null);
|
||||
expect(screen.getByText('登录后才能发布游戏。')).toBeTruthy();
|
||||
@@ -484,6 +647,7 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
|
||||
status: 'published',
|
||||
publicationRevision: 4,
|
||||
recoveryAction: 'none',
|
||||
priceMudPoints: 240,
|
||||
frozenMetadata: {
|
||||
coverAssetId: 'asset-cover-old',
|
||||
coverObjectKey: 'game-distribution/cover/old.png',
|
||||
@@ -540,6 +704,8 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
|
||||
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
packageEntryPath: 'index.html',
|
||||
// 价格回填自该版本冻结值,并随新版本一起提交。
|
||||
priceMudPoints: 240,
|
||||
gameMetadata: expect.objectContaining({
|
||||
title: '星轨防线',
|
||||
category: '动作',
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformGamePricingField } from '../common/PlatformGamePricingField';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import {
|
||||
GAME_METADATA_CATEGORIES,
|
||||
@@ -38,7 +39,11 @@ import {
|
||||
} from './gamePublishDraft';
|
||||
import { applyVersionMediaDefaults } from './gamePublishMediaDefaults';
|
||||
import { GamePublishMediaFieldset } from './GamePublishMediaFieldset';
|
||||
import { resolvePublishMetadataError } from './gamePublishMetadata';
|
||||
import {
|
||||
type GamePublishPriceMode,
|
||||
resolveGamePublishPriceMudPoints,
|
||||
resolvePublishMetadataError,
|
||||
} from './gamePublishMetadata';
|
||||
import { prepareGamePackage } from './gameZipPackage';
|
||||
|
||||
type GamePublishPageProps = {
|
||||
@@ -85,6 +90,9 @@ export function GamePublishPage({
|
||||
});
|
||||
const [orientation, setOrientation] =
|
||||
useState<GameDistributionOrientation>('responsive');
|
||||
/** 付费方式:免费等价于 0 泥点,买断制必须填 1..=1_000_000 的正整数。 */
|
||||
const [priceMode, setPriceMode] = useState<GamePublishPriceMode>('free');
|
||||
const [priceInput, setPriceInput] = useState('');
|
||||
const [packageFile, setPackageFile] = useState<File | null>(null);
|
||||
// 封面与截图都先上传成平台素材,再把素材 ID 交给服务端冻结;页面只保存素材标识与预览地址。
|
||||
const [coverAsset, setCoverAsset] = useState<GamePublishImageAsset | null>(
|
||||
@@ -152,6 +160,19 @@ export function GamePublishPage({
|
||||
setDescription(detail.game.description);
|
||||
setCategory(detail.game.category);
|
||||
setTagsInput(detail.game.tags.join(','));
|
||||
// 回填既有价格:无价格或 0 按免费展示,正数按买断制填进输入框。
|
||||
const existingPriceMudPoints =
|
||||
detail.version.priceMudPoints ?? detail.game.priceMudPoints ?? 0;
|
||||
if (
|
||||
typeof existingPriceMudPoints === 'number' &&
|
||||
existingPriceMudPoints > 0
|
||||
) {
|
||||
setPriceMode('paid');
|
||||
setPriceInput(String(existingPriceMudPoints));
|
||||
} else {
|
||||
setPriceMode('free');
|
||||
setPriceInput('');
|
||||
}
|
||||
setUpdateContext({
|
||||
gameId: game.id,
|
||||
publicationRevision: detail.version.publicationRevision,
|
||||
@@ -229,6 +250,17 @@ export function GamePublishPage({
|
||||
setTitle(detail.game.title);
|
||||
setSummary(detail.game.summary);
|
||||
setDescription(detail.game.description);
|
||||
// 草稿恢复的字段是禁用的,但定价必须显示这一版真正会沿用的价格:
|
||||
// 否则已付费作品会以「免费」示人,作者会以为价格被改掉了。
|
||||
const frozenPriceMudPoints =
|
||||
detail.version.priceMudPoints ?? detail.game.priceMudPoints ?? 0;
|
||||
if (frozenPriceMudPoints > 0) {
|
||||
setPriceMode('paid');
|
||||
setPriceInput(String(frozenPriceMudPoints));
|
||||
} else {
|
||||
setPriceMode('free');
|
||||
setPriceInput('');
|
||||
}
|
||||
void applyVersionMediaDefaults(detail, {
|
||||
setCover: setCoverAsset,
|
||||
setScreenshots: setScreenshotAssets,
|
||||
@@ -248,6 +280,12 @@ export function GamePublishPage({
|
||||
isResumingDraft &&
|
||||
draftDetail !== null &&
|
||||
draftDetail.version.recoveryAction !== 'submit';
|
||||
/** 恢复态定价字段被锁住,这里把将要沿用的冻结价直接写进提示,作者不必回表单里找。 */
|
||||
const resumeFrozenPriceMudPoints = draftDetail
|
||||
? (draftDetail.version.priceMudPoints ??
|
||||
draftDetail.game.priceMudPoints ??
|
||||
0)
|
||||
: 0;
|
||||
|
||||
/** 展示中的线上封面没有素材 ID 时无法冻结进新版本,必须先本地拦一次并说明原因。 */
|
||||
function resolveMediaSubmitError() {
|
||||
@@ -324,12 +362,18 @@ export function GamePublishPage({
|
||||
setError(mediaError);
|
||||
return;
|
||||
}
|
||||
const price = resolveGamePublishPriceMudPoints(priceMode, priceInput);
|
||||
if (price.error) {
|
||||
setError(price.error);
|
||||
return;
|
||||
}
|
||||
const metadataError = resolvePublishMetadataError({
|
||||
title,
|
||||
summary,
|
||||
deviceSupport,
|
||||
coverAssetId: coverAsset?.assetObjectId,
|
||||
screenshotCount: screenshotAssets.length,
|
||||
priceMudPoints: price.priceMudPoints,
|
||||
});
|
||||
if (metadataError) {
|
||||
setError(metadataError);
|
||||
@@ -363,6 +407,7 @@ export function GamePublishPage({
|
||||
const version = await createGameVersion(
|
||||
updateContext.gameId,
|
||||
{
|
||||
priceMudPoints: price.priceMudPoints,
|
||||
packageSha256: prepared.sha256,
|
||||
packageBytes: prepared.totalBytes,
|
||||
packageFileCount: prepared.fileCount,
|
||||
@@ -414,12 +459,18 @@ export function GamePublishPage({
|
||||
setError(mediaError);
|
||||
return;
|
||||
}
|
||||
const price = resolveGamePublishPriceMudPoints(priceMode, priceInput);
|
||||
if (price.error) {
|
||||
setError(price.error);
|
||||
return;
|
||||
}
|
||||
const metadataError = resolvePublishMetadataError({
|
||||
title,
|
||||
summary,
|
||||
deviceSupport,
|
||||
coverAssetId: coverAsset?.assetObjectId,
|
||||
screenshotCount: screenshotAssets.length,
|
||||
priceMudPoints: price.priceMudPoints,
|
||||
});
|
||||
if (metadataError) {
|
||||
setError(metadataError);
|
||||
@@ -454,6 +505,7 @@ export function GamePublishPage({
|
||||
const version = await createGameVersion(
|
||||
game.id,
|
||||
{
|
||||
priceMudPoints: price.priceMudPoints,
|
||||
packageSha256: prepared.sha256,
|
||||
packageBytes: prepared.totalBytes,
|
||||
packageFileCount: prepared.fileCount,
|
||||
@@ -662,6 +714,9 @@ export function GamePublishPage({
|
||||
<PlatformStatusMessage tone="info" surface="platform">
|
||||
<span>
|
||||
继续发布沿用上次冻结的资料与版本号,资料要改就撤回后在「我的游戏」重新发布。
|
||||
{resumeFrozenPriceMudPoints > 0
|
||||
? `本次将沿用版本冻结价 ${resumeFrozenPriceMudPoints} 泥点。`
|
||||
: '本次将沿用版本冻结价,作品为免费。'}
|
||||
</span>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
@@ -769,6 +824,13 @@ export function GamePublishPage({
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<PlatformGamePricingField
|
||||
mode={priceMode}
|
||||
priceInput={priceInput}
|
||||
disabled={isResumingDraft}
|
||||
onModeChange={setPriceMode}
|
||||
onPriceInputChange={setPriceInput}
|
||||
/>
|
||||
<GamePublishMediaFieldset
|
||||
cover={coverAsset}
|
||||
screenshots={screenshotAssets}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { normalizeGameEntryUrl } from './gameDistributionGuards';
|
||||
|
||||
const GAME_ID = `game_${'a'.repeat(32)}`;
|
||||
const GAME_ENTRY_PATH = `/games/${GAME_ID}/`;
|
||||
/** 与 `gameDistributionGuards.ts` 的 `MAX_ENTRY_URL_LENGTH` 对齐;守卫内部常量不导出。 */
|
||||
const MAX_ENTRY_URL_LENGTH = 4096;
|
||||
|
||||
describe('normalizeGameEntryUrl', () => {
|
||||
it('resolves a release gateway relative path against the current origin', () => {
|
||||
@@ -43,4 +45,86 @@ describe('normalizeGameEntryUrl', () => {
|
||||
|
||||
expect(normalizeGameEntryUrl(entryUrl)).toBe(entryUrl);
|
||||
});
|
||||
|
||||
it('resolves a paid play session entry against the current origin', () => {
|
||||
const sessionPath = '/api/game-distribution/play-sessions/token-1/';
|
||||
|
||||
expect(normalizeGameEntryUrl(sessionPath)).toBe(
|
||||
new URL(sessionPath, window.location.origin).href,
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes a same-origin absolute play session URL', () => {
|
||||
const sessionPath = '/api/game-distribution/play-sessions/token-1/';
|
||||
|
||||
expect(
|
||||
normalizeGameEntryUrl(`${window.location.origin}${sessionPath}`),
|
||||
).toBe(new URL(sessionPath, window.location.origin).href);
|
||||
});
|
||||
|
||||
it('accepts a uuid v4 play session token with and without the trailing slash', () => {
|
||||
const token = '3f2504e0-4f89-41d3-9a0c-0305e82c3301';
|
||||
const sessionPath = `/api/game-distribution/play-sessions/${token}/`;
|
||||
|
||||
expect(normalizeGameEntryUrl(sessionPath)).toBe(
|
||||
new URL(sessionPath, window.location.origin).href,
|
||||
);
|
||||
// 不带尾斜杠时按原样接受:是否带尾斜杠由服务端签发的 playUrl 决定,守卫不代它补。
|
||||
const withoutSlash = `/api/game-distribution/play-sessions/${token}`;
|
||||
expect(normalizeGameEntryUrl(withoutSlash)).toBe(
|
||||
new URL(withoutSlash, window.location.origin).href,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a play session token that escapes the prefix with dot segments', () => {
|
||||
// `[A-Za-z0-9._~/-]` 会放过 `..`,`new URL` 随后把它归一成同源其它路径。
|
||||
expect(
|
||||
normalizeGameEntryUrl('/api/game-distribution/play-sessions/../../x/'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeGameEntryUrl(
|
||||
`${window.location.origin}/api/game-distribution/play-sessions/../../x/`,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeGameEntryUrl(
|
||||
'/api/game-distribution/play-sessions/..%2F..%2Fx/',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['token.1', 'token_1', 'token~1', 'token%2F1', 'token 1'])(
|
||||
'refuses a play session token outside the uuid alphabet: %s',
|
||||
(token) => {
|
||||
expect(
|
||||
normalizeGameEntryUrl(`/api/game-distribution/play-sessions/${token}/`),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('refuses an entry URL longer than the guard limit', () => {
|
||||
expect(
|
||||
normalizeGameEntryUrl(
|
||||
`/api/game-distribution/play-sessions/${'a'.repeat(MAX_ENTRY_URL_LENGTH)}/`,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeGameEntryUrl(`https://play.example.test/${'a'.repeat(4200)}`),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/api/game-distribution/play-sessions/tok\u0001en/',
|
||||
'/api/game-distribution/play-sessions/token-1/\u007f',
|
||||
])('refuses an entry URL carrying control characters: %j', (entryUrl) => {
|
||||
expect(normalizeGameEntryUrl(entryUrl)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/api/game-distribution/play-sessions/',
|
||||
'/api/game-distribution/games/game-1/purchase',
|
||||
'/api/other/play-sessions/token-1/',
|
||||
])('rejects a malformed play session entry: %s', (entryUrl) => {
|
||||
expect(normalizeGameEntryUrl(entryUrl)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,21 @@ import { useEffect, useState } from 'react';
|
||||
const MAX_GAME_ID_LENGTH = 128;
|
||||
const MAX_ENTRY_URL_LENGTH = 4096;
|
||||
const GAME_ENTRY_PATH_PATTERN = /^\/games\/(game_[0-9a-f]{32})\/?$/;
|
||||
/**
|
||||
* 付费游戏的播放会话入口由服务端签发,形状为
|
||||
* `/api/game-distribution/play-sessions/<token>/`;包内资源经同一前缀相对读取。
|
||||
*
|
||||
* token 段只允许 `[A-Za-z0-9-]`(令牌是 UUID v4):放开 `.` 与 `/` 会让
|
||||
* `…/play-sessions/../../x` 这类输入被 `new URL` 归一成同源其它路径。
|
||||
*/
|
||||
const GAME_PLAY_SESSION_PATH_PATTERN =
|
||||
/^\/api\/game-distribution\/play-sessions\/[A-Za-z0-9-]+\/?$/;
|
||||
|
||||
function normalizeGameEntryPath(pathname: string) {
|
||||
const match = GAME_ENTRY_PATH_PATTERN.exec(pathname);
|
||||
return match ? `/games/${match[1]}/` : null;
|
||||
const gameMatch = GAME_ENTRY_PATH_PATTERN.exec(pathname);
|
||||
if (gameMatch) return `/games/${gameMatch[1]}/`;
|
||||
if (GAME_PLAY_SESSION_PATH_PATTERN.test(pathname)) return pathname;
|
||||
return null;
|
||||
}
|
||||
|
||||
function containsControlCharacter(value: string) {
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import {
|
||||
PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
|
||||
type PlatformGamePricingMode,
|
||||
resolvePlatformGamePriceMudPoints,
|
||||
} from '../../../packages/shared/src/components/platformGamePricingModel';
|
||||
import type { GameDistributionDeviceSupport } from '../../../packages/shared/src/contracts/gameDistribution';
|
||||
import { MAX_GAME_SCREENSHOTS } from './gamePublishAssets';
|
||||
|
||||
/** 买断价上限,复用共享定价组件常量,网页与 AGC 及服务端保持同一口径。 */
|
||||
export const MAX_GAME_PRICE_MUD_POINTS = PLATFORM_GAME_MAX_PRICE_MUD_POINTS;
|
||||
|
||||
export type GamePublishPriceMode = PlatformGamePricingMode;
|
||||
|
||||
/**
|
||||
* 解析发布表单的付费方式与买断价输入。
|
||||
*
|
||||
* 口径与服务端一致:免费等价于 `0`;买断制必须是 `1..=1_000_000` 的整数泥点。
|
||||
* 实现落在共享 `platformGamePricingModel`,网页与 AGC 发布面板共用同一份校验。
|
||||
*/
|
||||
export const resolveGamePublishPriceMudPoints =
|
||||
resolvePlatformGamePriceMudPoints;
|
||||
|
||||
/** 作者侧元数据校验;口径与服务端 `validate_game_metadata` 保持一致。 */
|
||||
export function resolvePublishMetadataError(input: {
|
||||
title: string;
|
||||
@@ -10,6 +29,8 @@ export function resolvePublishMetadataError(input: {
|
||||
coverAssetId?: string | null;
|
||||
/** 已选截图数量;上限与服务端 `MAX_GAME_SCREENSHOTS` 一致。 */
|
||||
screenshotCount?: number;
|
||||
/** 买断价(整数泥点,`0` 表示免费);缺省不校验,兼容未接入定价的调用方。 */
|
||||
priceMudPoints?: number | null;
|
||||
}) {
|
||||
const title = input.title.trim();
|
||||
const summary = input.summary.trim();
|
||||
@@ -31,5 +52,15 @@ export function resolvePublishMetadataError(input: {
|
||||
if ((input.screenshotCount ?? 0) > MAX_GAME_SCREENSHOTS) {
|
||||
return '游戏截图最多 6 张';
|
||||
}
|
||||
if (input.priceMudPoints !== undefined && input.priceMudPoints !== null) {
|
||||
const priceMudPoints = input.priceMudPoints;
|
||||
if (
|
||||
!Number.isInteger(priceMudPoints) ||
|
||||
priceMudPoints < 0 ||
|
||||
priceMudPoints > MAX_GAME_PRICE_MUD_POINTS
|
||||
) {
|
||||
return `买断价必须是 0 到 ${MAX_GAME_PRICE_MUD_POINTS} 之间的整数泥点`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1083,6 +1083,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
ownerView={gameSearchParams.get('owner') === '1'}
|
||||
onBack={backFromGameDetail}
|
||||
onPlay={openGamePlay}
|
||||
onOpenRecharge={openRecharge}
|
||||
onOpenLineage={openGameLineage}
|
||||
onOpenCompare={openGameCompare}
|
||||
onOpenTheme={openGameTheme}
|
||||
|
||||
@@ -21,6 +21,7 @@ const PLATFORM_WALLET_LEDGER_SOURCE_LABELS = {
|
||||
redeem_code_reward: '兑换码奖励',
|
||||
puzzle_author_incentive_claim: '拼图作者奖励',
|
||||
daily_task_reward: '每日任务奖励',
|
||||
game_purchase: '购买游戏',
|
||||
} satisfies Record<ProfileWalletLedgerEntry['sourceType'], string>;
|
||||
|
||||
export type PlatformWalletLedgerEntryPresentation = {
|
||||
|
||||
Reference in New Issue
Block a user