Merge remote-tracking branch 'origin/master' into feat/game-works-management
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m32s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m5s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 3m33s
Project CI / Frontend tests (pull_request) Successful in 3m39s
Project CI / Backend tests (pull_request) Successful in 6m49s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m39s
Project CI / Repository checks (pull_request) Successful in 6m43s
Project CI / Native shell tests (pull_request) Successful in 7m39s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	server-rs/crates/api-server/src/modules/game_distribution.rs
#	server-rs/crates/spacetime-client/src/active.rs
#	server-rs/crates/spacetime-module/src/game_distribution.rs
#	src/components/game-distribution/GameDistributionPages.test.tsx
This commit is contained in:
2026-10-04 15:00:23 +08:00
46 changed files with 1806 additions and 130 deletions
@@ -26,6 +26,7 @@ const listGamesMock = vi.hoisted(() => vi.fn());
const getGameMock = vi.hoisted(() => vi.fn());
const getMyGameMock = vi.hoisted(() => vi.fn());
const loadVersionRecoveryActionsMock = vi.hoisted(() => vi.fn());
const recordGamePlayMock = vi.hoisted(() => vi.fn());
const resolvedAssetUrlMock = vi.hoisted(() => vi.fn());
const listGameReviewsMock = vi.hoisted(() => vi.fn());
const getMyGameReviewMock = vi.hoisted(() => vi.fn());
@@ -35,6 +36,7 @@ vi.mock('../../services/gameDistributionClient', () => ({
getGame: getGameMock,
getMyGame: getMyGameMock,
loadVersionRecoveryActions: loadVersionRecoveryActionsMock,
recordGamePlay: recordGamePlayMock,
listGameReviews: listGameReviewsMock,
getMyGameReview: getMyGameReviewMock,
}));
@@ -140,6 +142,7 @@ beforeEach(() => {
ratingSummary: { averageScore: null, ratingCount: 0 },
});
getMyGameReviewMock.mockResolvedValue({ review: null });
recordGamePlayMock.mockResolvedValue(undefined);
resolvedAssetUrlMock.mockImplementation((objectKey: string) =>
objectKey ? `https://assets.example.test/${objectKey}` : '',
);
@@ -458,6 +461,7 @@ describe('GamePlayPage', () => {
fireEvent.click(screen.getByRole('button', { name: /开始游戏/u }));
const iframe = await screen.findByTitle('星轨防线 在线游玩');
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
expect(recordGamePlayMock).toHaveBeenCalledWith('game-1');
});
it('rejects an unsafe release entry URL before rendering an iframe', async () => {
@@ -12,6 +12,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import {
type GameDistributionGame,
getGame,
recordGamePlay,
} from '../../services/gameDistributionClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
@@ -105,6 +106,11 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
Boolean(game) && game?.orientation === 'landscape' && isPortraitViewport;
const startGame = () => {
if (mobilePlaybackBlocked) return;
// 计数是 fire-and-forget:点击即视为一次游玩,上报失败也不阻断进入游戏。
const playGameId = game?.id ?? normalizeGameId(gameId);
if (playGameId) {
void recordGamePlay(playGameId).catch(() => {});
}
setHasStarted(true);
setStartupState('loading');
setFrameRunId((runId) => runId + 1);
@@ -11,6 +11,7 @@ import {
listGameReviews,
listGames,
listMyGames,
recordGamePlay,
saveMyGameReview,
unpublishGame,
uploadGamePackage,
@@ -83,6 +84,38 @@ describe('gameDistributionClient', () => {
expect(await getGame('')).toBeNull();
});
it('游玩上报携带稳定的匿名 clientId,且不携带未登录 bearer', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ recorded: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await recordGamePlay('game-1');
await recordGamePlay('game-1');
const playCalls = fetchMock.mock.calls.filter(([url]) =>
String(url).includes('/plays'),
);
expect(playCalls).toHaveLength(2);
expect(playCalls[0]?.[0]).toBe('/api/game-distribution/games/game-1/plays');
expect(playCalls[0]?.[1]).toMatchObject({
method: 'POST',
cache: 'no-store',
});
expect(playCalls[0]?.[1]?.headers?.Authorization).toBeUndefined();
const clientIds = playCalls.map(
([, init]) =>
(JSON.parse(String(init?.body)) as { clientId?: string }).clientId,
);
expect(clientIds[0]).toBeTruthy();
expect(clientIds).toEqual([clientIds[0], clientIds[0]]);
});
it('版本创建携带幂等键并保留后端失败状态', async () => {
setStoredAccessToken('test-access-token', { emit: false });
vi.stubGlobal(
+27
View File
@@ -18,8 +18,10 @@ import type {
} from '../../packages/shared/src/contracts/gameDistribution';
import {
type ApiRequestOptions,
BACKGROUND_AUTH_REQUEST_OPTIONS,
requestJson as requestApiJson,
} from './apiClient';
import { getGamePlayClientId } from './gamePlayClientId';
export type {
GameDistributionAuthor,
@@ -69,6 +71,11 @@ const PUBLIC_GAME_REQUEST_OPTIONS: ApiRequestOptions = {
clearAuthOnUnauthorized: false,
};
/**
* 游玩上报是后台尽力而为的请求:带上已登录 bearer(后端据此按 userId 去重),但即使遇到 401
* 也不刷新会话、不改动全局登录态——直接复用后台请求已有的同一份鉴权降级策略。
*/
async function requestJson<T>(
path: string,
init: RequestInit,
@@ -310,6 +317,26 @@ export async function getGame(gameId: string) {
);
}
/**
* 上报一次「开始游戏」。调用方按 fire-and-forget 处理:失败只静默忽略,绝不阻断进入游戏。
*/
export function recordGamePlay(
gameId: string,
): Promise<{ recorded?: boolean }> {
const normalizedId = gameId.trim();
if (!normalizedId) return Promise.resolve({ recorded: false });
return requestJson<{ recorded?: boolean }>(
`/games/${encodeURIComponent(normalizedId)}/plays`,
{
method: 'POST',
cache: 'no-store',
body: JSON.stringify({ clientId: getGamePlayClientId() }),
},
'游玩上报失败',
BACKGROUND_AUTH_REQUEST_OPTIONS,
);
}
export function listGameReviews(gameId: string, page = 1) {
return requestJson<GameDistributionReviewsResponse>(
`/games/${encodeURIComponent(gameId)}/reviews?page=${page}&pageSize=20`,
+43
View File
@@ -0,0 +1,43 @@
/**
* 匿名游玩身份。
*
* 登录用户由后端从 bearer 取 `userId`,匿名用户只能靠前端持久一个随机 `clientId`。这里把
* 生成、读取与降级收在一个小模块里:`localStorage` 不可用(隐私模式、SSR、被禁用)时退化为
* 当前会话内存值,保证一次会话内多次点击仍去重。
*/
const GAME_PLAY_CLIENT_ID_STORAGE_KEY = 'genarrative.game-play.client-id.v1';
let cachedClientId: string | null = null;
/** 返回稳定的匿名游玩身份;同一标签页内重复调用返回同一个值。 */
export function getGamePlayClientId(): string {
if (cachedClientId) return cachedClientId;
try {
const stored = window.localStorage
.getItem(GAME_PLAY_CLIENT_ID_STORAGE_KEY)
?.trim();
if (stored) {
cachedClientId = stored;
return stored;
}
const created = createGamePlayClientId();
window.localStorage.setItem(GAME_PLAY_CLIENT_ID_STORAGE_KEY, created);
cachedClientId = created;
return created;
} catch {
cachedClientId = cachedClientId ?? createGamePlayClientId();
return cachedClientId;
}
}
function createGamePlayClientId(): string {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
return `gp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}