docs: plan startup configuration source implementation

This commit is contained in:
2026-09-05 14:23:25 +08:00
parent 081d9e9b06
commit 76de9432ef
2 changed files with 212 additions and 1 deletions
@@ -0,0 +1,211 @@
# Startup Configuration and Visitor Sources Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 交付可供后续应用根消费的 local 启动配置、游客缓存与设备来源,并通过真实登录 builder 联合验证。
**Architecture:** 沿用唯一 resolveRuntimeConfig;开发用途的范围约束包裹其结果。纯 TS 来源输出现有 LoginAccountIdentity / LoginDeviceSnapshot,不改 Runtime、Presenter、GameEntry 或引擎入口。
**Tech Stack:** TypeScript、node:test、tsx、现有协议验证器;不新增依赖。
**Spec:** `docs/superpowers/specs/2026-09-05-startup-config-account-design.md`。
## Global Constraints
- 服务器、远程配置和原生契约不变;本批不发网络请求。
- 无 `.scene/.prefab/.anim/.meta` 写入,不打开或刷新隔离工程的 Creator。
- 不重新执行 3A,不启用旧 LoginFlow,不实现其他 RPC、房间或子游戏。
- 无 fallback 账号/身份/IP/地址;缓存缺失与损坏分开处理。
- 生产 defaults/profiles/identity 解析语义不修改;新 provider 不通过旧 DebugAccount 取值。
- 每项 RED → GREEN → 规格审查 → 质量审查 → 修复复验 → scoped commit。
- 禁止 scripts/run-framework-tests.mjs、npm test 和含生成器的 test:framework;显式选择测试文件。
- tsx ENOMEM 是环境失败,不是有效 RED;在允许环境用相同精确命令复跑。
## 工作区与验证命令
执行时创建新 `codex/startup-config-account` 分支及 `.worktrees/startup-config-account`,基于包含本计划的提交;先确认名称未占用。主 checkout 的 41 个新增和 2 个删除 meta 保留。仅将依赖 node_modules 接入隔离工作树,不共享 assets 或 Creator 缓存。Creator 当前打开主 checkout。
下文代码与测试路径相对 `cocoscreator_projects/`。F 表示 `YouleNexus/assets/framework/`,T 表示 `framework-tests/`;实际工具命令使用完整路径。
```powershell
node --import tsx --test --test-concurrency=1 framework-tests/config/local-startup.test.ts
node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit
node scripts/check-import-boundaries.mjs
git diff --check
```
先实测 Node/npm 路径和版本。npm shim 不可用时验证安装的 npm-cli.js 与 Node 配对,不修改个人配置。测试直接调用 Node,不因 npm 不可用跳过验证。
## Task 1: 本地启动边界与唯一 RuntimeConfig
**Files:** Create `F/config/local-startup.ts`; Test `T/config/local-startup.test.ts`。
**Interfaces:**
- Consumes `ResolveRuntimeConfigOptions`, `RuntimeConfig`, `PROFILES.local`。
- Produces `LocalStartupContext { readonly hostKind: 'h5'; readonly profile: 'local'; readonly config: RuntimeConfig }`。
- `resolveLocalStartup(options: ResolveRuntimeConfigOptions): Promise<LocalStartupContext>`。
- `assertLocalStartupContext(context: LocalStartupContext): void`。
- `assertLocalConnectionTarget(context: LocalStartupContext, url: string): void`。
- [ ] RED:创建测试,使用永远抛错的 fetcher 验证 local 解析不联网:
```ts
const options = { mode: 'debug' as const, hostKind: 'h5' as const,
win: {}, search: '?profile=local',
fetcher: { async fetch(): Promise<never> { throw new Error('forbidden fetch'); } } };
const context = await resolveLocalStartup(options);
assert.equal(context.config.identity.gameid, BUILD_IDENTITY.gameid);
assert.deepEqual(context.config.servers, PROFILES.local!.servers);
await assert.rejects(resolveLocalStartup({ ...options, search: '' }), /profile/);
await assert.rejects(resolveLocalStartup({ ...options, mode: 'release' }), /debug/);
```
- [ ] 运行上述 Task 1 命令,确认缺失模块为 RED。
- [ ] 实现:先检查 mode/hostKind,query 必须恰有一个 profile=local;mode 若出现只能恰有一个 debug;拒绝非空 gameconfig 及歧义重复控制参数,之后才调用一次 resolveRuntimeConfig。不重建或复制 identity。
```ts
const config = await resolveRuntimeConfig(options);
const context = Object.freeze({ hostKind: 'h5' as const, profile: 'local' as const, config });
assertLocalStartupContext(context);
return context;
```
context 校验 config.mode=debug、isDebugger=true、source=direct、gameserver/rawConfig=null;有效服务器列表须逐项严格等于 PROFILES.local.servers。解析每个 URL,协议只允许 ws:/wss:,hostname 限 localhost、127.0.0.0/8、[::1],禁止凭据。连接目标必须既在权威列表中又满足上述检查;不重复定义端口/地址。
- [ ] RED/GREEN 逐项增加:native/release/prod/staging/mode 冲突、重复 query 拒绝且 fetch 调用为零;query 身份覆盖、version 数字、gameid 不可覆盖;伪造 context/远程切服目标拒绝;原 native 缺失身份测试继续通过。对错误字段抛有字段名的 Error。
- [ ] 执行 Task 1、既有 config/runtime-config.test.ts、类型与导入检查;规格/质量审查通过后只提交本任务文件。
## Task 2: 游客账号生成与独立缓存
**Files:** Create `F/adapters/local/local-storage.ts`, `F/adapters/local/visitor-account.ts`; Test `T/adapters/local/visitor-account.test.ts`。
**Interfaces:**
- `LocalStoragePort { getItem(key: string): string | null; setItem(key: string, value: string): void }`。
- storage 模块定义并导出 `localAccountKey(context: LocalStartupContext): string`、`LOCAL_MACHINE_KEY: string`;前缀分别 `youle.local-test.account.v1:`、`youle.local-test.machine.v1`。
- `createLocalVisitorProvider({ context, storage, random, isSessionActive }): LocalVisitorProvider`,依赖均必需。
- `LocalVisitorProvider { getAccount(): Promise<LoginAccountIdentity>; resetAccount(): Promise<LoginAccountIdentity> }`;isSessionActive 为 `() => boolean`,只用于拒绝活动会话内 reset。
- [ ] RED:测试中用 Map 实现存储,注入计数随机函数,调用并发 getAccount:
```ts
let randomCalls = 0;
const provider = createLocalVisitorProvider({ context, storage,
random: () => { randomCalls++; return 0.5; }, isSessionActive: () => false });
const [first, second] = await Promise.all([provider.getAccount(), provider.getAccount()]);
assert.strictEqual(first, second);
assert.equal(first.openid, 'testopenid_50000');
assert.equal(first.unionid, 'ylgame50000');
assert.equal(first.sex, 2);
assert.equal(randomCalls, 3);
```
- [ ] Run `node --import tsx --test framework-tests/adapters/local/visitor-account.test.ts`,确认模块缺失 RED。
- [ ] 实现唯一 scope key:JSON.stringify([context.profile, config.servers, identity.agentid, identity.gameid, identity.channelid, identity.marketid]);不包括 version。构造 provider 先 assertLocalStartupContext。
- [ ] 从原 05_Func.js simulatePlayerInfo 逐字迁移十项头像 URL 到 visitor 来源内唯一列表;不复制源码取值错误。三次随机分别生成编号、头像索引、性别,每次验证 finite 且 0≤r<1。
```ts
const id = Math.floor(sample(random) * 100000);
const avatar = AVATARS[Math.floor(sample(random) * AVATARS.length)]!;
const sex = Math.floor(sample(random) * 2) + 1;
const account = Object.freeze({ openid: `testopenid_${id}`, unionid: `ylgame${id}`,
nickname: `${id}_游客`, avatar, sex, province: 'jiangxi', city: 'nanchang' });
storage.setItem(key, JSON.stringify({ schemaVersion: 1, account }));
return account;
```
- [ ] 读取使用 schemaVersion=1 且准确记录结构;account 七字段完整、字符串/sex 类型正确,openid/unionid 不为空,nickname/avatar 不能同时为空。记录不接受未知字段或旧 headimgurl/Province 格式,不猜版本。错误带缓存键及字段路径,保留原错误原因;不泄露完整账号缓存文本。
- [ ] 对 getAccount 缓存一个进行中的 Promise;finally 清除 pending,读取成功可返回冻结账号;reset 与 pending 冲突明确拒绝。reset 前检查 isSessionActive;生成与校验成功后直接 setItem 替换,不先删旧值,失败保留存储原值。
- [ ] RED/GREEN:上下随机边界、NaN/Infinity/负数/1 拒绝;损坏 JSON/缺字段/schema 错误拒绝且不写;读写异常;跨 scope 隔离、数字/字符串 marketid 区别、version 改动复用;reset 只写当前账号键,活动会话拒绝,不操作旧产品键/机器键。复用时不随机化。
- [ ] 测试、类型与导入检查、规格/质量审查,通过后 scoped commit。
## Task 3: 设备缓存与同一次登录采样
**Files:** Create `F/adapters/local/login-sources.ts`; Test `T/adapters/local/login-sources.test.ts`。
**Interfaces:**
- `LocalHostSnapshot { readonly location: unknown; readonly returnCitySN?: unknown }`;只将 undefined 视为无 returnCitySN,null/错误对象显式拒绝。
- `createLocalLoginSources({ context, storage, random, now, sampleHost, isSessionActive }): LocalLoginSources`;random/now 为 `() => number`,sampleHost 为 `() => LocalHostSnapshot`,其他依赖同 Task 2。
- `LocalLoginSources { prepareLogin(): Promise<LoginAccountIdentity>; getLoginDeviceSnapshot(): LoginDeviceSnapshot; resetAccount(): Promise<LoginAccountIdentity> }`。
- 内部复用 Task 2 provider;getLoginDeviceSnapshot 在成功 prepare 前、失败 prepare 后或 reset 后显式拒绝。prepare 并发合并,reset 与 prepare 冲突拒绝。
- [ ] RED:注入 sampleHost 计数,检查 account 和 device 使用同次输入:
```ts
let samples = 0;
const sources = createLocalLoginSources({ context, storage, random: () => 0.5,
now: () => 123456, isSessionActive: () => false,
sampleHost: () => { samples++; return { location: null,
returnCitySN: { ip: '10.0.0.2', province: 'P', city: 'C' } }; } });
assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i);
const account = await sources.prepareLogin();
assert.equal(account.province, 'P');
assert.equal(samples, 1);
assert.equal(sources.getLoginDeviceSnapshot().machineid, '123456-5499');
```
- [ ] Run `node --import tsx --test framework-tests/adapters/local/login-sources.test.ts`,确认 RED。
- [ ] 实现机器缓存:使用 LOCAL_MACHINE_KEY,在存储所绑定的 origin 内复用;存储记录 schemaVersion=1、machineid 字符串。缺键才调用 now/random,now 要求非负安全整数,随机遵循 Task 2 规则(抽公共 sample helper 到 local-storage.ts,避免重复实现)。拒绝损坏记录/非法机器值,保留键名与原错误。
```ts
const machineid = `${timestamp}-${1000 + Math.floor(sample(random) * 8999)}`;
storage.setItem(LOCAL_MACHINE_KEY, JSON.stringify({ schemaVersion: 1, machineid }));
```
- [ ] 在所有异步账号准备完成后调用 sampleHost 一次;location 必须显式存在且为 null/record。returnCitySN 存在时要求 ip/province/city 为 string,形成冻结的新 account;不反写 account 缓存。location 原引用透传,不复制未知协议结构。
```ts
const device: LoginDeviceSnapshot = Object.freeze({ location: snapshot.location,
machineid, machineroom: '', deviceLogin: Object.freeze({ enabled: false as const }),
loginPlayerId: Object.freeze({ enabled: false as const }),
...(city === undefined ? {} : { ip: city.ip }) });
```
- [ ] prepare 开始先使旧设备快照失效,成功时原子保存新 device 并返回 account;失败不留可发送快照。reset 也使快照失效,但不改变机器键。
- [ ] RED/GREEN:缺 returnCitySN 省略 ip;非法/缺 location 与非法 city 字段拒绝;宿主只采样一次;机器持久复用/损坏/写失败;prepare 后宿主改变不污染当前 ip/省市;reset 不改机器;并发合并与 reset 冲突;第二次 prepare 失败不返回第一次旧 device。
- [ ] 测试、类型与导入检查、规格/质量审查,通过后 scoped commit。
## Task 4: 来源到真实协议 builder 联合验收
**Files:** Create `T/integration/local-login-sources.test.ts`; Modify 本计划完成记录。
**Interfaces:** 只消费 Tasks 1–3 与既有 `buildLoginRequest(context.config, account, device)`,无新增 Runtime/SDK API。
- [ ] 增加真实 resolver → sources → builder 联合用例,测试中显式模拟 Storage,不使用引擎或真实网络:
```ts
const account = await sources.prepareLogin();
const envelope = buildLoginRequest(context.config, account, sources.getLoginDeviceSnapshot());
assert.equal(envelope.app, 'youle');
assert.equal(envelope.route, 'agent');
assert.equal(envelope.rpc, 'player_login');
assert.equal(envelope.data.gameid, BUILD_IDENTITY.gameid);
assert.equal(typeof envelope.data.version, 'number');
assert.equal(Object.hasOwn(envelope.data, 'playerid'), false);
assert.equal(Object.hasOwn(envelope.data, 'telphone'), false);
assert.equal(Object.hasOwn(envelope.data, 'ip'), false);
```
- [ ] Run `node --import tsx --test framework-tests/integration/local-login-sources.test.ts`。若首次已绿,不制造伪 RED;针对有 returnCitySN 时省市覆盖的临时变异验证测试会失败,立即恢复 TS。
- [ ] 覆盖真实 resolveIdentity 的 query version/渠道值传入唯一 envelope、缓存 version 升级复用、未知/损坏账号绝不进入 builder;不新增房间或测试 GameEntry。
- [ ] 静态检查新增来源不依赖 cc/旧 Session/NetClient/RoomRPCBus,不导出到 SDK;代码没有重复定义 BUILD token、WS endpoint 或协议超时。不要为这项检查修改旧代码。
- [ ] 审阅纯 TS 测试无资源生成副作用后执行:
```powershell
$startupTests = @(rg --files framework-tests -g '*.test.ts')
if ($startupTests.Count -eq 0) { throw 'No TypeScript tests selected' }
node --import tsx --test --test-concurrency=1 @startupTests
node --test framework-tests/architecture/import-boundaries.test.mjs framework-tests/architecture/presentation-boundaries.test.mjs
node node_modules/typescript/bin/tsc -p tsconfig.framework.json --noEmit
node scripts/check-import-boundaries.mjs
git diff --check
git diff --name-only BASE_SHA HEAD
git status --short
```
BASE_SHA 使用隔离 worktree 实际执行基线,不原样执行占位参数。核对从该基线开始全部 committed/uncommitted/untracked 无资源文件变化;主 checkout 原有 meta 内容哈希不变。
- [ ] 规格与质量审查通过,记录实际命令/退出码/计数/基线和 head,scoped commit 测试及完成记录。随后整分支审查;不自动合并、切编辑器或宣称真实登录成功。
## 执行交接
用户已指定本会话采用 subagent-driven-development,不再询问执行方式。计划自审完成后执行 Task 1;后续 Task 按依赖串行实施,每项完成两阶段审查。
真实 Runtime 宿主、GameEntry 隔离、Cocos 资源/展示时长和本地服务器接受身份的证据属于本设计已说明的后续门槛。此计划交付来源模块,不填空加载器、不发旧脚本请求来跨过门槛。