两套热更清单,同一组红线
《潮汐消消》的总资源是 480MB,资源文件数 12,800;热更双周一次,平均 12MB,内容只有活动配置、关卡、皮肤与文本,不改玩法规则与付费点,配置回滚窗口 30 分钟。
《潮汐方舟》的总资源是 1,840MB,资源文件数 86,000;热更每周一次,平均 45MB,内容覆盖战斗数值、赛季配置、活动、UI 资源与语音。代码热更只开在 Android,iOS 只走资源与配置;战斗数值的回滚窗口是 15 分钟。
两个产品的体量、节奏与内容都不同,但共享同一组红线:不得通过热更改支付流程、抽奖概率、核心玩法规则,也不得热更原生权限与隐私声明相关逻辑。体量决定「一次能发多少」,内容决定「能不能发」,红线决定「永远不能发」。
四个区:允许、复核、阻断、商店
把内容判定分成四个区,比「能热更 / 不能热更」的二元判断更可操作。
| 区 | 含义 | 消消例子 | 方舟例子 |
|---|---|---|---|
| 允许 allow | 内容变更,不改变玩法与付费 | 活动配置、关卡、皮肤、文本 | UI 资源、语音 |
| 复核 review | 可热更,但必须有人签字并有回滚方案 | 无代码热更,极少使用 | 战斗数值、赛季配置、Android 脚本 |
| 阻断 blocked | 红线内容,不得通过热更下发 | 支付流程、抽奖概率、核心规则 | 同左,另含权限与隐私逻辑 |
| 商店 store | 技术上必须随整包发布 | 原生 SDK | 原生代码、权限、iOS 脚本 |
四区的关键区别在失败后果。允许区出错,回退资源即可;复核区出错,要在 15 分钟或 30 分钟内回滚;阻断区不是「回滚得快就能做」,而是根本不能进入热更通道;商店区不是「更安全」,而是「更慢、差量更大」,所以不能把所有东西都推给商店。
消消的白名单为什么窄
消消没有任何代码热更能力,脚本随商店整包发布。这个选择让白名单很窄,但也让边界非常清楚:线上热更只能改内容,不能改行为。
活动配置调的是开放时间、展示文案与奖励展示;关卡调的是布局、怪物配置与掉落表;皮肤与文本是纯表现层。它们共同的属性是:即使配置写错,最坏结果是活动开错时间或关卡难度异常,不会改变「玩家怎么玩」与「玩家怎么付费」。
最容易被误判的是关卡。改一张地图的布局、怪物数量与掉落,属于内容;改「什么条件判定通关」「失败是否扣体力」「奖励如何计算」,属于核心玩法规则。前者可以热更,后者必须走商店。判断时不要看文件放在哪个目录,要看这段配置是否参与规则判定。
30 分钟的回滚窗口对消消是够用的,因为变更集中在配置与资源,回滚只需要切回上一份已校验清单并让客户端重新拉取。窗口不是凭空承诺的,它由变更类型决定;如果消消也开放代码热更,30 分钟就未必成立。
方舟的灰名单:数值、赛季与脚本
方舟的热更内容比消消宽,但宽出来的部分几乎都落在复核区。战斗数值、赛季配置与 Android 脚本都可以热更,前提是有人签字、有回滚方案、能在 15 分钟内退回去。
战斗数值是典型的灰名单。它必须能快速调整,否则一次平衡性问题要等 6 周的商店整包;但它又直接影响付费角色的价值与对战公平,不能让数值策划单独决定。复核人必须是能对结果负责的人,不能填一个团队别名。
赛季配置的风险在时间边界。赛季切换、奖励结算、段位重置都发生在固定时刻,配置错误会让一整个赛季的开局异常。复核时要额外确认生效时间、时区与结算顺序,不能只看数值本身。
Android 脚本进入复核区,iOS 脚本直接进入商店区。同一段逻辑在两个平台走不同通道,评审记录必须写清楚差异,否则下次维护时无法解释为什么 Android 已经修了、iOS 还没修。
UI 资源与语音属于允许区。它们改变的是表现,不改变规则与付费;即使资源损坏,也可以回退到上一份已校验版本。这也是资源热更成为周更主体的原因:量大、风险低、回滚简单。
共同红线:四类内容不可热更
四条红线不是技术限制,而是产品与合规约束。
支付流程。价格、币种、订单状态机、发货条件都属于商店审核与消费者权益保护的范围。用热更改支付触发条件,会让线上行为与审核材料不一致,也会让退款与客诉失去可追溯性。
抽奖概率。概率公示是监管要求,也是玩家信任的基础。哪怕只是把概率表从一个配置文件换成另一个,也构成对已公示内容的改变,必须走商店版本并重新公示。
核心玩法规则。胜负判定、匹配规则、经济产出结构属于产品身份。修一个数值 bug 与改一套规则是两件事:前者可以热更,后者必须让玩家在版本说明里看到。
原生权限与隐私声明。权限申请时机、SDK 初始化、数据采集范围都写在隐私声明与商店数据安全表单里。动态下发这类逻辑,等于绕过用户同意与平台审查。
红线对配置同样有效。一张概率表、一个支付开关、一段规则脚本,在文件层面都是数据;只要它承载上述四类语义,就不能进入热更通道。判断依据是语义,不是扩展名。
审批与回滚方案是边界的一部分
复核区不是「填个名字就能发」。两个条件缺一不可:具名复核人,以及可执行的回滚方案。
具名复核人意味着责任可追溯。团队别名看起来更整齐,但出问题时找不到是谁判断的。复核人不需要是最高负责人,但必须理解这次变更对玩法、付费与玩家的影响。
回滚方案要能回答四个问题:回退到哪个版本、由谁触发、触发后多久生效、怎么确认已经恢复。只写「可回滚」不算方案;没有上一份已校验版本的指针,回滚时就只能重新下载,15 分钟窗口会被下载时间吃掉。
这两个条件也是审计字段。发布单里保留复核人与回滚方案,事后复盘时才能区分「判断错误」与「流程缺失」。前者需要改规则,后者需要改工具。
实现
下面的文件把四区判定写成可执行审计。输入是一批内容条目与一份产品策略,输出每条内容的区、回滚分钟数与原因;缺少复核人或回滚方案的复核项会被降级为阻断。
// file: src/hotfix/boundary.ts
// tier: L1
// verify: node --test test/hotfix/boundary.test.ts
export type Platform = "android" | "ios";
export type Surface = "config" | "asset" | "script" | "native";
export type Domain =
| "activity"
| "level"
| "cosmetic"
| "text"
| "battle-numbers"
| "season"
| "ui"
| "voice"
| "payment"
| "gacha-odds"
| "core-rules"
| "privacy";
export type Zone = "allow" | "review" | "blocked" | "store";
export interface BoundaryEntry {
readonly id: string;
readonly domain: Domain;
readonly surface: Surface;
readonly platform: Platform;
readonly reviewer?: string;
readonly hasRollbackPlan: boolean;
}
export interface BoundaryPolicy {
readonly name: string;
readonly scriptPlatforms: readonly Platform[];
readonly defaultRollbackMinutes: number;
readonly rollbackMinutes: Readonly<Record<string, number>>;
}
export interface ZoneDecision {
readonly id: string;
readonly zone: Zone;
readonly rollbackMinutes: number;
readonly reasons: readonly string[];
}
export interface BoundaryAudit {
readonly policy: string;
readonly decisions: readonly ZoneDecision[];
readonly counts: Readonly<Record<Zone, number>>;
readonly blockedIds: readonly string[];
readonly releaseBlocked: boolean;
}
export const TIDE_MATCH3_BOUNDARY: BoundaryPolicy = {
name: "潮汐消消",
scriptPlatforms: [],
defaultRollbackMinutes: 30,
rollbackMinutes: { activity: 30, level: 30, cosmetic: 30, text: 30 },
};
export const TIDE_ARK_BOUNDARY: BoundaryPolicy = {
name: "潮汐方舟",
scriptPlatforms: ["android"],
defaultRollbackMinutes: 15,
rollbackMinutes: { "battle-numbers": 15, season: 15, ui: 15, voice: 15 },
};
const RED_DOMAINS: readonly Domain[] = [
"payment",
"gacha-odds",
"core-rules",
"privacy",
];
const REVIEW_DOMAINS: readonly Domain[] = ["battle-numbers", "season"];
const DOMAINS: readonly Domain[] = [
"activity",
"level",
"cosmetic",
"text",
"battle-numbers",
"season",
"ui",
"voice",
"payment",
"gacha-odds",
"core-rules",
"privacy",
];
const SURFACES: readonly Surface[] = ["config", "asset", "script", "native"];
function validateEntry(entry: BoundaryEntry): void {
if (entry.id.trim() === "") throw new Error("entry id is required");
if (!DOMAINS.includes(entry.domain)) {
throw new Error(`unknown domain: ${String(entry.domain)}`);
}
if (!SURFACES.includes(entry.surface)) {
throw new Error(`unknown surface: ${String(entry.surface)}`);
}
if (entry.platform !== "android" && entry.platform !== "ios") {
throw new Error(`unknown platform: ${String(entry.platform)}`);
}
}
export function classifyBoundary(
entry: BoundaryEntry,
policy: BoundaryPolicy,
): ZoneDecision {
validateEntry(entry);
if (RED_DOMAINS.includes(entry.domain)) {
return {
id: entry.id,
zone: "blocked",
rollbackMinutes: 0,
reasons: [`红线内容:${entry.domain} 不得通过热更下发`],
};
}
if (entry.surface === "native") {
return {
id: entry.id,
zone: "store",
rollbackMinutes: 0,
reasons: ["原生代码与权限只能随商店整包发布"],
};
}
if (entry.surface === "script" && !policy.scriptPlatforms.includes(entry.platform)) {
return {
id: entry.id,
zone: "store",
rollbackMinutes: 0,
reasons: ["该平台只接受资源与配置热更"],
};
}
const rollbackMinutes =
policy.rollbackMinutes[entry.domain] ?? policy.defaultRollbackMinutes;
const reasons: string[] = [];
let needsReview = false;
if (entry.surface === "script") {
needsReview = true;
reasons.push("代码热更需要复核");
}
if (REVIEW_DOMAINS.includes(entry.domain)) {
needsReview = true;
reasons.push("影响战斗或赛季数值,需要复核与回滚方案");
}
if (!needsReview) {
return { id: entry.id, zone: "allow", rollbackMinutes, reasons };
}
if (entry.reviewer === undefined || entry.reviewer.trim() === "") {
reasons.push("缺少复核人");
}
if (!entry.hasRollbackPlan) {
reasons.push("缺少回滚方案");
}
const zone: Zone = reasons.some((line) => line.startsWith("缺少"))
? "blocked"
: "review";
return { id: entry.id, zone, rollbackMinutes, reasons };
}
export function auditBoundary(
entries: readonly BoundaryEntry[],
policy: BoundaryPolicy,
): BoundaryAudit {
const seen = new Set<string>();
const decisions: ZoneDecision[] = [];
for (const entry of entries) {
if (seen.has(entry.id)) throw new Error(`duplicate entry id: ${entry.id}`);
seen.add(entry.id);
decisions.push(classifyBoundary(entry, policy));
}
const counts: Record<Zone, number> = {
allow: 0,
review: 0,
blocked: 0,
store: 0,
};
for (const decision of decisions) counts[decision.zone] += 1;
const blockedIds = decisions
.filter((decision) => decision.zone === "blocked")
.map((decision) => decision.id);
return {
policy: policy.name,
decisions,
counts,
blockedIds,
releaseBlocked: blockedIds.length > 0,
};
}
export function formatBoundaryAudit(audit: BoundaryAudit): string {
const lines = ["entry\tzone\trollback\treasons"];
for (const decision of audit.decisions) {
const reasons = decision.reasons.length > 0 ? decision.reasons.join("; ") : "-";
lines.push(
`${decision.id}\t${decision.zone}\t${decision.rollbackMinutes}min\t${reasons}`,
);
}
lines.push(
`summary\tallow=${audit.counts.allow}\treview=${audit.counts.review}\tblocked=${audit.counts.blocked}\tstore=${audit.counts.store}\trelease=${audit.releaseBlocked ? "blocked" : "ok"}`,
);
return lines.join("\n");
}
classifyBoundary 先判红线,再判原生层与平台脚本能力,最后判复核区。复核区要求 reviewer 非空且 hasRollbackPlan 为真,任一缺失就变成 blocked。auditBoundary 汇总四区数量,并把 blocked 的 id 单独列出;只要存在阻断项,releaseBlocked 就为真。formatBoundaryAudit 输出可贴进发布单的文本。
运行结果与边界
用一组小样本运行,输出如下。样本只演示分区,不代表两个产品的真实条目:
entry zone rollback reasons
activity allow 30min -
level allow 30min -
battle-numbers review 15min 影响战斗或赛季数值,需要复核与回滚方案
gacha blocked 0min 红线内容:gacha-odds 不得通过热更下发
native-sdk store 0min 原生代码与权限只能随商店整包发布
summary allow=2 review=1 blocked=1 store=1 release=blocked
这张表把「能不能发」拆成了四个动作:直接发、签字后发、不许发、等商店。发布门禁读 releaseBlocked,评审人读 reasons,两条信息服务的是不同角色。
边界要写清楚。第一,分区是团队政策的编码,不是平台规则的原文;平台审核仍可能对所有热更内容提出额外要求。第二,store 不等于安全,只代表通道更慢、差量更大;原生层变更同样需要测试与回滚预案。第三,回滚分钟数是策略配置,不是实测值;它应该来自演练与运维承诺,并在每次复盘后校准。
与相邻章节的关系
10.1 回答「商店允许更新什么」,给出了通道与平台能力;本节把通道落到内容语义,回答「哪些内容属于允许、复核、阻断与商店」。10.3 接着讨论失败与回滚,回答「复核过的热更发出去但失败了怎么办」。11.2 的灰度发布会把复核区的范围进一步收窄到「先发给哪些分组」。
本节不讨论差量算法、下载器与 CDN,那些属于 11.x 与 12.x;也不讨论活动运营的具体配置格式。内容边界只回答「这段内容能不能通过热更改变玩家看到的行为」。
小结
- 两个产品体量与节奏不同,但共享四条红线:支付、概率、核心规则、权限隐私。
- 四区模型比二元判断可操作:允许、复核、阻断、商店,对应四种失败后果。
- 消消的白名单窄,因为线上只能改内容;关卡布局可热更,胜负规则不可热更。
- 方舟的战斗数值、赛季配置与 Android 脚本属于复核区,必须有具名复核人与回滚方案。
- 复核条件缺失应降级为阻断;发布门禁只认可追溯的签字与方案。
阅读导航:上一节:10.1 商店允许更新什么 · 下一节:10.3 热更失败与回滚兜底 。
常见错误与边界
- 按文件类型判风险。JSON 配置也可能是支付开关或概率表,风险在语义不在扩展名。
- 把核心规则拆成数值热更。一点点改判定条件,累计起来就是另一套玩法。
- 复核人填团队别名。责任无法追溯,复盘时找不到判断依据。
- 回滚方案只写「可回滚」。没有版本指针与触发条件,窗口内根本退不回去。
- 把商店区当成安全区。整包同样会出错,只是回滚成本更高。
- 忽略赛季生效时间。数值正确但时区或结算顺序错误,照样造成整季异常。
- 概率表走配置热更。已公示概率的任何变化都必须重新公示并走商店。
- 权限逻辑动态下发。绕过用户同意与商店数据安全表单,属于明确越界。
- 允许区不做资源校验。表现层资源损坏也会造成白屏与卡顿,回退能力必须真实可用。
- 边界:分区不替代平台审核。代码只执行团队政策,平台仍可提出额外要求。
- 边界:回滚分钟数需要演练校准。配置里的 15 分钟不是承诺,实际能力要靠演练证明。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。