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

增加受 migration operator 保护的固定 63 张旧表清空 procedure
生成 spacetime-client procedure、输入和结果绑定
更新后端数据契约与两阶段退役决策记录
清理未挂载的旧入口、分享路由、快照持久化与旧状态死链
清理未挂载的后台旧入口、api-server 旧玩法模块与旧 prompt
清理未挂载的 SpacetimeDB 旧模块根文件与前端类型孤儿
This commit is contained in:
2026-09-02 16:28:14 +08:00
parent 5de9554426
commit a558f99fcc
1401 changed files with 358 additions and 575382 deletions
@@ -1,328 +0,0 @@
/* @vitest-environment jsdom */
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import {
getAdminCreationEntryConfig,
upsertAdminCreationEntryBanners,
upsertAdminCreationEntryConfig,
upsertAdminPublicWorkInteractions,
} from '../api/adminApiClient';
import type {
AdminCreationEntryConfigResponse,
UnifiedCreationSpecPayload,
} from '../api/adminApiTypes';
import { AdminCreationEntrySwitchPage } from './AdminCreationEntrySwitchPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
getAdminCreationEntryConfig: vi.fn(),
isAdminApiError: vi.fn(() => false),
upsertAdminCreationEntryBanners: vi.fn(),
upsertAdminCreationEntryConfig: vi.fn(),
upsertAdminPublicWorkInteractions: vi.fn(),
}));
const puzzleSpec: UnifiedCreationSpecPayload = {
playId: 'puzzle',
title: '拼图',
mudPointCost: 10,
workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating',
resultStage: 'puzzle-result',
fields: [
{
id: 'pictureDescription',
kind: 'text',
label: '画面描述',
required: true,
},
],
};
const configResponse: AdminCreationEntryConfigResponse = {
eventBanners: [
{
title: '创作公告',
description: '',
coverImageSrc: '',
prizePoolMudPoints: 0,
startsAtText: '',
endsAtText: '',
renderMode: 'html',
htmlCode: '<section>后台公告</section>',
},
],
publicWorkInteractions: [
{
sourceType: 'puzzle',
likeEnabled: true,
remixEnabled: true,
likeDisabledMessage: '拼图点赞暂不可用。',
remixDisabledMessage: '拼图作品改造暂不可用。',
},
],
entries: [
{
id: 'puzzle',
title: '拼图',
subtitle: '拼图关卡创作',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
unifiedCreationSpec: puzzleSpec,
},
],
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(configResponse);
vi.mocked(upsertAdminCreationEntryBanners).mockResolvedValue(configResponse);
vi.mocked(upsertAdminCreationEntryConfig).mockResolvedValue(configResponse);
vi.mocked(upsertAdminPublicWorkInteractions).mockResolvedValue(
configResponse,
);
});
test('创作入口后台展示并保存统一创作契约', async () => {
const user = userEvent.setup();
const { container } = render(
<AdminCreationEntrySwitchPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('pictureDescription');
expect(
container.querySelector('.admin-subsection .admin-info-list'),
).not.toBeNull();
expect(
container.querySelector('.admin-subsection .admin-info-list')?.textContent,
).toContain('拼图');
expect(container.querySelector('.admin-panel .admin-panel')).toBeNull();
expect(container.querySelector('.admin-muted')).toBeNull();
expect(screen.queryByLabelText('契约 JSON')).toBeNull();
expect(screen.queryByText('puzzle-generating')).toBeNull();
await user.click(screen.getByRole('button', { name: '修改契约' }));
const dialog = screen.getByRole('dialog', { name: '统一创作契约' });
expect(within(dialog).queryByLabelText('玩法 ID')).toBeNull();
expect(within(dialog).queryByLabelText('工作台阶段')).toBeNull();
expect(within(dialog).queryByLabelText('生成阶段')).toBeNull();
expect(within(dialog).queryByLabelText('结果阶段')).toBeNull();
fireEvent.change(within(dialog).getByLabelText('泥点消耗'), {
target: { value: '12' },
});
await user.click(within(dialog).getByRole('button', { name: '应用修改' }));
expect(screen.queryByRole('dialog', { name: '统一创作契约' })).toBeNull();
expect(screen.getByText('12泥点数')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '保存入库' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(upsertAdminCreationEntryConfig).toHaveBeenCalledWith(
'admin-token',
expect.objectContaining({
id: 'puzzle',
unifiedCreationSpec: {
...puzzleSpec,
mudPointCost: 12,
},
}),
);
});
});
test('创作入口后台拒绝 playId 不一致的统一创作契约', async () => {
const user = userEvent.setup();
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
...configResponse,
entries: [
{
...configResponse.entries[0]!,
unifiedCreationSpec: {
...puzzleSpec,
playId: 'match3d',
},
},
],
});
render(
<AdminCreationEntrySwitchPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('pictureDescription');
await user.click(screen.getByRole('button', { name: '保存入库' }));
expect(
await screen.findByText('统一创作契约 playId 必须与入口 ID 一致'),
).toBeTruthy();
expect(upsertAdminCreationEntryConfig).not.toHaveBeenCalled();
});
test('创作入口后台用表单保存公告配置', async () => {
const user = userEvent.setup();
render(
<AdminCreationEntrySwitchPage
mode="announcements"
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(
await screen.findAllByRole('heading', { name: '创作入口公告' }),
).toHaveLength(2);
expect(screen.queryByLabelText('公告代码 JSON')).toBeNull();
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
target: { value: '周末创作赛' },
});
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
target: { value: '<section>新的入口公告</section>' },
});
await user.click(screen.getByRole('button', { name: '新增公告' }));
fireEvent.change(screen.getByLabelText('公告 2 标题'), {
target: { value: '第二条公告' },
});
fireEvent.change(screen.getByLabelText('公告 2 HTML'), {
target: { value: '<section>轮播第二条</section>' },
});
await user.click(screen.getByRole('button', { name: '保存公告' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(upsertAdminCreationEntryBanners).toHaveBeenCalled();
});
const [, payload] = vi.mocked(upsertAdminCreationEntryBanners).mock.calls[0]!;
expect(JSON.parse(payload.eventBannersJson)).toEqual([
{
title: '周末创作赛',
htmlCode: '<section>新的入口公告</section>',
},
{
title: '第二条公告',
htmlCode: '<section>轮播第二条</section>',
},
]);
expect(JSON.parse(payload.eventBannersJson)[0]).not.toHaveProperty(
'description',
);
expect(JSON.parse(payload.eventBannersJson)[0]).not.toHaveProperty(
'coverImageSrc',
);
});
test('创作入口后台用表单保存作品互动配置', async () => {
const user = userEvent.setup();
render(
<AdminCreationEntrySwitchPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('作品互动');
const likeToggle = screen.getAllByRole('checkbox')[0]!;
await user.click(likeToggle);
fireEvent.change(screen.getByLabelText('拼图 / puzzle 点赞关闭提示'), {
target: { value: '拼图点赞维护中。' },
});
await user.click(screen.getByRole('button', { name: '保存作品互动' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(upsertAdminPublicWorkInteractions).toHaveBeenCalledWith(
'admin-token',
{
publicWorkInteractions: [
{
sourceType: 'puzzle',
likeEnabled: false,
remixEnabled: true,
likeDisabledMessage: '拼图点赞维护中。',
remixDisabledMessage: '拼图作品改造暂不可用。',
},
],
},
);
});
});
test('创作入口后台把旧结构化公告回显成 HTML 表单', async () => {
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
...configResponse,
eventBanners: [
{
title: '旧公告 <标题>',
description: '旧描述 & 需要转义',
coverImageSrc: '/legacy.png',
prizePoolMudPoints: 120,
startsAtText: '2026-06-01',
endsAtText: '2026-06-30',
renderMode: 'structured',
},
],
});
render(
<AdminCreationEntrySwitchPage
mode="announcements"
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByLabelText('公告 1 标题')).toHaveProperty(
'value',
'旧公告 <标题>',
);
expect(screen.getByLabelText('公告 1 HTML')).toHaveProperty(
'value',
'<section><h1>旧公告 &lt;标题&gt;</h1><p>旧描述 &amp; 需要转义</p></section>',
);
});
test('创作入口后台拒绝空公告表单', async () => {
const user = userEvent.setup();
render(
<AdminCreationEntrySwitchPage
mode="announcements"
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
target: { value: '' },
});
await user.click(screen.getByRole('button', { name: '保存公告' }));
expect(await screen.findByText('公告 1 标题和 HTML 都不能为空')).toBeTruthy();
expect(upsertAdminCreationEntryBanners).not.toHaveBeenCalled();
});
File diff suppressed because it is too large Load Diff
@@ -1,283 +0,0 @@
import { Eye, EyeOff, RefreshCcw } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
listAdminWorkVisibility,
updateAdminWorkVisibility,
} from '../api/adminApiClient';
import type { AdminWorkVisibilityEntryPayload } from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminWorkVisibilityPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const sourceLabels: Record<string, string> = {
puzzle: '拼图',
'puzzle-clear': '拼消消',
'custom-world': '自定义世界',
'jump-hop': '跳一跳',
'wooden-fish': '敲木鱼',
match3d: '抓大鹅',
'square-hole': '方洞挑战',
'visual-novel': '视觉小说',
'big-fish': '大鱼吃小鱼',
'bark-battle': '汪汪声浪',
};
export function AdminWorkVisibilityPage({
token,
onUnauthorized,
}: AdminWorkVisibilityPageProps) {
const [entries, setEntries] = useState<AdminWorkVisibilityEntryPayload[]>([]);
const [keyword, setKeyword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [savingKey, setSavingKey] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshEntries();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const filteredEntries = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) {
return entries;
}
return entries.filter((entry) =>
[
entry.sourceType,
sourceLabels[entry.sourceType] ?? '',
entry.title,
entry.subtitle,
entry.authorDisplayName,
entry.publicWorkCode,
entry.profileId,
entry.workId,
]
.join(' ')
.toLowerCase()
.includes(normalizedKeyword),
);
}, [entries, keyword]);
async function refreshEntries() {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminWorkVisibility(token);
setEntries(sortEntries(response.entries));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleToggle(entry: AdminWorkVisibilityEntryPayload) {
const nextVisible = !entry.visible;
const target =
entry.title.trim() || entry.publicWorkCode || entry.profileId;
const confirmed = await confirmWrite({
action: nextVisible ? '显示作品' : '隐藏作品',
target,
});
if (!confirmed) {
return;
}
const rowKey = buildEntryKey(entry);
setSavingKey(rowKey);
setErrorMessage('');
try {
const response = await updateAdminWorkVisibility(token, {
sourceType: entry.sourceType,
profileId: entry.profileId,
visible: nextVisible,
});
upsertEntry(response.entry);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setSavingKey('');
}
}
function upsertEntry(next: AdminWorkVisibilityEntryPayload) {
setEntries((current) =>
sortEntries([
...current.filter(
(entry) => buildEntryKey(entry) !== buildEntryKey(next),
),
next,
]),
);
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshEntries}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
<section className="admin-panel">
<label className="admin-field">
<span></span>
<input
placeholder="标题 / 作者 / 公开码 / profileId"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredEntries.map((entry) => {
const rowKey = buildEntryKey(entry);
const isSaving = savingKey === rowKey;
return (
<tr key={rowKey}>
<td>
<span className="admin-tag">
{sourceLabels[entry.sourceType] ?? entry.sourceType}
</span>
</td>
<td>
<strong>{entry.title || entry.profileId}</strong>
<small>{entry.subtitle || entry.profileId}</small>
</td>
<td>
<div className="admin-inline-identity">
<div>
{entry.authorDisplayName || '玩家'}
<small>{entry.ownerUserId}</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td>
<span className="admin-table-cell-ellipsis">
{entry.publicWorkCode}
</span>
<small>{entry.profileId}</small>
</td>
<td>{formatMicros(entry.updatedAtMicros)}</td>
<td>
<span
className={
entry.visible
? 'admin-status admin-status-ok'
: 'admin-status admin-status-error'
}
>
{entry.visible ? '显示' : '隐藏'}
</span>
</td>
<td>
<button
className={
entry.visible
? 'admin-danger-button'
: 'admin-secondary-button'
}
disabled={isSaving}
type="button"
onClick={() => handleToggle(entry)}
>
{entry.visible ? (
<EyeOff size={16} aria-hidden="true" />
) : (
<Eye size={16} aria-hidden="true" />
)}
<span>
{isSaving
? '处理中'
: entry.visible
? '隐藏'
: '显示'}
</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{!isLoading && filteredEntries.length === 0 ? (
<div className="admin-empty-state"></div>
) : null}
</section>
{confirmDialog}
</section>
);
}
function sortEntries(entries: AdminWorkVisibilityEntryPayload[]) {
return [...entries].sort((left, right) => {
const timeCompare = right.updatedAtMicros - left.updatedAtMicros;
if (timeCompare !== 0) {
return timeCompare;
}
const sourceCompare = left.sourceType.localeCompare(right.sourceType);
if (sourceCompare !== 0) {
return sourceCompare;
}
return left.profileId.localeCompare(right.profileId);
});
}
function buildEntryKey(entry: AdminWorkVisibilityEntryPayload) {
return `${entry.sourceType}:${entry.profileId}`;
}
function formatMicros(value: number) {
if (!Number.isFinite(value)) {
return '-';
}
const date = new Date(Math.floor(value / 1000));
if (!Number.isFinite(date.getTime())) {
return '-';
}
return date.toLocaleString('zh-CN', { hour12: false });
}
+1 -6
View File
@@ -15,10 +15,5 @@
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"],
"exclude": [
"src/pages/AdminCreationEntrySwitchPage.tsx",
"src/pages/AdminCreationEntrySwitchPage.test.tsx",
"src/pages/AdminWorkVisibilityPage.tsx"
]
"include": ["src", "vite.config.ts"]
}
@@ -1,16 +0,0 @@
export type {
CanvasLayer,
CanvasViewport,
ImageCanvasAssetPort,
ImageCanvasCompletionPort,
ImageCanvasGenerationPort,
ImageCanvasProjectPort,
} from '@genarrative/image-canvas-core';
export {
CanvasViewport as SharedCanvasViewport,
CanvasWorld as SharedCanvasWorld,
LayerRenderer as SharedLayerRenderer,
Minimap as SharedMinimap,
SelectionOverlay as SharedSelectionOverlay,
ZoomControls as SharedZoomControls,
} from '@genarrative/image-canvas-react';
@@ -1,9 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* UI 布局在父级坐标系中的轴对齐矩形。
*
* `min` 是矩形的最小角,`size` 是沿两个坐标轴的尺寸。这里不规定 Y 轴方向,
* 因而既能用于 Y 轴向上的游戏坐标,也能用于 Y 轴向下的画布坐标。
*/
export type UIRect = { min: [number, number], size: [number, number], };
@@ -1,27 +0,0 @@
import type { UiEditorPrerequisiteIssue } from '../../../features/ui-editor/requisites';
export function PrerequisiteIssues({
issues,
}: {
issues: UiEditorPrerequisiteIssue[];
}) {
if (issues.length === 0) {
return (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-800">
State
</div>
);
}
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
<strong></strong>
<ul className="mt-2 space-y-1 pl-4">
{issues.map((issue, index) => (
<li key={`${issue.code}-${issue.resourceId ?? 'none'}-${index}`}>
{issue.message}
</li>
))}
</ul>
</div>
);
}
@@ -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>,
);

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