新增旧玩法表阶段一清空迁移能力
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 3m26s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Native shell tests (pull_request) Failing after 15m45s

增加受 migration operator 保护的固定 63 张旧表清空 procedure
生成 spacetime-client procedure、输入和结果绑定
更新后端数据契约与两阶段退役决策记录
清理未挂载的旧入口、分享路由、快照持久化与旧状态死链
This commit is contained in:
2026-09-02 16:28:14 +08:00
parent 5de9554426
commit 9129e1442a
1314 changed files with 355 additions and 533426 deletions
@@ -23,6 +23,16 @@
- 验证方式:运行评论弹层恢复竞态回归、完整 `appSurface.test.ts`,并执行类型、编码和 diff 检查。
- 关联文档:`docs/technical/【技术方案】立项策划AgentFast GDD-2026-08-10.md``apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx`
## 2026-09-02 旧玩法表采用两阶段退役清理
- 背景:旧创作模板的业务代码已退出现役编译链,但 SpacetimeDB 中的历史表仍需先完成数据清理;直接删除表定义会扩大 schema 迁移和客户端兼容风险。
- 决策:阶段一只在 `spacetime-module/src/migration.rs` 增加受 `database_migration_operator` 保护的 `clear_retired_database_tables` procedure。procedure 使用固定的 63 张旧玩法表清单,不接受动态表名;`dry_run=true` 只返回逐表行数统计,`dry_run=false` 在同一事务内逐表清空,任一失败整体回滚。阶段一不删除表定义、不修改 `legacy_schema/**`、migration 导入导出白名单或生成 bindings。
- 阶段边界:清理清单包含旧 gameplay、`custom_world`、Puzzle / Puzzle Clear、Bark Battle、Match3D、Jump Hop、Wooden Fish、Square Hole、Visual Novel 和 Big Fish 表;`runtime_setting``runtime_snapshot``user_browse_history``creation_entry_config` 等现役表明确排除。阶段二只有在备份、客户端兼容性和运行态确认完成后,才评估从 module 定义与 migration 白名单移除空表,并按 schema / bindings 流程发布;固定清单旁保留 TODO。
- 影响范围:SpacetimeDB migration procedure、`spacetime-client` 生成 bindings、后端数据契约和本决策记录;禁止新增 SQL `DROP TABLE``--delete-data=always` 或直接写系统表的实现。
- 验证方式:固定清单测试确认数量为 63 且不含现役表;本地数据库以已授权 operator 执行 dry-run,确认 63 张表均返回 0 行且未写入;apply 的单事务回滚由 procedure 实现,实际 apply 仅在另行授权的维护窗口执行。另运行 bindings 生成、SpacetimeDB schema / runtime 检查、编码和 diff 门禁。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``server-rs/crates/spacetime-module/src/migration.rs`
- 补充落地:阶段一同时删除此前仅因退役而保留的旧玩法业务实现、未挂载 API handler/router/worker、旧客户端 facade/mapper、旧领域 crate 和 `retired/legacy-creation-templates/**` 归档;`legacy_schema/**`、生成表 bindings 和当前前端仍引用的公共契约不在本次删除范围。
## 2026-08-31 DirectProject 客户端扩展按独立 Skill/MCP 导入
- 背景:DirectProject 需要使用用户在 AGC 客户端导入的市面原生 Skill、MCP 和 Plugin 内容,但第三方内容不应直接安装到运行时 Codex,也不应要求用户转换为 AGC 自定义格式。
@@ -6558,7 +6568,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 并发补验:确定性 Provider 只在 Runtime 明确返回 revision blocker、专业 verification-only repair、成功验证 observation,或项目锁 / repository context drift 这两类可恢复 observation 时重放终态;每个 logical run 最多 16 次。只读职责不得借补验调用未授权命令,验证失败或缺少 `ok` observation 不能交付,Provider completion 计数始终 exactly-once。
- 画布审计:资源 manifest 可以保留生成 prompt 作为本地来源元数据,但公开 `asset.register / asset.update` 审计记录必须移除 `source.prompt`,只保留 canvas/resource/task/model 等身份字段,避免完整生成正文进入公开 Agent DB 表面。
- 当前测试事实:已有回归覆盖固定画布合同不允许被模型改写、已登记 spritesheet 禁止先删除、只有静态 repair 可原位替换、替换期间原文件 fingerprint 漂移时拒绝覆盖,以及 `design-foundation``game/index.html` 的 write / patchset / delete 和预览工具均被 Runtime 策略阻断。2026-07-27 的独立 75 分钟上限外部真实 E2E 已按上一节单轮证据完整 **PASS**;后续合同变化仍须新起独立轮次,不能复用这次结果替代未来验收。
- 最终落地:本次退役范围覆盖整个旧创作模板体系,包括 RPG / 自定义世界、拼图、拼消消、大鱼吃小鱼、敲木鱼、方洞挑战、视觉小说、汪汪声浪、寓教于乐、Creative Agent、Match3D、跳一跳和儿童动作 Demo。全部相关历史表继续作为数据壳参与 `spacetime-module` 编译,`migration.rs` 白名单与历史数据不变;旧 reducer/procedure/view、API 路由/handler/worker、前端页面/工作台/运行态、共享业务 DTO纯业务 crate 从编译链与依赖图移除,但旧源码和素材保留在仓库中用于历史追溯
- 最终落地:本次退役范围覆盖整个旧创作模板体系,包括 RPG / 自定义世界、拼图、拼消消、大鱼吃小鱼、敲木鱼、方洞挑战、视觉小说、汪汪声浪、寓教于乐、Creative Agent、Match3D、跳一跳和儿童动作 Demo。全部相关历史表继续作为数据壳参与 `spacetime-module` 编译,`migration.rs` 白名单与历史数据不变;旧 reducer/procedure/view、API 路由/handler/worker、前端页面/工作台/运行态、共享业务 DTO纯业务 crate、未挂载旧实现和归档源码均删除,现役公共能力与历史表 schema 保留
- 兼容读取:只保留历史审计、迁移和资产归属核对所需的最小读取定义;旧 `worldType`、公开作品号、URL、详情页和专属运行态均不再形成用户可访问入口。
- 方案文档:`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`
@@ -6573,7 +6583,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- Rust 产物边界:`module-runtime` 继续承载账号、钱包、公共设置、追踪和 feature gate,但 `CreationEntry*`、旧公开作品、存档、浏览历史与游玩统计 DTO / command / mapper / 规则必须退出实际 rlib;只保留历史表需要的 `RuntimeBrowseHistoryThemeMode`、完整保序的钱包流水来源枚举等持久化 ABI。`check:server-rs-ddd` 必须执行 `check:module-runtime-artifact`,同时验证旧符号和字面量为零、必要 ABI 仍存在,不能以源码存在 `#[cfg(any())]` 或路由未挂载代替产物证明。
- 外围编译边界:`platform-auth` 不再编译 runtime guest token`platform-wechat` 不再编译旧玩法生成结果订阅消息,小程序不再注册订阅授权页;旧公开作品资产授权 view 退出 SpacetimeDB module,匿名素材读取只保留现役 editor showcase 派生授权。
- 历史队列边界:现役 external generation worker 只领取 `source_module = editor-canvas` 的任务,历史旧玩法 pending / running 行保持原状态,不得被新 worker 领取后改写为失败。
- Agent crate 边界:`platform-agent` 的执行器、工具注册表、回调和拼图 Phase 1 输入均属于已退役 Creative Agent 业务,不得因现役编辑器 Agent 共用一个模型名常量而留在 workspace 或 `api-server` 依赖图。该常量收口到 `platform-llm``platform-agent` 与仅由它引入的 `langchainrust` 退出在运 Cargo resolve graph源码目录继续仅作历史追溯
- Agent crate 边界:`platform-agent` 的执行器、工具注册表、回调和拼图 Phase 1 输入均属于已退役 Creative Agent 业务,不得因现役编辑器 Agent 共用一个模型名常量而留在 workspace 或 `api-server` 依赖图。该常量收口到 `platform-llm``platform-agent` 与仅由它引入的 `langchainrust` 退出在运 Cargo resolve graph相关源码已删除
- AI 游戏创作兼容边界:独立 AGC Tauri 壳仍复用 `platform-agent::game_creation` 的任务图与隔离协作数据模型。`platform-agent` 继续排除在 `server-rs` workspace 之外,但其独立 manifest 默认只编译 `game_creation` / `error`,旧执行器、工具注册表、回调、拼图 Phase 1 与 `langchainrust` 统一受关闭的 `legacy-creative-agent` feature 隔离;AGC lock 不得重新引入这些退役依赖。
- 防回流补充:顶层 `creationEntryConfigService``creationUrlState``customWorld*``runtimeGuestAuth``runtimeRequest``input-devices``useCombatFlow``useStoryOptions``useMocapInput` 和微信生成订阅 facade 同样属于退役前端模块;Vite dev 对旧 `/api/creation*``/api/public-works*` 前缀直接返回 404,不能回落 SPA HTML。
- Vite 全量边界补充:`src/games/**``src/data/**``src/prompts/**`、旧顶层 App / Playground、旧路由和 `services/ai.ts` 必须由 pre-transform 门禁直接拒绝;所有同源 `/generated-*` 裸读在 dev 与生产统一为空 `404`,历史对象只经现役签名读取接口兼容,不允许 SPA fallback 伪装成资产成功响应。
@@ -70,7 +70,7 @@ src/
根级原生壳检查同时锁定 HostBridge 模块分类:`dispatch` / `protocol` 是微信、移动、桌面三端共同模块;`appearance``badge``capabilities``clipboard``file-payloads``files``navigation``network``notifications``runtime``share` 是 Expo / Tauri 原生 App 壳共同模块;移动端专属 `bridge``haptics``scanner`,桌面端专属 `mod``title`,微信端专属 `payment``shareGrid``webView`。后续新增或拆分桥接模块不能只改文件清单,必须先说明它属于三端共同、原生 App 共同还是某端专属。
2026-07-18 调整:旧玩法生成结果订阅授权已退役,微信 capability profile 不再声明 `navigation.openNativePage``subscribeMessage` host-bridge / shell / page 历史源码移入 `retired/legacy-creation-templates/frontend/miniprogram/`,不再进入小程序包和 `check:native-shells` 现役文件清单。
2026-07-18 调整:旧玩法生成结果订阅授权已退役,微信 capability profile 不再声明 `navigation.openNativePage``subscribeMessage` host-bridge / shell / page 已从仓库删除,不再进入小程序包和 `check:native-shells` 现役文件清单。
生产替身词扫描只覆盖上述壳源码、分发配置、共享 HostBridge 契约和已接入真实宿主能力的 H5 调用链;Expo export、Tauri `target/`、Cargo / Metro 缓存和 release 构建产物不进入扫描范围,避免本地或 CI 生成文件污染源码门禁。
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -1,129 +0,0 @@
/* global wx */
const SUBSCRIBE_RESULT_STORAGE_KEY = 'genarrative:wechat-subscribe-result';
const WECHAT_SUBSCRIBE_UNAVAILABLE_REASON = 'wechat subscribe unavailable';
function logWechatSubscribeFailure(label, _error) {
console.error(`[subscribe-message] ${label}`);
}
function appendSubscribeResult(url, result) {
const hashIndex = String(url || '').indexOf('#');
const baseUrl =
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
const nextHash = rawHash
.split('&')
.filter((part) => part && !part.startsWith('wx_subscribe_result='))
.concat(`wx_subscribe_result=${encodeURIComponent(result)}`)
.join('&');
return `${baseUrl}#${nextHash}`;
}
function buildSubscribeResultValue(requestId, status, reason) {
const segments = [requestId, status];
if (reason) {
segments.push(encodeURIComponent(reason));
}
return segments.join(':');
}
function notifyPreviousWebView(requestId, status, reason) {
const result = buildSubscribeResultValue(requestId, status, reason);
wx.setStorageSync(SUBSCRIBE_RESULT_STORAGE_KEY, result);
}
function resolveSubscribeStatus(result, templateId) {
return result && result[templateId] === 'accept' ? 'success' : 'skip';
}
function createSubscribeMessagePageController(pageContext, options = {}) {
const templateId = String(options.templateId || '').trim();
const notifyPageResult = (methodThis, status, reason) => {
const page = pageContext ?? methodThis;
const requestId = page.requestId || '';
if (!requestId || page.hasNotifiedSubscribeResult) {
return;
}
page.hasNotifiedSubscribeResult = true;
notifyPreviousWebView(requestId, status, reason);
};
return {
data: {
title: '接收生成结果通知',
errorMessage: '',
requesting: false,
},
onLoad(query) {
const page = pageContext ?? this;
page.requestId = String(query.requestId || '');
page.hasNotifiedSubscribeResult = false;
},
notifyResult(status, reason) {
notifyPageResult(this, status, reason);
},
requestSubscribe() {
const page = pageContext ?? this;
const requestId = page.requestId || '';
if (!requestId) {
page.setData({
errorMessage: '缺少订阅请求参数。',
});
return;
}
if (!templateId) {
notifyPageResult(this, 'skip', 'missing_template_id');
wx.navigateBack();
return;
}
if (typeof wx.requestSubscribeMessage !== 'function') {
notifyPageResult(this, 'skip', 'unsupported');
wx.navigateBack();
return;
}
page.setData({
requesting: true,
errorMessage: '',
});
wx.requestSubscribeMessage({
tmplIds: [templateId],
success(result) {
notifyPageResult(
page,
resolveSubscribeStatus(result, templateId),
'',
);
wx.navigateBack();
},
fail(error) {
logWechatSubscribeFailure('request failed', error);
notifyPageResult(page, 'skip', WECHAT_SUBSCRIBE_UNAVAILABLE_REASON);
wx.navigateBack();
},
});
},
handleSkip() {
notifyPageResult(this, 'skip', 'user_skip');
wx.navigateBack();
},
onUnload() {
notifyPageResult(this, 'skip', 'page_unload');
},
};
}
module.exports = {
SUBSCRIBE_RESULT_STORAGE_KEY,
WECHAT_SUBSCRIBE_UNAVAILABLE_REASON,
appendSubscribeResult,
buildSubscribeResultValue,
createSubscribeMessagePageController,
resolveSubscribeStatus,
};
@@ -1,139 +0,0 @@
import path from 'node:path';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js';
const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU';
const subscribeBridgePath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/subscribeMessage.js',
);
describe('subscribe-message mini program bridge', () => {
let subscribeMessageBridge;
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
globalThis.wx = {
requestSubscribeMessage: vi.fn(),
setStorageSync: vi.fn(),
navigateBack: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => []);
subscribeMessageBridge = loadCommonJsModule(subscribeBridgePath);
});
test('requests subscribe message and stores result before returning', () => {
const {
SUBSCRIBE_RESULT_STORAGE_KEY,
createSubscribeMessagePageController,
} = subscribeMessageBridge;
const previousPage = {
data: { webViewUrl: 'https://web.test/#tab=create' },
setData: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => {
options.success?.({
m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU: 'accept',
});
});
const page = createSubscribeMessagePageController(
{
setData: vi.fn(),
},
{ templateId: TEST_TEMPLATE_ID },
);
page.onLoad({ requestId: 'request-1' });
page.requestSubscribe();
expect(globalThis.wx.requestSubscribeMessage).toHaveBeenCalledWith({
tmplIds: [TEST_TEMPLATE_ID],
success: expect.any(Function),
fail: expect.any(Function),
});
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
SUBSCRIBE_RESULT_STORAGE_KEY,
'request-1:success',
);
expect(previousPage.setData).not.toHaveBeenCalled();
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
});
test('hides requestSubscribeMessage native failure details from H5 result', () => {
const {
SUBSCRIBE_RESULT_STORAGE_KEY,
createSubscribeMessagePageController,
} = subscribeMessageBridge;
const subscribeError = {
errMsg: 'requestSubscribeMessage:fail private native detail',
};
globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => {
options.fail?.(subscribeError);
});
const page = createSubscribeMessagePageController(
{
setData: vi.fn(),
},
{ templateId: TEST_TEMPLATE_ID },
);
page.onLoad({ requestId: 'request-fail' });
page.requestSubscribe();
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
SUBSCRIBE_RESULT_STORAGE_KEY,
'request-fail:skip:wechat%20subscribe%20unavailable',
);
expect(console.error).toHaveBeenCalledWith(
'[subscribe-message] request failed',
);
expect(console.error.mock.calls.flat()).not.toContain(subscribeError);
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
});
test('skip action notifies previous web-view', () => {
const {
SUBSCRIBE_RESULT_STORAGE_KEY,
createSubscribeMessagePageController,
} = subscribeMessageBridge;
const previousPage = {
data: { webViewUrl: 'https://web.test/' },
setData: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
const page = createSubscribeMessagePageController(
{
setData: vi.fn(),
},
{ templateId: TEST_TEMPLATE_ID },
);
page.onLoad({ requestId: 'request-skip' });
page.handleSkip();
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
SUBSCRIBE_RESULT_STORAGE_KEY,
'request-skip:skip:user_skip',
);
expect(previousPage.setData).not.toHaveBeenCalled();
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
});
test('appendSubscribeResult replaces stale subscribe hash', () => {
const { appendSubscribeResult, buildSubscribeResultValue } =
subscribeMessageBridge;
expect(
appendSubscribeResult(
'https://web.test/#old=1&wx_subscribe_result=old',
'req:skip',
),
).toBe('https://web.test/#old=1&wx_subscribe_result=req%3Askip');
expect(buildSubscribeResultValue('req-1', 'skip', 'user_cancel')).toBe(
'req-1:skip:user_cancel',
);
});
});
@@ -1,10 +0,0 @@
/* global Page */
const { GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID } = require('../../config');
const { createSubscribeMessagePage } = require('../../shell/subscribeMessage');
Page(
createSubscribeMessagePage(null, {
templateId: GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID,
}),
);
@@ -1,3 +0,0 @@
{
"navigationBarTitleText": "生成通知"
}
@@ -1,19 +0,0 @@
<view class="subscribe-screen">
<view class="subscribe-card">
<view class="subscribe-title">{{title}}</view>
<view wx:if="{{errorMessage}}" class="subscribe-text subscribe-text--danger">
{{errorMessage}}
</view>
<button
class="primary-button"
loading="{{requesting}}"
disabled="{{requesting}}"
bindtap="requestSubscribe"
>
继续并接收通知
</button>
<button class="ghost-button" disabled="{{requesting}}" bindtap="handleSkip">
仅继续生成
</button>
</view>
</view>
@@ -1,58 +0,0 @@
.subscribe-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
background: #0b0f14;
box-sizing: border-box;
}
.subscribe-card {
width: 100%;
max-width: 560rpx;
padding: 36rpx;
border: 1rpx solid rgba(255, 255, 255, 0.14);
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.06);
box-sizing: border-box;
}
.subscribe-title {
font-size: 34rpx;
font-weight: 600;
line-height: 1.35;
color: #f5f7fb;
}
.subscribe-text {
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.55;
color: rgba(245, 247, 251, 0.72);
}
.subscribe-text--danger {
color: #ffb4a9;
}
.primary-button,
.ghost-button {
margin-top: 28rpx;
width: 100%;
border-radius: 8rpx;
font-size: 26rpx;
line-height: 2.6;
}
.primary-button {
background: #f5f7fb;
color: #0b0f14;
}
.ghost-button {
margin-top: 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.24);
background: transparent;
color: rgba(245, 247, 251, 0.86);
}
@@ -1,11 +0,0 @@
const {
createSubscribeMessagePageController,
} = require('../host-bridge/subscribeMessage');
function createSubscribeMessagePage(pageContext, options = {}) {
return createSubscribeMessagePageController(pageContext, options);
}
module.exports = {
createSubscribeMessagePage,
};
@@ -1,60 +0,0 @@
import path from 'node:path';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js';
const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU';
const subscribeBridgePath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/subscribeMessage.js',
);
const subscribeShellPath = path.resolve(
process.cwd(),
'miniprogram/shell/subscribeMessage.js',
);
describe('wechat subscribe-message shell page', () => {
let createSubscribeMessagePage;
beforeEach(() => {
globalThis.wx = {
navigateBack: vi.fn(),
requestSubscribeMessage: vi.fn(),
setStorageSync: vi.fn(),
};
const subscribeMessageBridge = loadCommonJsModule(subscribeBridgePath);
const subscribeMessageShell = loadCommonJsModule(subscribeShellPath, {
'../host-bridge/subscribeMessage': subscribeMessageBridge,
});
createSubscribeMessagePage =
subscribeMessageShell.createSubscribeMessagePage;
});
test('requests generation-result subscribe template and stores result', () => {
globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => {
options.success?.({
[TEST_TEMPLATE_ID]: 'accept',
});
});
const page = createSubscribeMessagePage(
{ setData: vi.fn() },
{ templateId: TEST_TEMPLATE_ID },
);
page.onLoad({ requestId: 'request-1' });
page.requestSubscribe();
expect(globalThis.wx.requestSubscribeMessage).toHaveBeenCalledWith({
tmplIds: [TEST_TEMPLATE_ID],
success: expect.any(Function),
fail: expect.any(Function),
});
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
'genarrative:wechat-subscribe-result',
'request-1:success',
);
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
});
});
@@ -1,271 +0,0 @@
import {
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useAuthUi } from './components/auth/AuthUiContext';
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
import { getInitialPlatformDesktopLayout } from './components/platform-entry/platformEntryResponsive';
import type {
CustomWorldRuntimeLaunchOptions,
SelectionStage,
} from './components/platform-entry/platformEntryTypes';
import { useHostNavigationCanGoBack } from './hooks/useHostNavigationCanGoBack';
import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes';
import {
APP_RUNTIME_ROUTES,
isAppHistoryState,
normalizeAppPath,
pushAppHistoryPath,
readPublicWorkCodeFromLocationSearch,
replaceAppHistoryPath,
resolveInitialSelectionStageFromPath,
resolvePathForSelectionStage,
shouldRedirectEditorCanvasWithoutProject,
} from './routing/appPageRoutes';
import type { RpgRuntimeAppIntent } from './RpgRuntimeApp';
import {
resolveAppTitleForSelectionStage,
syncAppTitle,
} from './services/appTitle';
import {
refreshNativeAppHostRuntime,
subscribeHostRuntimeChange,
} from './services/host-bridge/hostBridge';
import type { CustomWorldProfile } from './types';
const RpgRuntimeApp = lazy(async () => {
const module = await import('./RpgRuntimeApp');
return {
default: module.RpgRuntimeApp,
};
});
function RuntimeLoadingFallback() {
return (
<div className="platform-ui-shell platform-viewport-shell platform-theme platform-theme--dark flex h-screen items-center justify-center bg-[image:var(--platform-body-fill)] p-4 font-sans text-[var(--platform-text-strong)]">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
</div>
</div>
);
}
function isRpgRuntimeRoute(pathname: string) {
const normalizedPath = normalizeAppPath(pathname);
return (
normalizedPath === APP_RUNTIME_ROUTES['rpg-character-select'] ||
normalizedPath === APP_RUNTIME_ROUTES['rpg-adventure']
);
}
function resolveInitialAppSelectionStage() {
if (
shouldRedirectEditorCanvasWithoutProject(
window.location.pathname,
window.location.search,
)
) {
replaceAppHistoryPath('/creation');
return 'creation-home';
}
return resolveInitialSelectionStageFromPath(
window.location.pathname,
getInitialPlatformDesktopLayout(),
);
}
export default function App() {
const authUi = useAuthUi();
const runtimeIntentTokenRef = useRef(0);
const hasHostNavigationAnchorRef = useRef(
isAppHistoryState(window.history.state),
);
const hostNavigation = useHostNavigationCanGoBack();
const [runtimeIntent, setRuntimeIntent] =
useState<RpgRuntimeAppIntent | null>(null);
const [, setHostRuntimeRevision] = useState(0);
const [isRuntimeActive, setIsRuntimeActive] = useState(() =>
isRpgRuntimeRoute(window.location.pathname),
);
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(
resolveInitialAppSelectionStage,
);
const [runtimeReturnStage, setRuntimeReturnStage] =
useState<SelectionStage>('platform');
const [initialPublicWorkCode] = useState(() =>
readPublicWorkCodeFromLocationSearch(window.location.search),
);
const setSelectionStage = useCallback(
(stage: SelectionStage, options?: { path?: string }) => {
setRawSelectionStage(stage);
pushAppHistoryPath(options?.path ?? resolvePathForSelectionStage(stage));
},
[],
);
useEffect(() => {
const unsubscribe = subscribeHostRuntimeChange(() => {
setHostRuntimeRevision((revision) => revision + 1);
});
void refreshNativeAppHostRuntime();
return unsubscribe;
}, []);
useEffect(() => {
const syncStageFromHistory = () => {
hasHostNavigationAnchorRef.current = isAppHistoryState(
window.history.state,
);
if (
shouldRedirectEditorCanvasWithoutProject(
window.location.pathname,
window.location.search,
)
) {
replaceAppHistoryPath('/creation');
setIsRuntimeActive(false);
setRawSelectionStage('creation-home');
return;
}
if (isRpgRuntimeRoute(window.location.pathname)) {
setIsRuntimeActive(true);
return;
}
setIsRuntimeActive(false);
setRawSelectionStage(
resolveInitialSelectionStageFromPath(
window.location.pathname,
getInitialPlatformDesktopLayout(),
),
);
};
window.addEventListener('popstate', syncStageFromHistory);
return () => window.removeEventListener('popstate', syncStageFromHistory);
}, []);
useEffect(() => {
if (
!hostNavigation.isSupported ||
hostNavigation.canGoBack ||
isRuntimeActive ||
selectionStage === 'platform' ||
isAppHistoryState(window.history.state) ||
hasHostNavigationAnchorRef.current
) {
return;
}
const currentPath = normalizeAppPath(window.location.pathname);
const currentSearch = window.location.search;
hasHostNavigationAnchorRef.current = true;
replaceAppHistoryPath('/');
pushAppHistoryPath(`${currentPath}${currentSearch}`);
}, [
hostNavigation.canGoBack,
hostNavigation.isSupported,
isRuntimeActive,
selectionStage,
]);
const createRuntimeIntent = useCallback(
(intent: Omit<RpgRuntimeAppIntent, 'token'>) => {
runtimeIntentTokenRef.current += 1;
setRuntimeIntent({
...intent,
token: runtimeIntentTokenRef.current,
});
setIsRuntimeActive(true);
},
[],
);
const handleContinueGame = useCallback(
(snapshot?: HydratedSavedGameSnapshot | null) => {
createRuntimeIntent({
kind: 'snapshot',
snapshot: snapshot ?? null,
});
},
[createRuntimeIntent],
);
const handleCustomWorldSelect = useCallback(
(
customWorldProfile: CustomWorldProfile,
options?: CustomWorldRuntimeLaunchOptions,
) => {
// 中文注释:作品测试需要在结束测试后精确返回启动它的结果页;
// 正式进入世界仍保持既有平台首页返回语义。
setRuntimeReturnStage(options?.returnStage ?? 'platform');
createRuntimeIntent({
kind: 'custom-world',
profile: customWorldProfile,
mode: options?.mode ?? 'play',
disablePersistence: options?.disablePersistence,
exitToResult: options?.returnStage === 'custom-world-result',
});
},
[createRuntimeIntent],
);
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
const isImageEditorStage = selectionStage === 'image-editor';
const platformShellSurfaceClass = isImageEditorStage
? 'bg-white p-0'
: 'bg-[image:var(--platform-body-fill)] p-2 sm:p-4';
useEffect(() => {
syncAppTitle(
isRuntimeActive
? 'RPG 运行中 - 陶泥儿'
: resolveAppTitleForSelectionStage(selectionStage),
);
}, [isRuntimeActive, selectionStage]);
if (isRuntimeActive) {
return (
<Suspense fallback={<RuntimeLoadingFallback />}>
<RpgRuntimeApp
initialIntent={runtimeIntent}
onExitRuntime={() => {
setIsRuntimeActive(false);
setSelectionStage(runtimeReturnStage);
}}
/>
</Suspense>
);
}
return (
<div
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden ${platformShellSurfaceClass} font-sans text-[var(--platform-text-strong)]`}
>
<PlatformEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
initialPublicWorkCode={initialPublicWorkCode}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={handleContinueGame}
handleStartNewGame={() => {}}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
</div>
);
}
@@ -1,17 +0,0 @@
import { PlatformEntryFlowShellImpl } from './PlatformEntryFlowShellImpl';
import type {
PlatformEntryFlowShellProps,
SelectionStage,
} from './platformEntryTypes';
export type { PlatformEntryFlowShellProps, SelectionStage };
/**
* 平台入口通用壳层。
* RPG、Big Fish 等玩法创作入口在这里并列分流。
*/
export function PlatformEntryFlowShell(props: PlatformEntryFlowShellProps) {
return <PlatformEntryFlowShellImpl {...props} />;
}
export default PlatformEntryFlowShell;
@@ -1,179 +0,0 @@
import { describe, expect, test } from 'vitest';
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
import {
buildExternalGenerationQueuePresentation,
buildExternalGenerationQueueStatus,
shouldUseFastExternalGenerationTaskPolling,
} from './platformExternalGenerationQueueStatusModel';
import {
resolveFinishedMiniGameDraftGenerationState,
resolveMiniGameGenerationProgressTickState,
resolveMiniGameGenerationViewBusy,
} from './platformMiniGameDraftGenerationStateModel';
describe('resolveMiniGameGenerationProgressTickState', () => {
test('returns jump hop and wooden fish generation states for progress ticking', () => {
const jumpHopState = createMiniGameDraftGenerationState('jump-hop');
const woodenFishState = createMiniGameDraftGenerationState('wooden-fish');
expect(
resolveMiniGameGenerationProgressTickState('jump-hop-generating', {
'jump-hop': jumpHopState,
}),
).toBe(jumpHopState);
expect(
resolveMiniGameGenerationProgressTickState('wooden-fish-generating', {
'wooden-fish': woodenFishState,
}),
).toBe(woodenFishState);
});
test('returns null when the stage does not need generation ticking', () => {
expect(
resolveMiniGameGenerationProgressTickState('platform', {
'jump-hop': createMiniGameDraftGenerationState('jump-hop'),
}),
).toBeNull();
});
});
describe('resolveMiniGameGenerationViewBusy', () => {
test('敲木鱼恢复生成中草稿时继续隐藏重新生成按钮', () => {
const woodenFishGeneratingState =
createMiniGameDraftGenerationState('wooden-fish');
expect(
resolveMiniGameGenerationViewBusy(false, woodenFishGeneratingState),
).toBe(true);
});
test('生成态结束后只保留真实 busy', () => {
const woodenFishReadyState = resolveFinishedMiniGameDraftGenerationState(
createMiniGameDraftGenerationState('wooden-fish'),
'ready',
);
expect(resolveMiniGameGenerationViewBusy(false, woodenFishReadyState)).toBe(
false,
);
expect(resolveMiniGameGenerationViewBusy(true, woodenFishReadyState)).toBe(
true,
);
});
});
describe('buildExternalGenerationQueueStatus', () => {
test('合并队列概览和当前任务状态', () => {
expect(
buildExternalGenerationQueueStatus(
{
pendingCount: 7,
runningCount: 3,
unacknowledgedTerminalCount: 1,
updatedAtMicros: 1_781_222_400_000_000,
},
{
operationId: 'extgen-1',
status: 'running',
phaseLabel: '正在生成。',
phaseDetail: '正在生成。',
progress: 35,
updatedAtMicros: 1_781_222_400_000_000,
},
),
).toEqual({
currentStatus: 'running',
currentProgress: 35,
pendingCount: 7,
runningCount: 3,
unacknowledgedTerminalCount: 1,
tasks: null,
});
});
test('没有队列或任务信息时不显示状态条', () => {
expect(buildExternalGenerationQueueStatus(null, null)).toBeNull();
});
test('构造我的页生成队列展示状态', () => {
expect(
buildExternalGenerationQueuePresentation({
currentStatus: 'running',
currentProgress: 42.4,
pendingCount: 2,
runningCount: 1,
unacknowledgedTerminalCount: 0,
}),
).toEqual({
statusLabel: '生成中',
progressLabel: '42%',
pendingLabel: '2',
runningLabel: '1',
pendingCount: 2,
runningCount: 1,
unacknowledgedTerminalCount: 0,
progress: 42,
tasks: [],
shouldShow: true,
});
expect(buildExternalGenerationQueuePresentation(null).shouldShow).toBe(
false,
);
expect(
buildExternalGenerationQueuePresentation({
currentStatus: 'completed',
currentProgress: 100,
pendingCount: 0,
runningCount: 0,
}).shouldShow,
).toBe(false);
});
test('只在队列活跃或存在未确认终态任务时使用快速轮询', () => {
expect(
shouldUseFastExternalGenerationTaskPolling({
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: 0,
tasks: [],
}),
).toBe(false);
expect(
shouldUseFastExternalGenerationTaskPolling({
currentStatus: 'queued',
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: 0,
tasks: [],
}),
).toBe(true);
expect(
shouldUseFastExternalGenerationTaskPolling({
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: 0,
tasks: [
{
jobId: 'extgen-completed',
jobKind: 'editor_image_generation',
sourceModule: 'editor',
sourceEntityId: 'project-1',
requestLabel: '图片生成',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '已完成',
progress: 100,
priceMudPoints: 1,
createdAt: '2026-06-25T00:00:00.000Z',
updatedAt: '2026-06-25T00:00:00.000Z',
updatedAtMicros: 1_782_348_800_000_000,
},
],
}),
).toBe(true);
});
});
@@ -1,102 +0,0 @@
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile } from '../../types';
export type CustomWorldRuntimeLaunchMode = 'play';
export type CustomWorldRuntimeLaunchOptions = {
mode?: CustomWorldRuntimeLaunchMode;
disablePersistence?: boolean;
returnStage?: SelectionStage | null;
};
export type SelectionStage =
| 'platform'
| 'creation-home'
| 'project'
| 'image-editor'
| 'profile-feedback'
| 'work-detail'
| 'detail'
| 'agent-workspace'
| 'big-fish-agent-workspace'
| 'big-fish-generating'
| 'big-fish-result'
| 'big-fish-runtime'
| 'match3d-agent-workspace'
| 'match3d-generating'
| 'match3d-result'
| 'match3d-runtime'
| 'square-hole-agent-workspace'
| 'square-hole-generating'
| 'square-hole-result'
| 'square-hole-runtime'
| 'jump-hop-workspace'
| 'jump-hop-generating'
| 'jump-hop-result'
| 'jump-hop-runtime'
| 'jump-hop-gallery-detail'
| 'puzzle-clear-workspace'
| 'puzzle-clear-generating'
| 'puzzle-clear-result'
| 'puzzle-clear-runtime'
| 'bark-battle-workspace'
| 'bark-battle-generating'
| 'bark-battle-result'
| 'bark-battle-runtime'
| 'wooden-fish-workspace'
| 'wooden-fish-generating'
| 'wooden-fish-result'
| 'wooden-fish-runtime'
| 'creative-agent-workspace'
| 'visual-novel-agent-workspace'
| 'visual-novel-generating'
| 'visual-novel-result'
| 'visual-novel-gallery-detail'
| 'visual-novel-runtime'
| 'baby-object-match-workspace'
| 'baby-object-match-generating'
| 'baby-object-match-result'
| 'baby-object-match-runtime'
| 'baby-love-drawing-runtime'
| 'puzzle-agent-workspace'
| 'puzzle-generating'
| 'puzzle-onboarding'
| 'puzzle-result'
| 'puzzle-gallery-detail'
| 'puzzle-runtime'
| 'custom-world-generating'
| 'custom-world-result';
export type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null;
export type CustomWorldResultViewSource =
| 'saved-profile'
| 'agent-draft'
| null;
export type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
export type SyncedAgentDraftResult = {
session: CustomWorldAgentSessionSnapshot | null;
profile: CustomWorldProfile | null;
view?: RpgCreationResultView | null;
};
export type PlatformEntryFlowShellProps = {
selectionStage: SelectionStage;
setSelectionStage: (
stage: SelectionStage,
options?: { path?: string },
) => void;
initialPublicWorkCode?: string | null;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (
customWorldProfile: CustomWorldProfile,
options?: CustomWorldRuntimeLaunchOptions,
) => void;
};
@@ -1,82 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import './index.css';
import { StrictMode, Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import { FloatingFeedbackEntry } from './components/common/FloatingFeedbackEntry';
import { getInitialPlatformDesktopLayout } from './components/platform-entry/platformEntryResponsive';
import { stabilizeMobileViewportKeyboardFocus } from './mobileViewportKeyboardFocus';
import { lockMobileViewportZoom } from './mobileViewportZoomLock';
import {
resolveAppRoute,
shouldUseRecommendationRouteLoading,
} from './routing/appRoutes';
import { RouteImageReadyGate } from './routing/RouteImageReadyGate';
import { RouteLoadingScreen } from './routing/RouteLoadingScreen';
import {
getHostRuntime,
refreshNativeAppHostRuntime,
} from './services/host-bridge/hostBridge';
type AppRoot = ReturnType<typeof createRoot>;
declare global {
interface Window {
__tavernRealmsRoot__?: AppRoot;
}
}
const route = resolveAppRoute(window.location.pathname);
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Missing #root container');
}
function markWechatMiniProgramRuntime() {
if (getHostRuntime().kind === 'wechat_mini_program') {
document.documentElement.dataset.wechatMiniProgramRuntime = 'true';
}
}
const root = (window.__tavernRealmsRoot__ ??= createRoot(rootElement));
const RouteComponent = route.Component;
const routeElement = <RouteComponent {...(route.componentProps ?? {})} />;
const shouldGateRouteImages = shouldUseRecommendationRouteLoading(
window.location.pathname,
getInitialPlatformDesktopLayout(),
);
lockMobileViewportZoom();
stabilizeMobileViewportKeyboardFocus();
markWechatMiniProgramRuntime();
void refreshNativeAppHostRuntime();
root.render(
<StrictMode>
<Suspense
fallback={
shouldGateRouteImages ? (
<RouteLoadingScreen
eyebrow={route.loadingEyebrow}
text={route.loadingText}
/>
) : null
}
>
{shouldGateRouteImages ? (
<RouteImageReadyGate
eyebrow={route.loadingEyebrow}
text={route.loadingText}
>
{routeElement}
</RouteImageReadyGate>
) : (
routeElement
)}
</Suspense>
<FloatingFeedbackEntry />
</StrictMode>,
);
@@ -1,305 +0,0 @@
/* @vitest-environment jsdom */
import { describe, expect, it } from 'vitest';
import {
isAppHistoryState,
pushAppHistoryPath,
readEditorCanvasProjectIdFromLocationSearch,
replaceAppHistoryPath,
resolvePathForSelectionStage,
resolveSelectionStageFromPath,
shouldRedirectEditorCanvasWithoutProject,
} from './appPageRoutes';
describe('appPageRoutes', () => {
it('hides the legacy profile feedback route', () => {
expect(resolveSelectionStageFromPath('/profile/feedback')).toBe('platform');
expect(resolveSelectionStageFromPath('/profile/feedback/')).toBe(
'platform',
);
});
it('resolves the image editor route', () => {
expect(resolveSelectionStageFromPath('/editor/canvas')).toBe(
'image-editor',
);
expect(resolveSelectionStageFromPath('/EDITOR/CANVAS/')).toBe(
'image-editor',
);
expect(resolvePathForSelectionStage('image-editor')).toBe('/editor/canvas');
});
it('preserves project id when navigating to an editor canvas project', () => {
window.history.replaceState(null, '', '/creation');
pushAppHistoryPath('/editor/canvas?projectid=project-from-route-test');
expect(window.location.pathname).toBe('/editor/canvas');
expect(window.location.search).toBe('?projectid=project-from-route-test');
});
it('detects editor canvas routes that are missing project id', () => {
expect(readEditorCanvasProjectIdFromLocationSearch('?projectid=abc')).toBe(
'abc',
);
expect(shouldRedirectEditorCanvasWithoutProject('/editor/canvas', '')).toBe(
true,
);
expect(
shouldRedirectEditorCanvasWithoutProject('/EDITOR/CANVAS/', '?foo=bar'),
).toBe(true);
expect(
shouldRedirectEditorCanvasWithoutProject(
'/editor/canvas',
'?projectid=project-from-route-test',
),
).toBe(false);
expect(shouldRedirectEditorCanvasWithoutProject('/creation', '')).toBe(
false,
);
});
it('resolves the project route', () => {
expect(resolveSelectionStageFromPath('/project')).toBe('project');
expect(resolveSelectionStageFromPath('/PROJECT/')).toBe('project');
expect(resolvePathForSelectionStage('project')).toBe('/project');
});
it('resolves the creation home route', () => {
expect(resolveSelectionStageFromPath('/creation')).toBe('creation-home');
expect(resolveSelectionStageFromPath('/CREATION/')).toBe('creation-home');
expect(resolvePathForSelectionStage('creation-home')).toBe('/creation');
});
it('resolves jump-hop creation, gallery and runtime routes', () => {
expect(resolveSelectionStageFromPath('/creation/jump-hop')).toBe(
'jump-hop-workspace',
);
expect(resolveSelectionStageFromPath('/creation/jump-hop/generating')).toBe(
'jump-hop-generating',
);
expect(resolveSelectionStageFromPath('/creation/jump-hop/result')).toBe(
'jump-hop-result',
);
expect(resolveSelectionStageFromPath('/gallery/jump-hop/detail')).toBe(
'jump-hop-gallery-detail',
);
expect(resolveSelectionStageFromPath('/runtime/jump-hop')).toBe(
'jump-hop-runtime',
);
expect(resolvePathForSelectionStage('jump-hop-workspace')).toBe(
'/creation/jump-hop',
);
expect(resolvePathForSelectionStage('jump-hop-generating')).toBe(
'/creation/jump-hop/generating',
);
expect(resolvePathForSelectionStage('jump-hop-result')).toBe(
'/creation/jump-hop/result',
);
expect(resolvePathForSelectionStage('jump-hop-gallery-detail')).toBe(
'/gallery/jump-hop/detail',
);
expect(resolvePathForSelectionStage('jump-hop-runtime')).toBe(
'/runtime/jump-hop',
);
});
it('resolves puzzle-clear creation and runtime routes', () => {
expect(resolveSelectionStageFromPath('/creation/puzzle-clear')).toBe(
'puzzle-clear-workspace',
);
expect(
resolveSelectionStageFromPath('/creation/puzzle-clear/generating'),
).toBe('puzzle-clear-generating');
expect(resolveSelectionStageFromPath('/creation/puzzle-clear/result')).toBe(
'puzzle-clear-result',
);
expect(resolveSelectionStageFromPath('/runtime/puzzle-clear')).toBe(
'puzzle-clear-runtime',
);
expect(resolvePathForSelectionStage('puzzle-clear-workspace')).toBe(
'/creation/puzzle-clear',
);
expect(resolvePathForSelectionStage('puzzle-clear-generating')).toBe(
'/creation/puzzle-clear/generating',
);
expect(resolvePathForSelectionStage('puzzle-clear-result')).toBe(
'/creation/puzzle-clear/result',
);
expect(resolvePathForSelectionStage('puzzle-clear-runtime')).toBe(
'/runtime/puzzle-clear',
);
});
it('resolves wooden fish creation and runtime routes', () => {
expect(resolveSelectionStageFromPath('/creation/wooden-fish')).toBe(
'wooden-fish-workspace',
);
expect(
resolveSelectionStageFromPath('/creation/wooden-fish/generating'),
).toBe('wooden-fish-generating');
expect(resolveSelectionStageFromPath('/creation/wooden-fish/result')).toBe(
'wooden-fish-result',
);
expect(resolveSelectionStageFromPath('/runtime/wooden-fish')).toBe(
'wooden-fish-runtime',
);
expect(resolvePathForSelectionStage('wooden-fish-workspace')).toBe(
'/creation/wooden-fish',
);
expect(resolvePathForSelectionStage('wooden-fish-generating')).toBe(
'/creation/wooden-fish/generating',
);
expect(resolvePathForSelectionStage('wooden-fish-result')).toBe(
'/creation/wooden-fish/result',
);
expect(resolvePathForSelectionStage('wooden-fish-runtime')).toBe(
'/runtime/wooden-fish',
);
});
it('resolves creation routes to the existing entry form stages', () => {
expect(resolveSelectionStageFromPath('/creation/rpg')).toBe(
'agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/big-fish')).toBe(
'big-fish-agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/match3d')).toBe(
'match3d-agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/square-hole')).toBe(
'square-hole-agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/puzzle')).toBe(
'puzzle-agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/bark-battle')).toBe(
'bark-battle-workspace',
);
expect(resolveSelectionStageFromPath('/creation/visual-novel')).toBe(
'visual-novel-agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/baby-object-match')).toBe(
'baby-object-match-workspace',
);
expect(resolvePathForSelectionStage('agent-workspace')).toBe(
'/creation/rpg',
);
expect(resolvePathForSelectionStage('puzzle-agent-workspace')).toBe(
'/creation/puzzle',
);
expect(resolvePathForSelectionStage('bark-battle-workspace')).toBe(
'/creation/bark-battle',
);
expect(resolvePathForSelectionStage('baby-object-match-workspace')).toBe(
'/creation/baby-object-match',
);
});
it('preserves creation restore query params within the same creation flow', () => {
window.history.replaceState(
null,
'',
'/creation/rpg?sessionId=session-1&profileId=profile-1&draftId=draft-1&workId=work-1&clientRuntime=wechat_mini_program',
);
pushAppHistoryPath('/creation/rpg/result');
expect(window.location.pathname).toBe('/creation/rpg/result');
expect(window.location.search).toBe(
'?sessionId=session-1&profileId=profile-1&draftId=draft-1&workId=work-1&clientRuntime=wechat_mini_program',
);
});
it('preserves mini program runtime context while normalizing app paths', () => {
window.history.replaceState(
null,
'',
'/?clientType=mini_program&clientRuntime=wechat_mini_program&miniProgramEnv=trial',
);
pushAppHistoryPath('/');
expect(window.location.pathname).toBe('/');
expect(window.location.search).toBe(
'?clientType=mini_program&clientRuntime=wechat_mini_program&miniProgramEnv=trial',
);
});
it('keeps mini program runtime context when navigating to explicit query routes', () => {
window.history.replaceState(
null,
'',
'/?clientRuntime=wechat_mini_program',
);
pushAppHistoryPath('/works/detail?work=PZ-7A7B18D9');
expect(window.location.pathname).toBe('/works/detail');
expect(window.location.search).toBe(
'?work=PZ-7A7B18D9&clientRuntime=wechat_mini_program',
);
});
it('keeps native app runtime context and marks app history entries', () => {
window.history.replaceState(
null,
'',
'/?clientRuntime=native_app&hostShell=tauri_desktop&hostPlatform=linux&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime%2Chost.events%2Cnavigation.canGoBack',
);
pushAppHistoryPath('/creation/puzzle');
expect(window.location.pathname).toBe('/creation/puzzle');
expect(window.location.search).toBe(
'?clientRuntime=native_app&hostShell=tauri_desktop&hostPlatform=linux&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime%2Chost.events%2Cnavigation.canGoBack',
);
expect(isAppHistoryState(window.history.state)).toBe(true);
});
it('replaces app history paths through the same runtime context rules', () => {
window.history.replaceState(
{ existing: true },
'',
'/creation/puzzle?clientRuntime=native_app&hostShell=expo_mobile&hostCapabilities=host.events%2Cnavigation.canGoBack',
);
replaceAppHistoryPath('/');
expect(window.location.pathname).toBe('/');
expect(window.location.search).toBe(
'?clientRuntime=native_app&hostShell=expo_mobile&hostCapabilities=host.events%2Cnavigation.canGoBack',
);
expect(window.history.state).toMatchObject({
existing: true,
});
expect(isAppHistoryState(window.history.state)).toBe(true);
});
it('clears creation restore query params when leaving the flow or switching flows', () => {
window.history.replaceState(
null,
'',
'/creation/rpg?sessionId=session-1&profileId=profile-1',
);
pushAppHistoryPath('/creation/puzzle');
expect(window.location.pathname).toBe('/creation/puzzle');
expect(window.location.search).toBe('');
window.history.replaceState(
null,
'',
'/creation/rpg?sessionId=session-2&profileId=profile-2',
);
pushAppHistoryPath('/');
expect(window.location.pathname).toBe('/');
expect(window.location.search).toBe('');
});
});
@@ -1,263 +0,0 @@
import { HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS } from '../../packages/shared/src/contracts/hostBridge';
import type { SelectionStage } from '../components/platform-entry';
import {
buildCreationUrlSearchFromParams,
isCreationRestorePath,
isSameCreationFlowPath,
} from '../services/creationUrlState';
export type RuntimePageRoute = 'rpg-character-select' | 'rpg-adventure';
export const PUBLIC_WORK_QUERY_PARAM = 'work';
export const EDITOR_CANVAS_PROJECT_ID_QUERY_PARAM = 'projectid';
const STAGE_ROUTE_ENTRIES = [
['platform', '/'],
['creation-home', '/creation'],
['project', '/project'],
['image-editor', '/editor/canvas'],
['work-detail', '/works/detail'],
['detail', '/worlds/detail'],
['agent-workspace', '/creation/rpg'],
['big-fish-agent-workspace', '/creation/big-fish'],
['custom-world-generating', '/creation/rpg/generating'],
['custom-world-result', '/creation/rpg/result'],
['big-fish-generating', '/creation/big-fish/generating'],
['big-fish-result', '/creation/big-fish/result'],
['big-fish-runtime', '/runtime/big-fish'],
['match3d-agent-workspace', '/creation/match3d'],
['match3d-generating', '/creation/match3d/generating'],
['match3d-result', '/creation/match3d/result'],
['match3d-runtime', '/runtime/match3d'],
['square-hole-agent-workspace', '/creation/square-hole'],
['square-hole-generating', '/creation/square-hole/generating'],
['square-hole-result', '/creation/square-hole/result'],
['square-hole-runtime', '/runtime/square-hole'],
['jump-hop-workspace', '/creation/jump-hop'],
['jump-hop-generating', '/creation/jump-hop/generating'],
['jump-hop-result', '/creation/jump-hop/result'],
['jump-hop-gallery-detail', '/gallery/jump-hop/detail'],
['jump-hop-runtime', '/runtime/jump-hop'],
['puzzle-clear-workspace', '/creation/puzzle-clear'],
['puzzle-clear-generating', '/creation/puzzle-clear/generating'],
['puzzle-clear-result', '/creation/puzzle-clear/result'],
['puzzle-clear-runtime', '/runtime/puzzle-clear'],
['bark-battle-workspace', '/creation/bark-battle'],
['bark-battle-generating', '/creation/bark-battle/generating'],
['bark-battle-result', '/creation/bark-battle/result'],
['bark-battle-runtime', '/runtime/bark-battle'],
['wooden-fish-workspace', '/creation/wooden-fish'],
['wooden-fish-generating', '/creation/wooden-fish/generating'],
['wooden-fish-result', '/creation/wooden-fish/result'],
['wooden-fish-runtime', '/runtime/wooden-fish'],
['creative-agent-workspace', '/creation/creative-agent'],
['visual-novel-agent-workspace', '/creation/visual-novel'],
['visual-novel-generating', '/creation/visual-novel/generating'],
['visual-novel-result', '/creation/visual-novel/result'],
['visual-novel-gallery-detail', '/gallery/visual-novel/detail'],
['visual-novel-runtime', '/runtime/visual-novel'],
['baby-object-match-workspace', '/creation/baby-object-match'],
['baby-object-match-generating', '/creation/baby-object-match/generating'],
['baby-object-match-result', '/creation/baby-object-match/result'],
['baby-object-match-runtime', '/runtime/baby-object-match'],
['baby-love-drawing-runtime', '/runtime/baby-love-drawing'],
['puzzle-agent-workspace', '/creation/puzzle'],
['puzzle-generating', '/creation/puzzle/generating'],
['puzzle-result', '/creation/puzzle/result'],
['puzzle-gallery-detail', '/gallery/puzzle/detail'],
['puzzle-runtime', '/runtime/puzzle'],
] as const satisfies readonly (readonly [SelectionStage, string])[];
export const APP_STAGE_ROUTES: Record<SelectionStage, string> =
Object.fromEntries(STAGE_ROUTE_ENTRIES) as Record<SelectionStage, string>;
export const APP_RUNTIME_ROUTES: Record<RuntimePageRoute, string> = {
'rpg-character-select': '/runtime/rpg/characters',
'rpg-adventure': '/runtime/rpg/adventure',
};
const ROUTE_STAGE_BY_PATH = new Map(
STAGE_ROUTE_ENTRIES.map(([stage, path]) => [path, stage] as const),
) as Map<string, SelectionStage>;
const APP_RUNTIME_CONTEXT_QUERY_KEYS =
HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS;
export const APP_HISTORY_STATE_KEY = '__genarrativeAppHistoryEntry';
export function normalizeAppPath(pathname: string) {
const trimmedPathname = pathname.trim().toLowerCase();
if (!trimmedPathname || trimmedPathname === '/') {
return '/';
}
return trimmedPathname.replace(/\/+$/u, '');
}
export function resolveSelectionStageFromPath(
pathname: string,
): SelectionStage {
return ROUTE_STAGE_BY_PATH.get(normalizeAppPath(pathname)) ?? 'platform';
}
export function resolveInitialSelectionStageFromPath(
pathname: string,
isDesktopLayout: boolean,
): SelectionStage {
const normalizedPath = normalizeAppPath(pathname);
const selectionStage = resolveSelectionStageFromPath(pathname);
if (normalizedPath === '/' && isDesktopLayout) {
return 'creation-home';
}
return selectionStage;
}
export function resolvePathForSelectionStage(stage: SelectionStage) {
return APP_STAGE_ROUTES[stage] ?? APP_STAGE_ROUTES.platform;
}
export function readPublicWorkCodeFromLocationSearch(search: string) {
const params = new URLSearchParams(search);
return params.get(PUBLIC_WORK_QUERY_PARAM)?.trim() || null;
}
export function readEditorCanvasProjectIdFromLocationSearch(search: string) {
const params = new URLSearchParams(search);
return params.get(EDITOR_CANVAS_PROJECT_ID_QUERY_PARAM)?.trim() || null;
}
export function shouldRedirectEditorCanvasWithoutProject(
pathname: string,
search: string,
) {
return (
normalizeAppPath(pathname) === APP_STAGE_ROUTES['image-editor'] &&
!readEditorCanvasProjectIdFromLocationSearch(search)
);
}
export function buildPublicWorkDetailPath(publicWorkCode: string) {
return buildPublicWorkStagePath('work-detail', publicWorkCode);
}
export function buildPublicWorkStagePath(
stage: SelectionStage,
publicWorkCode: string,
) {
const code = publicWorkCode.trim();
const stagePath = resolvePathForSelectionStage(stage);
if (!code) {
return stagePath;
}
const params = new URLSearchParams();
params.set(PUBLIC_WORK_QUERY_PARAM, code);
return `${stagePath}?${params.toString()}`;
}
export function buildPublicWorkDetailUrl(
publicWorkCode: string,
origin = window.location.origin,
) {
return new URL(buildPublicWorkDetailPath(publicWorkCode), origin).href;
}
export function isKnownMainAppPagePath(pathname: string) {
const normalizedPath = normalizeAppPath(pathname);
const runtimePaths: readonly string[] = Object.values(APP_RUNTIME_ROUTES);
return (
ROUTE_STAGE_BY_PATH.has(normalizedPath) ||
runtimePaths.includes(normalizedPath)
);
}
export function pushAppHistoryPath(path: string) {
writeAppHistoryPath(path, 'push');
}
export function replaceAppHistoryPath(path: string) {
writeAppHistoryPath(path, 'replace');
}
function writeAppHistoryPath(path: string, mode: 'push' | 'replace') {
const nextUrl = new URL(path, window.location.origin);
const normalizedPath = normalizeAppPath(nextUrl.pathname);
const nextSearch = buildPreservedAppSearch(
nextUrl.search,
window.location.pathname,
normalizedPath,
window.location.search,
);
const nextRelativeUrl = `${normalizedPath}${nextSearch}`;
const currentRelativeUrl = `${normalizeAppPath(window.location.pathname)}${window.location.search}`;
if (currentRelativeUrl === nextRelativeUrl) {
return;
}
// 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。
if (mode === 'replace') {
window.history.replaceState(buildAppHistoryState(), '', nextRelativeUrl);
return;
}
window.history.pushState(buildAppHistoryState(), '', nextRelativeUrl);
}
export function isAppHistoryState(state: unknown) {
return Boolean(
state &&
typeof state === 'object' &&
(state as Record<string, unknown>)[APP_HISTORY_STATE_KEY] === true,
);
}
function buildAppHistoryState() {
const currentState =
window.history.state && typeof window.history.state === 'object'
? window.history.state
: {};
return {
...currentState,
[APP_HISTORY_STATE_KEY]: true,
};
}
function buildPreservedAppSearch(
explicitNextSearch: string,
currentPathname: string,
normalizedPath: string,
search: string,
) {
const preservedParams = new URLSearchParams(explicitNextSearch);
if (
!explicitNextSearch &&
isCreationRestorePath(normalizedPath) &&
isSameCreationFlowPath(currentPathname, normalizedPath)
) {
const creationParams = new URLSearchParams(
buildCreationUrlSearchFromParams(search),
);
creationParams.forEach((value, key) => {
preservedParams.set(key, value);
});
}
const currentParams = new URLSearchParams(search);
// 中文注释:小程序 WebView 依赖这些宿主上下文判断登录和充值通道,不能被前端阶段导航清掉。
APP_RUNTIME_CONTEXT_QUERY_KEYS.forEach((key) => {
if (preservedParams.has(key)) {
return;
}
const value = currentParams.get(key)?.trim();
if (value) {
preservedParams.set(key, value);
}
});
const queryString = preservedParams.toString();
return queryString ? `?${queryString}` : '';
}
@@ -1,106 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
matchAppRoute,
shouldUseRecommendationRouteLoading,
} from './appRoutes';
afterEach(() => {
vi.unstubAllEnvs();
});
describe('matchAppRoute', () => {
it('routes the main app by default', () => {
expect(matchAppRoute('/')).toEqual({
kind: 'game',
});
});
it('routes puzzle playground path to the standalone puzzle runtime', () => {
expect(matchAppRoute('/puzzle')).toEqual({
kind: 'puzzle-playground',
});
});
it('routes big fish playground path to the standalone big fish runtime', () => {
expect(matchAppRoute('/BIG-FISH/')).toEqual({
kind: 'big-fish-playground',
});
});
it('routes match3d playground path to the standalone Match3D runtime', () => {
expect(matchAppRoute('/MATCH3D/')).toEqual({
kind: 'match3d-playground',
});
});
it('routes child motion demo path to the standalone warmup demo', () => {
expect(matchAppRoute('/CHILD-MOTION-DEMO/')).toEqual({
kind: 'child-motion-demo',
});
});
it('blocks direct child motion demo path when edutainment entry is disabled', () => {
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
expect(matchAppRoute('/child-motion-demo')).toEqual({
kind: 'game',
});
});
it('routes baby love drawing path to the standalone runtime when edutainment is enabled', () => {
expect(matchAppRoute('/runtime/baby-love-drawing')).toEqual({
kind: 'baby-love-drawing',
});
});
it('blocks direct baby love drawing path when edutainment entry is disabled', () => {
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
expect(matchAppRoute('/runtime/baby-love-drawing')).toEqual({
kind: 'game',
});
});
it('routes former standalone editor paths back to the main game', () => {
expect(matchAppRoute('/item-editor/tools')).toEqual({
kind: 'game',
});
expect(matchAppRoute('/behavior-editor')).toEqual({
kind: 'game',
});
expect(matchAppRoute('/NPC-EDITOR/profiles/')).toEqual({
kind: 'game',
});
});
it('routes independent page paths to the main app shell', () => {
expect(matchAppRoute('/creation/rpg/agent')).toEqual({
kind: 'game',
});
expect(matchAppRoute('/runtime/puzzle')).toEqual({
kind: 'game',
});
expect(matchAppRoute('/runtime/big-fish')).toEqual({
kind: 'game',
});
});
it('does not treat unrelated prefixes as preset editor routes', () => {
expect(matchAppRoute('/npc-editorial')).toEqual({
kind: 'game',
});
});
});
describe('shouldUseRecommendationRouteLoading', () => {
it('only enables the dark recommendation loading screen for mobile root route', () => {
expect(shouldUseRecommendationRouteLoading('/', false)).toBe(true);
expect(shouldUseRecommendationRouteLoading('/', true)).toBe(false);
expect(shouldUseRecommendationRouteLoading('/creation', false)).toBe(false);
expect(shouldUseRecommendationRouteLoading('/project', false)).toBe(false);
expect(shouldUseRecommendationRouteLoading('/editor/canvas', false)).toBe(
false,
);
});
});
@@ -1,184 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import { type ComponentType, lazy, type LazyExoticComponent } from 'react';
import { isEdutainmentEntryEnabled } from '../components/platform-entry/platformEdutainmentVisibility';
import { normalizeAppPath } from './appPageRoutes';
type AppRouteComponent = LazyExoticComponent<
ComponentType<Record<string, unknown>>
>;
export type AppRouteMatch =
| {
kind: 'puzzle-playground';
}
| {
kind: 'big-fish-playground';
}
| {
kind: 'match3d-playground';
}
| {
kind: 'bark-battle-playground';
}
| {
kind: 'child-motion-demo';
}
| {
kind: 'baby-love-drawing';
}
| {
kind: 'game';
};
export type ResolvedAppRoute = {
kind: AppRouteMatch['kind'];
loadingEyebrow: string;
loadingText: string;
Component: AppRouteComponent;
componentProps?: Record<string, unknown>;
};
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
const BigFishPlaygroundApp = lazy(
() => import('../BigFishPlaygroundApp'),
) as AppRouteComponent;
const Match3DPlaygroundApp = lazy(
() => import('../Match3DPlaygroundApp'),
) as AppRouteComponent;
const BarkBattlePlaygroundApp = lazy(
() => import('../BarkBattlePlaygroundApp'),
) as AppRouteComponent;
const PuzzlePlaygroundApp = lazy(
() => import('../PuzzlePlaygroundApp'),
) as AppRouteComponent;
const ChildMotionDemoApp = lazy(
() => import('../ChildMotionDemoApp'),
) as AppRouteComponent;
const BabyLoveDrawingRuntimeApp = lazy(
() => import('../components/edutainment-runtime/BabyLoveDrawingRuntimeShell'),
) as AppRouteComponent;
function normalizeRoutePath(pathname: string) {
return normalizeAppPath(pathname);
}
export function shouldUseRecommendationRouteLoading(
pathname: string,
isDesktopLayout: boolean,
) {
return normalizeRoutePath(pathname) === '/' && !isDesktopLayout;
}
export function matchAppRoute(pathname: string): AppRouteMatch {
const normalizedPath = normalizeRoutePath(pathname);
if (normalizedPath === '/puzzle') {
return {
kind: 'puzzle-playground',
};
}
if (normalizedPath === '/big-fish') {
return {
kind: 'big-fish-playground',
};
}
if (normalizedPath === '/match3d') {
return {
kind: 'match3d-playground',
};
}
if (normalizedPath === '/bark-battle') {
return {
kind: 'bark-battle-playground',
};
}
if (normalizedPath === '/child-motion-demo' && isEdutainmentEntryEnabled()) {
return {
kind: 'child-motion-demo',
};
}
if (
normalizedPath === '/runtime/baby-love-drawing' &&
isEdutainmentEntryEnabled()
) {
return {
kind: 'baby-love-drawing',
};
}
return {
kind: 'game',
};
}
export function resolveAppRoute(pathname: string): ResolvedAppRoute {
const matchedRoute = matchAppRoute(pathname);
if (matchedRoute.kind === 'puzzle-playground') {
return {
kind: 'puzzle-playground',
loadingEyebrow: '正在载入拼图',
loadingText: '正在进入拼图关卡...',
Component: PuzzlePlaygroundApp,
};
}
if (matchedRoute.kind === 'big-fish-playground') {
return {
kind: 'big-fish-playground',
loadingEyebrow: '正在载入大鱼',
loadingText: '正在进入玩法...',
Component: BigFishPlaygroundApp,
};
}
if (matchedRoute.kind === 'match3d-playground') {
return {
kind: 'match3d-playground',
loadingEyebrow: '正在载入抓大鹅',
loadingText: '正在进入消除关卡...',
Component: Match3DPlaygroundApp,
};
}
if (matchedRoute.kind === 'bark-battle-playground') {
return {
kind: 'bark-battle-playground',
loadingEyebrow: '正在载入汪汪声浪',
loadingText: '正在进入竖屏声浪竞技场...',
Component: BarkBattlePlaygroundApp,
};
}
if (matchedRoute.kind === 'child-motion-demo') {
return {
kind: 'child-motion-demo',
loadingEyebrow: '正在载入热身关',
loadingText: '正在进入寓教于乐 Demo...',
Component: ChildMotionDemoApp,
};
}
if (matchedRoute.kind === 'baby-love-drawing') {
return {
kind: 'baby-love-drawing',
loadingEyebrow: '正在载入宝贝爱画',
loadingText: '正在进入画板...',
Component: BabyLoveDrawingRuntimeApp,
};
}
return {
kind: 'game',
loadingEyebrow: '正在载入游戏',
loadingText: '正在加载内容',
Component: GameApp,
};
}
@@ -1,46 +0,0 @@
import { expect, test } from 'vitest';
import {
resolveRuntimeNotFoundRecoveryAction,
resolveWorkNotFoundRecoveryAction,
} from './runtimeNotFoundRecovery';
test('runtime not found recovery returns home after direct runtime route alert', () => {
expect(resolveRuntimeNotFoundRecoveryAction('/runtime/puzzle')).toEqual({
nextStage: 'platform',
nextPath: '/',
shouldAlert: true,
});
expect(resolveRuntimeNotFoundRecoveryAction('/runtime/puzzle/')).toEqual({
nextStage: 'platform',
nextPath: '/',
shouldAlert: true,
});
});
test('runtime not found recovery only handles direct runtime routes', () => {
expect(
resolveRuntimeNotFoundRecoveryAction('/gallery/puzzle/detail'),
).toBeNull();
expect(
resolveRuntimeNotFoundRecoveryAction('/creation/puzzle/result'),
).toBeNull();
});
test('work not found recovery returns home for direct public detail routes', () => {
expect(resolveWorkNotFoundRecoveryAction('/works/detail')).toEqual({
nextStage: 'platform',
nextPath: '/',
shouldAlert: true,
});
expect(resolveWorkNotFoundRecoveryAction('/works/detail/')).toEqual({
nextStage: 'platform',
nextPath: '/',
shouldAlert: true,
});
expect(resolveWorkNotFoundRecoveryAction('/gallery/puzzle/detail')).toEqual({
nextStage: 'platform',
nextPath: '/',
shouldAlert: true,
});
});
@@ -1,54 +0,0 @@
export type RuntimeNotFoundRecoveryAction = {
nextStage: 'platform';
nextPath: '/';
shouldAlert: true;
};
/**
* 中文注释:直接打开 /runtime/<玩法>?work=<作品号> 时,如果作品不存在,
* 弹窗关闭后必须回到首页,避免继续停留在没有运行态数据的空白页面。
*/
export function resolveRuntimeNotFoundRecoveryAction(
pathname: string,
): RuntimeNotFoundRecoveryAction | null {
const normalizedPath = pathname.trim().toLowerCase().replace(/\/+$/u, '');
if (
normalizedPath === '/runtime/puzzle' ||
normalizedPath === '/runtime/match3d' ||
normalizedPath === '/runtime/big-fish' ||
normalizedPath === '/runtime/square-hole' ||
normalizedPath === '/runtime/visual-novel'
) {
return {
nextStage: 'platform',
nextPath: '/',
shouldAlert: true,
};
}
return null;
}
/**
* 中文注释:公开作品详情页和运行态深链都可能在作品被删除或下架后失效。
* 这类入口没有上一层可回退的详情数据,确认提示后统一回首页,避免空详情页白屏。
*/
export function resolveWorkNotFoundRecoveryAction(
pathname: string,
): RuntimeNotFoundRecoveryAction | null {
const normalizedPath = pathname.trim().toLowerCase().replace(/\/+$/u, '');
if (
normalizedPath === '/works/detail' ||
normalizedPath === '/gallery/puzzle/detail' ||
normalizedPath === '/gallery/visual-novel/detail'
) {
return {
nextStage: 'platform',
nextPath: '/',
shouldAlert: true,
};
}
return resolveRuntimeNotFoundRecoveryAction(pathname);
}
@@ -1,58 +0,0 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
APP_HOME_TITLE,
resolveAppTitleForSelectionStage,
syncAppTitle,
} from './appTitle';
const hostBridgeMock = vi.hoisted(() => ({
setHostAppTitle: vi.fn(),
}));
vi.mock('./host-bridge/hostBridge', () => ({
setHostAppTitle: hostBridgeMock.setHostAppTitle,
}));
afterEach(() => {
document.title = '';
hostBridgeMock.setHostAppTitle.mockReset();
});
describe('appTitle', () => {
test('按平台阶段生成可读页面标题', () => {
expect(resolveAppTitleForSelectionStage('platform')).toBe(APP_HOME_TITLE);
expect(resolveAppTitleForSelectionStage('creation-home')).toBe(
APP_HOME_TITLE,
);
expect(resolveAppTitleForSelectionStage('work-detail')).toBe(
'作品详情 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('match3d-runtime')).toBe(
'抓大鹅 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('puzzle-generating')).toBe(
'拼图生成中 - 陶泥儿',
);
});
test('同步浏览器标题并请求宿主窗口标题', () => {
syncAppTitle(' 拼图 - 陶泥儿 ');
expect(document.title).toBe('拼图 - 陶泥儿');
expect(hostBridgeMock.setHostAppTitle).toHaveBeenCalledWith({
title: '拼图 - 陶泥儿',
});
});
test('空标题回退到品牌名', () => {
syncAppTitle(' ');
expect(document.title).toBe('陶泥儿');
expect(hostBridgeMock.setHostAppTitle).toHaveBeenCalledWith({
title: '陶泥儿',
});
});
});

Some files were not shown because too many files have changed in this diff Show More