Merge origin/master(33552d2e)进 fix/agc-composer-layout
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m38s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m13s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m20s
Project CI / Frontend tests (pull_request) Successful in 3m27s
Project CI / Backend tests (pull_request) Successful in 7m2s
Project CI / Repository checks (pull_request) Failing after 5m14s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m46s
Project CI / Native shell tests (pull_request) Successful in 9m10s

- 目的:master 在上一轮 CI 期间又前进了(#602 素材引用注册表修复等 9 个提交),PR 的 base 门禁要求 head 含最新 base。上一轮 CI 的 2 条红(Backend tests 18s、Repository checks 35s)日志都是同一条门禁:`pull request head does not contain the latest base commit; update the branch and rerun CI.`,与本轮代码无关
- 冲突 2 处,均按「两侧内容都保留」解决:
  - `apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx`:#602 的 `ref` / `useImperativeHandle`(`insertReferences` + `focus` 转发)与本轮新增的 `pushModelNotice` 并存
  - `docs/project-memory/shared-memory/pitfalls.md`:本分支的三条(输入区不留常驻状态行、档位改 popover 滑块、#600 输入区叠压)与 master 的两条(画布「引用」死按钮、渠道更新互相查杀)并存
- 其余文件自动合并;本分支的 `styles.css`、两个契约测试、`ConversationModelSelect` 改动未被 master 触碰
This commit is contained in:
2026-10-04 16:33:00 +08:00
67 changed files with 2709 additions and 218 deletions
+1
View File
@@ -219,6 +219,7 @@ module.exports = {
'!src/services/frontendRuntimeConfigService.test.ts',
'!src/services/gameDistributionClient.ts',
'!src/services/gameDistributionClient.test.ts',
'!src/services/gamePlayClientId.ts',
'!src/services/sseStream.ts',
'!src/services/sseStream.test.ts',
'src/AdventurePanel.tsx',
@@ -0,0 +1,78 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type { AdminSessionPayload } from '../api/adminApiTypes';
import { adminRoutes } from './adminRoutes';
import { AdminShell } from './AdminShell';
const admin: AdminSessionPayload = {
subject: 'admin-1',
username: 'owner',
displayName: '后台管理员',
roles: ['owner'],
accountRole: 'owner',
tabPermissions: [],
actionPermissions: [],
issuedAt: '2026-10-04T00:00:00.000Z',
expiresAt: '2026-10-05T00:00:00.000Z',
};
test('侧边栏默认展开当前页分组并支持折叠其它分组', async () => {
const user = userEvent.setup();
const onRouteChange = vi.fn();
const view = render(
<AdminShell
admin={admin}
routeId="dashboard"
routes={adminRoutes}
onLogout={vi.fn()}
onRouteChange={onRouteChange}
>
<div>页面内容</div>
</AdminShell>,
);
const sidebar = screen.getAllByRole('navigation', { name: '后台导航' })[0]!;
const workspaceToggle = within(sidebar).getByRole('button', {
name: /工作台/,
});
const observabilityToggle = within(sidebar).getByRole('button', {
name: /数据与监控/,
});
expect(workspaceToggle.getAttribute('aria-expanded')).toBe('true');
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('false');
expect(
within(sidebar).getByRole('button', { name: '服务总览' }),
).toBeTruthy();
expect(
within(sidebar).queryByRole('button', { name: '埋点数据' }),
).toBeNull();
await user.click(observabilityToggle);
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('true');
await user.click(within(sidebar).getByRole('button', { name: '埋点数据' }));
expect(onRouteChange).toHaveBeenCalledWith('tracking');
await user.click(observabilityToggle);
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('false');
view.rerender(
<AdminShell
admin={admin}
routeId="tracking"
routes={adminRoutes}
onLogout={vi.fn()}
onRouteChange={onRouteChange}
>
<div>页面内容</div>
</AdminShell>,
);
expect(
within(screen.getAllByRole('navigation', { name: '后台导航' })[0]!)
.getByRole('button', { name: /数据与监控/ })
.getAttribute('aria-expanded'),
).toBe('true');
});
+96 -14
View File
@@ -3,6 +3,7 @@ import {
Activity,
BadgeDollarSign,
Bug,
ChevronDown,
Coins,
Database,
FolderArchive,
@@ -22,10 +23,15 @@ import {
Users,
WalletCards,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { type ReactNode, useEffect, useState } from 'react';
import type { AdminSessionPayload } from '../api/adminApiTypes';
import type { AdminRouteDefinition, AdminRouteId } from './adminRoutes';
import {
type AdminNavGroupId,
type AdminRouteDefinition,
type AdminRouteId,
getAdminNavGroups,
} from './adminRoutes';
interface AdminShellProps {
admin: AdminSessionPayload;
@@ -72,6 +78,67 @@ export function AdminShell({
onRouteChange,
onLogout,
}: AdminShellProps) {
const navGroups = getAdminNavGroups(routes);
const activeGroupId =
navGroups.find((group) =>
group.routes.some((route) => route.id === routeId),
)?.id ?? navGroups[0]?.id;
const [collapsedGroupIds, setCollapsedGroupIds] = useState<
Set<AdminNavGroupId>
>(() => {
return new Set(
navGroups
.map((group) => group.id)
.filter((groupId) => groupId !== activeGroupId),
);
});
useEffect(() => {
if (!activeGroupId) {
return;
}
setCollapsedGroupIds((current) => {
if (!current.has(activeGroupId)) {
return current;
}
const next = new Set(current);
next.delete(activeGroupId);
return next;
});
}, [activeGroupId, routeId]);
const toggleGroup = (groupId: AdminNavGroupId) => {
setCollapsedGroupIds((current) => {
const next = new Set(current);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
const renderRouteButton = (route: AdminRouteDefinition) => {
const Icon = routeIcons[route.id];
return (
<button
className="admin-nav-button"
aria-current={route.id === routeId ? 'page' : undefined}
data-active={route.id === routeId}
key={route.id}
title={route.label}
type="button"
onClick={() => onRouteChange(route.id)}
>
<Icon size={18} aria-hidden="true" />
<span>{route.label}</span>
</button>
);
};
return (
<div className="admin-shell">
<aside className="admin-sidebar">
@@ -86,20 +153,35 @@ export function AdminShell({
</div>
<nav className="admin-nav" aria-label="后台导航">
{routes.map((route) => {
const Icon = routeIcons[route.id];
{navGroups.map((group) => {
const isCollapsed = collapsedGroupIds.has(group.id);
const groupItemsId = `admin-nav-group-${group.id}`;
return (
<button
className="admin-nav-button"
data-active={route.id === routeId}
key={route.id}
title={route.label}
type="button"
onClick={() => onRouteChange(route.id)}
<div
className="admin-nav-group"
data-collapsed={isCollapsed}
key={group.id}
>
<Icon size={18} aria-hidden="true" />
<span>{route.label}</span>
</button>
<button
aria-controls={groupItemsId}
aria-expanded={!isCollapsed}
className="admin-nav-group-toggle"
type="button"
onClick={() => toggleGroup(group.id)}
>
<ChevronDown size={15} aria-hidden="true" />
<span>{group.label}</span>
<small aria-hidden="true">{group.routes.length}</small>
</button>
<div
className="admin-nav-group-items"
data-collapsed={isCollapsed}
hidden={isCollapsed}
id={groupItemsId}
>
{group.routes.map(renderRouteButton)}
</div>
</div>
);
})}
</nav>
@@ -3,11 +3,46 @@ import { expect, test } from 'vitest';
import {
adminRoutes,
getAccessibleAdminRoutes,
getAdminNavGroups,
resolveAccessibleAdminRoute,
resolveAdminRoute,
routeHash,
} from './adminRoutes';
test('后台侧边栏路由按业务分组且不丢失任何页签', () => {
const groups = getAdminNavGroups(adminRoutes);
const groupedRouteIds = groups.flatMap((group) =>
group.routes.map((route) => route.id),
);
expect(groups.map((group) => group.label)).toEqual([
'工作台',
'数据与监控',
'运营配置',
'充值与支付',
'AGC 管理',
'内容运营',
'账号权限',
]);
expect(groupedRouteIds).toEqual(adminRoutes.map((route) => route.id));
});
test('后台侧边栏分组只保留当前管理员可见的页签', () => {
const groups = getAdminNavGroups(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['tracking', 'game-reviews', 'accounts'],
}),
);
expect(
groups.map((group) => [group.label, group.routes.map((route) => route.id)]),
).toEqual([
['数据与监控', ['tracking']],
['内容运营', ['game-reviews']],
]);
});
test('游戏评价路由独立授权并支持游戏参数', () => {
expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews');
expect(
+85
View File
@@ -71,6 +71,91 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
];
/**
* 桌面端侧边栏的导航分组。分组只影响展示顺序和折叠状态,不改变路由
* 权限、hash 或移动端底栏的平铺顺序。
*/
export type AdminNavGroupId =
| 'workspace'
| 'observability'
| 'operations'
| 'billing'
| 'content'
| 'agc'
| 'access';
export interface AdminNavGroupDefinition {
id: AdminNavGroupId;
label: string;
routeIds: AdminRouteId[];
}
export const adminNavGroups: AdminNavGroupDefinition[] = [
{
id: 'workspace',
label: '工作台',
routeIds: ['dashboard', 'overview', 'tables', 'debug'],
},
{
id: 'observability',
label: '数据与监控',
routeIds: ['tracking', 'agc-tracking', 'error-reports'],
},
{
id: 'operations',
label: '运营配置',
routeIds: ['gray-release', 'redeem', 'invite', 'profile-wallet', 'tasks'],
},
{
id: 'billing',
label: '充值与支付',
routeIds: ['recharge-products', 'recharge-orders', 'payment-orders'],
},
{
id: 'agc',
label: 'AGC 管理',
routeIds: ['editor-generation-pricing', 'agc-models', 'agc-templates'],
},
{
id: 'content',
label: '内容运营',
routeIds: [
'editor-showcase',
'game-distribution',
'game-management',
'game-reviews',
'editor-assets',
'project-snapshots',
],
},
{
id: 'access',
label: '账号权限',
routeIds: ['accounts'],
},
];
export interface AdminNavGroup {
id: AdminNavGroupId;
label: string;
routes: AdminRouteDefinition[];
}
/** 按固定业务分组筛选当前管理员可见的路由。 */
export function getAdminNavGroups(
routes: AdminRouteDefinition[],
): AdminNavGroup[] {
return adminNavGroups
.map((group) => ({
id: group.id,
label: group.label,
routes: group.routeIds
.map((routeId) => routes.find((route) => route.id === routeId))
.filter((route): route is AdminRouteDefinition => route !== undefined),
}))
.filter((group) => group.routes.length > 0);
}
export interface AdminRouteAccess {
accountRole: 'owner' | 'member';
tabPermissions: string[];
@@ -290,6 +290,7 @@ export function AdminGameReviewsPage({ token, onUnauthorized }: PageProps) {
</AdminPanel>
<AdminListPanel
title="评价列表"
tableClassName="admin-game-reviews-table"
busy={loading}
busyText="正在加载评价…"
busyVariant="line"
+76 -3
View File
@@ -289,7 +289,66 @@ button:disabled {
.admin-nav {
display: grid;
gap: 8px;
gap: 12px;
}
.admin-nav-group {
display: grid;
gap: 4px;
}
.admin-nav-group-toggle {
display: flex;
align-items: center;
gap: 6px;
min-height: 28px;
border: 0;
border-radius: 6px;
padding: 0 8px;
color: #a08472;
background: transparent;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-align: left;
cursor: pointer;
}
.admin-nav-group-toggle svg {
flex: 0 0 auto;
transition: transform 160ms ease;
}
.admin-nav-group[data-collapsed='true'] .admin-nav-group-toggle svg {
transform: rotate(-90deg);
}
.admin-nav-group-toggle small {
min-width: 18px;
margin-left: auto;
border-radius: 999px;
padding: 2px 5px;
color: #b18d76;
background: #faf0e8;
font-size: 10px;
line-height: 1.2;
text-align: center;
}
.admin-nav-group-toggle:hover,
.admin-nav-group-toggle:focus-visible {
color: #8f3f27;
background: #fff8f2;
outline: none;
}
.admin-nav-group-items {
display: grid;
gap: 4px;
}
.admin-nav-group-items[data-collapsed='true'] {
display: none;
}
.admin-nav-button,
@@ -305,8 +364,8 @@ button:disabled {
.admin-nav-button {
justify-content: flex-start;
gap: 10px;
min-height: 42px;
padding: 0 12px;
min-height: 40px;
padding: 0 12px 0 28px;
color: #755a49;
background: transparent;
}
@@ -787,6 +846,20 @@ button:disabled {
max-width: 320px;
}
.admin-game-reviews-table .admin-status {
white-space: nowrap;
overflow-wrap: normal;
}
.admin-game-reviews-table td:last-child .admin-action-row {
flex-wrap: nowrap;
justify-content: flex-start;
}
.admin-game-reviews-table td:last-child .admin-action-row > button {
flex-shrink: 0;
}
@media (max-width: 700px) {
.admin-review-filters {
grid-template-columns: minmax(0, 1fr);
@@ -18,6 +18,7 @@ import {
import {
AGC_DEFAULT_CHANNEL,
resolveChannelInstallIdentity,
resolveChannelMainBinaryName,
resolveReleaseChannel,
} from './channel-identity.mjs';
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
@@ -38,6 +39,9 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
const devChannelInstallerHooks = fileURLToPath(
new URL('../src-tauri/windows/installer-hooks.nsh', import.meta.url),
);
const releaseChannelInstallerHooks = fileURLToPath(
new URL('../src-tauri/windows/release-installer-hooks.nsh', import.meta.url),
);
// Git 命令用于读取 release revision,必须在仓库根执行,不能在应用目录里执行。
const repoRoot = path.resolve(appRoot, '..', '..');
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
@@ -423,6 +427,7 @@ export function createChannelConfig(
const config = {
productName,
identifier,
mainBinaryName: resolveChannelMainBinaryName(channel),
app: {
windows: [{ ...baseClientWindow, title: productName }],
},
@@ -432,10 +437,20 @@ export function createChannelConfig(
},
},
};
// 只有 dev 渠道改过展示名:它的包需要自带旧身份迁移;其它渠道不能拿这份旧名表去删安装。
if (channel === AGC_DEFAULT_CHANNEL && target.includes('windows'))
// dev 渠道迁移改名前的旧身份;release 渠道迁移曾使用过「陶泥儿 Release」的安装与快捷方式。
if (
target.includes('windows') &&
(channel === AGC_DEFAULT_CHANNEL || channel === 'release')
)
config.bundle = {
windows: { nsis: { installerHooks: devChannelInstallerHooks } },
windows: {
nsis: {
installerHooks:
channel === AGC_DEFAULT_CHANNEL
? devChannelInstallerHooks
: releaseChannelInstallerHooks,
},
},
};
return config;
}
@@ -35,8 +35,10 @@ import {
} from './build-release.mjs';
import {
AGC_APP_IDENTIFIER,
AGC_MAIN_BINARY_NAME,
AGC_PRODUCT_NAME,
resolveChannelInstallIdentity,
resolveChannelMainBinaryName,
} from './channel-identity.mjs';
import { supportedHostTarget } from './prepare-bundled-resources.mjs';
@@ -188,6 +190,7 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
productName: `${AGC_PRODUCT_NAME}开发版`,
identifier: AGC_APP_IDENTIFIER,
mainBinaryName: AGC_MAIN_BINARY_NAME,
app: {
windows: [
{
@@ -236,6 +239,15 @@ test('channel install identity isolates co-installed builds and keeps the defaul
productName: '陶泥儿 Beta-2',
identifier: `${AGC_APP_IDENTIFIER}.beta-2`,
});
assert.equal(resolveChannelMainBinaryName('dev'), AGC_MAIN_BINARY_NAME);
assert.equal(
resolveChannelMainBinaryName('release'),
`${AGC_MAIN_BINARY_NAME}-release`,
);
assert.equal(
resolveChannelMainBinaryName('beta-2'),
`${AGC_MAIN_BINARY_NAME}-beta-2`,
);
// release 与其它非默认渠道通过独立 identifier 隔离运行身份与数据目录;
// 自定义渠道另外保留可区分的产品名。
@@ -269,6 +281,7 @@ test('channel install identity is baked into the same build-time config as the e
const config = createChannelConfig('release', windowsTarget);
assert.equal(config.productName, AGC_PRODUCT_NAME);
assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`);
assert.equal(config.mainBinaryName, `${AGC_MAIN_BINARY_NAME}-release`);
assert.match(
config.plugins.updater.endpoints[0],
/\/release-win\/latest\.json$/u,
@@ -280,7 +293,7 @@ test('channel install identity is baked into the same build-time config as the e
* dev 渠道展示名改过两次(Genarrative AI Game Creator → 陶泥儿 → 陶泥儿开发版),而更新路径
* (/UPDATE)不会重建快捷方式:改名后的包必须自带旧身份迁移,否则桌面快捷方式会一直指向旧安装。
*/
test('dev 渠道的 Windows 包注入改名迁移钩子,其它渠道与目标不注入', () => {
test('渠道 Windows 包注入各自迁移钩子,且主进程文件名按渠道隔离', () => {
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
const devWindows = createChannelConfig('dev', windowsTarget);
const hooksPath = devWindows.bundle.windows.nsis.installerHooks;
@@ -296,10 +309,27 @@ test('dev 渠道的 Windows 包注入改名迁移钩子,其它渠道与目标
`迁移钩子缺少旧身份:${legacyIdentity}`,
);
}
assert.match(
hooksSource,
/AGC_RELEASE_MAIN_BINARY\s+"genarrative-ai-game-creator-shell-release"/u,
);
// 改名只发生在 dev:其它渠道注入会拿 dev 的旧名表去删别人的安装。
const releaseWindows = createChannelConfig('release', windowsTarget);
const releaseHooksPath = releaseWindows.bundle.windows.nsis.installerHooks;
assert.ok(releaseHooksPath);
assert.equal(existsSync(releaseHooksPath), true);
assert.match(readFileSync(releaseHooksPath, 'utf8'), /陶泥儿 Release/u);
assert.equal(
createChannelConfig('release', windowsTarget).bundle,
releaseWindows.mainBinaryName,
`${AGC_MAIN_BINARY_NAME}-release`,
);
assert.notEqual(
devWindows.mainBinaryName,
releaseWindows.mainBinaryName,
'dev 与 release 的 NSIS 进程匹配名必须不同',
);
assert.equal(
createChannelConfig('beta-2', windowsTarget).bundle,
undefined,
);
// macOS 包没有 NSIS 安装器,不能带上 Windows 钩子。
@@ -395,6 +425,44 @@ test('安装钩子的可执行逻辑必须全在 !macro 内,顶层不得引用
);
});
test('release 安装钩子迁移旧 Release 身份且保持编译期位置纪律', () => {
const hooksPath = createChannelConfig('release', windowsTarget).bundle.windows
.nsis.installerHooks;
const hooksSource = readFileSync(hooksPath, 'utf8');
assert.deepEqual(
readFileSync(hooksPath).subarray(0, 3),
Buffer.from([0xef, 0xbb, 0xbf]),
'NSIS 中文钩子必须保留 UTF-8 BOM',
);
assert.match(hooksSource, /陶泥儿 Release/u);
assert.match(hooksSource, /AGC_RELEASE_LEGACY_BINARY/u);
assert.match(hooksSource, /NSIS_HOOK_PREINSTALL/u);
assert.match(hooksSource, /NSIS_HOOK_POSTINSTALL/u);
let macroDepth = 0;
const offenders = [];
for (const [index, rawLine] of hooksSource.split('\n').entries()) {
const line = rawLine.trim();
if (line === '' || line.startsWith(';')) continue;
if (/^!macroend\b/u.test(line)) {
macroDepth = Math.max(0, macroDepth - 1);
continue;
}
if (/^!macro\b/u.test(line)) {
macroDepth += 1;
continue;
}
if (macroDepth > 0) continue;
if (
line.includes('::') ||
/^(?:Function(?:End)?|Return|Delete|ReadRegStr|StrCpy|CreateShortCut)\b/u.test(
line,
)
)
offenders.push(`第 ${index + 1} 行出现顶层可执行语句:${line}`);
}
assert.deepEqual(offenders, []);
});
/**
* RFC 7386(tauri-utils 用 `json_patch::merge`)语义:对象递归合并,数组整体替换。
* 这里按同样语义复现 Tauri CLI 的 `--config` 合并,用来守住"渠道配置不得丢窗口契约"。
@@ -16,6 +16,12 @@
export const AGC_DEFAULT_CHANNEL = 'dev';
export const AGC_PRODUCT_NAME = '陶泥儿';
export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator';
/**
* Tauri/NSIS 默认按主可执行文件名查找并结束进程。
* dev 保留历史文件名以维持既有更新链;其它渠道使用独立文件名,
* 这样更新一个渠道时不会把另一个渠道的进程一起结束。
*/
export const AGC_MAIN_BINARY_NAME = 'genarrative-ai-game-creator-shell';
const reservedChannelNames = new Set([
'win',
@@ -75,3 +81,11 @@ export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) {
: `${AGC_APP_IDENTIFIER}.${channel}`,
});
}
/** 渠道对应的主可执行文件名(不含平台扩展名)。 */
export function resolveChannelMainBinaryName(channel = AGC_DEFAULT_CHANNEL) {
validateReleaseChannel(channel);
return channel === AGC_DEFAULT_CHANNEL
? AGC_MAIN_BINARY_NAME
: `${AGC_MAIN_BINARY_NAME}-${channel}`;
}
@@ -25,7 +25,9 @@ execFileSync(
import {
AGC_APP_IDENTIFIER,
AGC_MAIN_BINARY_NAME,
resolveChannelInstallIdentity,
resolveChannelMainBinaryName,
} from './channel-identity.mjs';
import {
askHidden,
@@ -1299,9 +1301,14 @@ if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) {
throw new Error('AI game creator shell identifier drifted');
}
if (tauriConfig.mainBinaryName !== AGC_MAIN_BINARY_NAME) {
throw new Error('AI game creator shell mainBinaryName drifted');
}
if (
tauriConfig.productName !== defaultChannelIdentity.productName ||
tauriConfig.identifier !== defaultChannelIdentity.identifier
tauriConfig.identifier !== defaultChannelIdentity.identifier ||
tauriConfig.mainBinaryName !== resolveChannelMainBinaryName('dev')
) {
throw new Error(
'AI game creator shell baseline config must match the default channel identity',
@@ -1314,6 +1321,8 @@ for (const channel of ['release', 'beta-2']) {
if (
identity.productName === defaultChannelIdentity.productName ||
identity.identifier === defaultChannelIdentity.identifier ||
resolveChannelMainBinaryName(channel) ===
resolveChannelMainBinaryName('dev') ||
!identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)
) {
throw new Error(`channel install identity not isolated: ${channel}`);
@@ -1631,13 +1640,11 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) {
);
}
if (
!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)
) {
if (!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)) {
throw new Error(
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
);
@@ -1,6 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "陶泥儿开发版",
"mainBinaryName": "genarrative-ai-game-creator-shell",
"version": "0.1.67",
"identifier": "world.genarrative.ai-game-creator",
"build": {
@@ -30,16 +30,19 @@
; 旧展示名表:以后再改展示名,必须在这里追加旧名,否则升级后旧快捷方式会继续指向旧安装。
!define AGC_LEGACY_IDENTITY_1 "陶泥儿"
!define AGC_LEGACY_IDENTITY_2 "Genarrative AI Game Creator"
!define AGC_RELEASE_MAIN_BINARY "genarrative-ai-game-creator-shell-release"
Var AgcLegacyIdentity
Var AgcLegacyInstallDir
Var AgcMigratedLegacyDesktopShortcut
Var AgcLegacyOwnedByRelease
; 清理一个旧身份。调用方先把旧展示名写进 $AgcLegacyIdentity,再 !insertmacro 本宏;宏不返回错误。
; 只在「旧展示名目录下确实存在我们的主程序」时才动手,避免误删同名目录;
; 当前安装目录 $INSTDIR 永不进入删除路径。
!macro AgcMigrateLegacyIdentity
StrCpy $AgcLegacyInstallDir ""
StrCpy $AgcLegacyOwnedByRelease 0
${If} ${FileExists} "$LOCALAPPDATA\$AgcLegacyIdentity\${MAINBINARYNAME}.exe"
StrCpy $AgcLegacyInstallDir "$LOCALAPPDATA\$AgcLegacyIdentity"
@@ -49,6 +52,15 @@ Var AgcMigratedLegacyDesktopShortcut
${If} $AgcLegacyInstallDir != ""
${AndIf} $AgcLegacyInstallDir != $INSTDIR
; release 新包使用独立主程序名;同一旧展示名目录若已有该文件,不能当作旧 dev 清理。
${If} ${FileExists} "$AgcLegacyInstallDir\${AGC_RELEASE_MAIN_BINARY}.exe"
StrCpy $AgcLegacyOwnedByRelease 1
${EndIf}
${EndIf}
${If} $AgcLegacyInstallDir != ""
${AndIf} $AgcLegacyInstallDir != $INSTDIR
${AndIf} $AgcLegacyOwnedByRelease = 0
; 桌面图标:只有目标正好指向旧目录的主程序时才删,避免误删同名的用户快捷方式。
!insertmacro IsShortcutTarget "$DESKTOP\$AgcLegacyIdentity.lnk" "$AgcLegacyInstallDir\${MAINBINARYNAME}.exe"
Pop $0
@@ -0,0 +1,82 @@
; AGC release 渠道的 Windows 安装迁移钩子。
;
; release 曾使用「陶泥儿 Release」作为 productName,后来统一为「陶泥儿」。
; Tauri 的 NSIS 更新模式不会为改名后的身份重建快捷方式,也不会自动读取旧身份的安装目录,
; 所以这里把旧安装目录、旧快捷方式迁移到当前 release 身份。
;
; release 渠道同时使用独立的 MAINBINARYNAME(genarrative-ai-game-creator-shell-release),
; NSIS 默认的按文件名查杀逻辑因此不会碰 dev 渠道的主进程。
;
!define AGC_RELEASE_LEGACY_IDENTITY "陶泥儿 Release"
!define AGC_RELEASE_LEGACY_BINARY "genarrative-ai-game-creator-shell"
Var AgcReleaseLegacyInstallDir
Var AgcReleaseMigratedDesktopShortcut
; Tauri 模板会在 Section Install 开头先执行 SetOutPath,再展开 PREINSTALL 宏。
; 此处仍可在复制文件前改写 $INSTDIR,让旧 release 安装原地升级。
!macro NSIS_HOOK_PREINSTALL
StrCpy $AgcReleaseLegacyInstallDir ""
ReadRegStr $R0 SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AGC_RELEASE_LEGACY_IDENTITY}" "InstallLocation"
; Tauri 将 InstallLocation 写成带引号的 REG_SZ;去掉包裹引号后再交给 FileExists。
StrCpy $R1 $R0 1
${If} $R1 == '$\"'
StrCpy $R0 $R0 "" 1
StrLen $R1 $R0
IntOp $R1 $R1 - 1
StrCpy $R0 $R0 $R1
${EndIf}
${If} $R0 != ""
${AndIf} ${FileExists} "$R0\${AGC_RELEASE_LEGACY_BINARY}.exe"
StrCpy $AgcReleaseLegacyInstallDir $R0
StrCpy $INSTDIR $R0
${EndIf}
!macroend
!macro NSIS_HOOK_POSTINSTALL
; 更新模式下模板不会重建快捷方式。先把旧名称和旧主程序名指向当前 release。
!insertmacro IsShortcutTarget "$DESKTOP\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe"
Pop $0
${If} $0 = 1
StrCpy $AgcReleaseMigratedDesktopShortcut 1
!insertmacro UnpinShortcut "$DESKTOP\${AGC_RELEASE_LEGACY_IDENTITY}.lnk"
Delete "$DESKTOP\${AGC_RELEASE_LEGACY_IDENTITY}.lnk"
${EndIf}
!insertmacro IsShortcutTarget "$SMPROGRAMS\${AGC_RELEASE_LEGACY_IDENTITY}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe"
Pop $0
${If} $0 = 1
!insertmacro UnpinShortcut "$SMPROGRAMS\${AGC_RELEASE_LEGACY_IDENTITY}.lnk"
Delete "$SMPROGRAMS\${AGC_RELEASE_LEGACY_IDENTITY}.lnk"
${EndIf}
; 用户可能早已把快捷方式改成当前名称,但仍指向旧主程序名。
!insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe"
Pop $0
${If} $0 = 1
!insertmacro SetShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
${EndIf}
!insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe"
Pop $0
${If} $0 = 1
!insertmacro SetShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
${EndIf}
; 开始菜单图标始终存在;桌面图标只在迁移前存在时补建,保持用户选择。
StrCpy $R0 "$INSTDIR\${MAINBINARYNAME}.exe"
${IfNot} ${FileExists} "$SMPROGRAMS\${PRODUCTNAME}.lnk"
CreateShortCut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$R0"
!insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk"
${EndIf}
${If} $AgcReleaseMigratedDesktopShortcut = 1
CreateShortCut "$DESKTOP\${PRODUCTNAME}.lnk" "$R0"
!insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk"
${EndIf}
; 新 release 已接管旧安装目录后,删除旧主程序,避免 dev 的迁移钩子把该目录误判为旧 dev。
${If} $AgcReleaseLegacyInstallDir != ""
Delete "$INSTDIR\${AGC_RELEASE_LEGACY_BINARY}.exe"
DeleteRegKey SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AGC_RELEASE_LEGACY_IDENTITY}"
DeleteRegKey HKCU "Software\genarrative\${AGC_RELEASE_LEGACY_IDENTITY}"
${EndIf}
!macroend
+39 -3
View File
@@ -71,6 +71,7 @@ import {
isAbsoluteProjectPath,
projectPathHasControlCharacter,
} from './features/project-summary/projectSummary';
import { insertChatReferences } from './features/project-workspace/activeChatComposer';
import { importDesignFiles } from './features/project-workspace/importDesignFiles';
import {
needsInitializedChatProject,
@@ -78,10 +79,13 @@ import {
} from './features/project-workspace/projectCommandPolicy';
import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput';
import {
type ChatReference,
directCodexContentToLegacyContentDto,
hasMeaningfulDirectCodexContent,
RESOURCE_REFERENCE_INSERT_EVENT,
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
type ResourceReferenceInsertEventDetail,
type ResourceReferenceInsertManyEventDetail,
} from './features/project-workspace/resourceReferences';
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
import { readGamePublishAvailability } from './services/gameDistributionPublish';
@@ -1273,22 +1277,54 @@ export function App({
}
useEffect(() => {
/*
画布引用只有这一处消费者:单条「引用」按钮与拖拽批量引用都从这里落进草稿。
落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、
策划链路挂策划面,事件本身不携带这个判断。
插入失败(此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索,
否则用户看到的又是一次「点了没反应」。空批次不是失败,它没有要插的东西,
也就不能把原因指到输入区上。
*/
const insertReferences = (references: readonly ChatReference[]) => {
if (references.length === 0) return;
if (insertChatReferences(references)) return;
if (import.meta.env.DEV) {
console.warn(
'[resource-reference] 引用没有落进草稿:没有可用的聊天输入区(未挂载或已卸载)',
);
}
};
const handleResourceReferenceInsert = (event: Event) => {
const detail = (event as CustomEvent<ResourceReferenceInsertEventDetail>)
.detail;
if (!detail?.reference) return;
chatComposerRef.current?.insertReferences([detail.reference]);
chatComposerRef.current?.focus();
insertReferences([detail.reference]);
};
const handleResourceReferenceInsertMany = (event: Event) => {
const detail = (
event as CustomEvent<ResourceReferenceInsertManyEventDetail>
).detail;
insertReferences(detail?.references ?? []);
};
window.addEventListener(
RESOURCE_REFERENCE_INSERT_EVENT,
handleResourceReferenceInsert,
);
return () =>
window.addEventListener(
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
handleResourceReferenceInsertMany,
);
return () => {
window.removeEventListener(
RESOURCE_REFERENCE_INSERT_EVENT,
handleResourceReferenceInsert,
);
window.removeEventListener(
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
handleResourceReferenceInsertMany,
);
};
}, []);
/**
@@ -0,0 +1,66 @@
import type { ChatReference } from './resourceReferences';
/**
* 当前挂载的聊天输入区句柄:只保留「把引用插进草稿」这一件事。
*
* `insertReferences` 回答**这一批有没有真的落进草稿**(输入区自己那份句柄可能还没挂上、
* 或者已经被卸载),注册表按它决定成功还是失败,不让「注册表里有句柄」冒充「用户看得见
* 的结果」。输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板
* 内部用,不进这里——注册表只负责跨面板的引用插入。
*/
export type ActiveChatComposerHandle = {
insertReferences: (references: ChatReference[]) => boolean;
focus: () => void;
};
/**
* 同一时刻只可能有一个聊天输入区挂载:普通项目走 DirectProject,立项策划走策划面,
* 两条链路互斥渲染(见 `App.tsx` 的 `directProjectMode`)。
*/
let activeChatComposer: ActiveChatComposerHandle | null = null;
/**
* 注册当前挂载的聊天输入区,返回注销函数。
*
* 注销时按身份校验:新输入区已经接管、旧输入区才卸载时(切换项目、两条链路互换),
* 旧注销不能把新句柄一起清掉。
*
* 两个输入区同时挂载属于调用方接线错误(本应互斥,见 `App.tsx` 的 `directProjectMode`):
* 后注册者会顶掉前者,引用会落进用户看不见的那份草稿。这里只留一条线索,不改运行时语义
* ——注册表仍然按最后注册的那个工作,注销的身份校验也照旧兜住乱序卸载。
*/
export function registerActiveChatComposer(
handle: ActiveChatComposerHandle,
): () => void {
if (activeChatComposer && activeChatComposer !== handle) {
if (import.meta.env.DEV) {
console.warn(
'[resource-reference] 检测到第二个聊天输入区注册:引用会插进最后注册的那一个',
);
}
}
activeChatComposer = handle;
return () => {
if (activeChatComposer === handle) {
activeChatComposer = null;
}
};
}
/**
* 把一批引用插进当前挂载的聊天输入区,回答**有没有落进草稿**。
*
* 三种情况都返回 `false`:空批次、此刻没有任何输入区挂载、注册表里的句柄已经插不进去
* (它转发的那份输入区没挂上或已卸载)。这些都不能静默,由调用方(`App.tsx` 的事件监听)
* 决定怎么留痕或提示,注册表本身不吞;只有真的插进去了才把焦点交给输入区。
*/
export function insertChatReferences(
references: readonly ChatReference[],
): boolean {
if (references.length === 0) return false;
const handle = activeChatComposer;
if (!handle) return false;
if (!handle.insertReferences([...references])) return false;
handle.focus();
return true;
}
@@ -4,17 +4,22 @@ import {
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { claimInitialTurnForPage } from '../../../app/initialTurnClaims';
import type { PendingUiConfirmation } from '../../../app/types';
import { projectNameFromPath } from '../../../features/agent-runtime';
import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer';
import { userItemFromContent } from '../../../features/project-workspace/resourceReferences';
import { type ApprovalMode, approvalModeLabel } from '../approvalMode';
import { ApprovalModeDialog } from '../ApprovalModeDialog';
import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader';
import { DirectProjectComposer } from './components/DirectProjectComposer/DirectProjectComposer';
import {
DirectProjectComposer,
type DirectProjectComposerHandle,
} from './components/DirectProjectComposer/DirectProjectComposer';
import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation';
import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog';
import {
@@ -103,6 +108,7 @@ export function DirectProjectChatView({
}: DirectProjectChatViewProps) {
const { assets, projectId, refresh, versions } =
useDirectProjectManifest(projectPath);
const composerHandleRef = useRef<DirectProjectComposerHandle | null>(null);
const [runtimeNotice, setRuntimeNotice] = useState('');
const [settingsOpen, setSettingsOpen] = useState(false);
const [approvalOpen, setApprovalOpen] = useState(false);
@@ -212,6 +218,28 @@ export function DirectProjectChatView({
},
}));
/*
画布的「引用」与拖拽批量引用是 window 事件,唯一消费者在 `App.tsx`;它只认注册表里
**当前挂载**的输入区。普通项目固定渲染这里、策划链路渲染 `PlanningChatView`,两条链路
互斥,所以同一时刻注册表里只有一个句柄。
注册的是一个**按 ref 转发**的句柄、且不依赖「输入区此刻已挂上」:注册只表达「这个聊天面
在用」,插入成功与否由转发那一刻的实际情况回答(`DirectProjectComposerHandle.insertReferences`
返回 boolean)。这样挂载顺序、子组件重挂载都不会让注册表漏挂或指向死句柄。
*/
useEffect(
() =>
registerActiveChatComposer({
insertReferences: (references) => {
const handle = composerHandleRef.current;
if (!handle) return false;
return handle.insertReferences(references);
},
focus: () => composerHandleRef.current?.focus(),
}),
[],
);
return (
<section
className="project-chat-surface is-direct-codex"
@@ -252,6 +280,7 @@ export function DirectProjectChatView({
</div>
) : null}
<DirectProjectComposer
ref={composerHandleRef}
assets={assets}
{...(versions ? { versions } : {})}
projectPath={projectPath}
@@ -1,10 +1,18 @@
import { ArrowUp, AtSign } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import {
type Ref,
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import type {
GameCreationAppAssetManifestEntry,
GameIterationVersion,
} from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
import type { ActiveChatComposerHandle } from '../../../../../features/project-workspace/activeChatComposer';
import {
ConversationModelSelect,
type ConversationModelSelectHandle,
@@ -21,7 +29,10 @@ import {
ResourceReferencePicker,
type ResourceReferencePickerHandle,
} from '../../../../../features/project-workspace/ResourceReferencePicker';
import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences';
import {
assetsSignature,
type ChatReference,
} from '../../../../../features/project-workspace/resourceReferences';
import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments';
import type { DirectPendingTurn } from '../../conversation/directPendingTurns';
import type { UserContentPart } from '../../generated/UserContentPart';
@@ -42,6 +53,12 @@ function draftAttachmentCount(draft: { content: UserContentPart[] }) {
).length;
}
/**
* 输入区对外的命令式出口:就是「活跃聊天输入区」注册表要的那两件事
* (见 `features/project-workspace/activeChatComposer.ts`)。
*/
export type DirectProjectComposerHandle = ActiveChatComposerHandle;
/**
* DirectProject 输入区:待发消息 chip、附件、`@` 引用输入框、模型/推理选择和发送/终止。
*
@@ -64,6 +81,7 @@ export function DirectProjectComposer({
onReferencePickerOpen,
onSubmit,
onUploadFiles,
ref,
}: {
assets: GameCreationAppAssetManifestEntry[];
versions?: GameIterationVersion[];
@@ -109,6 +127,11 @@ export function DirectProjectComposer({
files: readonly File[],
draftAttachmentCount: number,
) => Promise<DirectCodexTurnAttachment[]>;
/**
* 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」,
* 画布的「引用」/拖拽批量引用事件才能落进这份草稿。
*/
ref?: Ref<DirectProjectComposerHandle>;
}) {
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
const composerRootRef = useRef<HTMLDivElement | null>(null);
@@ -178,6 +201,24 @@ export function DirectProjectComposer({
},
[onUploadFiles],
);
/*
对外句柄只做一层转发:内部输入区句柄会随编辑器重挂载换对象,这里按 ref 读最新值,
句柄本身(注册表持有的那个)保持稳定。插入与聚焦都不自己实现,避免出现第二套草稿真相;
`insertReferences` 回答有没有真的递到输入区——注册表拿它区分「插进去了」和「这一步落空」。
*/
useImperativeHandle(
ref,
() => ({
insertReferences: (references: ChatReference[]) => {
const handle = composerRef.current;
if (!handle) return false;
handle.insertReferences([...references]);
return true;
},
focus: () => composerRef.current?.focus(),
}),
[],
);
/** 记一条浮层提示:id 自增,保证重复文案也会重新弹一次并重起计时。 */
const pushModelNotice = useCallback((message: string) => {
modelNoticeIdRef.current += 1;
@@ -1,6 +1,6 @@
import { ArrowUp, FileUp, Loader2 } from 'lucide-react';
import type { FormEventHandler, RefObject, UIEventHandler } from 'react';
import { useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { AgentMessageContent } from '../../../../../../packages/shared/src/components/AgentMessageContent';
import type {
@@ -15,6 +15,7 @@ import {
projectNameFromPath,
projectRuntimeVisibleError,
} from '../../../features/agent-runtime';
import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer';
import { ConversationModelSelect } from '../../../features/project-workspace/ConversationModelSelect';
import { attachmentReferenceProvider } from '../../../features/project-workspace/reference-source/attachmentReferenceProvider';
import { createResourceReferenceProvider } from '../../../features/project-workspace/reference-source/resourceReferenceProvider';
@@ -133,6 +134,23 @@ export function PlanningChatView({
onDesignRetry,
}: PlanningChatViewProps) {
const designFileInputRef = useRef<HTMLInputElement>(null);
/*
策划输入盒也进「活跃聊天输入区」注册表:画布的引用事件消费者只有 `App.tsx` 一处,
它不关心当前挂哪条链路。句柄按 ref 转发(`composerRef` 指向的是输入区自己那份
可变句柄),注册的那个包装对象因此永远读到最新值,并由它回答「这一批有没有真的插进去」。
*/
useEffect(() => {
if (!composerRef) return;
return registerActiveChatComposer({
insertReferences: (references) => {
const handle = composerRef.current;
if (!handle) return false;
handle.insertReferences([...references]);
return true;
},
focus: () => composerRef.current?.focus(),
});
}, [composerRef]);
/*
策划输入盒的引用来源:**只注入资源与两个静默 provider**。
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
type ActiveChatComposerHandle,
insertChatReferences,
registerActiveChatComposer,
} from '../src/features/project-workspace/activeChatComposer';
import type { ChatReference } from '../src/features/project-workspace/resourceReferences';
function resourceReference(resourceId: string): ChatReference {
return {
type: 'resource',
resourceId,
kind: 'character',
mediaType: 'image/png',
label: resourceId,
category: 'character',
tags: [],
source: 'resource-card',
};
}
/** 一个「插入总是成功」的输入区句柄替身;`inserted` 为 false 时模拟输入区已不在位。 */
function composerHandle(inserted = true): {
handle: ActiveChatComposerHandle;
insertReferences: ReturnType<typeof vi.fn>;
focus: ReturnType<typeof vi.fn>;
} {
const insertReferences = vi.fn(() => inserted);
const focus = vi.fn();
return { handle: { insertReferences, focus }, insertReferences, focus };
}
/**
* 「活跃聊天输入区」注册表的合同。
*
* 这些语义就是 `App.tsx` 那处监听区分「引用落进草稿」与「这一步落空(要留痕)」的唯一依据,
* 所以在这里钉死;链路本身的端到端断言在 `resourceCanvasChatReferenceDrop.test.tsx` 与
* `appSurface/*.suite.ts`。
*/
describe('活跃聊天输入区注册表', () => {
// 注册表是模块级单例:用例之间靠对称注销回到空态,只在这里清 mock 记录。
afterEach(() => {
vi.restoreAllMocks();
});
it('没有任何输入区挂载时插入返回 false', () => {
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
});
it('空批次不算成功,也不打扰已挂载的输入区', () => {
const { handle, insertReferences, focus } = composerHandle();
const unregister = registerActiveChatComposer(handle);
expect(insertChatReferences([])).toBe(false);
expect(insertReferences).not.toHaveBeenCalled();
expect(focus).not.toHaveBeenCalled();
unregister();
});
it('挂载期间:整批一次交给输入区、插入成功后才聚焦,返回 true', () => {
const { handle, insertReferences, focus } = composerHandle();
const unregister = registerActiveChatComposer(handle);
expect(
insertChatReferences([
resourceReference('hero'),
resourceReference('npc'),
]),
).toBe(true);
expect(insertReferences).toHaveBeenCalledTimes(1);
expect(insertReferences).toHaveBeenCalledWith([
expect.objectContaining({ resourceId: 'hero' }),
expect.objectContaining({ resourceId: 'npc' }),
]);
expect(focus).toHaveBeenCalledTimes(1);
unregister();
});
it('句柄报「这一批没插进去」时返回 false,且不抢焦点', () => {
const { handle, focus } = composerHandle(false);
const unregister = registerActiveChatComposer(handle);
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
expect(focus).not.toHaveBeenCalled();
unregister();
});
it('注销按身份校验:旧输入区卸载不会把已经接管的新输入区一起清掉', () => {
// 这条用例故意让两个句柄同时在册(重复注册的告警本身由下一条用例覆盖),先静音。
vi.spyOn(console, 'warn').mockImplementation(() => {});
const first = composerHandle();
const second = composerHandle();
const unregisterFirst = registerActiveChatComposer(first.handle);
const unregisterSecond = registerActiveChatComposer(second.handle);
// 旧链路后卸载(切换项目 / 两条链路互换时可能发生):新句柄必须留在注册表里。
unregisterFirst();
expect(insertChatReferences([resourceReference('hero')])).toBe(true);
expect(second.insertReferences).toHaveBeenCalledTimes(1);
expect(first.insertReferences).not.toHaveBeenCalled();
unregisterSecond();
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
});
it('重复注册:后注册者接管并留一条告警,乱序注销也清不掉更新的句柄', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const first = composerHandle();
const second = composerHandle();
const third = composerHandle();
const unregisterFirst = registerActiveChatComposer(first.handle);
expect(warn).not.toHaveBeenCalled();
// 第二个注册:接线错误(两个聊天面同时挂载),后注册者接管,只在 dev 留线索。
const unregisterSecond = registerActiveChatComposer(second.handle);
expect(warn.mock.calls.map((call) => String(call[0]))).toEqual([
expect.stringContaining('第二个聊天输入区注册'),
]);
expect(insertChatReferences([resourceReference('hero')])).toBe(true);
expect(second.insertReferences).toHaveBeenCalledTimes(1);
// 再注册第三个(模拟又一条链路接管):同样留线索。
const unregisterThird = registerActiveChatComposer(third.handle);
expect(warn).toHaveBeenCalledTimes(2);
// 乱序注销:第一个、第二个先卸载,都不能清掉当前接管的第三个。
unregisterFirst();
unregisterSecond();
expect(insertChatReferences([resourceReference('npc')])).toBe(true);
expect(third.insertReferences).toHaveBeenCalledTimes(1);
expect(first.insertReferences).not.toHaveBeenCalled();
expect(second.insertReferences).toHaveBeenCalledTimes(1);
unregisterThird();
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
warn.mockRestore();
});
});
@@ -1,5 +1,6 @@
import type { Mock } from 'vitest';
import { dispatchResourceReferenceInsert } from '../../src/features/project-workspace/resourceReferences';
import {
act,
App,
@@ -935,4 +936,35 @@ export function registerDesignAgentSurfaceTests() {
expect(details[1]!.open).toBe(false);
await waitFor(() => expect(summaries[0]!.textContent).toBe('思考过程'));
});
it('画布派发的「引用」落进策划输入盒草稿', async () => {
const harness = createProjectChatRuntimeHarness({
initialSessionExists: false,
});
renderDesignAgent(harness);
const editor = await screen.findByLabelText('项目需求');
// 资源画布唯一的生产入口就是这个 window 事件;消费者在 `App.tsx`,落到哪份输入盒由
// 「活跃聊天输入区」注册表回答。策划链路与普通项目共用一个消费者,这条用例钉的是
// 它在 `PlanningChatView` 这一侧也真的进了草稿。
act(() => {
dispatchResourceReferenceInsert({
type: 'resource',
resourceId: 'planning-hero',
kind: 'character',
mediaType: 'image/png',
label: 'hero.png',
category: 'character',
tags: [],
source: 'resource-card',
});
});
await waitFor(() =>
expect(
editor.querySelectorAll('[data-resource-reference-id="planning-hero"]'),
).toHaveLength(1),
);
expect(editor.textContent).toContain('@hero.png');
});
}
@@ -11,7 +11,6 @@ import {
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences';
import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog';
import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout';
import {
@@ -31,8 +30,10 @@ import {
} from '../resourceGenerationPromptTestUtils';
import {
act,
App,
cleanup,
createGameCreationAppManifest,
createProjectChatRuntimeHarness,
expect,
findResourceSelectButton,
fireEvent,
@@ -53,6 +54,11 @@ import {
within,
} from './harness';
// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。
const AppComponent = App as React.ComponentType<
NonNullable<React.ComponentProps<typeof App>>
>;
/**
* 历史夹具沿用的旧 kind 字面量(如 `design-document` / `game-code`)已不在当前
* `GameCreationAppAssetKind` 联合里;这些用例依赖未识别 kind 落「待归类」的运行时语义,
@@ -3608,7 +3614,7 @@ export function registerProjectWorkbenchFoundationTests() {
);
});
it('工具条里的「引用」用键盘也能插进聊天输入框', async () => {
it('工具条里的「引用」把素材 @ 进真实聊天草稿(键盘与鼠标两条通路)', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-reference',
'引用入口测试',
@@ -3623,6 +3629,13 @@ export function registerProjectWorkbenchFoundationTests() {
source: { kind: 'generated' },
});
let layoutRevision = 0;
const projectPath = '/tmp/workbench-resource-reference';
// 对话面用真实 DirectProject 链路(`App` → `DirectProjectChatView` → `DirectProjectComposer`):
// 引用事件的消费者在 `App.tsx`,桩接不住它,链路断点正好在「谁把它落进草稿」。
const chatHarness = createProjectChatRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
@@ -3660,20 +3673,45 @@ export function registerProjectWorkbenchFoundationTests() {
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
};
}
throw new Error(`unexpected invoke ${command}`);
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: manifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'get_design_agent_runtime_mode') {
return null;
}
return chatHarness.invoke(command, args);
},
);
installTauriRuntime({ core: { invoke } });
installTauriRuntime({
core: { invoke },
event: { listen: chatHarness.listen },
});
render(
React.createElement(ProjectDevelopmentView, {
projectName: '引用入口测试',
projectPath: '/tmp/workbench-resource-reference',
projectPath,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
chat: React.createElement(AppComponent, {
initialProjectPath: projectPath,
}),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
@@ -3701,49 +3739,39 @@ export function registerProjectWorkbenchFoundationTests() {
'引用资源 hero.png',
);
const inserted: unknown[] = [];
const onInsert = (event: Event) => {
inserted.push(
(event as CustomEvent<{ reference: unknown }>).detail.reference,
);
};
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert);
try {
// 键盘通路:聚焦后回车确认。App 侧监听同一个事件并把它插成输入框里的引用 chip
// (`App.tsx` 的 `handleResourceReferenceInsert`),所以这里钉的是真链路而不是按钮长相。
referenceButton.focus();
expect(document.activeElement).toBe(referenceButton);
await userEvent.setup().keyboard('{Enter}');
expect(inserted).toHaveLength(1);
// 端到端契约:交付结果是草稿里的引用芯片,不是 window 上的一次 dispatch。
// 链路断点一直是「谁把引用落进草稿」,所以断言必须读真实输入盒的 DOM。
const draftChipIds = () =>
Array.from(
document.querySelectorAll<HTMLElement>(
'form.project-chat-composer [data-resource-reference-id]',
),
).map((chip) => chip.getAttribute('data-resource-reference-id'));
const composerEditor = () =>
document.querySelector<HTMLElement>(
'form.project-chat-composer [contenteditable="true"][aria-label="陶泥儿对话内容"]',
)!;
// 芯片显示名走聊天自己的 manifest 口径(文件名去扩展名),与画布卡片上的文件名不同。
const firstChipLabel = () =>
document.querySelector<HTMLElement>(
'form.project-chat-composer [data-resource-reference-id] .resource-reference-chip-label',
)?.textContent;
// 鼠标通路仍然只派发一次;两条通路带的是逐字相同的出站负载。
fireEvent.click(referenceButton);
expect(inserted).toHaveLength(2);
} finally {
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert);
}
expect(inserted).toEqual([
{
type: 'resource',
resourceId: 'scene-hero',
kind: 'image',
mediaType: 'image/png',
label: 'hero.png',
category: 'scene',
tags: ['主舞台'],
source: 'resource-card',
},
{
type: 'resource',
resourceId: 'scene-hero',
kind: 'image',
mediaType: 'image/png',
label: 'hero.png',
category: 'scene',
tags: ['主舞台'],
source: 'resource-card',
},
]);
// 键盘通路:聚焦后回车确认。
referenceButton.focus();
expect(document.activeElement).toBe(referenceButton);
await userEvent.setup().keyboard('{Enter}');
await waitFor(() => expect(draftChipIds()).toEqual(['scene-hero']));
expect(firstChipLabel()).toBe('hero');
expect(composerEditor().textContent).toContain('@hero');
// 鼠标通路:再点一次,第二枚芯片接在第一枚之后——光标留在插入之后,不覆盖已插的引用。
fireEvent.click(referenceButton);
await waitFor(() =>
expect(draftChipIds()).toEqual(['scene-hero', 'scene-hero']),
);
// 插入后焦点回到输入盒,用户可以接着打字。
expect(document.activeElement).toBe(composerEditor());
});
it('信息面板在画布浮层与运行页签里渲染同一份只读字段', async () => {
@@ -15,8 +15,11 @@ import {
type ResourceReferenceInsertManyEventDetail,
} from '../src/features/project-workspace/resourceReferences';
import ProjectDevelopmentView from '../src/view/project-development';
import type { ProjectAttachmentResult } from '../src/view/project-development/resourceProjectionModel';
import {
App,
createGameCreationAppManifest,
createProjectChatRuntimeHarness,
fireEvent,
React,
render,
@@ -24,6 +27,11 @@ import {
} from './appSurface/harness';
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。
const AppComponent = App as React.ComponentType<
NonNullable<React.ComponentProps<typeof App>>
>;
/**
* 「拖动素材到对话 = 批量 @ 引用」的行为级验收。
*
@@ -89,10 +97,21 @@ type LayoutWrite = {
positions: ProjectResourceCanvasPosition[];
};
function installTauri(): { layoutWrites: LayoutWrite[] } {
/** 画布与真实 DirectProject 聊天面共用的项目身份。 */
const CANVAS_PROJECT_PATH = '/tmp/chat-reference-drop';
function installTauri(manifest: GameCreationAppManifest): {
layoutWrites: LayoutWrite[];
} {
const layoutWrites: LayoutWrite[] = [];
const persisted = new Map<string, ProjectResourceCanvasPosition[]>();
const revisions = new Map<string, number>();
// 聊天面是**真的** DirectProject:它自己的清单 / 订阅 / 历史 IPC 全部由这份运行时夹具回答,
// 画布命令再叠在它上面。夹具与 `chat-composer.suite` 同一套,不另造聊天替身。
const chatHarness = createProjectChatRuntimeHarness({
projectPath: CANVAS_PROJECT_PATH,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_project_revision') {
@@ -142,10 +161,33 @@ function installTauri(): { layoutWrites: LayoutWrite[] } {
if (command === 'list_local_project_asset_generations') {
return [];
}
return undefined;
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'inspect_local_project_directory') {
return {
projectPath: CANVAS_PROJECT_PATH,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: manifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'get_design_agent_runtime_mode') {
return null;
}
return chatHarness.invoke(command, args);
},
);
installTauriRuntime({ core: { invoke } });
installTauriRuntime({
core: { invoke },
event: { listen: chatHarness.listen },
});
return { layoutWrites };
}
@@ -187,21 +229,34 @@ function collectReferenceInserts() {
};
}
async function mountCanvas() {
const tauri = installTauri();
async function mountCanvas(
options: {
/** 项目附件(无 manifest 登记的素材来源)。 */
attachments?: ProjectAttachmentResult[];
/** 打开哪个栏目找卡片;未登记素材统一落在「待归类」。 */
categoryLabel?: string;
} = {},
) {
const { attachments = [], categoryLabel = '角色与对象' } = options;
const manifest = manifestFor('chat-reference-drop', [
characterAsset('drop-a', 'a.png'),
characterAsset('drop-b', 'b.png'),
]);
const tauri = installTauri(manifest);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/chat-reference-drop',
projectPath: CANVAS_PROJECT_PATH,
manifest,
attachments: [],
attachments,
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
// 对话面是真实链路:`App`(`directProjectMode`)→ `DirectProjectChatView` →
// `DirectProjectComposer`。桩接不住引用事件,链路断点正好在「谁把它落进草稿」,
// 所以这里不能再拿 `<div>项目总控</div>` 顶替。
chat: React.createElement(AppComponent, {
initialProjectPath: CANVAS_PROJECT_PATH,
}),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
@@ -222,7 +277,7 @@ async function mountCanvas() {
toJSON: () => ({}),
} as DOMRect);
act(() => window.dispatchEvent(new Event('resize')));
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
fireEvent.click(screen.getByRole('button', { name: `打开${categoryLabel}` }));
await settle();
const chat = document.querySelector<HTMLElement>('.game-workbench-chat')!;
vi.spyOn(chat, 'getBoundingClientRect').mockReturnValue(CHAT_RECT);
@@ -236,6 +291,20 @@ function cardIn(manager: HTMLElement, resourceId: string) {
)!;
}
/**
* 真实 DirectProject 输入盒草稿里的资源引用芯片,按 DOM 顺序读出 `resourceId`。
*
* 事件派发本身不是交付结果:链路断点一直是「谁把它落进草稿」,所以断言必须读草稿,
* 不能再读 window 上的一次 dispatch。
*/
function draftReferenceIds() {
return Array.from(
document.querySelectorAll<HTMLElement>(
'form.project-chat-composer [data-resource-reference-id]',
),
).map((chip) => chip.getAttribute('data-resource-reference-id') ?? '');
}
afterEach(() => {
resetTauriRuntime();
vi.restoreAllMocks();
@@ -281,6 +350,8 @@ describe('拖动素材到对话:批量 @ 引用', () => {
kind: 'character',
source: 'resource-card',
});
// 端到端:这一批引用真的落进了真实 DirectProject 输入盒的草稿。
await waitFor(() => expect(draftReferenceIds()).toEqual(['drop-a']));
// 拖到对话不是排版:一条坐标都不写。
expect(tauri.layoutWrites).toHaveLength(writesBefore);
expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull();
@@ -327,6 +398,10 @@ describe('拖动素材到对话:批量 @ 引用', () => {
.map((reference) => reference.resourceId),
),
).toEqual(new Set(['drop-a', 'drop-b']));
// 端到端:整批一次插进同一份草稿,顺序就是拖动集合(画布可见顺序)的顺序。
await waitFor(() =>
expect(draftReferenceIds()).toEqual(['drop-a', 'drop-b']),
);
expect(tauri.layoutWrites).toHaveLength(writesBefore);
dispose();
});
@@ -404,4 +479,81 @@ describe('拖动素材到对话:批量 @ 引用', () => {
expect(inserts).toHaveLength(0);
dispose();
});
it('未登记素材:不出现「引用」按钮,拖到对话栏只给原因', async () => {
// 导入的附件没有 manifest 登记(`manifestAssetId: null`),画布把它归到「待归类」。
const { manager, inserts, dispose } = await mountCanvas({
categoryLabel: '待归类',
attachments: [
{
fileName: 'raw-shot.png',
mediaType: 'image/png',
status: 'imported',
localPath: 'assets/raw-shot.png',
},
],
});
const card = cardIn(manager, 'attachment:assets/raw-shot.png');
expect(card).not.toBeNull();
fireEvent.click(card);
// 引用入口与「编辑 / 删除」同一条 `manifestAssetId` 判据:没登记就没有这枚按钮。
expect(screen.queryByRole('button', { name: /^引用资源/ })).toBeNull();
fireEvent.pointerDown(card, {
pointerId: 74,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(card, {
pointerId: 74,
buttons: 1,
clientX: 1000,
clientY: 300,
});
// 落点提示也说清原因,不承诺一次插不进去的引用。
expect(
screen.getByText('这些素材还没登记为项目资源,不能 @ 引用'),
).not.toBeNull();
fireEvent.pointerUp(card, {
pointerId: 74,
button: 0,
clientX: 1000,
clientY: 300,
});
await settle();
expect(
screen.getByText('选中的素材都还没登记为项目资源,暂时不能 @ 引用'),
).not.toBeNull();
expect(inserts).toHaveLength(0);
expect(draftReferenceIds()).toEqual([]);
dispose();
});
it('空批次引用事件不报「没有可用的聊天输入区」', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { dispose } = await mountCanvas();
// 派发器自己会挡掉空批次(`dispatchResourceReferenceInsertMany` 对空数组直接 return),
// 这里直接造事件:钉的是 App 侧不把「没有要插的东西」说成「没有输入区」。
act(() => {
window.dispatchEvent(
new CustomEvent(RESOURCE_REFERENCE_INSERT_MANY_EVENT, {
detail: { references: [] },
}),
);
});
await settle();
expect(
warn.mock.calls
.map((call) => String(call[0]))
.filter((message) => message.includes('没有可用的聊天输入区')),
).toEqual([]);
expect(draftReferenceIds()).toEqual([]);
warn.mockRestore();
dispose();
});
});
+1
View File
@@ -24,6 +24,7 @@
- [后台游戏评价管理合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同):查找、分页、隐藏/恢复/删除、必填原因、统计与个人状态联动;已实现并通过本地验证,待用户验收,未部署。
- [后台游戏评价管理里程碑](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。
- [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。
- [游戏游玩次数计数](./adr/【ADR】游戏游玩次数计数-2026-10-03.md):点「开始游戏」前端上报一次游玩,api-server 纯内存聚合(5s flush、30min 去重、`IP+game` 限流、关停不强制 flush),批量 procedure 自增现有 `game_distribution_game.play_count`,不 bump `updated_at`。
- [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md)
- [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。
- [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。
@@ -0,0 +1,143 @@
# 【ADR】游戏游玩次数计数
状态:已接受(2026-10-03)
## 背景
网站游戏分发已经展示「X 次游玩」:目录卡片、详情、作者「我的游戏」与后台「游戏管理」都读
`game_distribution_game.play_count`,契约里也有 `playCount`。但这条链路只读不写——游戏创建时置 `0`,
之后没有任何自增、reducer 或 procedure;因此所有游戏恒显示 `0` 次游玩。
主规范要求「不虚构评分、玩家数、排名和收藏状态」,里程碑与证据矩阵以「新游戏显示 0、目录数据来自真实
接口」作为无假统计的证据,却从未定义「一次游玩如何累加」。相关现状:
- **触发信号不存在**:游玩页 `/games/play` 先给封面与「开始游戏」,点击后才挂
`sandbox="allow-scripts"` 的 iframe;`startGame()` 纯本地状态,不发任何请求。AGC 客户端 12 类埋点白名单
不含「开始游玩」,主站路由埋点只有查看统计。
- **发行网关不能承担计数**:`serve_release_entry` / `serve_release_asset` 只按 `gameId` 服务当前公开版本
文件;一次加载会打多个资源请求,且按设计禁带平台 Cookie、拿不到会话身份。
- **平台没有匿名身份**:没有匿名访客 cookie,web 端没有 device/client id;`x-client-instance-id` 只在登录
链路采集。
- **没有现成的服务端缓冲管线可复用**:AGC 埋点管线是客户端本地 15 分钟一批、服务端同步原子入库且明确
不做聚合;真正的「内存缓冲 + 周期 flush + 关停 flush」范式是主站路由埋点 `tracking_outbox`。
- **部署与限流现状**:api-server 单实例;无 CSRF/Origin 中间件;应用层只有并发背压,按 IP 令牌桶在独立
的 `pingora-gateway`;现有缓存惯例是 std `OnceLock`/`LazyLock` + `Mutex`(无 `dashmap`/`moka`)。
## 决策
### 1. 触发点 = 游玩页点「开始游戏」
以用户点击「开始游戏」后的前端上报作为一次游玩。理由:这是产品定义的启动动作,游客可用;`iframe load`
只代表文档加载(规范已明确不能当业务状态),发行网关无法区分会话且会因资源请求重复。点击后即使 iframe
超时/未真正载入也计一次(用户意图)。
### 2. 落点 = 复用现有 `play_count`
`game_distribution_game.play_count` 已经是 `u64`、已进公开/后台 DTO、已进四处 UI。只新增写入路径,不改
字段语义、不新建计数表,避免双源。计数跟随游戏身份,不随发行版本。
### 3. 范围 = 只做累计总次数
不做日粒度 / 近 7 天热度、独立玩家数、榜单、推荐。`public_work_play_daily_stat` 属已退役自定义世界口径,
不复活。
### 4. api-server 纯内存缓冲 + 周期 flush
- **纯内存,不落盘**:崩溃、被杀和正常 SIGTERM/滚动重启都允许丢最后一个 flush 窗口;进程退出不做
force flush,关停路径不为计数等待网络(2026-10-04 修订,见「修订记录」)。
- **两张表**:增量表 `pending: HashMap<gameId, u64>`(5 秒级、flush 即清)与 30 分钟去重窗口表 `seen:
HashMap<identity + gameId, timestamp>`(30 分钟级、按 TTL 清理)。两者键不同、生命周期差 360 倍,不能合并:
合并会把 30 分钟窗口状态塞进 5 秒清空的表,或让 flush 需要按 gameId 重新聚合。
- **限流表**:`rate: HashMap<ip + gameId, (windowStart, count)>` 固定窗口。
- **并发**:请求路径只在短锁内做 HashMap 命中 + 自增;**flush 的网络调用移出锁外**。
### 5. 写入形状 = 批量 procedure
一次 flush 发一个 procedure,入参 `Vec<{gameId, delta}>`(按 500 分块),事务内逐条
`play_count = play_count.saturating_add(delta)`;procedure 内**原子校验**游戏当前为 `published` 且
`active_version_id` 存在,非公开跳过。**不 bump `updated_at`**:它只表示公开资料变更,且作者自有列表按它
排序,bump 会让每次游玩重排作者列表。
### 6. 失败语义 = 少计优于双计
只重试确定未发出的 `Build`;`Timeout` / `ConnectDropped` 无法判断是否已提交,直接丢弃该批并 `warn!` 记录
丢量。理由是:超时后重试会在"其实已提交"时造成系统性双计,而丢弃只是偶发少计——对一个非交易展示指标,
后者更可接受(perf 优先于 correctness)。
一次 flush 按 500 分块;任一分片失败即终止本次 flush 的后续分片,剩余增量直接丢弃(`Build` 只把当前分片
放回)。连接不通时剩余分片只会重复同样的失败,逐个重试会把 worker 卡在多次连接超时上(2026-10-04 补充)。
### 7. 接口
`POST /api/game-distribution/games/{game_id}/plays`,公开端点(可选 bearer):
- 不挂 `require_bearer_auth`;用 `optional_access_token_from_headers` 拿可选 `userId`。
- **不加 `Idempotency-Key`**(与其它游戏分发写路由惯例不同):高频计数用不上幂等收据,30 分钟去重窗口就是
护栏。
- 非公开 / 下架 / 封禁返回 `404` 且不计数;被限流返回 `429`;成功统一 `200 {recorded: bool}`。
- 前端 fire-and-forget,**任何失败静默、绝不阻断游玩**;不做发行网关兜底计数。
### 8. 身份与去重键
登录用 `userId`;匿名用前端 `localStorage` 持久随机 `clientId`(随请求体带上);两者都缺失时回退
`IP + UA`。30 分钟窗口按 `identity + gameId`。不新造匿名 cookie、不依赖登录后才有的设备指纹。`IP`
取 nginx 覆盖写入的 `X-Real-IP`(无 CDN 时即真实 TCP 对端),不取可伪造的 `X-Forwarded-For` 首段;
限流键与微信支付下单的 `payer_client_ip` 同源(2026-10-04 补充)。
### 9. 落位 = 纯持久化 / 读模型
procedure 在 `spacetime-module`,facade + mapper 在 `spacetime-client`,缓冲 / worker / 端点在
`api-server`;`module-game-distribution` 不动(自增不是领域规则,与既有「点赞计数」直接落持久化流程同构)。
新增 procedure 不改表,schema guard 不触发,但必须 `npm run spacetime:generate` 重生成绑定并同步文档。
### 10. 展示一致性 = 接受滞后
读路径(目录 / 详情)继续只读 DB,不叠加内存 pending;接受「flush 间隔 + 写库」的 ≤10 秒滞后。
## 影响与代价
- 计数非实时,最多一个 flush 窗口的滞后;崩溃 / kill 丢最后一个窗口;模糊传输错误少计。
- 匿名 `clientId` 可被清除 / 伪造,指标定位为展示用次数,仅靠限流兜底。
- 单实例前提:去重 / 限流窗口不跨实例;将来多实例时各实例自行 flush(加法幂等),窗口不共享。
- 不 bump `updated_at`,作者自有列表排序、公开修订 CAS 均不受影响。
- 新增 procedure 只改 ABI(绑定),不改表 schema / `migration.rs`。
## 备选方案与取舍
1. **发行网关服务端计数**:一次加载多资源请求会重复、禁 Cookie 拿不到会话、无法去重。已否决。
2. **复用 AGC 客户端埋点管线**:服务端同步入库且明确不做聚合,客户端 15 分钟批次,白名单不含游玩。已否决。
3. **落盘 / outbox 保可靠**:无持久性需求(崩溃丢窗口已接受),引入磁盘与独立目录要求。已否决。
4. **单张 map / 不做去重**:会重复计数;去重状态与增量生命周期不同。已否决。
5. **每个游戏一次 procedure**:N 次 WebSocket 往返。已否决。
6. **模糊失败重试**:超时已提交时系统性双计。已否决。
7. **匿名 HttpOnly cookie / 纯 IP 去重**:前者要新造 cookie 与跨端 / 沙箱处理,后者在 NAT 下把多人并成一人。
已否决。
## 明确不做
- 日粒度 / 近 7 天 / 独立玩家数 / 榜单 / 推荐。
- AGC 客户端界面埋点、外部 API / External OpenAPI 扩展。
- 服务端网关兜底计数、CSRF token、应用层全局按 IP 限流(沿用既有限流与网关能力)。
- 幂等收据表、双计补偿、跨实例窗口共享。
## 落地与验收
- 实施边界:`spacetime-module` 新增批量自增 procedure 与 `SpacetimeType`;`spacetime-client` facade +
mapper;`api-server` 新增计数模块(增量 / 去重 / 限流 / flush worker)、AppState 接线与公开
端点;前端 `gameDistributionClient` 增 `recordGamePlay` 并在 `startGame()` 触发。
- 权威文档同步:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(路由表与游戏分发合同节)、
`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`(`game_distribution_game` 的 procedure 与
写入口径)。
- 验收判据:新游戏 `0` → 游客点一次 ≤10s 内显示 `1`;同身份 30 分钟内重复点击不增、不同身份各 `+1`;
超限流 `429` 且不写;下架 `404` 不计数但历史值保留;SIGTERM 关停允许丢最后一个未落库窗口;计数接口任何失败都
不影响游玩页;相关 Rust / 前端定向测试与 schema / DDD / 绑定 / DTO parity / 编码 / doc-index 门禁全绿。
## 修订记录
- 2026-10-03:初版。
- 2026-10-04:关停不再强制 flush(原「正常 SIGTERM/滚动重启必须在 `finalize_shutdown` 内 force flush」
作废)。理由:关停时最后一个窗口丢失概率极低,而强制 flush 需要把 worker 生命周期接进关停顺序并为在途
网络写入等待;按"perf 与简单优先"取舍,直接放弃该窗口。同日明确 flush 任一分片失败即丢弃剩余分片。
- 2026-10-04:客户端 IP 解析改为优先 nginx 覆盖写入的 `X-Real-IP`,`X-Forwarded-For` 只作回退且取最后
一段(nginx 用 `$proxy_add_x_forwarded_for` 追加的真实对端),不再信任可伪造的首段——公开上报端点原来
用它做匿名身份与限流键,可被伪造 IP 绕过并灌水。无 CDN 前置时 `X-Real-IP` 即真实客户端。

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