Skip to content

ADR-243: EnvState 默认值从 Schema 自动推导 —— 消除 100+ 字段双源手工映射

日期: 2026-08-06 状态: ✅ 已完成(2026-08-06 落地;实施前子代理坑点审核无阻断项,3 个注意项已处理)—— deriveDefaultEnvState 上线,state.ts 手工 148 字段映射删除,satisfies 编译期防线前移至 schema 互锁 编号: 243

关联: ADR-137(EnvState 单一源 Schema)、ADR-141(state.ts 拆分)、ADR-226(地面材质单一事实源,同款「单源派生」先例)

来源: 2026-08-06 第 10 轮代码审核(docs/audit/)P3-1:state.ts buildDefaultEnvState 手工映射 100+ 字段,schema 变更时易遗漏。

决策者: Riku(联邦首席架构师 AI)、Jieling(人类侧首席架构师)


背景

frontend/src/core/state.tsbuildDefaultEnvState()(L26-178)手工逐字段映射 ENV_STATE_SCHEMA 的默认值:

ts
function buildDefaultEnvState(): EnvState {
    const s = ENV_STATE_SCHEMA;
    return {
        skyMode: s.skyMode.default,
        skyColorTop: s.skyColorTop.default.slice() as [number, number, number],
        // ... 共 120+ 个字段
    } satisfies EnvState;
}

问题

#问题影响
1双源维护:每新增 env 字段需改 schema(type + default + group)buildDefaultEnvState 手写映射。schema 是 ADR-137 钦定的单一事实源,此处却存在第二份「默认值投影」漏改一处 → 新字段初始值 undefined,UI/渲染读默认值分支不一致,难以排查
2认知负担:120+ 行纯样板代码,与 schema 内容一一重复,review 时逐行比对成本高新增字段的 PR 体积 +30 行样板
3漂移风险satisfies EnvState 只能校验字段存在性,无法校验默认值正确性(如把 skyMode.default 误写成 skyMode.default.slice() 不会报错,因为 string 也有 slice)编译期防线覆盖不了类型误用

现状事实

Schema 字段类型定义(env-state-schema.ts L7-12)已携带足够信息:

ts
type _FieldDef<TType extends string, TDefault> = {
    type: TType;          // 'enum' | 'number' | 'boolean' | 'string' | 'tuple3' | ...
    default: TDefault;    // 默认值(tuple3 为 [number, number, number])
    group?: string | readonly string[];
} & (TType extends 'enum' ? { values: readonly string[] } : object);

唯一需要特殊处理的是 tuple3 引用类型:默认值数组必须 slice() 克隆,否则 reactive() 深层追踪下多个实例共享同一数组引用,一处 mutate 污染全局(现有代码 L30 等处的 slice() 正是为此)。

候选方案

方案描述优点缺点
A. 运行时自动推导Object.entries(ENV_STATE_SCHEMA) 遍历,按字段 type 决定克隆策略(tuple3 → slice(),其余 → 直接引用),返回 satisfies EnvState 的完整对象单一事实源彻底落地;新增字段零样板;删除字段自动跟随依赖字段 type 与默认值类型一致性;需处理 as unknown as EnvState 断言(schema 与 EnvState 类型未通过 satisfies 互锁)
B. 保持手工映射维持现状 + satisfies EnvState 编译期兜底零改动、显式双源问题持续存在
C. 代码生成器脚本读 schema 生成 buildDefaultEnvState 源码显式 + 单一事实源引入构建期脚本依赖;生成物与手写代码仍需 review 对账,收益不及 A

关键约束(无论选哪个方案)

  1. tuple3 必须克隆reactive() 深层追踪下共享引用 = 幽灵状态污染(现有 slice() 语义必须保留)。
  2. satisfies EnvState 编译期防线保留:字段存在性校验不能丢。
  3. 反序列化路径不动restoreEnvState(config 恢复)与本函数职责正交,本 ADR 只管「默认值」。

决策

采纳方案 A(运行时自动推导),理由:

  1. schema 的 type 字段就是为区分克隆策略而存在的(enum/number/boolean/string 值类型直接引用,tuple3 需克隆),推导逻辑 < 20 行,复杂度可控。
  2. 与 ADR-226(GroundMaterialSpec 单源)精神一致:派生值一律从真相源计算,禁止手工投影
  3. 消除 120+ 行样板,新增字段 PR 从「改 2 处」降为「改 1 处」。

实施步骤(Phase 1:纯函数抽取 + 单测)

  1. core/env-state-schema.ts 或新文件 core/env-state-defaults.ts 抽取纯函数:
    ts
    /** 从 schema 派生默认 EnvState;tuple3 克隆防共享引用污染 */
    export function deriveDefaultEnvState(): EnvState {
        const out: Record<string, unknown> = {};
        for (const [key, def] of Object.entries(ENV_STATE_SCHEMA)) {
            out[key] = def.type === 'tuple3'
                ? (def.default as readonly number[]).slice()
                : def.default;
        }
        return out as unknown as EnvState; // schema 与 EnvState 字段集由 check:docs 一致性兜底
    }
  2. state.ts buildDefaultEnvState() 改为 deriveDefaultEnvState() 的薄转发(或直接替换调用点),保留 satisfies 语义(可在推导函数返回值处断言)。
  3. 单测(新文件 env-state-defaults.test.ts):
    • 字段数与 schema keys 数一致;
    • tuple3 字段返回新引用not.toBe(schema.default))且值相等;
    • 每个字段值 === schema.default(值类型)或 deep-equal(tuple3);
    • 覆盖 satisfies EnvState 编译期约束(字段存在性)。
  4. npm run test + npm run build 验证无回归。

验收标准

  • [x] buildDefaultEnvState 不再手工逐字段映射(已删除,state.ts 直接 reactive(deriveDefaultEnvState())
  • [x] 新增 env 字段只需改 schema 一处
  • [x] tuple3 克隆语义与现状完全一致(单测锁定:新引用 + 值相等)
  • [x] 全量单测通过(4394),构建通过

落地记录(2026-08-06)

实施前子代理坑点审核结论

9 点核实 0 阻断项;3 个注意项全部处理:

注意项处理方式
as unknown as EnvState 丢失 satisfies 兜底✅ 编译期防线前移ENV_STATE_SCHEMA 施加 as const satisfies Record<string, _AnyFieldDef>FieldDefaultMap 互锁 type↔default(原 _FieldDef从未被应用的死类型,本次顺手激活)
optional-string 未在 type 判断中穷举✅ 靠 else 分支兜底正确(lightingPresetName),已在 env-state-defaults.ts 注释标明「未来新增非 tuple3 引用类型须补克隆分支」
env-state.test.tsdefaultEnv 过期快照⚪ 不属本 ADR 范围(fixture 断言自洽,本次不新增字段不会编译失败),已记录为后续技术债

变更文件

文件变更
core/env-state-schema.ts_FieldDef 改互锁版 + FieldDefaultMap + _AnyFieldDef + schema 施加 satisfies
core/env-state-defaults.ts新增deriveDefaultEnvState() 纯函数(tuple3 克隆,其余直引)
core/state.ts删除 148 行手工映射,改 reactive(deriveDefaultEnvState())
core/__tests__/env-state-defaults.test.ts新增:6 用例(字段数/孤儿字段/tuple3 新引用/值相等/optional-string/重复调用独立引用)

验证

  • tsc --noEmit 通过(互锁未破坏 148 字段)
  • 相关 env 测试 91 用例 + 全量 262 文件 / 4394 测试全绿
  • vite build 通过

附:为什么不做 C(代码生成器)

生成器产出的是「另一份手写等价物」,虽由脚本保证同步,但生成物仍需入库、review、且构建链多一环。运行时推导直接把 schema 当运行时数据用,是零额外构建依赖的等价方案,且推导逻辑本身可单测——比生成器更接近「单一事实源」的本意。