三个产品,三种“变强”
《裂隙战场》的局内属性由英雄模板和当局等级决定,账号等级只解锁天赋槽,不直接进战斗公式;《群岛纪元》的英雄属性由基础成长、装备、临时 buff 和赛季天赋四层叠出来;《泡泡工坊》最轻,只有外观收集和一条几乎不影响玩法的等级线。三款产品都叫“成长”,但需要服务端保证的东西完全不同。
北辰工作室在《群岛纪元》季前赛遇到过一次事故:同一个英雄,客户端预览的攻击力比服务端战斗结算高 7%(示例值)。排查后发现,战斗服先乘了赛季加成再乘 buff,而养成服先乘 buff 再乘赛季加成,两边都“按百分比加”,结果不同。更麻烦的是,两个服务各自缓存了计算结果,没有版本或指纹,谁也不知道自己算的是哪一版。
问题不在某一行乘法,而在契约缺失:属性来源没有分层,合成顺序没有写进接口,快照没有身份。本节把这三件事拆开,给出一个可以被两个服务同时实现的合成器,以及一个只接受合法迁移的成长状态机。
属性来源必须分层
先决定“谁有资格改属性”。如果每个系统都能直接写最终值,排查时只能看到结果,看不到来源。北辰的做法是固定四层,每层只允许两种运算:
| 层 | 典型来源 | 运算 | 何时重算 | 示例值 |
|---|---|---|---|---|
| 基础 | 等级、突破、职业模板 | 平加 | 等级/阶段/职业变化 | atk 100 |
| 装备 | 武器、套装、强化 | 平加 + 百分比 | 换装、强化成功 | +20,+10% |
| buff | 技能、药剂、场景光环 | 百分比 | 进入/离开战斗 | +5% |
| 赛季 | 赛季天赋、排名加成 | 平加 | 赛季切换 | +5 |
这张表最重要的不是四层本身,而是“层”与“运算”的绑定。基础层只做平加,因为等级带来的应该是确定数值;装备层允许平加和百分比,因为强化同时有固定值和词条;buff 只做百分比,避免临时效果被当成永久基础;赛季放在最后,作为全局修正,不能被 buff 再次放大。
层内也有规则:同一层先汇总所有平加,再汇总所有百分比,最后只乘一次。这样同一层里修改器的输入顺序不会改变结果,两个服务即使从不同的 Map 顺序遍历,也能得到同一个数。跨层则按固定顺序连乘,赛季永远在 buff 之后。
计算顺序决定谁乘谁
把上面的规则写成伪代码,只有四行:
for layer in [base, equipment, buff, season]:
value = value + sum(flat modifiers in layer)
value = value * (10000 + sum(percent basis points in layer)) / 10000
这段伪代码有两个容易被忽略的边界。第一,百分比用基点整数表示,1500 就是 15%,不用浮点。浮点在单机上看起来没问题,但不同 CPU、不同编译器优化、不同语言运行时对 0.1 + 0.2 的处理并不保证一致;一次战斗回放只要差 1 点血,整个结算就无法复现。第二,舍入规则必须写死。示例实现采用“四舍五入到整数”,并且只对非负值做除法,避免 Go 的整数除法向零截断在负值上产生另一种结果。
如果某一层只给了百分比、没有基础值,合成结果仍是 0,而不是报错。这是有意的:装备可能只加暴击率,而暴击率的基础值由职业模板提供;缺失的基础值应该在配置校验阶段发现,而不是在合成阶段猜测。
快照与传播:哪些要实时算
合成结果不能只是一个 map[string]int64。两个服务交换属性时,需要知道“这是哪一版”。示例实现给每次合成结果算一个指纹:把所有属性名排序,拼成 name=value; 的规范串,再做 SHA-256。指纹相同,属性一定相同;指纹不同,才需要逐项比较。
有了指纹,传播策略就可以按消费者分级,而不是一律全量推送:
| 属性 | 消费者 | 传播方式 | 可接受延迟 |
|---|---|---|---|
| hp_max、atk、def、speed | 战斗 tick | 主动推送 | 下一 tick 前 |
| crit、crit_dmg | 伤害公式 | 主动推送 | 下一 tick 前 |
| power、战力评分 | 个人主页、匹配 | 拉取 | 秒级 |
| 收集度、成就统计 | 非战斗系统 | 懒计算 | 分钟级 |
分界线不是“重要不重要”,而是“错了会不会立刻改变战斗结果”。hp_max 晚一个 tick 更新,可能出现“血条已满但实际上限还是旧值”的瞬间;收集度晚几秒,玩家只会看到数字跳一下。把这条分界写进接口,战斗服就不需要订阅所有属性变化,养成服也不需要为展示字段维护实时推送。
成长状态机:拒绝非法跃迁
等级、突破、转职表面上是一条线,实际上有多个不能跨越的边界。示例规则是:未觉醒 1–20 级,觉醒 21–40 级,高级 41–60 级;20 级满经验且材料足够才能突破,50 级且已觉醒才能转职。数值是示例值,关键是每条迁移的前置条件都在服务端。
| 当前状态 | 动作 | 前置条件 | 结果 |
|---|---|---|---|
| 1–19 未觉醒 | 升级 | 未到本阶段上限 | 等级 +1 |
| 20 未觉醒 | 升级 | 已到上限 | 拒绝,返回 level cap |
| 20 未觉醒 | 突破 | 材料 ≥ 12 | 进入觉醒,等级 21 |
| 10 未觉醒 | 突破 | 未到上限 | 拒绝,返回 not ready |
| 60 高级 | 突破 | 已是最终阶段 | 拒绝,返回 illegal transition |
| 49 高级 | 转职 | 等级 < 50 | 拒绝,返回 not ready |
| 50 高级 | 转职 | 目标职业不同 | 写入新职业 |
| 50 高级 | 转职 | 目标职业相同 | 拒绝,返回 class unavailable |
这张表是服务端和客户端共同的契约。客户端可以提前灰掉按钮,但服务端不能因此省略校验:重连、断线重发、协议重放都会把同一个“突破”包送到服务端两次。状态机的职责不是让合法操作更快,而是让非法操作稳定失败,并且失败时状态不变。
可运行片段:growthfsm
下面的 internal/growthfsm 把四层合成、快照指纹和状态机放在同一个包里。它不依赖数据库,也不依赖时间,所有输入都显式传入,方便在两个服务里复用同一份实现。
// file: internal/growthfsm/growthfsm.go
// tier: L1
// verify: go test ./internal/growthfsm/
package growthfsm
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sort"
"strings"
)
// Layer fixes composition order. A season bonus must never be multiplied by
// a buff, so the order is part of the contract, not an implementation detail.
type Layer int
const (
LayerBase Layer = iota
LayerEquipment
LayerBuff
LayerSeason
numLayers
)
// Op is deliberately small. Percent values are basis points: 1500 = +15%.
type Op int
const (
OpAdd Op = iota
OpPct
)
type Modifier struct {
Layer Layer
Stat string
Op Op
Value int64
}
var (
ErrUnknownLayer = errors.New("growthfsm: unknown layer")
ErrUnknownOp = errors.New("growthfsm: unknown op")
ErrEmptyStat = errors.New("growthfsm: empty stat")
ErrNegativeBase = errors.New("growthfsm: negative base stat")
ErrNegativeStat = errors.New("growthfsm: negative composed stat")
)
// Stats are integers. Floats would let two servers disagree after a few
// thousand operations, and replay would stop matching.
type Stats map[string]int64
// Compose applies layers from base to season. Within one layer all flat adds
// are summed first, then all percentages are summed and applied once, so the
// order of modifiers in the input never changes the output.
func Compose(base Stats, mods []Modifier) (Stats, error) {
out := make(Stats, len(base))
for stat, v := range base {
if strings.TrimSpace(stat) == "" {
return nil, ErrEmptyStat
}
if v < 0 {
return nil, fmt.Errorf("%w: %s=%d", ErrNegativeBase, stat, v)
}
out[stat] = v
}
flat := make([]map[string]int64, int(numLayers))
pct := make([]map[string]int64, int(numLayers))
for i := range flat {
flat[i] = map[string]int64{}
pct[i] = map[string]int64{}
}
for _, m := range mods {
if strings.TrimSpace(m.Stat) == "" {
return nil, ErrEmptyStat
}
if m.Layer < LayerBase || m.Layer >= numLayers {
return nil, fmt.Errorf("%w: %d", ErrUnknownLayer, m.Layer)
}
switch m.Op {
case OpAdd:
flat[m.Layer][m.Stat] += m.Value
case OpPct:
pct[m.Layer][m.Stat] += m.Value
default:
return nil, fmt.Errorf("%w: %d", ErrUnknownOp, m.Op)
}
}
for layer := LayerBase; layer < numLayers; layer++ {
for stat, add := range flat[layer] {
out[stat] += add
if out[stat] < 0 {
return nil, fmt.Errorf("%w: %s=%d", ErrNegativeStat, stat, out[stat])
}
}
for stat, bp := range pct[layer] {
if 10000+bp < 0 {
return nil, fmt.Errorf("%w: %s percent=%d", ErrNegativeStat, stat, bp)
}
out[stat] = mulDiv(out[stat], 10000+bp, 10000)
}
}
return out, nil
}
// mulDiv rounds half up. Only non-negative values reach it.
func mulDiv(v, mul, div int64) int64 {
if v == 0 {
return 0
}
return (v*mul + div/2) / div
}
// Snapshot is an immutable view plus a fingerprint. Combat caches compare the
// fingerprint instead of walking every stat.
type Snapshot struct {
Stats Stats
Fingerprint string
}
// NewSnapshot copies the map: a caller still mutating its own Stats must not
// be able to invalidate a fingerprint that is already in flight.
func NewSnapshot(stats Stats) Snapshot {
copied := make(Stats, len(stats))
keys := make([]string, 0, len(stats))
for k, v := range stats {
copied[k] = v
keys = append(keys, k)
}
sort.Strings(keys)
h := sha256.New()
for _, k := range keys {
fmt.Fprintf(h, "%s=%d;", k, copied[k])
}
return Snapshot{Stats: copied, Fingerprint: hex.EncodeToString(h.Sum(nil))}
}
type Phase int
const (
PhaseUnawakened Phase = iota
PhaseAwakened
PhaseAdvanced
)
// MaxLevel caps one phase. A level-up never crosses a phase; only a
// breakthrough does, and it costs materials.
func MaxLevel(p Phase) int {
switch p {
case PhaseUnawakened:
return 20
case PhaseAwakened:
return 40
case PhaseAdvanced:
return 60
default:
return 0
}
}
type State struct {
Level int
Phase Phase
Class string
}
type ActionKind int
const (
ActLevelUp ActionKind = iota
ActBreakthrough
ActChangeClass
)
type Action struct {
Kind ActionKind
Class string
Materials int
}
const (
breakthroughMaterials = 12
classChangeLevel = 50
)
var (
ErrIllegalTransition = errors.New("growthfsm: illegal transition")
ErrMaxLevel = errors.New("growthfsm: level cap reached")
ErrNotReady = errors.New("growthfsm: transition prerequisite not met")
ErrClassUnavailable = errors.New("growthfsm: class unavailable")
ErrMaterials = errors.New("growthfsm: insufficient materials")
)
// Apply is the only mutation path. Rejecting replayed client packets matters
// more than accepting a legal action quickly.
func Apply(st State, act Action) (State, error) {
if st.Level < 1 || st.Level > MaxLevel(st.Phase) {
return st, fmt.Errorf("%w: level %d in phase %d", ErrIllegalTransition, st.Level, st.Phase)
}
switch act.Kind {
case ActLevelUp:
if st.Level == MaxLevel(st.Phase) {
return st, fmt.Errorf("%w: phase %d at %d", ErrMaxLevel, st.Phase, st.Level)
}
st.Level++
return st, nil
case ActBreakthrough:
if st.Phase == PhaseAdvanced {
return st, fmt.Errorf("%w: phase %d is final", ErrIllegalTransition, st.Phase)
}
if st.Level != MaxLevel(st.Phase) {
return st, fmt.Errorf("%w: need level %d, have %d", ErrNotReady, MaxLevel(st.Phase), st.Level)
}
if act.Materials < breakthroughMaterials {
return st, fmt.Errorf("%w: need %d, have %d", ErrMaterials, breakthroughMaterials, act.Materials)
}
st.Level = MaxLevel(st.Phase) + 1
st.Phase++
return st, nil
case ActChangeClass:
if st.Phase != PhaseAdvanced || st.Level < classChangeLevel {
return st, fmt.Errorf("%w: need advanced level %d", ErrNotReady, classChangeLevel)
}
if act.Class == "" || act.Class == st.Class {
return st, fmt.Errorf("%w: %q", ErrClassUnavailable, act.Class)
}
st.Class = act.Class
return st, nil
default:
return st, fmt.Errorf("%w: action %d", ErrIllegalTransition, act.Kind)
}
}
包内另有 4 个表驱动测试,覆盖:同层顺序无关、跨层连乘、非法层/非法运算/负属性被拒绝、指纹对 Map 顺序不敏感且对值变化敏感、以及 10 条合法与非法迁移。本机执行 gofmt -l internal/growthfsm 与 go vet ./internal/growthfsm/... 无输出,go test -count=1 ./internal/growthfsm/ 通过。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 客户端预计算最终属性 | 与服务端不一致,伤害漂移 | 服务端为准,回传快照与指纹 |
| 浮点百分比 | 重放结果不同,回放对不上 | 整数基点,固定舍入规则 |
| 同层修改器顺序依赖 | 两个服务算出不同值 | 同层先汇总平加,再汇总百分比 |
| 重复突破包 | 阶段跳跃或材料重复扣 | 状态机校验来源等级与阶段 |
| 快照别名 | 调用方改 Map,缓存被污染 | 构造快照时复制属性 Map |
| 负属性 | 战斗公式出现负数或除零 | 合成阶段拒绝负的基础值与结果 |
最后一行常被当成“配置问题”而不是“运行时问题”。但配置会热更,运营会发错邮件,GM 会手动改值;合成器如果不拒绝负值,战斗公式就要在每次读属性时做防御,成本和遗漏都比在入口拒绝高。
常见错误
第一,把赛季加成放进 buff 层。赛季是全局修正,放进 buff 会被其他 buff 乘一遍,季初和季末的数值口径立刻分叉。
第二,只校验目标等级,不校验来源状态。客户端发来“升到 21 级”,服务端直接写 21,就绕过了突破材料和阶段上限;状态机必须同时检查当前等级、当前阶段和动作类型。
第三,让客户端上报最终属性。客户端可以做预览,但服务端必须自己合成;否则改一个本地文件就能改战斗数值。
第四,快照不带指纹。没有指纹时,缓存只能靠 TTL 失效,要么更新太慢,要么全量重算太频繁。
第五,用浮点存百分比。百分比一旦进入战斗公式,就会被反复乘加;浮点误差会随着 buff 数量累积,最终表现为“同样的操作,两次结算差一点”。
小结
属性系统的核心不是公式,而是三份契约:来源分层、合成顺序、快照身份。把这三件事写死,两个服务才能独立计算又得到同一个结果;把状态迁移的前置条件写进服务端,客户端预览才只是预览。
下一节从“角色自己”转向“角色做了什么”:任务进度如何从领域事件里累加,重复投递、跨天补算和条件回填分别由谁负责。
阅读导航:上一节:6.3 经济风控与价格指数 · 下一节:7.2 任务事件与进度服务 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。