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

# 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:
2026-10-06 16:10:52 +08:00
314 changed files with 13094 additions and 2427 deletions
+4
View File
@@ -1183,6 +1183,8 @@ export interface AdminGameDistributionReviewEntry {
packageBytes: number;
status: string;
publicationRevision: number;
/** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */
priceMudPoints?: number;
reviewReason: string | null;
createdAt: string;
updatedAt: string;
@@ -1211,6 +1213,8 @@ export interface AdminGameDistributionGameDetail {
orientation: string;
status: string;
publicationRevision: number;
/** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */
priceMudPoints?: number;
playCount: number;
createdAt: string;
}
@@ -19,6 +19,7 @@ import {
reviewAdminGameDistributionVersion,
} from '../api/adminApiClient';
import type {
AdminGameDistributionGameDetail,
AdminGameDistributionGameListResponse,
AdminGameDistributionReviewEntry,
AdminGameDistributionVersionDetailResponse,
@@ -50,6 +51,7 @@ const entry: AdminGameDistributionReviewEntry = {
packageBytes: 2048,
status: 'pending_review',
publicationRevision: 4,
priceMudPoints: 30,
reviewReason: null,
// 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。
createdAt: '1791008678.817257Z',
@@ -94,6 +96,7 @@ const detailResponse: AdminGameDistributionVersionDetailResponse = {
orientation: 'landscape',
status: 'unpublished',
publicationRevision: 7,
priceMudPoints: 30,
playCount: 0,
createdAt: '2026-09-20T08:00:00Z',
},
@@ -230,7 +233,8 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间',
const row = screen.getByText(gameTitle).closest('tr');
expect(row).not.toBeNull();
const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3];
// 列序:游戏 / 版本 / 发行包 / 价格 / 提交时间 / 审核。
const submittedCell = within(row as HTMLElement).getAllByRole('cell')[4];
// 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。
expect(submittedCell?.textContent).not.toBe('-');
expect(submittedCell?.textContent).toMatch(
@@ -238,6 +242,56 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间',
);
});
test('价格列按买断制口径展示,免费与缺失价格都不显示 NaN', async () => {
const freeEntry: AdminGameDistributionReviewEntry = {
...entry,
versionId: 'version-2',
gameId: 'game_2',
versionNumber: 1,
priceMudPoints: 0,
};
// 旧响应可能完全没有该字段;列表必须回落到“免费”而不是 NaN。
const legacyEntry = {
...entry,
versionId: 'version-3',
gameId: 'game_3',
versionNumber: 3,
priceMudPoints: undefined,
} as unknown as AdminGameDistributionReviewEntry;
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
entries: [entry, freeEntry, legacyEntry],
nextCursor: null,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText(gameTitle);
const table = screen.getByText(gameTitle).closest('table');
expect(table).not.toBeNull();
// 按列名定位:绑「第 4 个格子」的话,任何插列或换序都会读到别的字段,断言随之失效。
const priceColumnIndex = within(table as HTMLElement)
.getAllByRole('columnheader')
.findIndex((header) => header.textContent?.trim() === '价格');
expect(priceColumnIndex).toBeGreaterThanOrEqual(0);
const cellAt = (text: string) => {
const row = screen.getByText(text).closest('tr');
expect(row).not.toBeNull();
return within(row as HTMLElement).getAllByRole('cell')[priceColumnIndex]
?.textContent;
};
// 三行三态都按精确文案钉死:付费、免费与旧响应缺字段都不能渲染成 NaN。
expect(cellAt(gameTitle)).toBe('30 泥点');
expect(cellAt('game_2')).toBe('免费');
expect(cellAt('game_3')).toBe('免费');
});
test('取消理由输入时不做审核操作', async () => {
render(
<AdminGameDistributionReviewPage
@@ -300,6 +354,9 @@ test('详情展示发布者和冻结资料,并试玩当前待审版本', async
fireEvent.click(screen.getByRole('button', { name: '详情' }));
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
expect(screen.getByText('测试详细介绍')).toBeTruthy();
// 详情资料区展示买断制价格(0 或缺字段按“免费”)。
expect(screen.getByText('价格')).toBeTruthy();
expect(screen.getByText('30 泥点')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
const frame = await screen.findByTitle('测试游戏 待审版本试玩');
expect(frame.getAttribute('src')).toBe(
@@ -372,7 +429,6 @@ test('试玩加载超过上限后给出超时说明与重建会话入口', async
expiresAt: '2026-10-02T12:20:00Z',
versionId: 'version-1',
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
@@ -415,3 +471,60 @@ test('试玩加载超过上限后给出超时说明与重建会话入口', async
vi.useRealTimers();
}
});
test('详情价格只渲染服务端下发的 priceMudPoints,不按冻结资料二次取价', async () => {
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({
// 服务端 `version_detail_payload` 已把「待审版本冻结价优先、缺字段按 0」归一写进
// `game.priceMudPoints`,审核页只渲染这个字段。
game: { ...detailResponse.game, priceMudPoints: 30 },
version: {
...detailResponse.version,
// 对抗用例:冻结资料里的价格与服务端归一结果故意不同(999 ≠ 30)。客户端若自行
// 复刻「冻结价优先」,就会显示 999 —— 这里必须仍然是服务端字段的 30。
frozenMetadata: { title: '测试游戏', priceMudPoints: 999 },
},
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText(gameTitle);
fireEvent.click(screen.getByRole('button', { name: '详情' }));
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
expect(screen.getByText('价格')).toBeTruthy();
expect(screen.getByText('30 泥点')).toBeTruthy();
expect(screen.queryByText('999 泥点')).toBeNull();
expect(screen.queryByText('免费')).toBeNull();
});
test('旧版本冻结资料没有价格时按免费回落', async () => {
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({
game: {
...detailResponse.game,
priceMudPoints: undefined,
} as unknown as AdminGameDistributionGameDetail,
version: {
...detailResponse.version,
frozenMetadata: { title: '测试游戏' },
},
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText(gameTitle);
fireEvent.click(screen.getByRole('button', { name: '详情' }));
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
expect(screen.getByText('免费')).toBeTruthy();
expect(screen.queryByText(/NaN/u)).toBeNull();
});
@@ -100,6 +100,8 @@ function applyFrozenGameMetadata(
typeof metadata.orientation === 'string'
? metadata.orientation
: game.orientation,
// 价格不在这里二次叠加:服务端 `version_detail_payload` 已按「版本冻结价优先、
// 缺字段按 0」把结果归一写进 `game.priceMudPoints`,客户端直接沿用该字段。
};
}
@@ -113,6 +115,11 @@ function formatBytes(value: number) {
return `${value} B`;
}
/** 买断制价格口径:0 或缺失(旧响应 / undefined)都按“免费”展示,避免出现 NaN。 */
function formatPriceMudPoints(value: number | null | undefined) {
return typeof value === 'number' && value > 0 ? `${value} 泥点` : '免费';
}
function createReviewIdempotencyKey(versionId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
@@ -439,6 +446,7 @@ export function AdminGameDistributionReviewPage({
{ key: 'gameId', label: '游戏' },
{ key: 'versionNumber', label: '版本' },
{ key: 'packageBytes', label: '发行包' },
{ key: 'priceMudPoints', label: '价格' },
{ key: 'createdAt', label: '提交时间' },
{ key: 'actions', label: '审核' },
]}
@@ -484,6 +492,7 @@ export function AdminGameDistributionReviewPage({
<code>{entry.packageSha256.slice(0, 12)}</code>
</div>
</td>
<td>{formatPriceMudPoints(entry.priceMudPoints)}</td>
<td>{formatAdminDateTime(entry.createdAt)}</td>
<td className="admin-game-review-cell--actions">
<AdminActionRow className="admin-review-actions">
@@ -649,6 +658,10 @@ function AdminGameDistributionReviewDetailView({
versionLabel={`v${detail.version.versionNumber}`}
infoCards={[
{ label: '状态', value: detail.version.status },
{
label: '价格',
value: formatPriceMudPoints(detail.game.priceMudPoints),
},
{
label: '提交时间',
value: formatAdminDateTime(detail.version.createdAt),
@@ -27,14 +27,22 @@ table.admin-game-review-table td:nth-child(3) {
width: 124px;
}
/* 价格列:只展示「免费」或「N 泥点」,上限 1000000 泥点最长;给足宽度并允许换行,
固定列宽 + overflow:hidden 下保持 nowrap 会把最大价硬裁掉。 */
table.admin-game-review-table th:nth-child(4),
table.admin-game-review-table td:nth-child(4) {
width: 172px;
white-space: nowrap;
width: 120px;
white-space: normal;
}
table.admin-game-review-table th:nth-child(5),
table.admin-game-review-table td:nth-child(5) {
width: 172px;
white-space: nowrap;
}
table.admin-game-review-table th:nth-child(6),
table.admin-game-review-table td:nth-child(6) {
width: 240px;
}
@@ -100,7 +108,9 @@ table.admin-game-review-table td .admin-muted-text {
table.admin-game-review-table th:nth-child(4),
table.admin-game-review-table td:nth-child(4),
table.admin-game-review-table th:nth-child(5),
table.admin-game-review-table td:nth-child(5) {
table.admin-game-review-table td:nth-child(5),
table.admin-game-review-table th:nth-child(6),
table.admin-game-review-table td:nth-child(6) {
width: auto;
}
}