6.3 战斗结算与防重复发奖
前两节把命令送进了场景循环、把技能变成了带命中时间的 Cast。这一节处理最后的落地:命中那一刻,世界状态怎么变。具体是三件事——扣血、判定死亡、发放奖励。前两件是纯计算,第三件一旦沾上「玩家收益」和「不可逆」,就会变成整条战斗管线里最容易出事的地方。
为什么奖励特别难做对?因为发奖的触发源天然会重复。一场 Boss 战可能由多个伤害来源共同击杀,每个来源的客户端都会上报「我打死了它」;网关在超时后可能重发;玩家断线重连后可能补发最后一次攻击。如果每次「结算请求」都老老实实发一次奖,玩家就会收到一背包重复的装备——这不是理论风险,而是每款上线游戏都踩过的真实事故。
这一节的做法是把结算和发奖拆成两层:结算层只负责让 HP 走到 0,它是幂等的——对一个已经死掉的单位再打一次,什么都不会发生;发奖层则用一个业务指纹(这场战斗的这次掉落)做去重,不管请求带着什么序号来,同一个指纹只兑现一次。前半段的思路和卷一 11.1 的幂等窗口一脉相承,但这里用的是更强的、永不忘记的唯一约束。
结算为什么必须和发奖分开
把结算和发奖揉在一起,最直接的写法是「HP 减到 0 就发奖」。它在单线程、单来源的测试里能过,问题出在两个地方。
第一,HP 归零可以被多次观察到。HP 减到 0 的那一帧,如果因为某种原因(比如同帧两个伤害包、或者前摇命中与 Buff 周期伤害同时落地)走了两遍结算,第二遍看到的是「HP 已经是 0」,如果发奖逻辑是「HP<=0 就发」,就会发两次。正确做法是:结算层在第一次把 HP 打到 0 时返回一个「这是致命一击」的布尔,之后所有针对该单位的伤害都因为「目标已死」被丢弃。发奖只在收到那个布尔时触发一次。
第二,发奖的触发不该依赖结算的执行次数,而该依赖战斗这件事本身的唯一标识。一场战斗有一个 Battle 编号,一次掉落有一个 Drop 编号,两者拼起来就是这个奖励的身份证。无论结算被触发几次、请求带着什么 Seq 到达,同一个身份证只兑现一次。这把「发奖的幂等性」从「保证结算只跑一次」里解耦了出来——后者几乎不可能保证,前者却可以用一个 map 或一行唯一索引轻松做到。
一句话:结算负责让状态正确地到达死亡,发奖负责让收益正确地只到账一次。 两件事的幂等键不一样,所以必须分开。
命中判定与死亡结算
结算层持有场景里的所有 Actor(ID、HP、Buff 集合),对外只暴露一个 Apply(DamageEvent)。它的核心契约是:对活着的单位扣血,对已死的单位什么都不做,只在把 HP 打到 0 的那一次报告击杀。下面这段是 L1,go test 可以直接跑:
// file: internal/combat/settlement.go
// tier: L1
// verify: go test ./internal/combat/ -run 'TestSettle|TestLedger' -v
package combat
import "sync"
// DamageEvent is one resolved hit produced by the cast pipeline.
type DamageEvent struct {
Attacker EntityID
Target EntityID
Skill uint32
Amount int
Tick int64
}
// Actor is the minimal combat state the settlement needs: HP and live buffs.
type Actor struct {
ID EntityID
HP int
MaxHP int
Buffs BuffSet
}
// Settlement applies resolved hits and, on a kill, hands the reward to the
// ledger. It is the only place HP moves, so the scene loop can reason about
// combat as a pure function of the drained command order.
type Settlement struct {
mu sync.Mutex
Actors map[EntityID]*Actor
Ledger *RewardLedger
Kills int
}
// NewSettlement returns a settlement with an empty actor table.
func NewSettlement(ledger *RewardLedger) *Settlement {
return &Settlement{Actors: make(map[EntityID]*Actor), Ledger: ledger}
}
// Add registers an actor.
func (s *Settlement) Add(a *Actor) {
a.Buffs = *NewBuffSet()
s.Actors[a.ID] = a
}
// Apply folds one hit into the target. It returns the target's HP after the hit
// and whether this hit was the killing blow. A hit against an already-dead actor
// is ignored (returns dead=false), which is how a late-arriving packet after
// death is neutralised without special-casing the network layer.
func (s *Settlement) Apply(ev DamageEvent) (hp int, dead bool) {
s.mu.Lock()
defer s.mu.Unlock()
a := s.Actors[ev.Target]
if a == nil || a.HP <= 0 {
return 0, false
}
a.HP -= ev.Amount
if a.HP > 0 {
return a.HP, false
}
a.HP = 0
s.Kills++
return 0, true
}
// RewardCmd is the reward side of a kill. Battle+Drop is the business
// fingerprint; Seq is the transport sequence of the originating request and is
// carried only for logging and ordering, never for the dedup decision.
type RewardCmd struct {
Battle uint64
Drop uint32
Seq uint32
Amount int
}
// RewardLedger grants each (Battle, Drop) reward exactly once. It stands in for
// the persistent unique key described in volume 1: the key is the business
// fingerprint, not the transport Seq, so a retry that arrives with a fresh Seq
// still collapses to a single grant.
type RewardLedger struct {
mu sync.Mutex
granted map[rewardKey]int
total int64
deduped int64
}
type rewardKey struct {
Battle uint64
Drop uint32
}
// NewRewardLedger returns an empty ledger.
func NewRewardLedger() *RewardLedger {
return &RewardLedger{granted: make(map[rewardKey]int)}
}
// Apply credits the reward and returns the amount actually granted: the full
// Amount on first sight, 0 when the fingerprint was already granted.
func (l *RewardLedger) Apply(c RewardCmd) int {
l.mu.Lock()
defer l.mu.Unlock()
k := rewardKey{Battle: c.Battle, Drop: c.Drop}
if _, ok := l.granted[k]; ok {
l.deduped++
return 0
}
l.granted[k] = c.Amount
l.total += int64(c.Amount)
return c.Amount
}
// Granted reports whether a fingerprint has already been paid out.
func (l *RewardLedger) Granted(battle uint64, drop uint32) bool {
l.mu.Lock()
defer l.mu.Unlock()
_, ok := l.granted[rewardKey{Battle: battle, Drop: drop}]
return ok
}
// Stats returns the total credited amount and the number of deduped retries.
func (l *RewardLedger) Stats() (total, deduped int64) {
l.mu.Lock()
defer l.mu.Unlock()
return l.total, l.deduped
}
Apply 里那个 if a == nil || a.HP <= 0 { return 0, false } 是整段的关键。它把迟到的伤害包和重复的结算统一挡在门外:目标已经死了,再来多少伤害都返回 dead=false,发奖方因此不会被二次触发。这不是给网络层打的补丁,而是结算层本该有的语义——死人是不会再死一次的。
防重复发奖:业务指纹而不是传输序号
现在看发奖层。RewardCmd 里有 Battle、Drop、Seq、Amount 四个字段,但去重只用前两个。这是本卷最容易搞错、也最值得反复强调的一点:传输序号不是业务标识。
Seq来自卷一固定头的第 3 个字段,是每条连接自己数的,客户端重连后会重置,网关重发时甚至可能换个连接、换个Seq。Battle + Drop是业务指纹:这场战斗的第几次掉落。它由服务端在战斗开始时分配,与传输层无关。
如果拿 Seq 去重,一条被网关重发、但 Seq 变了的老请求就会绕过去重,发两次奖;如果拿业务指纹去重,无论请求来几次、带什么 Seq,都只兑现一次。卷一 11.1 的幂等窗口是按时间记「最近处理过什么」,它会随时间遗忘;而发奖用的是永不遗忘的唯一约束,两者的分工正是:窗口挡住绝大多数短期重试,唯一约束兜住剩下的、跨越很久的重放。这一段和 /mmo-net-11-1-idempotency-window/
是同一个思路的落地版。
测试把这两条性质都钉死——死亡只报一次、指纹去重、不同掉落各自发奖:
// snippet: internal/combat/settlement_test.go(节选)
func TestSettleKillOnce(t *testing.T) {
s := NewSettlement(NewRewardLedger())
s.Add(&Actor{ID: 1, HP: 30, MaxHP: 30})
hp, dead := s.Apply(DamageEvent{Attacker: 9, Target: 1, Skill: 1, Amount: 20})
if dead || hp != 10 {
t.Fatalf("first hit: hp=%d dead=%v, want 10/false", hp, dead)
}
_, dead = s.Apply(DamageEvent{Attacker: 9, Target: 1, Skill: 1, Amount: 20})
if !dead {
t.Fatal("killing blow not reported")
}
// A late packet against the corpse must not resurrect or re-kill it.
if _, dead := s.Apply(DamageEvent{Attacker: 9, Target: 1, Skill: 1, Amount: 50}); dead {
t.Fatal("hit on a dead actor was treated as a kill")
}
if s.Kills != 1 {
t.Fatalf("kills = %d, want 1", s.Kills)
}
}
func TestLedgerGrantsOncePerFingerprint(t *testing.T) {
l := NewRewardLedger()
if got := l.Apply(RewardCmd{Battle: 7, Drop: 1, Seq: 1, Amount: 100}); got != 100 {
t.Fatalf("first grant = %d, want 100", got)
}
// Same fingerprint, new transport Seq: still exactly one grant.
if got := l.Apply(RewardCmd{Battle: 7, Drop: 1, Seq: 2, Amount: 100}); got != 0 {
t.Fatalf("duplicate grant = %d, want 0", got)
}
// A different drop in the same battle is a different reward.
if got := l.Apply(RewardCmd{Battle: 7, Drop: 2, Seq: 3, Amount: 40}); got != 40 {
t.Fatalf("second drop = %d, want 40", got)
}
total, deduped := l.Stats()
if total != 140 || deduped != 1 {
t.Fatalf("total=%d deduped=%d, want 140/1", total, deduped)
}
if !l.Granted(7, 1) || l.Granted(7, 9) {
t.Fatal("Granted reported the wrong fingerprints")
}
}
一个必须说清的边界:本节的内存 RewardLedger 只是演示用的替身。真实系统里这个 granted 表要落到数据库的唯一索引上——UNIQUE(battle_id, drop_id),插入冲突就说明是重复发奖,直接返回 0。内存版会在重启后丢失,唯一索引不会。这正是「结算可以内存做,发奖必须持久化」的分界线。
真跑与实测结论
本机(Go 1.26,macOS arm64,Apple M1 Pro)实测,同机相对量级,绝对数值不可移植:
| 指标 | 实测值 | 说明 |
|---|---|---|
TestSettleKillOnce | 击杀计数 = 1 | 三发伤害只报一次死 |
TestLedgerGrantsOncePerFingerprint | total=140,deduped=1 | 换 Seq 仍只发一次 |
RewardLedger.Apply | 178 ns/op,94 B/op | 含 map 查找与首次插入 |
两个结论。其一,「死亡状态」本身就是最强的去重。TestSettleKillOnce 里第三发 50 点伤害打在尸体上返回 dead=false,靠的不是序号、不是时间窗,而是「死人不会死第二次」这个业务事实——这类去重最便宜也最可靠,因为它不需要额外的簿记结构。其二,发奖去重的成本可以忽略。Apply 一次不到两百纳秒,而它挡下的是「玩家凭空多一件神装」这种能直接摧毁经济的事故;把去重键从易变的 Seq 换成稳定的业务指纹,是整条战斗管线里性价比最高的一次修正。
到这里,第 6 章的战斗管线闭环了:命令按确定的顺序排空(6.1)、技能与 Buff 在时间线上正确建模(6.2)、命中落地成一次且仅一次的结算与发奖(6.3)。下一章处理这些死亡和击杀之后要发生的事——掉落表怎么查、随机怎么定、保底怎么算。
阅读导航:上一节:6.2 技能与 Buff 建模 · 下一节:7.1 掉落表服务 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。