《游戏服务端架构·核心系统与契约》7.1 属性、成长与状态机

《裂隙战场》《群岛纪元》《泡泡工坊》都要回答属性从哪里来、按什么顺序合成、哪些成长迁移必须拒绝。本节把基础、装备、buff、赛季四层拆开,讨论整数化合成、快照指纹、实时与懒计算的分界,并给出带非法迁移测试的 growthfsm 可运行包与边界。

三个产品,三种“变强”

《裂隙战场》的局内属性由英雄模板和当局等级决定,账号等级只解锁天赋槽,不直接进战斗公式;《群岛纪元》的英雄属性由基础成长、装备、临时 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 任务事件与进度服务 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查