新增旧玩法表阶段一清空迁移能力
增加受 migration operator 保护的固定 63 张旧表清空 procedure 生成 spacetime-client procedure、输入和结果绑定 更新后端数据契约与两阶段退役决策记录 清理未挂载的旧入口、分享路由、快照持久化与旧状态死链
This commit is contained in:
@@ -23,6 +23,16 @@
|
||||
- 验证方式:运行评论弹层恢复竞态回归、完整 `appSurface.test.ts`,并执行类型、编码和 diff 检查。
|
||||
- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast 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
-129
@@ -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,
|
||||
};
|
||||
-139
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
-10
@@ -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,
|
||||
}),
|
||||
);
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"navigationBarTitleText": "生成通知"
|
||||
}
|
||||
-19
@@ -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>
|
||||
-58
@@ -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>
|
||||
);
|
||||
}
|
||||
-17
@@ -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;
|
||||
-179
@@ -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);
|
||||
});
|
||||
});
|
||||
-18264
File diff suppressed because it is too large
Load Diff
-102
@@ -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>,
|
||||
);
|
||||
-305
@@ -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,
|
||||
};
|
||||
}
|
||||
-46
@@ -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,
|
||||
});
|
||||
});
|
||||
-54
@@ -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
Reference in New Issue
Block a user