场景:结算页上的四个数字
赛季最后一天,方舟的结算页会同时出现四个数字:段位、积分进度、名次百分位、奖励列表。玩家截图问客服:「我明明超过了大多数玩家,为什么只给了这一档奖励?」
客服查服务端,发现四个数字都来自同一条结算消息,但客户端的展示口径把它们拼错了:百分位用了「前 X%」的文案,数值却是「超过 X%」;奖励列表把同一道具的两个来源拆成两行,看起来像少发了一份。
结算数据的问题很少出在计算,更多出在口径。服务端给的是权威值,客户端要做的是投影;投影规则一旦含糊,同一份数据就能被展示成两种结论。
权威与投影:客户端只做最后一步
结算消息里的最终积分、峰值积分、名次、总人数与奖励列表都是服务端算好的。客户端不应该重新计算奖励数量,也不应该用本地缓存的旧积分覆盖新值;它只做三件事:校验赛季、映射段位、格式化展示。
| 数据 | 权威方 | 客户端职责 | 禁止行为 |
|---|---|---|---|
| 最终积分 | 服务端 | 显示与段位映射 | 用本地积分覆盖 |
| 名次 / 总人数 | 服务端 | 百分位换算 | 自行估算总人数 |
| 奖励列表 | 服务端 | 合并与分组展示 | 按规则重算数量 |
| 段位阈值 | 配置 | 段位与进度映射 | 改写阈值 |
这条边界不是形式主义。奖励如果由客户端重算,任何一个版本差异都会变成「少发」投诉;积分如果允许本地覆盖,离线期间的旧值会把新赛季的进度拉回去。
客户端可以做的是幂等展示:同一条结算消息重复到达,视图不变;旧赛季消息迟到,直接拒绝,而不是覆盖当前赛季。
段位投影:阈值、进度与末段
段位是一组从 0 分开始、严格递增的阈值。映射规则很简单:取最后一个不超过当前积分的段位。边界要明确,刚好等于某段阈值的积分属于该段,而不是上一段的满进度。
进度用千分位整数表示,避免浮点在 UI 上出现一长串小数。当前段位内的积分除以段位跨度,四舍五入到千分位;最后一段没有下一段,进度固定为 1000。
| 情况 | 结果 | 说明 |
|---|---|---|
| 积分低于首段 | 不合法 | 首段阈值必须为 0 |
| 积分等于某段阈值 | 归入该段 | 不是上一段的满进度 |
| 段位内有下一段 | 返回剩余分与进度 | 进度按跨度四舍五入 |
| 已在最高段 | 剩余 0、进度 1000 | 不计算下一段 |
末段要单独处理。很多实现会用「下一段阈值减当前积分」算剩余分,最后一段没有下一段,就会得到负数或除零。模型显式返回下一段为 null、剩余分为 0、进度为 1000。
百分位口径:「前 X%」还是「超过 X%」
名次可以换成两种百分位:一种是「你排在前 X%」,用名次除以总人数;另一种是「你超过了 X% 的玩家」,用总人数减名次再除以总人数。两者数值接近,含义相反,混用就会产生截图里的矛盾。
模型选择「超过 X%」,因为它对玩家更直观,也不容易在第一名时出现「前百分之零」的歧义。计算用整数:先乘 1000 再整除总人数,得到千分位,最后格式化成一位小数。
口径一旦选定,就要在文案、客服话术和埋点里保持一致。不要在一个地方写「前 X%」,另一个地方写「超过 100−X%」,即使它们描述的是同一群人。
赛季错位与软重置预览
结算消息必须带 seasonId。客户端缓存的配置、排行榜和奖励表都可能跨赛季;如果消息里的赛季与当前配置不一致,正确动作是拒绝展示并刷新,而不是把旧数据套进新阈值。
软重置是另一个容易越界的展示。客户端可以根据峰值积分预览下季保留分,但预览不是承诺:真正的保留分由服务端在下赛季初始化时下发。预览值必须标注「预计」,并且与正式值使用同一套取整规则。
方舟每周热更平均 45MB,赛季配置就在其中;战斗数值的回滚时限是 15 分钟。消消双周热更平均 12MB,活动配置的回滚时限是 30 分钟。两边的共同点是:配置会变,所以客户端不能把阈值写死。
奖励合并:同一道具多来源
同一场结算里,同一个道具可能来自排名、段位和参与奖。服务端可以下发三行,客户端展示时应该合并成一行,并保留来源标签;否则玩家会看到「金币 1000」「金币 500」两行,误以为只发了一份。
合并规则要确定:按道具 id 分组,数量求和,来源按固定顺序去重。排序也要确定,按道具 id 字典序,而不是依赖映射的插入顺序;否则同一份数据在不同设备上可能顺序不同。
合并只发生在展示层,不回写服务端。服务端仍是三条奖励记录,客服查询时能看到来源;客户端只是把它们折叠成玩家能读懂的一行。
可运行模型:season.ts
模型导出四个入口:段位映射、软重置预览、结算视图与文案。视图里所有比例都是千分位整数,避免浮点;奖励在展示层合并,来源保留。
// file: src/net/season.ts
// tier: L1
// verify: node --test test/net/season.test.ts
/**
* 赛季结算的客户端展示模型(mga-net-lab)。
* 段位阈值、软重置比例与展示口径均为实验设定,不是案例事实。
*/
export interface Tier {
readonly id: string;
readonly minPoints: number;
}
export interface SeasonConfig {
readonly seasonId: string;
readonly tiers: readonly Tier[];
readonly softResetRatio: number;
readonly maxCarryPoints: number;
}
export type RewardSource = "rank" | "tier" | "participation";
export interface RewardLine {
readonly itemId: string;
readonly count: number;
readonly source: RewardSource;
}
export interface SettlementPayload {
readonly seasonId: string;
readonly finalPoints: number;
readonly peakPoints: number;
readonly rank: number;
readonly totalPlayers: number;
readonly rewards: readonly RewardLine[];
}
export interface RewardView {
readonly itemId: string;
readonly count: number;
readonly sources: readonly RewardSource[];
}
export interface SettlementView {
readonly seasonId: string;
readonly tierId: string;
readonly tierIndex: number;
readonly points: number;
readonly nextTierId: string | null;
readonly pointsToNext: number;
readonly progressPermille: number;
readonly percentilePermille: number;
readonly rewards: readonly RewardView[];
readonly carryPoints: number;
}
const SOURCE_ORDER: readonly RewardSource[] = [
"rank",
"tier",
"participation",
];
function assertInteger(value: number, label: string, min: number): void {
if (!Number.isInteger(value) || value < min) {
throw new RangeError(`${label} 必须是不小于 ${min} 的整数,收到 ${value}`);
}
}
function validateConfig(config: SeasonConfig): void {
if (config.seasonId.trim() === "") {
throw new Error("seasonId 不能为空");
}
if (config.tiers.length === 0) {
throw new Error("段位表不能为空");
}
const ids = new Set<string>();
let previous = -1;
for (let index = 0; index < config.tiers.length; index += 1) {
const tier = config.tiers[index];
if (tier.id.trim() === "") {
throw new Error(`tiers[${index}].id 不能为空`);
}
if (ids.has(tier.id)) {
throw new Error(`段位 id 重复:${tier.id}`);
}
ids.add(tier.id);
assertInteger(tier.minPoints, `tiers[${index}].minPoints`, 0);
if (index === 0 && tier.minPoints !== 0) {
throw new Error(`首个段位必须从 0 分开始,收到 ${tier.minPoints}`);
}
if (tier.minPoints <= previous) {
throw new Error(
`段位阈值必须严格递增:${previous} → ${tier.minPoints}`,
);
}
previous = tier.minPoints;
}
if (!(config.softResetRatio >= 0 && config.softResetRatio <= 1)) {
throw new RangeError(
`softResetRatio 必须在 0..1 之间,收到 ${config.softResetRatio}`,
);
}
assertInteger(config.maxCarryPoints, "maxCarryPoints", 0);
}
export function tierForPoints(
config: SeasonConfig,
points: number,
): { readonly index: number; readonly tier: Tier } {
validateConfig(config);
assertInteger(points, "points", 0);
let index = 0;
for (let cursor = 0; cursor < config.tiers.length; cursor += 1) {
if (points >= config.tiers[cursor].minPoints) {
index = cursor;
} else {
break;
}
}
return { index, tier: config.tiers[index] };
}
export function softResetCarry(
config: SeasonConfig,
peakPoints: number,
): number {
validateConfig(config);
assertInteger(peakPoints, "peakPoints", 0);
return Math.min(
Math.floor(peakPoints * config.softResetRatio),
config.maxCarryPoints,
);
}
function mergeRewards(rewards: readonly RewardLine[]): readonly RewardView[] {
const merged = new Map<
string,
{ count: number; sources: Set<RewardSource> }
>();
for (const reward of rewards) {
if (reward.itemId.trim() === "") {
throw new Error("奖励 itemId 不能为空");
}
assertInteger(reward.count, `奖励 ${reward.itemId} 的数量`, 1);
const entry = merged.get(reward.itemId);
if (entry === undefined) {
merged.set(reward.itemId, {
count: reward.count,
sources: new Set<RewardSource>([reward.source]),
});
} else {
entry.count += reward.count;
entry.sources.add(reward.source);
}
}
return [...merged.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([itemId, entry]) => ({
itemId,
count: entry.count,
sources: SOURCE_ORDER.filter((source) => entry.sources.has(source)),
}));
}
export function buildSettlementView(
config: SeasonConfig,
payload: SettlementPayload,
): SettlementView {
validateConfig(config);
if (payload.seasonId !== config.seasonId) {
throw new Error(
`结算赛季 ${payload.seasonId} 与当前配置 ${config.seasonId} 不一致`,
);
}
assertInteger(payload.finalPoints, "finalPoints", 0);
assertInteger(payload.peakPoints, "peakPoints", 0);
assertInteger(payload.rank, "rank", 1);
assertInteger(payload.totalPlayers, "totalPlayers", 1);
if (payload.peakPoints < payload.finalPoints) {
throw new Error(
`峰值积分 ${payload.peakPoints} 低于最终积分 ${payload.finalPoints}`,
);
}
if (payload.rank > payload.totalPlayers) {
throw new Error(
`名次 ${payload.rank} 超出总人数 ${payload.totalPlayers}`,
);
}
const { index, tier } = tierForPoints(config, payload.finalPoints);
const next =
index + 1 < config.tiers.length ? config.tiers[index + 1] : null;
const pointsToNext =
next === null ? 0 : next.minPoints - payload.finalPoints;
let progressPermille = 1_000;
if (next !== null) {
const span = next.minPoints - tier.minPoints;
const current = payload.finalPoints - tier.minPoints;
progressPermille = Math.max(
0,
Math.min(1_000, Math.round((current * 1_000) / span)),
);
}
const percentilePermille = Math.floor(
((payload.totalPlayers - payload.rank) * 1_000) / payload.totalPlayers,
);
return {
seasonId: config.seasonId,
tierId: tier.id,
tierIndex: index,
points: payload.finalPoints,
nextTierId: next === null ? null : next.id,
pointsToNext,
progressPermille,
percentilePermille,
rewards: mergeRewards(payload.rewards),
carryPoints: softResetCarry(config, payload.peakPoints),
};
}
function permilleText(value: number): string {
return `${Math.floor(value / 10)}.${value % 10}%`;
}
export function describeSettlement(view: SettlementView): string {
const tierLabel =
view.nextTierId === null
? "已是最高段"
: `距下一段 ${view.pointsToNext}`;
return `赛季 ${view.seasonId} 结算:段位 ${view.tierId}(第 ${view.tierIndex + 1} 段),积分 ${view.points},${tierLabel},进度 ${permilleText(view.progressPermille)},超过 ${permilleText(view.percentilePermille)} 的玩家,奖励 ${view.rewards.length} 类,下季保留 ${view.carryPoints} 分`;
}
测试覆盖十条路径:段位边界、非法阈值、软重置取整与封顶、正常视图、最高段、三档百分位、跨来源合并、赛季不一致、峰值倒挂与越界名次、文案格式。
双案例:两套结算节奏
方舟是强联网、8–12 分钟一场、内购占 90% 的中重度产品,赛季结算是它最重要的运营节点之一:DAU 25 万、MAU 180 万,结算页的每一次口径错误都会被放大。它的奖励以段位和排名为主,客户端更适合「只展示、不重算」。
消消是弱联网、90 秒一局、广告 55% 加内购 45% 的休闲产品,DAU 80 万、MAU 620 万。它的结算更频繁,单次金额更小,展示重点是「这次拿到什么」,而不是长期段位。
消消的弱网会话占 22%,断线率 2.1%,重连成功率 97.2%;结算消息迟到或重复的概率更高,客户端更需要对赛季标识和消息幂等做保护。
与相邻章节的关系
6.2 校验单场战斗的结果是否一致,本节把视角拉到赛季:即使每场结果都对,赛季结算仍可能因为口径、缓存或配置版本而展示错。6.1 的战报是结算申诉的证据,本节不重复事件结构。
下一章 7.1 开始讨论会话流量;结算消息本身很小,但它触发的排行榜刷新、奖励领取和资源下载会进入后续章节的预算。
小结
- 结算数据由服务端权威计算,客户端只做赛季校验、段位映射与格式化。
- 段位阈值从 0 开始严格递增,进度用千分位整数,末段固定 1000。
- 百分位要明确是「前 X%」还是「超过 X%」,并在文案、客服与埋点中统一。
- seasonId 不一致时拒绝展示;软重置只是预览,正式值以服务端下发为准。
- 奖励在展示层按道具 id 合并,保留来源,不回写服务端。
阅读导航:上一节:6.2 战报回放与一致性校验 · 下一节:7.1 一次会话的流量构成 。
常见错误与边界
- 客户端重算奖励数量。版本差异会直接变成「少发」投诉;奖励数量只以服务端下发为准。
- 用本地积分覆盖结算积分。离线旧值会把新赛季进度拉回去;结算消息优先,本地只做缓存。
- 段位边界归属含糊。1200 分应进入 1200 对应的新段位;边界要写进测试。
- 进度用浮点百分比。UI 会出现 66.66666%;用千分位整数,展示时再格式化。
- 末段除零或负数。没有下一段时剩余分应为 0、进度为 1000;单独处理。
- 「前 X%」与「超过 X%」混用。数值接近、含义相反;选定一种口径并全局统一。
- 百分位用四舍五入。名次口径容易在边界产生 100%;用整除并明确展示为「超过」。
- 忽略 seasonId。旧赛季消息会套进新阈值;不一致时拒绝展示并刷新。
- 把软重置预览当承诺。预览只是展示,正式保留分以服务端初始化值为准。
- 奖励按到达顺序展示。不同设备顺序可能不同;按道具 id 排序、按来源固定顺序去重。
- 边界:客户端配置可能落后。热更未完成或回滚期间,阈值可能旧;展示要能识别配置版本,必要时降级为纯文本结果。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。