《移动游戏产品的架构·网络、资源与热更新》6.3 赛季结算与客户端展示口径

赛季结算页上,段位、进度、百分位和奖励四个数字经常互相矛盾。本节以两项目对照为线索,区分服务端权威数据与客户端展示投影,给出段位阈值、千分位进度、百分位口径、赛季错位校验与奖励合并规则,并配一套可运行的 TypeScript 展示模型与取舍。

场景:结算页上的四个数字

赛季最后一天,方舟的结算页会同时出现四个数字:段位、积分进度、名次百分位、奖励列表。玩家截图问客服:「我明明超过了大多数玩家,为什么只给了这一档奖励?」

客服查服务端,发现四个数字都来自同一条结算消息,但客户端的展示口径把它们拼错了:百分位用了「前 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 一次会话的流量构成 。

常见错误与边界

  1. 客户端重算奖励数量。版本差异会直接变成「少发」投诉;奖励数量只以服务端下发为准。
  2. 用本地积分覆盖结算积分。离线旧值会把新赛季进度拉回去;结算消息优先,本地只做缓存。
  3. 段位边界归属含糊。1200 分应进入 1200 对应的新段位;边界要写进测试。
  4. 进度用浮点百分比。UI 会出现 66.66666%;用千分位整数,展示时再格式化。
  5. 末段除零或负数。没有下一段时剩余分应为 0、进度为 1000;单独处理。
  6. 「前 X%」与「超过 X%」混用。数值接近、含义相反;选定一种口径并全局统一。
  7. 百分位用四舍五入。名次口径容易在边界产生 100%;用整除并明确展示为「超过」。
  8. 忽略 seasonId。旧赛季消息会套进新阈值;不一致时拒绝展示并刷新。
  9. 把软重置预览当承诺。预览只是展示,正式保留分以服务端初始化值为准。
  10. 奖励按到达顺序展示。不同设备顺序可能不同;按道具 id 排序、按来源固定顺序去重。
  11. 边界:客户端配置可能落后。热更未完成或回滚期间,阈值可能旧;展示要能识别配置版本,必要时降级为纯文本结果。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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