《移动游戏产品的架构·客户端与移动运行时》5.1 配置 schema 与版本化

配置是客户端与玩法之间的层间契约,不是策划附件。本节用纯 TypeScript 为关卡、卡牌与活动表定义 schema,把字段类型、引用目标、可读层与兼容窗口写成可执行约束;再用 schema 版本与客户端构建号解析出每个玩家应加载的那一版,并给出旧版本退役前双版本共存的发布顺序。

事故:字段改名当天,两个版本同时读不了配置

《潮汐方舟》的案例设定里有一次典型事故:活动表把布尔字段 enabled 改成枚举 status,配置平台只发布了新表。老客户端仍按 enabled 判断活动是否开放,活动入口直接消失;新客户端按 status 读取,但运营给部分活动填了 enabled 时代的值,结算时拿不到奖励。整个过程没有崩溃,JSON 也能正常解析,问题出在配置没有契约。

这类事故的共同点是:配置被当成策划附件,而不是客户端的一部分。附件可以随时替换,契约不能。契约要回答四件事:字段是什么类型、允许什么范围、引用哪张表的哪一行、哪一层代码可以读。只要有一项没写进机器可校验的地方,线上就会用玩家的设备来补这个缺口。

配置是层间契约,不是策划附件

配置的所有者是系统层。玩法层读取规则配置来推进状态机,表现层读取展示配置来渲染界面,平台层只提供文件和网络能力,不能反过来读玩法配置。这个方向如果破了,配置就会变成绕过分层的后门。

配置表所有者允许读取典型字段改动风险
关卡表系统层玩法层、表现层章节、推荐战力、敌方卡牌、下一关引用错会让关卡无法进入或无法结算
卡牌表系统层玩法层、表现层稀有度、费用、攻击、元素、进化目标枚举或数值越界会改变战斗结果
活动表系统层玩法层、表现层活动类型、起止时间、主推卡牌、状态时间窗口或状态错会漏发或错发奖励

把「允许读取」写进 schema,而不是写在文档里,才能在评审时发现越权。比如平台层为了判断网络环境去读活动表,这在代码里可能只多一行 import,在依赖图上却是一条向上依赖。schema 注册时直接拒绝这种表定义,比上线后排查便宜得多。

schema 描述什么

一个够用的配置 schema 不需要完整 JSON Schema 的表达力。关卡、卡牌、活动这三类表,绝大多数错误集中在六种字段约束上:

字段种类例子拦住的错误
string卡牌 id空 id、数字被导成字符串
int攻击力、章节号小数、负数、超出策划填表范围
bool是否启用表格里填了 1、yes、空白
enum稀有度、元素新值未进白名单,老客户端不认识
ref下一关、进化目标指向已删除或不存在的行
ref-list敌方卡牌列表列表里混入空值或重复项

这里刻意不复用第三方校验库。原因是配置校验必须能在打包机、配置平台和客户端三处跑出同样结果,依赖越少越容易对齐;同时 schema 本身要进代码评审,字段增删和版本号必须和客户端改动一起被看到。纯 TypeScript 的常量对象还有一个好处:它能被类型系统引用,但又不要求在运行时加载任何东西。

三个版本号不要混用

配置相关至少有三种版本,混用会让「回滚」变成一句空话。

版本含义谁负责递增回滚单位
schema 版本表结构、字段含义、引用关系客户端与配置平台共同评审一整套表结构
数据版本同一结构下的具体内容配置平台每次发布一份配置产物
客户端构建号安装包能理解到哪一版 schema客户端发版一个安装包

兼容窗口由客户端构建号定义:[minClientBuild, retiredAtClientBuild)。构建号低于下限的客户端读不了这版 schema;达到退役构建号的客户端不再接受旧 schema。两者之间是共存区,配置平台需要同时保留新旧两版,注册表按构建号解析出「这个玩家能读的最新一版」。

发布顺序必须是「先扩后收」:先让配置兼容老客户端,再让新客户端覆盖到发布门槛,最后才退役旧 schema。反过来做,就会出现新配置已经上线、老客户端还在跑的局面,任何字段改名都会变成一次线上事故。

可运行模型:schema.ts

下面的 schema.ts 把表结构、字段约束、层归属和兼容窗口写成一个注册表。它不依赖引擎、文件或网络,测试里用构建号直接验证解析结果。这里是模型,不代表真实设备行为;真实项目还要把这份 schema 与配置平台的导出器对齐。

// file: src/config/schema.ts
// tier: L1
// verify: node --test test/config/schema.test.ts

import type { Layer } from "../core/layers.ts";
import { canDependOn } from "../core/layers.ts";
import { fail, ok, type Result } from "../core/result.ts";

export type ConfigTableName = "level" | "card" | "activity";

export type FieldKind = "string" | "int" | "bool" | "enum" | "ref" | "ref-list";

export interface FieldSpec {
  readonly kind: FieldKind;
  readonly required: boolean;
  readonly min?: number;
  readonly max?: number;
  readonly values?: readonly string[];
  readonly table?: ConfigTableName;
}

export interface TableSchema {
  readonly table: ConfigTableName;
  readonly primaryKey: string;
  readonly ownerLayer: Layer;
  readonly readableBy: readonly Layer[];
  readonly fields: Readonly<Record<string, FieldSpec>>;
}

const ID: FieldSpec = { kind: "string", required: true };

export const LEVEL_V2: TableSchema = {
  table: "level",
  primaryKey: "id",
  ownerLayer: "system",
  readableBy: ["gameplay", "presentation"],
  fields: {
    id: ID,
    chapter: { kind: "int", required: true, min: 1, max: 12 },
    recommendedPower: { kind: "int", required: true, min: 1, max: 1_000_000 },
    enemies: { kind: "ref-list", required: true, min: 1, max: 6, table: "card" },
    bossCardId: { kind: "ref", required: true, table: "card" },
    nextLevelId: { kind: "ref", required: false, table: "level" },
  },
};

export const CARD_V2: TableSchema = {
  table: "card",
  primaryKey: "id",
  ownerLayer: "system",
  readableBy: ["gameplay", "presentation"],
  fields: {
    id: ID,
    rarity: { kind: "enum", required: true, values: ["normal", "rare", "epic", "legendary"] },
    cost: { kind: "int", required: true, min: 0, max: 10 },
    power: { kind: "int", required: true, min: 1, max: 9_999 },
    element: { kind: "enum", required: true, values: ["tide", "ember", "gale", "stone"] },
    evolvesTo: { kind: "ref", required: false, table: "card" },
  },
};

export const ACTIVITY_V2: TableSchema = {
  table: "activity",
  primaryKey: "id",
  ownerLayer: "system",
  readableBy: ["gameplay", "presentation"],
  fields: {
    id: ID,
    kind: { kind: "enum", required: true, values: ["login", "season", "shop"] },
    startAtMs: { kind: "int", required: true, min: 0 },
    endAtMs: { kind: "int", required: true, min: 0 },
    featuredCardId: { kind: "ref", required: true, table: "card" },
    enabled: { kind: "bool", required: true },
  },
};

export const LEVEL_V3: TableSchema = {
  table: "level",
  primaryKey: "id",
  ownerLayer: "system",
  readableBy: ["gameplay", "presentation"],
  fields: {
    id: ID,
    chapter: { kind: "int", required: true, min: 1, max: 12 },
    recommendedPower: { kind: "int", required: true, min: 1, max: 1_000_000 },
    enemyCardIds: { kind: "ref-list", required: true, min: 1, max: 6, table: "card" },
    bossCardId: { kind: "ref", required: true, table: "card" },
    nextLevelId: { kind: "ref", required: false, table: "level" },
  },
};

export const CARD_V3: TableSchema = {
  table: "card",
  primaryKey: "id",
  ownerLayer: "system",
  readableBy: ["gameplay", "presentation"],
  fields: {
    id: ID,
    rarity: { kind: "enum", required: true, values: ["common", "rare", "epic", "legendary"] },
    cost: { kind: "int", required: true, min: 0, max: 10 },
    attack: { kind: "int", required: true, min: 1, max: 9_999 },
    element: { kind: "enum", required: true, values: ["tide", "ember", "gale", "stone"] },
    evolvesTo: { kind: "ref", required: false, table: "card" },
  },
};

export const ACTIVITY_V3: TableSchema = {
  table: "activity",
  primaryKey: "id",
  ownerLayer: "system",
  readableBy: ["gameplay", "presentation"],
  fields: {
    id: ID,
    kind: { kind: "enum", required: true, values: ["login", "season", "shop"] },
    startAtMs: { kind: "int", required: true, min: 0 },
    endAtMs: { kind: "int", required: true, min: 0 },
    featuredCardId: { kind: "ref", required: true, table: "card" },
    status: { kind: "enum", required: true, values: ["draft", "live", "ended"] },
  },
};

export interface SchemaSet {
  readonly version: number;
  readonly tables: Readonly<Record<ConfigTableName, TableSchema>>;
  readonly minClientBuild: number;
  readonly retiredAtClientBuild: number | null;
}

export const ARK_SCHEMA_V2: SchemaSet = {
  version: 2,
  tables: { level: LEVEL_V2, card: CARD_V2, activity: ACTIVITY_V2 },
  minClientBuild: 10_000,
  retiredAtClientBuild: 10_700,
};

export const ARK_SCHEMA_V3: SchemaSet = {
  version: 3,
  tables: { level: LEVEL_V3, card: CARD_V3, activity: ACTIVITY_V3 },
  minClientBuild: 10_500,
  retiredAtClientBuild: null,
};

export type CompatState = "supported" | "deprecated" | "unsupported";

export interface CompatVerdict {
  readonly accepted: boolean;
  readonly state: CompatState;
  readonly reason: string;
}

export function acceptsClient(set: SchemaSet, clientBuild: number): CompatVerdict {
  if (!Number.isInteger(clientBuild) || clientBuild < 0) {
    throw new RangeError(`client build must be a non-negative integer: ${clientBuild}`);
  }
  if (clientBuild < set.minClientBuild) {
    return {
      accepted: false,
      state: "unsupported",
      reason: `build ${clientBuild} is older than schema v${set.version} minimum ${set.minClientBuild}`,
    };
  }
  if (set.retiredAtClientBuild !== null && clientBuild >= set.retiredAtClientBuild) {
    return {
      accepted: false,
      state: "unsupported",
      reason: `schema v${set.version} retired at build ${set.retiredAtClientBuild}`,
    };
  }
  if (set.retiredAtClientBuild !== null) {
    return {
      accepted: true,
      state: "deprecated",
      reason: `schema v${set.version} retires at build ${set.retiredAtClientBuild}`,
    };
  }
  return { accepted: true, state: "supported", reason: `schema v${set.version} is current` };
}

export function layerViolations(set: SchemaSet): readonly string[] {
  const violations: string[] = [];
  for (const schema of Object.values(set.tables)) {
    if (schema.ownerLayer !== "system") {
      violations.push(`${schema.table} must be owned by system, got ${schema.ownerLayer}`);
    }
    for (const reader of schema.readableBy) {
      if (reader !== schema.ownerLayer && !canDependOn(reader, schema.ownerLayer)) {
        violations.push(`${schema.table} reader ${reader} may not depend on ${schema.ownerLayer}`);
      }
    }
  }
  return violations;
}

export class SchemaRegistry {
  readonly #sets: SchemaSet[] = [];

  register(set: SchemaSet): Result<SchemaSet, string> {
    if (!Number.isInteger(set.version) || set.version < 1) {
      return fail("invalid-version", `schema version must be a positive integer: ${set.version}`);
    }
    if (this.#sets.some((candidate) => candidate.version === set.version)) {
      return fail("duplicate-version", `schema v${set.version} is already registered`);
    }
    const violations = layerViolations(set);
    if (violations.length > 0) {
      return fail("layer-violation", violations.join("; "));
    }
    this.#sets.push(set);
    return ok(set);
  }

  list(): readonly SchemaSet[] {
    return [...this.#sets].sort((a, b) => a.version - b.version);
  }

  resolve(clientBuild: number): Result<SchemaSet, string> {
    const accepted = this.#sets
      .filter((set) => acceptsClient(set, clientBuild).accepted)
      .sort((a, b) => b.version - a.version);
    const newest = accepted[0];
    if (newest === undefined) {
      return fail("no-schema", `no schema accepts client build ${clientBuild}`);
    }
    return ok(newest);
  }
}

export function createArkRegistry(): SchemaRegistry {
  const registry = new SchemaRegistry();
  registry.register(ARK_SCHEMA_V2);
  registry.register(ARK_SCHEMA_V3);
  return registry;
}

配套测试 test/config/schema.test.ts 覆盖兼容窗口的闭开边界、注册表解析、重复版本拒绝、层依赖违规和 v2/v3 共存。运行命令是 node --test test/config/schema.test.ts。

双案例:一周活动与两周版本

消消的活动配置字段少、生命周期短,schema 通常只有一两个版本在跑,出错的影响面是一周活动。它更需要的是「改完当天能发」,而不是复杂的兼容矩阵。

方舟的卡牌、关卡、活动互相引用,客户端又有多个构建在线上,schema 必须同时支持老包和新包。它的配置发布要按构建号解析版本,任何字段改名都要跨两个发版周期。

与相邻章节的关系

2.1 定义了四层与依赖方向,2.2 说明了玩法内核为什么必须是纯逻辑;本节把「谁能读配置」变成 schema 里的可执行字段。5.2 会在这套 schema 上做校验、引用完整性与灰度判据,5.3 再把策划工作流串成发布流水线。配置驱动的数据表设计可以参考配置驱动的数据表 ,分层职责可以参考分层职责 。

小结

  • 配置是层间契约:类型、范围、引用和读取层都要写进 schema,而不是写在文档里。
  • schema 版本描述结构,数据版本描述内容,客户端构建号描述理解能力,三者不能混用。
  • 兼容窗口是闭开区间;注册表按构建号返回玩家能读的最新一版,旧版本在窗口内继续保留。
  • 发布顺序是先扩后收:配置先兼容老客户端,新客户端覆盖达标后再退役旧 schema。
  • 消消可以接受更短的兼容窗口,方舟必须按多构建共存来设计。

阅读导航:上一节:4.3 杀进程与异常退出的兜底 · 下一节:5.2 校验、引用完整性与灰度 。

常见错误与边界

  • 把配置当附件,字段改名只改导出器。老客户端不会因为配置平台升级而升级;字段改名必须同时提升 schema 版本并保留旧字段一个窗口。
  • 只用一个版本号同时表示结构和内容。结构没变但内容回滚时,版本号会误导客户端重新走迁移;结构和数据必须分开。
  • 兼容窗口只写开始不写结束。没有退役构建号,旧 schema 会永久留在代码里,最终没人敢删。
  • 允许平台层读玩法配置。这是向上依赖,会让配置成为绕过分层的通道;schema 注册时应直接拒绝。
  • 边界:本节只描述 schema 与解析,不做真实文件加载、热更和配置平台对接。这些属于 5.2、5.3 与资源管线章节的范围。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查