Merge pull request '修复在线游玩加载体验:加载期黑屏/空白与发行资源传输开销(#636)' (#639) from fix/game-play-loading into master
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled

Reviewed-on: http://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/639
This commit was merged in pull request #639.
This commit is contained in:
2026-10-05 19:13:44 +08:00
21 changed files with 1215 additions and 34 deletions
@@ -1,6 +1,8 @@
/* @vitest-environment jsdom */
import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components';
import {
act,
fireEvent,
render,
screen,
@@ -317,3 +319,99 @@ test('详情展示发布者和冻结资料,并试玩当前待审版本', async
),
);
});
/** 打开详情并点开试玩,返回试玩 iframe。 */
async function openPendingVersionPreview() {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText(gameTitle);
fireEvent.click(screen.getByRole('button', { name: '详情' }));
await screen.findAllByText(/测试作者/u);
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
return screen.findByTitle('测试游戏 待审版本试玩');
}
test('试玩期间先给加载面,待审版本真正载入后让位给画面', async () => {
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse);
vi.mocked(createAdminGameDistributionPreviewSession).mockResolvedValue({
previewUrl:
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
expiresAt: '2026-10-02T12:10:00Z',
versionId: 'version-1',
});
const frame = await openPendingVersionPreview();
// 加载期不能只留一片空白:审核员要看到正在加载哪个游戏。
const loadingSurface = screen.getByRole('status');
expect(loadingSurface.textContent).toContain('测试游戏');
expect(
loadingSurface.classList.contains('platform-game-loading--panel'),
).toBe(true);
fireEvent.load(frame);
await waitFor(() => expect(screen.queryByRole('status')).toBeNull());
});
test('试玩加载超过上限后给出超时说明与重建会话入口', async () => {
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse);
vi.mocked(createAdminGameDistributionPreviewSession)
.mockResolvedValueOnce({
previewUrl:
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
expiresAt: '2026-10-02T12:10:00Z',
versionId: 'version-1',
})
.mockResolvedValueOnce({
previewUrl:
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-2/',
expiresAt: '2026-10-02T12:20:00Z',
versionId: 'version-1',
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText(gameTitle);
fireEvent.click(screen.getByRole('button', { name: '详情' }));
await screen.findAllByText(/测试作者/u);
// 试玩会话与超时定时器都必须落在假定时器窗口内,才能按上限推进等待。
vi.useFakeTimers();
try {
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTitle('测试游戏 待审版本试玩')).toBeTruthy();
expect(screen.queryByText('试玩版本加载超时')).toBeNull();
await act(async () => {
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS);
});
expect(screen.getByRole('status').textContent).toContain(
'试玩版本加载超时',
);
fireEvent.click(screen.getByRole('button', { name: '重新创建试玩会话' }));
await act(async () => {
await Promise.resolve();
});
expect(createAdminGameDistributionPreviewSession).toHaveBeenCalledTimes(2);
// 新会话必须回到加载态,不能沿用上一个会话的「已就绪」或超时结论。
const rebuilt = screen.getByTitle('测试游戏 待审版本试玩');
expect(rebuilt.getAttribute('src')).toBe(
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-2/',
);
expect(screen.queryByText('试玩版本加载超时')).toBeNull();
} finally {
vi.useRealTimers();
}
});
@@ -10,6 +10,8 @@ import {
formatAdminDateTime,
GameDetailDisplay,
Modal,
PLATFORM_GAME_LOADING_TIMEOUT_MS,
PlatformGameLoadingSurface,
TextField,
} from '@genarrative/shared/components';
import { RefreshCcw } from 'lucide-react';
@@ -591,6 +593,20 @@ function AdminGameDistributionReviewDetailView({
onReject: () => void;
onApprove: () => void;
}) {
// 试玩会话是按 version 现建的:新会话必须回到加载态,不能沿用上一个会话的 ready。
const [isPreviewFrameReady, setIsPreviewFrameReady] = useState(false);
const [isPreviewTimedOut, setIsPreviewTimedOut] = useState(false);
useEffect(() => {
setIsPreviewFrameReady(false);
setIsPreviewTimedOut(false);
if (!previewUrl) return undefined;
const timer = window.setTimeout(() => {
setIsPreviewTimedOut(true);
}, PLATFORM_GAME_LOADING_TIMEOUT_MS);
return () => window.clearTimeout(timer);
}, [previewUrl]);
if (!detail) {
return (
<AdminPage wide>
@@ -606,6 +622,10 @@ function AdminGameDistributionReviewDetailView({
);
}
const coverUrl = detail.game.coverObjectKey
? assetUrls[detail.game.coverObjectKey]
: '';
return (
<AdminPage wide>
<AdminPageHeading
@@ -682,13 +702,46 @@ function AdminGameDistributionReviewDetailView({
<AdminAlert tone="warning">{previewError}</AdminAlert>
) : null}
{previewUrl ? (
<iframe
title={`${detail.game.title} 待审版本试玩`}
src={previewUrl}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
className="admin-game-review-preview"
/>
<div className="admin-game-review-preview-shell">
{isPreviewFrameReady ? null : (
<PlatformGameLoadingSurface
tone="panel"
title={detail.game.title}
cover={
coverUrl ? (
<img alt={`${detail.game.title} 封面`} src={coverUrl} />
) : null
}
message={isPreviewTimedOut ? '试玩版本加载超时' : undefined}
hint={
isPreviewTimedOut
? '可以重新创建试玩会话,或稍后再试。'
: undefined
}
stalled={isPreviewTimedOut}
/>
)}
<iframe
title={`${detail.game.title} 待审版本试玩`}
src={previewUrl}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
className="admin-game-review-preview"
onLoad={() => setIsPreviewFrameReady(true)}
/>
</div>
) : null}
{previewUrl && isPreviewTimedOut && !isPreviewFrameReady ? (
<AdminActionRow className="admin-review-actions">
<AdminButton
variant="secondary"
type="button"
disabled={isPreviewLoading}
onClick={onPreview}
>
{isPreviewLoading ? '准备试玩…' : '重新创建试玩会话'}
</AdminButton>
</AdminActionRow>
) : null}
</section>
</GameDetailDisplay>
+13 -1
View File
@@ -3590,16 +3590,28 @@ button:disabled {
color: var(--platform-text-strong);
}
.admin-game-review-preview-shell {
/* 加载面(PlatformGameLoadingSurface)按绝对定位铺在预览区上,外壳必须是定位祖先。 */
position: relative;
margin-top: 1rem;
min-height: 42rem;
overflow: hidden;
border-radius: 1rem;
}
.admin-game-review-preview {
display: block;
width: 100%;
min-height: 42rem;
margin-top: 1rem;
border: 1px solid var(--platform-surface-border);
border-radius: 1rem;
background: #fff;
}
.admin-game-review-preview-shell .platform-game-loading {
border-radius: 1rem;
}
/*
* 游戏管理列表(AdminGameManagementPage)专属排布,选择器全部框在本页表格内,
* 不影响其它后台页面:
+3 -1
View File
@@ -104,7 +104,9 @@ curl -sSI -H 'Accept-Encoding: br' \
- 现役发行入口是平台同源路径 `https://<平台域名>/games/<gameId>/`。三份常驻模板(`genarrative.conf`、`genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都内联同一条同源发行入口 location,把 `/games/<gameId>/` 与 `/games/<gameId>/<asset>` 转发到 `api-server` 发行网关;不再需要独立发行域名、`*.games.<域名>` 通配 DNS 或通配 TLS。
- 该 location 的正则必须整体加双引号:`location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"`。不加引号时 nginx 会把 `{32}` 当块定界符,`nginx -t` 报 `pcre2_compile() failed: missing closing parenthesis`。
- 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。
- 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单、`ETag` 与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。
- 传输编码:发行网关自己对文本类资源(HTML/JS/CSS/JSON/SVG/WASM,≥1 KiB)下发 `Content-Encoding: gzip` + `Vary: Accept-Encoding`,边缘的 gzip/Brotli 不会二次压缩(两边都以「上游已带 `Content-Encoding` 就跳过」收口),因此本地 dev(Vite 代理)与 nginx 边缘的用户口径一致,不再有「本地引擎包原样 1.31 MiB」的落差。注意 **Pingora 网关在自身压缩关闭时会移除请求里的 `Accept-Encoding`**,那种部署形态下源站压缩不生效、边缘也不压,属于网关侧口径(见 `docs/project-memory/shared-memory/pitfalls.md` 2026-10-05 条目)。
- 条件请求:命中 `If-None-Match` 时发行网关直接返回 `304`(无正文,保留 `Cache-Control` 与 `ETag`),所以 60 秒 `max-age` 之后的重复游玩只重验证、不重下整包;下架与换版仍按 60 秒窗口在新请求上生效。
- 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。
- 审核通过时 `api-server` 按 gameId 派生同源路径 `/games/<gameId>/` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/<checkoutToken>` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。
@@ -49,3 +49,10 @@
- 自动化:admin-web 详情/动作测试、api-server 预览会话和权限测试、DTO/类型检查、编码和 diff 检查。
- 运行时:本地管理员打开待审版本,确认资料、封面/截图和待审包试玩;确认旧公开版本不会被误播。
- 边界:过期 Token、错误 versionId、Cookie、越权管理员、缺入口、资源 404、CAS 冲突和幂等重放。
## 本轮核对(2026-10-05,试玩加载体验与超时收口)
- 「试玩当前待审版本」原先只渲染 `<iframe class="admin-game-review-preview">`:加载期是一块 `min-height: 42rem` 的空白,没有加载文案、没有超时、没有失败提示(会话过期或资源 404 会永远停在空白)。2026-10-05 起与网页游玩页共用共享加载面 `PlatformGameLoadingSurface`(`packages/shared`):加载期显示游戏标题/封面/动效进度与阶段文案,iframe `onLoad` 后才让位给画面。
- 加载上限与网页游玩页统一为 `PLATFORM_GAME_LOADING_TIMEOUT_MS`(20 秒):超时后加载面换成「试玩版本加载超时」并给出「重新创建试玩会话」入口;新建会话会重置加载态,不会沿用上一个会话的 ready。
- 用例:`apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx` 新增两条(加载面在画面载入后让位、超过上限后给出超时说明与重建入口,重建后新会话 URL 生效且回到加载态)。
- 仍未变化:预览会话 token 与 `no-store`、`sandbox="allow-scripts"`、Cookie 拒绝、版本绑定与审核 CAS 口径都不动。
@@ -333,3 +333,9 @@
- **详情页返回按来源回到上一页**:`GameDetailPage` 左上角原来是写死的「返回游戏广场」,并且直接 `setSelectionStage('games')`(等于 push 一条新的 `/games`)。从「我的游戏」点进详情再返回就落到广场,从广场进详情返回也会多压一条重复历史。现在文案简化为「返回」,行为改成优先 `window.history.back()`,由 `ActiveApp` 已有的 `popstate` 同步把舞台还原成真正的来源页(我的游戏 / 广场);游玩页的返回同样处理,避免详情↔游玩互相 push。协议侧给应用写入的历史条目补了 `__genarrativeAppHistoryDepth`,新增 `hasAppHistoryBackEntry()` 判断「当前条目是应用内导航写入的且存在上一页」;直接打开详情深链、或原生壳里没有可回退条目时,才 `replaceAppHistoryPath` 兜底到广场(游玩页兜底到自己的详情)。回归用例 `activeAppPageRoutes.test.ts` 锁定深度标记语义,`PlatformEntryActiveFlowShell.test.tsx`「游戏详情返回」两条锁定原生返回与深链兜底。
- **游戏分发的返回文案统一成「返回」**:`GameDetailPage`、`GamePlayPage`(含超时面板里的按钮)、`MyGamesPage`、`GamePublishPage` 四处原本是「返回游戏广场 / 返回详情」,同一套流程里出现三种写法;现在统一成「返回」,目标页仍由各自的 `onBack`(广场 / 我的游戏 / 详情)决定,按钮不再承诺一个可能不对的目的地。`GameDistributionPages.test.tsx` 的断言同步改为 `/^返回$/u`。游玩页工具栏上那个按钮展示的是游戏名(退出开始面板后就靠它认游戏),不属于「返回 xx」文案,保持不动。
- 验证:`npx vitest run src/components/game-distribution/GameDistributionPages.test.tsx`(21 passed)、`npx vitest run src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx`(22 passed)、`npx vitest run src/routing/activeAppPageRoutes.test.ts`、`npm run typecheck`、`npx eslint`(五个改动文件)、`npm run check:encoding`、`git diff --check` 全部通过;启动面板的溢出可达性、「我的游戏」移动端横向溢出都用真实 Chromium 静态夹具量过(见 pitfalls 同日条目),详情返回链在真实 Chromium(dev 栈 390x844)实测 `/games`→详情→返回=`/games`、深链直开详情→返回=`/games`、详情→立即玩→返回详情=`/games/detail?id=…`。视觉与真机横竖屏走查仍需人工/截图评审(沿用阶段 C 第 8 条未取证口径)。
## 本轮核对(2026-10-05,游玩页加载体验与发行资源传输)
- 游玩页此前在 `onLoad` 前只盖一行 `游戏正在启动…`(近黑壳上),用户反馈「一直黑屏」。现在加载期显示共享加载面 `PlatformGameLoadingSurface`(`packages/shared`,封面/标题 + 动效进度 + 按时长推进的阶段文案),`onLoad` 后淡入画面;超时面板与重试/返回不变,上限仍与后台试玩统一为 `PLATFORM_GAME_LOADING_TIMEOUT_MS`(20 秒)。
- 发行网关(`api-server`)对文本类发行资源下发 `Content-Encoding: gzip` + `Vary: Accept-Encoding`,并下发强 `ETag`:`If-None-Match` 命中返回 304。`max-age=60, must-revalidate` 与撤销窗口不变,差别只是 60 秒之后重复游玩不再重下整包。后台试玩会话仍是 `no-store` 且无 ETag。
- 证据:真实栈 4 Mbps/100 ms 模拟链路对照(改前 3298 ms / 4587 ms 才出画面,且期间无有效加载反馈),发行网关字节数对照见 PR;`cargo test -p api-server --bin api-server -- game_distribution` 与三条前端用例(共享加载面、游玩页、后台试玩)为回归门禁。
@@ -6406,3 +6406,13 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **别踩**:不要写成裸前缀正则(`^/pay`)——它会吞掉 `/payment/x`、`/paycheckout/x` 这类同名邻居;也不要把深链塞进精确 allowlist 的 alternatives 里(`pay` 的 alternatives 只匹配 `/pay`)。
- **判据/取证**:`node --test scripts/check-nginx-spa-routes.test.mjs`(正/反用例,含「写回精确匹配即红」)、`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway -- pay_checkout_deep_link matches_nginx_route_parity_matrix`;线上复验 `curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/<checkoutToken>` → 200 且正文与 `/` 同一份 `index.html`。
- **关联**:`scripts/check-nginx-spa-routes.mjs`、`deploy/pingora/nginx-route-parity.matrix.json`、`server-rs/crates/pingora-gateway/src/main.rs`、`server-rs/crates/api-server/src/payment.rs`、`deploy/nginx/genarrative.conf`。
## 2026-10-05 在线游玩「一直黑屏」:加载面缺失 + 发行网关不压不发 ETag
- **现象**:用户反馈「进入游玩…加载有点慢,一直黑屏体验不好」。真实栈(真实发行包 + Chromium + 4 Mbps/100 ms 模拟链路)实测:网页游玩页点击「开始游戏」后 99.3% 像素亮度 < 24 的近黑面板 + 一行 `游戏正在启动…`,游戏画面 **3298 ms** 才出现;后台审核页点「试玩当前待审版本」后 iframe 直接以 `opacity:1` 出现、区域**全白空白** 4587 ms,页面**全程没有任何加载文案**。
- **根因**:① 网页游玩页的加载态只有一行小字盖在 `#17131b` 近黑壳上,没有封面/进度/阶段文案,`ready` 判定又只看 iframe `onLoad`(文档加载完成 ≠ 游戏可玩);② 后台试玩只有 `<iframe class="admin-game-review-preview">`,没有加载态、没有超时、没有失败处理,会话过期或资源 404 会永远停在空白;③ 发行网关把 ZIP 解压后的原文直出,`phaser.min.js` 1,375,976 B 原样下发(`Content-Encoding: none`),也没有 ETag —— 4 Mbps 下光这一个文件就 ~2.7 s,且 60 秒 `max-age` 过后浏览器只能重下整包。
- **处理(现行口径)**:新增共享组件 `PlatformGameLoadingSurface`(`packages/shared`,封面/标题 + 动效进度 + 按时长推进的阶段文案 + 慢加载提示),网页游玩页与后台试玩共用;两端的加载上限统一为 `PLATFORM_GAME_LOADING_TIMEOUT_MS`(20 秒),后台超时后给出「重新创建试玩会话」。发行网关对文本类资源(HTML/JS/CSS/JSON/SVG/WASM,≥1 KiB、客户端接受 gzip)下发 `Content-Encoding: gzip` + `Vary: Accept-Encoding`,并下发按 `versionId + 资源路径` 摘要的强 ETag,命中 `If-None-Match` 返回 304(无正文)。
- **边界**:图片/音频/视频等已是压缩格式的资源不压;后台试玩会话仍是 `no-store` 且不给 ETag;`max-age=60, must-revalidate` 与撤销窗口不变(换版/下架仍最迟 60 秒对新请求生效,304 只是在窗口之后省掉重下)。边缘 gzip/Brotli 与本层不冲突:两边都以「响应已带 `Content-Encoding` 就跳过」收口;但 Pingora 网关在自身压缩关闭时会**移除** `accept-encoding`(`normalize_accept_encoding_for_gateway_compression`),那种部署形态下源站压缩不会生效,属于网关侧口径。
- **CPU 边界**:发行网关是公开无鉴权端点,压缩按请求实时算。release 构建实测 level 6 为 1.3 MiB→10 ms、8 MiB→59 ms、64 MiB(单文件上限)→522 ms 纯 CPU,因此在 `RELEASE_COMPRESSION_FAST_ABOVE_BYTES`(2 MiB)以上改用 level 1(zlib 端实测 level 1 约为 level 6 的 1/3 耗时、压缩比只差约 3%)。若后续要再做减法,优先把压缩结果按 `(对象键, 资源路径)` 缓存,而不是放宽级别。
- **验证**:`cargo test -p api-server --bin api-server -- game_distribution`(新增 ETag 作用域、`If-None-Match` 列表/弱校验命中、`gzip;q=0` 拒绝、文本压缩与二进制/小文件不压、304 无正文、`*` 对包内缺失路径仍 404 等用例);`npx vitest run packages/shared/src/components/PlatformGameLoadingSurface.test.tsx`、`src/components/game-distribution/GameDistributionPages.test.tsx`、`apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx`;`npm run check:game-distribution-ops-rollback-e2e` 47 项通过(含压缩/Vary/ETag/304/不接受 gzip 四条新断言)。真实栈同链路复跑:`phaser.min.js` 1,375,976 B → 353,336 B(gzip,4 Mbps 下 2724 ms → 774 ms),用户端游戏画面 3298 ms → 1543 ms、后台试玩 4587 ms → 2694 ms。
- **关联**:`packages/shared/src/components/PlatformGameLoadingSurface.tsx`、`src/components/game-distribution/GamePlayPage.tsx`、`apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx`、`server-rs/crates/api-server/src/modules/game_distribution.rs`、`deploy/nginx/README.md`。
@@ -744,6 +744,7 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
- 路由约定:`https://<平台域名>/games/<gameId>/` 是该游戏的入口,`/games/<gameId>/<asset>` 映射到发行网关 `/api/game-distribution/releases/<gameId>/<asset>`。同源发行入口 location 写在 `deploy/nginx/genarrative.conf`(生产 HTTPS)、`deploy/nginx/genarrative-dev-http.conf`(开发 HTTP)与 `deploy/container/nginx.conf`(容器)里。正则必须整体加双引号:`location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"`,否则 nginx 会把 `{32}` 当块定界符并在 `nginx -t` 报 missing closing parenthesis。
- 会话与隔离:边缘在转发前 `proxy_set_header Cookie ""`,发行网关自身也对带 `Cookie` 的请求返回 `403`;游戏文档跑在 iframe `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载整套游戏代码。
- 响应头与缓存:`X-Content-Type-Options`、CORP(`cross-origin`)、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate` 都由发行网关设置,边缘不覆盖。换版与下架只改变后端公开投影,因此**最迟 60 秒**内新请求不再拿到旧版本;已经下载到浏览器的脚本无法远程抹除,撤销能力以"停止继续分发"为准。
- 传输与条件请求(2026-10-05):发行网关对文本类资源(HTML/JS/CSS/JSON/SVG/WASM,≥1 KiB)在客户端接受 `gzip` 时下发 `Content-Encoding: gzip` 并带 `Vary: Accept-Encoding`;图片、音频、视频等已是压缩格式的资源保持原样。同时下发强 `ETag`(按 `versionId + 资源路径` 摘要),命中 `If-None-Match` 时返回 `304`(无正文、保留 `Cache-Control` 与 `ETag`),所以 60 秒缓存窗口之后浏览器只需重验证而不是重下整包;`max-age` 与 `must-revalidate` 都不变,撤销窗口仍是 60 秒。后台试玩会话固定 `no-store`,不给 ETag。
- 审核动作:管理员只提交审核结论与公开修订号,`entryUrl` 由 `api-server` 按 gameId 派生**同源路径** `/games/{gameId}/` 写入公开投影;dev / release / 预览环境口径完全一致,入口不再由部署侧配置,历史数据里的绝对 URL 继续兼容。
- 门禁:
@@ -166,6 +166,7 @@
- 作者下架或管理员封禁成功后,公开列表、详情和启动 API 立即停止返回游戏;发行网关同步按游戏和版本授权拒绝新请求,原始对象不可绕过网关访问。管理员封禁禁止作者自行重发绕过;解除需管理员明确动作并重新审核。
- 建议 HTML、公开状态与启动 API 使用 `no-store`;发行静态资源的浏览器与 CDN 有效期均不超过 60 秒,禁止 `stale-while-revalidate`、`stale-if-error` 和发行 Service Worker。下架主动 purge 相关 CDN 键,60 秒作为最大缓存撤销窗口,不把 purge 成功当唯一保障。旧版本被更新替代后,新启动只用当前版;旧游戏已经载入的脚本/资源不承诺远程抹除,用户退出或刷新后按当前授权重新判断。
- 发行网关对文本类资源按 `Accept-Encoding` 下发 `Content-Encoding: gzip` 并带 `Vary: Accept-Encoding`;同一 `(versionId, 资源路径)` 的字节在版本冻结后不变,因此可以下发强 `ETag` 并在 `If-None-Match` 命中时返回 `304`(无正文,保留 `Cache-Control` 与 `ETag`)。这只是省掉 60 秒窗口之后的重复下载:`max-age=60, must-revalidate` 与上面的撤销窗口都不放宽,换版与下架仍按 60 秒窗口对新的请求生效。
- 发布所需部署依赖包括独立站点域名及通配 TLS、每游戏 host 路由、私有存储、网关 CSP/CORS/MIME、CDN TTL/purge、管理员审核运营入口和可恢复校验执行器;缺少任一项不能宣布公开上线。
- 观察上传失败、校验耗时、审核积压、发行 4xx/5xx、撤销传播时间与容量,日志按游戏/版本/操作 ID 关联,不记录 Token、完整用户文件内容或 signed URL。原始失败/撤回包建议保留 7 天后清理,公开版本和审核记录的保留周期在上线前确定;清理必须先检查引用,不能删除仍在服务的版本。
- 回滚部署时关闭新提交和新版本激活,保留当前可玩版本与状态读取;数据库迁移不以删表回滚。安全事件通过服务端关闭游戏发行权限,不依赖前端隐藏按钮。现役实现:`game-distribution:publish` 灰度开关(后台「灰度发布配置」)控制作者写入与新版本激活——灰度默认关闭,没有 gate 行或 `enabled=false` 时不允许发布;`enabled=true` 时只有白名单/灰度命中的用户能发布(`rolloutPercent=0` 且无白名单即仍然全关)。关闭期间目录、详情、版本回读、发行网关、审核队列读取、拒绝审核与安全下架都不受影响;开关读取失败按关闭处理。
@@ -0,0 +1,106 @@
/* @vitest-environment jsdom */
import { act, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { PlatformGameLoadingSurface } from './PlatformGameLoadingSurface';
import {
PLATFORM_GAME_LOADING_ASSET_PHASE_MS,
PLATFORM_GAME_LOADING_ENGINE_PHASE_MS,
PLATFORM_GAME_LOADING_SLOW_PHASE_MS,
resolvePlatformGameLoadingMessage,
} from './platformGameLoadingSurfaceModel';
function readMessage() {
return screen
.getByRole('status')
.querySelector('.platform-game-loading__message')?.textContent;
}
afterEach(() => {
vi.useRealTimers();
});
describe('PlatformGameLoadingSurface', () => {
test('阶段文案随等待时长推进,慢加载才追加提示行', () => {
vi.useFakeTimers();
render(<PlatformGameLoadingSurface title="星尘防线" />);
const surface = screen.getByRole('status');
expect(surface.getAttribute('aria-busy')).toBe('true');
expect(surface.getAttribute('aria-label')).toContain('星尘防线');
expect(readMessage()).toBe(resolvePlatformGameLoadingMessage(0));
expect(surface.querySelector('.platform-game-loading__hint')).toBeNull();
act(() => {
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_ASSET_PHASE_MS);
});
expect(readMessage()).toBe(
resolvePlatformGameLoadingMessage(PLATFORM_GAME_LOADING_ASSET_PHASE_MS),
);
expect(
screen.getByRole('status').querySelector('.platform-game-loading__hint'),
).toBeNull();
act(() => {
vi.advanceTimersByTime(
PLATFORM_GAME_LOADING_ENGINE_PHASE_MS -
PLATFORM_GAME_LOADING_ASSET_PHASE_MS,
);
});
expect(readMessage()).toBe(
resolvePlatformGameLoadingMessage(PLATFORM_GAME_LOADING_ENGINE_PHASE_MS),
);
act(() => {
vi.advanceTimersByTime(
PLATFORM_GAME_LOADING_SLOW_PHASE_MS -
PLATFORM_GAME_LOADING_ENGINE_PHASE_MS,
);
});
expect(
screen.getByRole('status').querySelector('.platform-game-loading__hint')
?.textContent,
).toBeTruthy();
});
test('宿主文案与提示覆盖默认阶段文案,封面按宿主给的内容渲染', () => {
render(
<PlatformGameLoadingSurface
title="待审版本试玩"
tone="panel"
message="正在创建试玩会话…"
hint="会话过期后请重新创建"
cover={<img alt="封面" src="data:image/gif;base64,R0lGOD" />}
/>,
);
const surface = screen.getByRole('status');
expect(surface.className).toContain('platform-game-loading--panel');
expect(readMessage()).toBe('正在创建试玩会话…');
expect(
surface.querySelector('.platform-game-loading__hint')?.textContent,
).toBe('会话过期后请重新创建');
expect(screen.getByRole('img', { name: '封面' })).toBeTruthy();
});
test('宿主判定停止推进时不再声明 busy,并停掉进度动画', () => {
render(<PlatformGameLoadingSurface title="待审版本试玩" stalled />);
const surface = screen.getByRole('status');
expect(surface.getAttribute('aria-busy')).toBe('false');
expect(surface.className).toContain('platform-game-loading--stalled');
});
test('没有封面时渲染图标占位而不是空白区块', () => {
const { container } = render(
<PlatformGameLoadingSurface title="加载中" tone="player" />,
);
const surface = container.querySelector('.platform-game-loading');
expect(surface?.className).toContain('platform-game-loading--player');
expect(
surface?.querySelector('.platform-game-loading__cover svg'),
).toBeTruthy();
});
});
@@ -0,0 +1,97 @@
import { Gamepad2 } from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import {
PLATFORM_GAME_LOADING_SLOW_PHASE_MS,
resolvePlatformGameLoadingMessage,
} from './platformGameLoadingSurfaceModel';
export type PlatformGameLoadingSurfaceTone = 'player' | 'panel';
export type PlatformGameLoadingSurfaceProps = {
/** 游戏标题:加载面用来说明正在加载哪一个游戏。 */
title: string;
/**
* 封面内容由宿主提供:网页端与后台端各自持有素材换签逻辑,共享组件只负责摆放,
* 缺省时退化为平台图标占位。
*/
cover?: ReactNode | null;
/** `player` 用于暗色播放器壳,`panel` 用于浅色后台面板。 */
tone?: PlatformGameLoadingSurfaceTone;
/** 覆盖默认阶段文案(例如宿主自己知道正在创建试玩会话)。 */
message?: string;
/** 追加一行提示(例如失败的兜底说明)。 */
hint?: string;
/**
* 宿主已判定这次等待不再推进(例如已经给出超时结论):停掉进度动画并把
* `aria-busy` 置回 `false`,避免「一边转圈一边说超时」。
*/
stalled?: boolean;
className?: string;
};
/**
* 平台通用「游戏加载面」。
*
* 发行包(Phaser/Vite 产物)首屏要下完整个引擎与资源,网络稍慢就是好几秒;这段时间
* 网页游玩页与后台审核试玩都必须给出**看得见的**加载反馈,而不是近黑面板上的一行小字
* 或一片空白。阶段文案由组件自己按时长推进,宿主只负责在真正进入游戏后把它换掉。
*
* 组件按绝对定位铺满**最近的定位祖先**:宿主必须给它一个 `position: relative` 的容器
* (网页游玩页是 `.game-player-shell`,后台试玩是 `.admin-game-review-preview-shell`)。
*/
export function PlatformGameLoadingSurface({
title,
cover = null,
tone = 'panel',
message,
hint,
stalled = false,
className,
}: PlatformGameLoadingSurfaceProps) {
const [elapsedMs, setElapsedMs] = useState(0);
useEffect(() => {
const startedAt = Date.now();
const timer = window.setInterval(() => {
setElapsedMs(Date.now() - startedAt);
}, 500);
return () => window.clearInterval(timer);
}, []);
const statusMessage = message ?? resolvePlatformGameLoadingMessage(elapsedMs);
const slowHint =
!hint && elapsedMs >= PLATFORM_GAME_LOADING_SLOW_PHASE_MS
? '加载时间比平时更久,仍在继续…'
: hint;
return (
<div
role="status"
aria-busy={stalled ? 'false' : 'true'}
aria-label={`${title} ${statusMessage}`}
className={[
'platform-game-loading',
`platform-game-loading--${tone}`,
stalled ? 'platform-game-loading--stalled' : null,
className,
]
.filter(Boolean)
.join(' ')}
>
<div className="platform-game-loading__cover">
{cover ?? <Gamepad2 aria-hidden="true" />}
</div>
<div className="platform-game-loading__copy">
<strong className="platform-game-loading__title">{title}</strong>
<p className="platform-game-loading__message">{statusMessage}</p>
<div className="platform-game-loading__track" aria-hidden="true">
<span className="platform-game-loading__fill" />
</div>
{slowHint ? (
<p className="platform-game-loading__hint">{slowHint}</p>
) : null}
</div>
</div>
);
}
+12
View File
@@ -44,6 +44,18 @@ export type {
PlatformFilterToolbarTabItem,
} from './PlatformFilterToolbar';
export { PlatformFilterToolbar } from './PlatformFilterToolbar';
export type {
PlatformGameLoadingSurfaceProps,
PlatformGameLoadingSurfaceTone,
} from './PlatformGameLoadingSurface';
export { PlatformGameLoadingSurface } from './PlatformGameLoadingSurface';
export {
PLATFORM_GAME_LOADING_ASSET_PHASE_MS,
PLATFORM_GAME_LOADING_ENGINE_PHASE_MS,
PLATFORM_GAME_LOADING_SLOW_PHASE_MS,
PLATFORM_GAME_LOADING_TIMEOUT_MS,
resolvePlatformGameLoadingMessage,
} from './platformGameLoadingSurfaceModel';
export type {
PlatformIconBadgeProps,
PlatformIconBadgeShape,
@@ -0,0 +1,29 @@
/** 阶段文案切换点:先「准备」,再「资源」,最后「引擎」。 */
export const PLATFORM_GAME_LOADING_ASSET_PHASE_MS = 1_500;
export const PLATFORM_GAME_LOADING_ENGINE_PHASE_MS = 6_000;
/** 超过这个时长仍未进入游戏,补一行「比平时久」的状态,避免用户以为卡死。 */
export const PLATFORM_GAME_LOADING_SLOW_PHASE_MS = 12_000;
/**
* 发行包首屏加载上限。
*
* 网页游玩页与后台审核试玩共用同一口径:超过上限就给出显式超时与重试,而不是让
* 用户对着加载面无限期等待。
*/
export const PLATFORM_GAME_LOADING_TIMEOUT_MS = 20_000;
const DEFAULT_COPY_BY_PHASE: Record<'prepare' | 'assets' | 'engine', string> = {
prepare: '正在准备游戏…',
assets: '正在加载游戏资源…',
engine: '正在启动游戏引擎…',
};
/** 按已等待时长给出阶段文案;与 `PlatformGameLoadingSurface` 内的计时同源。 */
export function resolvePlatformGameLoadingMessage(elapsedMs: number) {
if (elapsedMs >= PLATFORM_GAME_LOADING_ENGINE_PHASE_MS) {
return DEFAULT_COPY_BY_PHASE.engine;
}
if (elapsedMs >= PLATFORM_GAME_LOADING_ASSET_PHASE_MS) {
return DEFAULT_COPY_BY_PHASE.assets;
}
return DEFAULT_COPY_BY_PHASE.prepare;
}
+180 -1
View File
@@ -1398,7 +1398,8 @@ textarea.genarrative-ui-text-field__control {
.genarrative-ui-switch,
.genarrative-ui-progress__fill,
.genarrative-ui-spinner,
.genarrative-ui-skeleton {
.genarrative-ui-skeleton,
.platform-game-loading__fill {
transition: none;
animation: none;
}
@@ -1541,3 +1542,181 @@ textarea.genarrative-ui-text-field__control {
)::after {
content: attr(aria-label);
}
/*
* 平台「游戏加载面」:发行包(Phaser/Vite 产物)首屏要下完整个引擎与资源,网络稍慢就是
* 好几秒;网页游玩页与后台审核试玩都必须在这段时间给出看得见的加载反馈,而不是近黑面板
* 上的一行小字或一片空白。
*
* 样式放在这里而不是任一宿主的业务样式表:后台 `admin-web` 只引入本文件与 `theme.css`,
* 没有 Tailwind,因此共享组件不能只靠工具类撑起外观。
*/
.platform-game-loading {
position: absolute;
inset: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: clamp(1rem, 3vw, 2.25rem);
padding: clamp(1rem, 4vw, 2.5rem);
overflow-y: auto;
}
.platform-game-loading--player {
background: linear-gradient(135deg, #1e1820, #302329);
color: #fff8f0;
}
.platform-game-loading--panel {
background: var(--platform-panel-fill-soft, rgba(255, 254, 252, 0.96));
color: var(--platform-text-base, #6f5848);
}
.platform-game-loading__cover {
display: grid;
aspect-ratio: 4 / 3;
width: clamp(6.5rem, 18vw, 11rem);
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: 1.1rem;
}
.platform-game-loading--player .platform-game-loading__cover {
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.platform-game-loading--panel .platform-game-loading__cover {
background: rgba(199, 101, 61, 0.1);
box-shadow: inset 0 0 0 1px var(--platform-surface-border, rgba(226, 203, 184, 0.88));
}
.platform-game-loading__cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
/*
* 宿主自己的封面组件(例如游玩页的 `.game-cover`)带自己的最小高度与内边距;加载面只借
* 它的图,盒子尺寸一律由加载面决定,否则封面会把封面槽撑破。
*/
.platform-game-loading__cover > * {
width: 100%;
height: 100%;
min-height: 0;
border-radius: inherit;
}
.platform-game-loading__cover svg {
width: 2.4rem;
height: 2.4rem;
opacity: 0.55;
}
.platform-game-loading__copy {
display: grid;
min-width: 0;
max-width: 26rem;
gap: 0.55rem;
}
.platform-game-loading__title {
overflow: hidden;
font-size: clamp(1.05rem, 2.2vw, 1.5rem);
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.platform-game-loading__message {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
}
.platform-game-loading--player .platform-game-loading__message {
color: rgba(255, 241, 227, 0.78);
}
.platform-game-loading--panel .platform-game-loading__message {
color: var(--platform-text-soft, #988476);
}
.platform-game-loading__track {
position: relative;
height: 0.35rem;
overflow: hidden;
border-radius: 999px;
}
.platform-game-loading--player .platform-game-loading__track {
background: rgba(255, 255, 255, 0.14);
}
.platform-game-loading--panel .platform-game-loading__track {
background: var(--platform-track-fill, rgba(226, 203, 184, 0.45));
}
.platform-game-loading__fill {
position: absolute;
top: 0;
bottom: 0;
width: 38%;
border-radius: inherit;
animation: platform-game-loading-sweep 1.2s ease-in-out infinite;
}
.platform-game-loading--player .platform-game-loading__fill {
background: linear-gradient(90deg, rgba(255, 196, 150, 0.4), #ffb27a);
}
.platform-game-loading--panel .platform-game-loading__fill {
background: var(--platform-button-primary-fill, #c7653d);
}
/* 宿主已判定这次等待不再推进(例如已经给出超时结论):进度条停下并压暗,
避免出现「一边转圈一边说超时」的自相矛盾。 */
.platform-game-loading--stalled .platform-game-loading__fill {
animation: none;
transform: none;
opacity: 0.45;
}
.platform-game-loading__hint {
margin: 0;
font-size: 0.72rem;
}
.platform-game-loading--player .platform-game-loading__hint {
color: rgba(255, 241, 227, 0.6);
}
.platform-game-loading--panel .platform-game-loading__hint {
color: var(--platform-text-muted, #a38f80);
}
@keyframes platform-game-loading-sweep {
from {
transform: translateX(-110%);
}
to {
transform: translateX(280%);
}
}
@media (max-width: 560px) {
.platform-game-loading {
align-content: safe center;
text-align: center;
}
.platform-game-loading__copy {
max-width: 100%;
}
.platform-game-loading__track {
margin: 0 auto;
width: min(14rem, 100%);
}
}
@@ -18,6 +18,7 @@
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-ops-rollback-e2e
import { createHash, randomBytes } from 'node:crypto';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import JSZip from 'jszip';
@@ -99,6 +100,37 @@ async function release(pathname) {
return { status: response.status, body, headers: response.headers };
}
/// 原样请求(不自动解压):压缩与条件请求必须看真实线字节,不能让 fetch 帮忙解码。
function releaseRaw(pathname, { acceptEncoding, ifNoneMatch } = {}) {
return new Promise((resolve, reject) => {
const target = new URL(`${API}${pathname}`);
const headers = {};
if (acceptEncoding) headers['accept-encoding'] = acceptEncoding;
if (ifNoneMatch) headers['if-none-match'] = ifNoneMatch;
const request = http.request(
{
hostname: target.hostname,
port: target.port,
path: `${target.pathname}${target.search}`,
headers,
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () =>
resolve({
status: response.statusCode,
headers: response.headers,
body: Buffer.concat(chunks),
}),
);
},
);
request.on('error', reject);
request.end();
});
}
async function adminToken() {
const login = await api('/admin/api/login', {
method: 'POST',
@@ -397,6 +429,47 @@ async function main() {
);
assertReleaseHeaders('v1 CSS', baselineCss, { html: false });
// 传输与条件请求契约:引擎包动辄 1 MiB 以上,文本资源必须按 Accept-Encoding 压缩,
// 并在 60 秒缓存窗口之后靠 ETag 重验证(304)而不是重下整包。
const releasePath = `/api/game-distribution/releases/${gameId}/index.html`;
const compressedHtml = await releaseRaw(releasePath, {
acceptEncoding: 'gzip',
});
check(
'发行文本资源按 Accept-Encoding 压缩并声明 Vary',
compressedHtml.status === 200 &&
compressedHtml.headers['content-encoding'] === 'gzip' &&
String(compressedHtml.headers.vary ?? '').includes('accept-encoding') &&
compressedHtml.body.length < Buffer.byteLength(baselineRelease.body),
`encoding=${compressedHtml.headers['content-encoding'] ?? ''} vary=${compressedHtml.headers.vary ?? ''} bytes=${compressedHtml.body.length}/${Buffer.byteLength(baselineRelease.body)}`,
);
const releaseEtag = baselineRelease.headers.get('etag') ?? '';
check(
'发行资源下发强 ETag',
/^"[0-9a-f]{32}"$/u.test(releaseEtag),
`etag=${releaseEtag}`,
);
const revalidated = await releaseRaw(releasePath, {
ifNoneMatch: releaseEtag,
});
check(
'If-None-Match 命中时返回 304 且不带正文',
revalidated.status === 304 &&
revalidated.body.length === 0 &&
revalidated.headers['cache-control'] === RELEASE_CACHE_CONTROL &&
revalidated.headers.etag === releaseEtag,
`status=${revalidated.status} body=${revalidated.body.length} cache-control=${revalidated.headers['cache-control'] ?? ''}`,
);
const identityHtml = await releaseRaw(releasePath, {
acceptEncoding: 'identity',
});
check(
'客户端不接受 gzip 时不下发压缩体',
identityHtml.status === 200 &&
identityHtml.headers['content-encoding'] === undefined,
`encoding=${identityHtml.headers['content-encoding'] ?? ''}`,
);
const pendingIds = {};
for (const marker of ['V2-OK', 'V3-OK']) {
const built = await buildPackage(marker);
+1
View File
@@ -208,6 +208,7 @@ dependencies = [
"bytes",
"cbc",
"dotenvy",
"flate2",
"futures-util",
"hex",
"hmac",
+1
View File
@@ -12,6 +12,7 @@ base64 = { workspace = true }
cbc = { workspace = true }
bytes = { workspace = true }
dotenvy = { workspace = true }
flate2 = { workspace = true }
hex = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
http-body-util = { workspace = true }
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components';
import {
act,
cleanup,
@@ -19,7 +20,7 @@ import type {
} from '../../services/gameDistributionClient';
import { GameDetailPage } from './GameDetailPage';
import { GameGalleryPage } from './GameGalleryPage';
import { GAME_PLAY_STARTUP_TIMEOUT_MS, GamePlayPage } from './GamePlayPage';
import { GamePlayPage } from './GamePlayPage';
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
const listGamesMock = vi.hoisted(() => vi.fn());
@@ -464,6 +465,28 @@ describe('GamePlayPage', () => {
expect(recordGamePlayMock).toHaveBeenCalledWith('game-1');
});
it('shows the shared loading surface until the release frame is ready', async () => {
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
await screen.findByRole('button', { name: /开始游戏/u });
fireEvent.click(screen.getByRole('button', { name: /开始游戏/u }));
const iframe = await screen.findByTitle('星轨防线 在线游玩');
// 发行包首屏要下完整个引擎:加载期必须有看得见的加载面,而不是一行小字或黑屏。
const loadingSurface = screen.getByRole('status');
expect(loadingSurface.textContent).toContain('星轨防线');
expect(
loadingSurface.classList.contains('platform-game-loading--player'),
).toBe(true);
expect(
loadingSurface.querySelector('.platform-game-loading__track'),
).not.toBeNull();
fireEvent.load(iframe);
expect(screen.queryByRole('status')).toBeNull();
expect(iframe.className.includes('game-player-frame--ready')).toBe(true);
});
it('rejects an unsafe release entry URL before rendering an iframe', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
@@ -690,7 +713,7 @@ describe('GamePlayPage 启动超时与旋转提示', () => {
const firstFrame = screen.getByTitle('星轨防线 在线游玩');
act(() => {
vi.advanceTimersByTime(GAME_PLAY_STARTUP_TIMEOUT_MS);
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS);
});
expect(screen.getByText(/启动超时/u)).toBeTruthy();
expect(screen.getByRole('button', { name: /^返回$/u })).toBeTruthy();
@@ -706,7 +729,7 @@ describe('GamePlayPage 启动超时与旋转提示', () => {
);
act(() => {
vi.advanceTimersByTime(GAME_PLAY_STARTUP_TIMEOUT_MS);
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS);
});
expect(screen.queryByText(/启动超时/u)).toBeNull();
@@ -1,5 +1,9 @@
import './gameDistribution.css';
import {
PLATFORM_GAME_LOADING_TIMEOUT_MS,
PlatformGameLoadingSurface,
} from '@genarrative/shared/components';
import {
ArrowLeft,
Expand,
@@ -26,9 +30,6 @@ import { GameCover } from './GameGalleryPage';
type GamePlayPageProps = { gameId: string | null; onBack: () => void };
/** 发行包首屏加载上限:超过后给出显式超时与重试,不用无限期“正在启动”。 */
export const GAME_PLAY_STARTUP_TIMEOUT_MS = 20_000;
type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout';
export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
@@ -93,7 +94,7 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
setStartupState((current) =>
current === 'loading' ? 'timeout' : current,
);
}, GAME_PLAY_STARTUP_TIMEOUT_MS);
}, PLATFORM_GAME_LOADING_TIMEOUT_MS);
return () => window.clearTimeout(timeoutId);
}, [hasStarted, startupState]);
@@ -212,7 +213,12 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
</div>
) : null}
{hasStarted && startupState === 'loading' ? (
<div className="game-player-loading">游戏正在启动…</div>
<PlatformGameLoadingSurface
key={`game-loading-${frameRunId}`}
tone="player"
title={game.title}
cover={<GameCover game={game} compact />}
/>
) : null}
{hasStarted && startupState === 'timeout' ? (
<PlatformStatusMessage
@@ -517,15 +517,6 @@
.game-player-frame--ready {
opacity: 1;
}
.game-player-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: rgba(255, 241, 227, 0.75);
font-size: 0.88rem;
font-weight: 700;
}
.game-play-safety-note {
margin: 0;
color: var(--platform-text-soft);
@@ -645,7 +636,6 @@
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.game-player-loading,
.game-player-timeout {
font-size: 0.8rem;
}