02 数值与经济系统
本章目标:从服务端开发者的视角理解游戏的数值与经济系统——不是为了替策划做设计,而是为了在需求评审阶段识别技术风险,在系统设计阶段预留正确的数据结构和计算管线,在上线之后能用数据定位问题。
参考书籍:本章核心概念参考《游戏编程模式》(Robert Nystrom)中的游戏循环与状态模式、《游戏服务器架构与优化》中的数值系统设计章节,以及《百万在线》中的经济系统平衡实践。
阅读提示:本章的每一条设计都对应一个服务端实现决策——属性三层架构对应计算管线,保底状态机对应持久化字段,货币流水对应账表设计。读完可以对照第 8 章数据存储、第 10 章交易系统和第 17 章配置管线一起看。
1. 为什么服务端需要懂策划
很多服务端开发者认为"策划的事跟我无关",这是一种危险的误解。策划的每一个决策——经验值曲线、抽卡概率、活动时间——最终都会变成你需要处理的数据结构和计算逻辑。如果你不理解策划为什么这样设计,你就无法判断一个需求是否合理、是否存在技术风险。
举一个真实案例:某卡牌手游的数值策划设计了一个"装备强化概率递减"系统——每次强化成功率从 80% 逐级降低到 5%,但强化失败装备不消失。结果玩家发现通过反复失败+重试,可以用少量材料把装备升到满级。数值策划认为"概率已经很低了",但服务端如果理解了概率系统的数学本质,就能提前预警:当重试的期望成本低于直接购买时,这个系统会变成一个变相的折扣商店。概率设计的问题不能靠"概率看起来很低"来判断,只能靠期望值计算来回答。
服务端开发者需要理解策划的思维框架,才能做到:在需求评审阶段就发现技术风险,而不是在上线后被玩家利用漏洞。
1.1 策划的职责与协作
| 策划类型 | 核心工作 | 与服务端的关系 | 服务端需要关注什么 |
|---|---|---|---|
| 系统策划 | 设计游戏系统(背包、公会、邮件) | 接口设计、数据结构 | 数据模型是否可扩展、并发安全 |
| 数值策划 | 设计数值公式(伤害、属性、经济) | 计算逻辑、配置表 | 公式是否有溢出风险、配置是否可热更 |
| 关卡策划 | 设计关卡内容(副本、地图) | 关卡配置、怪物刷新 | 关卡数据量、加载策略 |
| 战斗策划 | 设计战斗系统(技能、AI、平衡) | 战斗逻辑、同步方案 | 计算量是否可控、是否需要帧同步 |
| 运营策划 | 设计活动和运营策略 | 活动系统、奖励发放 | 活动数据隔离、并发发放安全 |
关键认知:服务端不是策划的"工具人",而是策划的"技术顾问"。当你发现一个策划方案在技术上不可行或者存在风险时,可以考虑主动提出来,而不是默默实现然后在上线后擦屁股。
1.2 接数值需求时的四个技术提问
数值需求评审时,服务端可以用四个问题快速定位技术风险:
- 这个公式在哪里算?——客户端算就是作弊后门,服务端算就要考虑性能和热更(第 2.4 节)
- 这个随机要不要可复现?——涉及回放/裁决/跨端一致,就要确定性随机(第 3.3 节)
- 这个计数器存在哪?——保底、签到、限购这类"次数状态"是资产,持久化在服务端(第 4.3 节)
- 这个数值变化要不要留痕?——涉及货币和资产的变更,流水先行(第 6.3 节)
2. 属性系统:数值计算的基础
属性系统是所有 RPG 类游戏的基础。理解属性计算的层次关系,对于设计合理的服务端数据结构至关重要。
2.1 属性计算的三层架构
游戏属性的计算通常分为三层,从内到外依次是:基础属性 → 百分比加成 → 固定加成。这个顺序非常重要,因为不同的计算顺序会导致完全不同的结果。
基础属性来自角色等级和成长值,是"出生就确定"的底子。百分比加成来自装备、buff、被动技能等,它放大基础属性。固定加成是最终叠加的"附加值",比如"攻击力+50"这种。
为什么这个顺序很重要?因为它直接影响数值膨胀的速度。如果百分比加成和固定加成的顺序搞反(先加固定值再放大),固定加成也会被百分比放大,高等级玩家和低等级玩家的差距会被加速放大,导致平衡失控。《游戏服务器架构与优化》中特别强调:属性计算公式需要在服务端执行,客户端只做展示,否则就是给外挂留后门。
| 属性 | 说明 | 典型公式 |
|---|---|---|
| HP | 生存能力 | (base + growth × level) × (1 + 百分比加成) + 固定加成 |
| ATK | 伤害输出 | (base + growth × level + 装备基础) × (1 + 百分比加成) |
| DEF | 减伤能力 | 同上,影响伤害公式的减伤部分 |
| SPD | 行动顺序 | base + buff加成,通常不参与百分比计算 |
| CRIT | 暴击概率 | base + 装备加成,通常有上限(如 75%) |
2.2 加法区与乘法区:多来源合并的规则
当一个属性有多个加成来源时(装备 A +20%、buff B +30%、公会科技 +10%),合并规则是一个明确的策划决策,服务端照做即可,但需要理解两种规则的手感差异:
- 加法区:
(1 + 20% + 30% + 10%) = 1.6。来源越多收益越线性,策划容易预期总量 - 乘法区:
(1 + 20%) × (1 + 30%) × (1 + 10%) = 1.716。收益边际递增,天然鼓励堆叠同类型来源
减伤类属性几乎总是用乘法区合并:多个减伤来源 (1-20%) × (1-30%) 永远不会把减伤叠过 100%,加法合并则可能叠出"完全免伤"的漏洞。服务端实现时不要自作主张统一成一种规则——同一属性在不同来源上用不同合并区("暴击伤害走加法区、全伤害加深走乘法区")是常见做法,配置表里每个来源需要带"作用区"字段。
2.3 服务端实现:属性重算管线
属性的完整值不需要每次访问都重算——重算只在"来源变化"时发生(升级、穿装备、buff 生效/消失)。标准做法是脏标记 + 惰性重算:
type Character struct {
base BaseStats // 等级成长 + 装备基础
modifiers []Modifier // 所有加成来源:装备、buff、被动、科技
final FinalStats // 重算结果缓存
dirty bool // 任一来源变化时置位
}
// 来源变化时只做一件事:打脏标记
func (c *Character) OnBuffAdded(m Modifier) {
c.modifiers = append(c.modifiers, m)
c.dirty = true
}
// 访问最终属性时,脏了才重算
func (c *Character) Stats() FinalStats {
if c.dirty {
c.recalc()
c.dirty = false
}
return c.final
}
// 重算管线:整数运算,万分比
func (c *Character) recalc() {
var pctAtk, flatAtk int64
for _, m := range c.modifiers {
if m.Attr != AttrATK {
continue
}
switch m.Zone {
case ZonePercent:
pctAtk += m.Value // 万分比,如 2000 = +20%
case ZoneFlat:
flatAtk += m.Value
}
}
base := c.base.ATK
c.final.ATK = (base*(10000+pctAtk))/10000 + flatAtk // 先百分比后固定
}三个实现纪律:全程整数运算(万分比,避免浮点累积误差)、重算在逻辑线程内完成(与第 5 章单写者原则一致,modifiers 列表不需要锁)、分层来源的生效与过期由统一的 buff/timer 系统驱动(而不是每处代码自己改 modifiers)。
2.4 为什么数值要服务端计算
一个常见的错误是让客户端计算属性值,服务端只是"记录"结果。这看起来省事,但后果很严重:
- 作弊风险:客户端可以伪造任何属性值,你无法验证
- 数据不一致:不同客户端版本可能计算结果不同
- 热更困难:修改公式需要客户端更新
合理的做法是:客户端只负责展示,服务端负责计算和验证。 客户端可以缓存计算结果用于本地显示,但所有涉及游戏逻辑的属性值需要来自服务端。
3. 伤害公式:策划设计的核心
伤害公式决定了战斗系统的手感和平衡性。不同游戏类型使用完全不同的伤害模型,理解这些模型有助于你设计合理的战斗服务。
3.1 四种主流伤害模型
模型一:简单减伤(适合休闲游戏)。伤害 = 攻击力 - 防御/2,最低为 1。优点是简单直观,缺点是防御力的收益是线性的,高等级时防御力要么完全没用(攻击力远大于防御),要么过于强大(防御接近攻击力的两倍)。
模型二:百分比减伤(适合卡牌游戏)。伤害 = 攻击力 × (1 - 防御/(防御+K))。这里的 K 是一个常数,决定了防御力的"饱和点"。当防御力等于 K 时,减伤 50%。这个模型让防御力的收益递减,更合理。
模型三:乘算减伤(适合 MMO)。在百分比减伤的基础上叠加多个减伤来源(装备减伤、buff 减伤、被动减伤),用乘法而非加法合并,避免减伤率超过 100%(见 2.2 节)。
模型四:多乘区流水线。这是大多数商业游戏使用的模型:
伤害计算流水线:
攻击力 × 技能倍率 → 基础伤害
↓
基础伤害 × (1 - 防御减伤率) → 减伤后伤害
↓
减伤后伤害 × 暴击倍率 → 暴击后伤害
↓
暴击后伤害 × 元素/阵营克制倍率 → 克制后伤害
↓
克制后伤害 × (1 ± 随机浮动) → 最终伤害多乘区设计的价值在于策划可以独立调每个乘区——削弱某个职业时调克制倍率而不动技能倍率,出限时 buff 时只动特定乘区。服务端的实现要保留这个结构:伤害计算函数接收"乘区参数包",而不是把所有系数揉死在一个公式里。
3.2 服务端实现要点
伤害计算看似简单,但在服务端实现时有几个需要注意的坑:
- 浮点精度问题:不要用浮点数做最终的伤害值。浮点运算有精度误差,可能导致"1点伤害"和"0点伤害"的判定出错。实践中推荐全程整数运算(万分比),例如:
const RATE = 10000 // 万分比
func calcDamage(p DamageParams) int64 {
dmg := p.ATK * p.SkillRate / RATE // 基础伤害
dmg = dmg * (RATE - p.DefReduce) / RATE // 防御减伤(万分比)
if p.IsCrit {
dmg = dmg * p.CritDamage / RATE // 暴击倍率(万分比)
}
dmg = dmg * (RATE*2 - randRange(p.Min, p.Max)) / (RATE * 2) // 浮动
if dmg > p.Target.HP*15/100 { // 伤害封顶:单次 ≤15% 最大生命
dmg = p.Target.HP * 15 / 100
}
if dmg < 1 {
dmg = 1 // 保底 1 点
}
return dmg
}随机数与确定性:普通玩法用全局随机源即可;帧同步或需要回放/裁决的玩法,需要每场战斗独立的确定性随机源(见 3.3 节)
伤害溢出:攻击力和技能倍率都很高时,
ATK × SkillRate可能超过 int32。Go 里用 int64 计算再裁剪,C++ 里注意整型提升规则伤害封顶:策划通常会要求"单次伤害不超过最大生命值的 X%",这个限制需要在服务端实现——它同时是数值安全和展示体验(防止一击跳屏)的双重需求
3.3 确定性随机:种子管理与回放
只要战斗结果需要跨端一致(帧同步)或事后重演(回放、裁决、离线演算),随机数就需要确定性:相同的种子序列产生相同的数列。
服务端的标准做法:
// 每场战斗独立的随机源(创建战斗时调用)
func newBattle(seedA, seedB uint64, frameInputs []FrameInput, logicVersion string) *Battle {
battle := &Battle{
rng: rand.New(rand.NewPCG(seedA, seedB)), // 独立源,不用全局
seedA: seedA, // seed 由服务器生成:matchID ^ 时间戳等
seedB: seedB,
}
// 战斗日志里记录种子——回放/裁决时重建完全相同的随机序列
battleLog := BattleLog{
Seed: [2]uint64{seedA, seedB},
Inputs: frameInputs, // 帧输入序列(完整、有序、可重放)
Version: logicVersion, // 数值配置版本,回放必须用当时的配置
}
_ = battleLog // 演示代码里仅用于说明;真实代码写入持久层
return battle
}三个纪律:战斗内所有随机都走这个源(不许混入 math/rand 全局函数或时间戳);数值配置有版本号(回放时用当时的配置表,否则调一次数值所有旧回放全部失真);种子进战斗日志(这是"服务端裁决"能力的根基)。这与帧同步的确定性要求(知识库"同步、战斗与实时交互"一章)是同一件事的两面。
4. 概率系统:玩家最敏感的领域
概率系统是游戏中最容易引发玩家投诉的领域。中国法律规定抽卡概率需要公示,服务端的实现需要经得起审计。
4.1 概率的类型与心理效应
| 概率类型 | 说明 | 心理效应 | 典型应用 |
|---|---|---|---|
| 固定概率 | 每次概率相同 | 期望值明确 | 抽卡 1% 出 SSR |
| 保底概率 | 达到阈值必出 | 降低挫败感 | 90 抽保底 |
| 动态概率 | 概率随次数变化 | 越抽越兴奋 | 软保底机制 |
| 条件概率 | 满足条件才触发 | 增加策略性 | 暴击、连击 |
4.2 随机源的选择:每玩家独立,可审计
抽卡这类涉及资产的概率,对随机源有三个要求:
- 不用全局共享源:
math/rand的全局函数在 goroutine 间有锁竞争,且任何一处代码消耗随机数都会影响别的玩家的序列——每个玩家/每次抽卡独立源(如 PCG)更干净 - 种子不可预测:
time.Now()秒级时间戳做种子可以被暴力猜——种子用 crypto 级熵源生成后记录,而不是让玩家可推算 - 结果可追溯:每次抽取留下日志(谁、什么池、第几抽、随机值、结果),这是应对投诉和监管审查的证据链
4.3 保底机制的状态机实现
保底有两种:硬保底(达到次数必出)和软保底(达到次数后概率逐步提升)。以常见的手游卡池为例——基础 0.6%,第 74 抽起每抽递增 6%,90 抽硬保底,出金后重新计数,UP 未中则下次必中(大保底):
type PityState struct {
Count int // 距上次出金的抽数(持久化字段)
Guaranteed bool // 大保底:上次 UP 未中(持久化字段)
}
type GachaPool struct {
BaseRate float64 // 0.006
SoftStart int // 74
SoftStep float64 // 0.06
HardPity int // 90
}
func (p *GachaPool) Roll(state *PityState, rng rand.Source) RollResult {
state.Count++
rate := p.BaseRate
if state.Count >= p.SoftStart {
rate += float64(state.Count-p.SoftStart+1) * p.SoftStep
}
if state.Count >= p.HardPity {
rate = 1.0
}
fiveStar := rand.New(rng).Float64() < rate
if fiveStar {
state.Count = 0
}
// Guaranteed 的翻转在 UP 判定处进行,此处从略
return RollResult{FiveStar: fiveStar, Rate: rate}
}实现上的关键点:Count 和 Guaranteed 是资产级状态——必须随抽卡在同一事务里持久化,绝不能信任客户端上报的抽数(外挂伪造抽数可以直接"跳过"保底);换池规则要明确(卡池轮换时计数继承还是清零,是策划决策,但服务端要显式处理而不是隐式沿用);概率曲线参数进配置表而不是硬编码,配合概率公示页一起热更。
4.4 概率审计:每次抽取都留痕
{
"uid": 10086,
"pool_id": "pool_2026spring",
"pull_seq": 37,
"pity_count_before": 36,
"rate_at_pull": 0.066,
"random_value": 0.041923,
"result_item": 90321,
"is_guarantee": false,
"server_time": "2026-09-22T12:00:00+08:00",
"logic_version": "3.2.1"
}这份日志同时服务三类需求:玩家投诉时重演单次抽取(random_value 是服务端生成后写入的,不是玩家可议的);运营核对实际出率 vs 公示概率(大样本偏差需要能被发现——真实 bug 往往是配置错了而不是随机数"不公平");监管审查时提供概率实现说明。
4.5 概率公示的法律要求
根据中国《网络游戏管理暂行办法》和《关于规范网络游戏运营加强事中事后监管工作的通知》,抽卡概率需要公示:
- 每个品级的基础概率
- 保底机制的详细说明
- 概率提升活动的额外概率
- 概率计算的随机种子说明(可选)
服务端需要做到:概率计算在服务端完成,概率结果有日志记录可追溯,保底计数准确且不可被刷。
5. 掉落系统:随机性的艺术
掉落系统是概率系统的具体应用。一个好的掉落系统应该让玩家觉得"努力就有回报",而不是"纯看运气"。
5.1 掉落类型的选择
| 掉落类型 | 说明 | 适用场景 | 玩家心理 |
|---|---|---|---|
| 固定掉落 | 必定掉落 | 金币、经验 | 稳定感 |
| 随机掉落 | 概率掉落 | 装备、材料 | 期待感 |
| 首通奖励 | 首次通关额外 | 稀有道具 | 成就感 |
| 保底掉落 | N次必出 | 稀有装备 | 安全感 |
5.2 权重轮盘的实现
权重掉落是最常见的随机掉落实现。每种物品一个权重,掉落率 = 该物品权重 / 总权重。实现用前缀和 + 二分,物品数千个时依然高效:
type DropTable struct {
items []DropItem
prefix []int64 // 前缀和
total int64
}
func (t *DropTable) Roll(rng rand.Source) int {
r := rand.New(rng).Int63n(t.total)
// 二分找第一个 prefix > r 的位置
return sort.Search(len(t.prefix), func(i int) bool {
return t.prefix[i] > r
})
}对策划友好的一面是:把某物品权重从 100 改成 50,掉率就直观减半,不需要换算百分比。
5.3 稀释陷阱与分池设计
权重制有一个隐蔽陷阱:新增普通物品会稀释稀有物品的实际概率。策划想让 SSR 保持 1% 权重,后来陆续加入 50 个 R 级物品,SSR 的权重占比就从 1% 滑到了 0.7%——没有任何人"改过"它的概率。
工程上的解法是分池:SSR 单独一个池(固定 1% 先判定是否进池),进池后按权重决定是哪一张 SSR;R 级另一个池。这样池之间互不稀释,策划调 R 池不会影响 SSR 出率。分池结构同时是概率公示的天然口径——每个池公示一个概率,与实现一一对应。
配套工具:概率校验脚本接入 CI,每次掉落表变更自动重算各物品的最终概率(含分池),与策划配置的预期概率比对,偏差超阈值就阻断合入。这类防呆的详细管线见附录 A3。
6. 经济系统:游戏的命脉
经济系统决定了游戏的长期生命力。一个设计良好的经济系统可以让游戏运营数年,而一个糟糕的经济系统可能在几个月内就让游戏崩溃。
6.1 货币体系设计原则
游戏货币体系通常呈金字塔结构:顶层是付费货币(钻石),中层是游戏货币(金币),底层是活动货币(代币)。设计原则是:
- 货币种类不超过 3-4 种:太多会让玩家困惑,太少会限制设计空间
- 每种货币有明确的产出/消耗场景:不能让一种货币"万能"
- 付费货币和游戏货币不能逆向转换:否则会影响付费意愿
6.2 产出-消耗平衡的数学模型
经济平衡的核心公式是:每日净流量 = 总产出 - 总消耗。这个值应该略大于零(让玩家有"赚到了"的感觉),但不能太大(否则通胀),也不能小于零(否则通缩,玩家觉得"越玩越穷")。
一个简单的监控模型是检查每种货币的"消耗/产出比":这个值在 0.8 到 1.2 之间是健康的。低于 0.8 意味着产出过剩(通胀风险),高于 1.2 意味着消耗不足(玩家囤积,后续释放会造成冲击)。
6.3 服务端实现:货币流水先行
货币是游戏里最重要的资产,它的实现纪律比公式更值得投入。核心原则:每一笔货币变更,先写流水,再改余额:
type CurrencyLog struct {
ID uint64 // 全局唯一(雪花ID),幂等键
PlayerID uint64
Currency uint8 // 货币类型:1=钻石 2=金币 3=活动代币...
Delta int64 // 变化量:正为产出,负为消耗
Balance int64 // 变更后余额快照(对账锚点)
Reason uint16 // 原因枚举:1=副本奖励 2=商店购买 3=GM补发...
RefID string // 关联单号(订单号/活动ID/副本ID),幂等去重键
CreatedAt time.Time
}这条表结构承载了四个关键能力:
- 幂等:
RefID加唯一索引,同一订单号重复请求直接拒绝——网络重试、消息重复投递都不会重复发放 - 对账:任一时刻的余额 = 初始余额 + 流水累加,
Balance快照字段让增量对账可以分段进行,发现不一致立刻报警 - 审计:GM 补发、退款扣回都有流水可查,客诉仲裁有据可依
- 统计:6.2 节的产出/消耗比不需要另做埋点——按 Reason + Delta 正负聚合流水就是权威经济数据,比客户端埋点准(客户端埋点会被弱网丢包、被脚本污染)
并发纪律:同一玩家的货币变更需要串行(单写者上下文或行锁),跨玩家交易走两阶段(先冻结后转移),这些分布式细节见第 10 章交易系统。
6.4 通胀的早期信号
通胀等它显形(物价飞涨)再处理就晚了。流水中能更早看到的信号:
- 产消比持续走低:某货币连续两周消耗/产出比 < 0.8
- 头部集中度上升:头部 1% 玩家持有的货币占比快速上升——可能是刷子入场
- 囤积拐点:人均持有量上升但消耗场景参与率持平——玩家在"存钱等什么",消耗内容供给不足
6.5 通胀控制的四种武器
| 控制手段 | 原理 | 适用场景 | 副作用 |
|---|---|---|---|
| 产出控制 | 每日产出上限 | 所有游戏 | 影响玩家体验 |
| 消耗设计 | 装备强化递增消耗 | RPG/MMO | 新手门槛高 |
| 回收机制 | 装备分解、道具回收 | 有装备系统的游戏 | 需要设计回收价值 |
| 价值锚定 | 关键道具固定价格 | 有交易所的游戏 | 限制自由度 |
真实案例:某 MMO 上线半年后,金币通胀达到 300%,一把普通武器的价格从 1000 金币涨到了 30000 金币。原因是策划设计了太多金币产出途径(日常、副本、活动),但消耗途径只有装备强化(而且强化到高等级后消耗增长不够快)。最终的解决方案是引入"金币税"——交易时收取 10% 的手续费,同时开放"金币兑换材料"的限时活动。这个案例说明:经济系统需要在上线前就做好数学模型验证,不能等到出了问题再修。
7. 经济、平衡与风控:同一系统的三个侧面
经济分析、数值平衡和风控分析经常分属不同岗位:策划看平衡,运营看收入,安全团队看异常。但在长线项目里,这三者追踪的是同一个系统的不同侧面——资源和行为是否仍在可控区间。
7.1 为什么不能分家
只要玩家行为会影响资源分布、成长速度和社交竞争,这三类分析就天然耦合:
- 经济异常可能来自数值投放失控,也可能来自作弊或黑产
- 付费结构波动可能来自活动设计,也可能来自洗号、退款套利或代充
- 某个玩法参与率下降,可能是平衡问题,也可能是奖励结构出了偏差
如果团队把这些分析完全切开,就会反复出现"每个人都看到局部异常,但没人能解释整体变化"。
7.2 平衡分析不能只靠主观体验
很多项目的数值调整停留在"感觉太肝""感觉太难"的争论上,没有行为数据支撑的调整容易变成摆锤:看到流失就加福利,结果掏空核心挑战;看到付费下降就上促销,结果打坏价格锚点。更有效的平衡分析同时看:
- 分层通过率和失败率(哪个战力段的玩家卡在哪一关)
- 关键养成线的完成时间
- 核心玩法的参与深度和放弃节点
- 不同付费层级的成长效率差异
7.3 一个可复用的分析框架
把三类分析放进"事实 → 偏离 → 原因 → 动作"的框架:
- 事实:哪些关键指标发生了异常变化(产消比、集中度、通过率)
- 偏离:与历史、同层用户、目标区间相比偏在哪里
- 原因:是版本调整、活动投放、经济结构变化,还是异常行为
- 动作:调参数、改规则、封号,还是接受(有些偏离是预期内的)
风控尤其需要和经济共享数据基础(资产流水、订单流水、登录行为、设备指纹)——否则风控只能看到"可疑",看不到"影响了什么"。刷金脚本不只破坏公平,它会直接放大通胀拖垮所有人的数值体验;这就是为什么反作弊(第 18 章)和经济监控共享同一套流水。
8. 版本更新对经济系统的冲击
每次版本更新都是一次经济体系的"地震"。新玩法、新道具、新活动都会打破原有的产出-消耗平衡。服务端需要提前做好预案。
8.1 经济影响评估
版本更新前,应该做一次经济影响评估:新增了多少产出途径?新增了多少消耗途径?预计通胀变化百分比是多少?哪些道具会受到冲击?
一个简单的评估模型是:净流量变化 = 新增日产出 - 新增日消耗。如果净流量变化导致通胀率超过 15%,就需要调整方案。
8.2 经济失衡的修复工具箱
| 失衡程度 | 修复策略 | 执行速度 | 副作用 |
|---|---|---|---|
| 轻微(通胀<10%) | 微调产出/消耗参数 | 快(热更新) | 低 |
| 中等(通胀10-30%) | 新增消耗途径+降低产出 | 中(版本更新) | 中 |
| 严重(通胀>30%) | 紧急回收+市场干预 | 慢(需多版本) | 高 |
| 崩溃(恶性通胀) | 货币改版/合服 | 极慢 | 极高 |
注意修复手段的执行速度差异:参数级修复能热更(分钟级),结构性修复需要版本(周级),崩溃级修复需要多版本(月级)。这要求服务端的数值参数全部走配置热更管线(见第 17 章),而结构性规则尽量参数化——把"可能会调的东西"都放进配置,是把月级响应压缩到分钟级的唯一办法。
9. 玩家心理学:设计的底层逻辑
理解玩家心理学不是策划的专利,服务端开发者也需要理解——因为你需要实现这些心理机制对应的技术系统。
9.1 自我决定理论(SDT)
SDT 理论认为人类有三种基本心理需求:自主性(自由选择)、胜任感(掌控感)、归属感(社交连接)。对应到服务端:自主性需要多种玩法入口和自由度;胜任感需要难度匹配系统和成长系统;归属感需要公会、组队、聊天等社交系统。
9.2 变比率强化:最强的激励机制
变比率强化(Variable Ratio Reinforcement)是心理学中最强的激励机制——奖励的出现是随机的,但长期期望稳定。在游戏中的应用:抽卡、怪物掉落、宝箱。服务端实现时需要注意:概率需要服务端计算、保底机制需要准确、概率需要公示。这不仅是技术要求,也是法律要求(见 4.5 节)。
9.3 损失厌恶:时间敏感系统的实现要求
损失厌恶指失去 100 元的痛苦大于得到 100 元的快乐。游戏利用这一点设计了大量"限时"和"每日"机制:限时活动、每日签到、体力系统。
服务端对应的是三类时间敏感系统:活动倒计时、签到记录与补签、体力恢复与溢出处理。它们的共同实现要求:时间以服务端为准(客户端时间可被篡改,签到回溯、体力凭空回满是常见漏洞)、跨零点边界正确处理(时区、夏令时、服务器停服跨越零点)、补签/找回语义明确(断签第几天、找回消耗什么,都是配置化的规则而不是代码分支)。
10. 链游经济设计的特殊挑战
链游(GameFi)将游戏经济搬到区块链上,带来了全新的设计挑战。传统游戏的经济系统是中心化控制的,服务端可以随时调整参数;但链游的经济规则写在智能合约里,一旦部署就很难修改。
10.1 双代币模型
链游通常使用双代币模型:游戏内代币(中心化管理,用于游戏内消耗)和治理代币(去中心化,用于交易和治理)。这种设计的目的是将"游戏内经济"和"链上经济"解耦,降低游戏内经济波动对链上价格的影响。
10.2 死亡螺旋:链游的最大风险
死亡螺旋是链游最可怕的噩梦:新玩家减少 → Token 价格下跌 → 老玩家抛售 → 价格进一步下跌 → 更多玩家离开 → 经济崩盘。
Axie Infinity 就是死亡螺旋的典型案例:SLP 代币从最高 $0.36 跌到几乎归零。根本原因是产出远大于消耗——玩家不断产出 SLP,但游戏内没有足够的消耗途径。
预防死亡螺旋的关键:设计多元化的消耗途径、设置产出上限、建立回购机制、控制新玩家增长节奏。但最根本的是:游戏需要有"可玩性"作为基础,而不能纯粹靠"赚钱"吸引玩家。
11. 代码示例:一套可对账的经济管线
本章从属性计算讲到货币流水,代码分散在各节里。这一节把经济系统最要紧的一条纪律——流水先行——组装成一个最小闭环:任何资产变更先留下证据行(6.3 节),幂等键挡住重试与重投,产消比直接从流水聚合(6.2、6.4 节)。示例是本章设计决策的最小 Go 演示实现(非摘自真实项目),小节内代码块合并后可通过 go vet 与 go build 编译,并受全库代码块回归测试约束。
账务闭环:先幂等,再余额,后写流水
// accountKey:账户主键 = 玩家 × 货币类型
type accountKey struct {
UID uint64
Currency uint8
}
// CurrencyLog:一行流水 = 一次资产变更的完整证据(6.3 节表结构的精简版)
type CurrencyLog struct {
RefID string // 幂等键:订单号/活动ID/副本ID,生产落库时加唯一索引
UID uint64
Currency uint8
Delta int64 // 正为产出、负为消耗,全程 int64(3.2 节防溢出纪律)
Balance int64 // 变更后余额快照:对账锚点
Reason uint16 // 原因枚举:1=副本奖励 2=商店购买 3=GM补发...
}
var (
ErrDuplicateRef = errors.New("duplicate ref") // 幂等命中
ErrInsufficient = errors.New("insufficient balance")
)
// Ledger:内存演示版。生产中两个 map 换成数据库表,Apply 的校验顺序不变;
// 整个结构只暴露给玩家的单写者上下文(第 5 章),自身无需加锁
type Ledger struct {
balances map[accountKey]int64
seen map[string]struct{} // RefID 幂等集合(生产为唯一索引)
logs []CurrencyLog
}
func NewLedger() *Ledger {
return &Ledger{
balances: make(map[accountKey]int64),
seen: make(map[string]struct{}),
}
}
// Apply:流水先行的货币变更。任何调用方都必须带 refID——
// 不给幂等键的资产变更,代码评审阶段就该被打回
func (l *Ledger) Apply(uid uint64, cur uint8, delta int64, reason uint16, refID string) (int64, error) {
if _, ok := l.seen[refID]; ok {
return 0, ErrDuplicateRef // 网络重试/消息重投在这里被吸收
}
key := accountKey{UID: uid, Currency: cur}
next := l.balances[key] + delta
if next < 0 {
return 0, ErrInsufficient // 余额不为负是账务不变式,不依赖调用方自觉
}
l.seen[refID] = struct{}{}
l.balances[key] = next
l.logs = append(l.logs, CurrencyLog{
RefID: refID, UID: uid, Currency: cur,
Delta: delta, Balance: next, Reason: reason,
})
return next, nil
}三个纪律都落在这一个函数里。幂等在前:RefID 查重是第一道闸,同一订单号的重复请求在这里被吸收,网络重试、消息重复投递都不会变成重复发放;余额校验在中:扣减前置检查保证「余额永不为负」这条不变式;流水与余额一起变更:生产实现里这两步是同一数据库事务(第 8 章),内存演示版把顺序写清楚就够了——先记 seen、再改余额、追加流水,中途任何失败都不会出现「钱变了但没有证据行」的中间态。
产消比:不另做埋点,聚合流水就是权威口径
// produceConsumeRatio:某货币的消耗/产出比(6.2 节健康区间 0.8-1.2)。
// 不做埋点:按 Delta 正负聚合流水就是权威口径,Reason 留作下钻维度
func produceConsumeRatio(logs []CurrencyLog, cur uint8) float64 {
var produced, consumed int64
for _, e := range logs {
if e.Currency != cur {
continue
}
if e.Delta > 0 {
produced += e.Delta
} else {
consumed -= e.Delta // 负数取绝对值累加
}
}
if produced == 0 {
return 0 // 无产出的货币无从谈比值,交由上层按异常数据排查
}
return float64(consumed) / float64(produced)
}6.2 节的健康区间与 6.4 节的通胀早期信号,全部建立在「产消数据可信」之上。这段代码的意义在于统计口径的唯一性:按 Currency 过滤、按 Delta 正负聚合,产出与消耗各算各的,Reason 字段留作下一层下钻的维度(哪个玩法在放水)。客户端埋点会被弱网丢包、被脚本污染,服务端流水不会——这正是 6.3 节说「按 Reason + Delta 正负聚合流水就是权威经济数据」的落地形态。
这套管线的扩展方向都不动摇结构:接数据库事务只是把 seen 换成唯一索引、把顺序写换成同事务写;接 GM 补发只是多一个 Reason 枚举值;接对账系统只是离线扫 logs 核对 Balance 快照链。经济监控(6.4 节)与反作弊(第 18 章)共享的正是这张流水表。
12. 服务端开发 Checklist
接到策划需求时,先问这 10 个问题:
□ 1. 这个系统的状态需要持久化吗?
□ 2. 这个操作需要事务保证吗?
□ 3. 这个计算应该在客户端还是服务端?
□ 4. 这个功能的并发安全性如何?
□ 5. 这个系统的性能瓶颈在哪里?
□ 6. 这个功能需要跨服支持吗?
□ 7. 这个功能的反作弊策略是什么?
□ 8. 这个功能的数据量预估是多少?
□ 9. 这个功能的上线/下线方案是什么?
□ 10. 这个功能需要灰度发布吗?数值专项再补 6 个:
□ 11. 随机数需要确定性吗?(回放/裁决/跨端一致)
□ 12. 概率结果可追溯吗?(审计日志 + 实际出率监控)
□ 13. 货币/资产变更有流水和幂等键吗?
□ 14. 数值参数全部配置化了吗?(热更能力 = 修复速度)
□ 15. 掉落概率会被新增物品稀释吗?(分池 + CI 概率校验)
□ 16. 上线后看哪些指标?(产消比、集中度、通过率)13. 小结
| 关键问题 | 答案 |
|---|---|
| 服务端为什么要懂数值? | 每个数值决策都会变成数据结构和计算逻辑,评审期识别风险的成本远低于上线后补救 |
| 属性计算的层次? | 基础 → 百分比 → 固定;多来源合并区分加法区/乘法区,减伤必须乘法区 |
| 伤害计算的技术纪律? | 全程整数运算(万分比)、int64 防溢出、服务端封顶、确定性随机进战斗日志 |
| 保底怎么实现才可信? | 计数器是服务端资产级状态,随抽卡同事务持久化,绝不信任客户端上报 |
| 掉落系统的稀释陷阱? | 权重制下新增物品会稀释稀有掉率,用分池设计隔离 + CI 概率校验 |
| 货币变更的实现纪律? | 流水先行:幂等键 + 余额快照 + 原因枚举,统计和对账都从流水聚合 |
| 通胀的早期信号? | 产消比走低、头部集中度上升、囤积拐点——都在流水里,不等物价飞涨 |
| 数值参数怎么管理? | 全部配置化热更——参数级修复是分钟级,结构性修复是版本级 |
| 经济/平衡/风控的关系? | 同一系统的三个侧面,共享流水数据基础,用"事实→偏离→原因→动作"框架协作 |
14. 实战案例:一次「攒了 45 抽白攒」的保底清零排查
案例为教学示例(非摘自真实项目),数字经过取整简化,聚焦排查思路与回填逻辑。
背景与现象
卡池轮换次日,客诉 2417 份,集中同一句话:「换池后保底没了」。公示页写明「抽数继承」,玩家换池时平均已攒 45 抽——按 90 抽硬保底算,正是攒到半程;新池却人人从 0 开始。运营第一反应是显示 bug,客户端与服务端对完账:新池 PityState.Count 真的是 0。
第一步:现象与口径
三个口径对同一段抽数给出三个答案:
- 公示口径:换池公示写「抽数继承」——4.5 节公示的 4 条里第 2 条「保底机制的详细说明」承诺了保底规则,本池文本写明继承;
- 实现口径:换池逻辑是新建
PityState{}零值——Count与Guaranteed双双归零,「继承」在代码里没有对应实现; - 审计口径:4.4 节留痕抽样——10 个字段的日志里,
pity_count_before逐人可回溯:抽样换池前有计数的 400 名玩家,新池首抽为 0 的有 400 人(100%)。
口径差不在抽数上,在承诺上:公示「继承」,审计实测 100% 清零——先对齐这条差,再谈归因。
第二步:分层归因
按抽数状态从表象到契约逐层走:
客户端层 新池抽数显示 0,与服务端一致——排除显示 bug 与本地缓存
状态机层 换池 = 新建 PityState 零值:Count 与 Guaranteed 双双归零,继承规则从未被实现
配置层 需求单写「继承」,配置表与代码都没有「换池规则」这个字段——无从显式执行
公示层 公示页按需求单写「继承」,实现按默认零值走——承诺与实现差着一整条规则
审计层 4.4 留痕 10 字段完整,pity_count_before 逐人可回溯——清零前的计数全在日志里账本收敛:换池时有计数的玩家 8000 人,平均清掉 45 抽——正是硬保底 90 抽的一半(90 ÷ 2 = 45);合计 36 万抽(8000 × 45),按单抽 6 元折算 = 216 万元;客诉 2417 份(影响玩家的 30%)只是这批损失里发声的那一角。
第三步:根因
- 直接原因:换池实现是「新建
PityState{}零值」——继承规则只存在于需求单和公示页,代码里从没有被实现过。这不是持久化字段丢失(Count、Guaranteed每次抽取都写得好好的),是资产在状态机里被重建。 - 机制原因:4.3 节的换池关键点「显式处理而不是隐式沿用」走了默认零值这条最隐式的路;对账只对了概率没对规则——4.4 节的「实际出率 vs 公示」不含保底规则。12 节 Checklist 的 16 项里第 9 条「上线/下线方案」没把卡池轮换当上下线方案评审,需求单里「继承」两个字没有任何执行点。
- 深层原因:1.2 节的 4 问里,「这个计数器存在哪」是第 3 问——答案「持久化在服务端」全对,但四问覆盖了存不存、在哪存,没覆盖生命周期事件(轮换/合并/回档)谁定规则;保底计数是资产,换池是这个资产最大的一次交易,而它没有契约。
第四步:处置与回填
- 止血回补:以 4.4 节留痕为依据,按每人
pity_count_before恢复计数——8000 人逐人回补、0 失败;审计日志从「应对投诉的证据」变成了修复工具。 - 规则显式化:换池规则(继承/清零)进配置表,策划显式配置、代码按配置执行,空配置拒绝上线——对齐 4.3 节「显式处理而不是隐式沿用」。
- 公示对账进 CI:公示文本的保底规则与配置表逐字段比对,不一致阻断——把 4.4 节「实际出率 vs 公示」的对账从概率扩展到规则层。
- 评审补问:接需求的四问补上第 5 问——「换池、合并、回档这类生命周期事件,规则是什么?」进需求评审模板。
复核:客诉 2417 份 → 0 份,抽样 400 人计数恢复 400 人(100%);下次换池演练 0 起清零——公示、配置、实现三者第一次逐字对上。
回填清单:
| 回填项 | 落点 | 回链 |
|---|---|---|
| 换池规则进配置表,空配置拒绝上线 | 数值配置 | 第 02 讲 4.3 节 / 12 节 Checklist |
| 公示文本与保底配置 CI 对账 | 概率对账 | 第 02 讲 4.4、4.5 节 |
| 抽数生命周期事件补进接需求四问 | 需求评审 | 第 02 讲 1.2 节 |
| 留痕回补流程进客诉与风控 SOP | 客诉处理 | 第 02 讲 4.4 节 / 第 18 讲 |
案例的三个教训
- 资产级状态的敌人不是丢失,而是重建——
Count、Guaranteed每次抽取都在正常持久化,换池时一次零值重建就全没了;状态机要为轮换、合并、回档这类生命周期事件写显式规则(4.3 节「显式处理而不是隐式沿用」)。 - 公示是承诺,不是文案——概率能做「实际 vs 公示」的对账(4.4 节),保底规则同样能:公示文本与配置表之间缺一道机器对账,差的正好就是这条规则。
- 留痕是最便宜的保险——
pity_count_before让 8000 人逐人回补零失败;没有留痕,同样的客诉只能拍脑袋补偿(1.2 节第 4 问「要不要留痕」)。