《MMO 服务器开发》6.3 战斗结算与防重复发奖

一次公会战结算要同时做对三件事:命中扣血、判定死亡、发放奖励,而且重复到达的结算请求只能发一次奖。本节给 aegis 从零写战斗结算与防重复发奖:为什么结算和发奖必须分开、迟到的伤害包怎么被死亡状态挡掉、为什么发奖的去重键是业务指纹而不是传输序号,并链回卷一的幂等窗口。

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三发伤害只报一次死
TestLedgerGrantsOncePerFingerprinttotal=140,deduped=1换 Seq 仍只发一次
RewardLedger.Apply178 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 掉落表服务 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标