《MMO 服务器开发》15.2 移动与战斗合法性校验

移动和战斗是外挂最爱下手的两条路径。本节给 aegis 写一套真跑的校验:把「目的地」当提议、用位移与时间的比值判定超速、用单帧位移上限抓瞬移、用线段采样挡穿墙,再让服务端独占技能冷却与命中判定;并说明为什么校验函数要写成无时钟、无 I/O 的纯函数。每条规则都配了断言,跑得绿才贴进来。

15.2 移动与战斗合法性校验

上一节把边界画在了「客户端提议、服务端裁决」上。这一节把这条边界落到最频繁、也最容易被滥用的两条路径上:移动和战斗。它们之所以是重灾区,是因为它们每帧都在发生——一个玩家的移动包一秒钟来十几条,攻击包也几乎不断,任何一条没校验的路径都会被脚本放大成灾难。

移动校验的难点在于「合法」没有唯一答案。一个玩家从 A 走到 B,可能是正常走路、可能是被位移技能推过去的、可能是在延迟补偿窗口里被回溯的。如果校验只认「直线距离 ÷ 时间 ≤ 最大速度」,它会把一堆合法的位移判成作弊。所以这一节不追求「完美判定」,而是给一组保守、可解释、可调参的规则:宁可放过一个慢速作弊者,也不要误伤一个被技能推飞的正常玩家。

战斗侧的重点则更简单:服务端独占冷却和命中判定。客户端可以表演,但「能不能放」和「打没打中」由服务端说了算。

移动校验:把「提议」变成可判定的数

先把输入约束干净。From 不是客户端说的,是服务端上一次确认的位置。客户端只能提供 To(目的地)和 elapsed(它声称过去的时间)。这两个都是提议:

  • To 是 Advisory:可以接受,但要过校验。
  • elapsed 是 Untrusted:客户端说「过了 100ms」不算数,服务端用自己的 tick 间隔或收到包的时间差。

然后有三条递进的规则:

  1. 瞬移(teleport):单帧位移超过一个硬上限,直接拒。这条最粗暴也最有效——正常玩家的单帧位移有物理上限,任何超过它的都是传送。
  2. 超速(overspeed):位移 ÷ 时间 = 观测速度,再除以角色的服务端已知最大速度,得到比值。比值超过容差(默认 1.1)就拒。容差是给浮点误差和调度抖动留的余量。
  3. 穿墙(wall clip):即便速度和位移都合理,如果这条线段穿过了地图的实心格子,也拒。

这三条的顺序很重要:先判瞬移(便宜、绝对),再判超速(要算除法),最后判穿墙(要查地图)。便宜的先做,绝大多数非法包在第一步就被挡掉了。

还要提醒一句:校验的输出不只是「过 / 不过」。被拒绝时我们要记下「差了多少」——超速的比值、瞬移的距离。这些数字本身不是惩罚依据(一个卡顿的玩家也可能偶尔超速),但它们是下一节嫌疑分的原料。一个只会说「非法」的校验器,在 15.3 里什么都喂不出来。

代码:位移/时间比值、瞬移与穿墙

下面这段是 L1,go test 可以直接跑。它把三条规则和冷却校验放在一个文件里:

// file: internal/authority/move.go
// tier: L1
// verify: go test ./internal/authority/ -run 'TestValidateMove|TestCooldown|TestPathBlocked'

package authority

import (
	"math"
	"time"
)

// Vec3 is a world-space position. Y is the up axis.
type Vec3 struct{ X, Y, Z float64 }

// Sub returns v - o.
func (v Vec3) Sub(o Vec3) Vec3 { return Vec3{v.X - o.X, v.Y - o.Y, v.Z - o.Z} }

// Len returns the Euclidean length of v.
func (v Vec3) Len() float64 { return math.Sqrt(v.X*v.X + v.Y*v.Y + v.Z*v.Z) }

// MoveProposal is what the client claims it did between two server ticks. Note
// that "From" is the server's last acknowledged position, not whatever the
// client says it was: the server seeds it, the client only supplies To.
type MoveProposal struct {
	From    Vec3
	To      Vec3
	Elapsed time.Duration
	// Speed is the entity's server-known maximum speed in units per second.
	Speed float64
}

// MoveVerdict is the server's decision for one proposal.
type MoveVerdict struct {
	Allowed bool
	Reason  string
	// SpeedRatio is observed speed / allowed speed. It is the single number
	// 15.3 feeds into the anomaly accumulator.
	SpeedRatio float64
}

// MoveConfig carries the two knobs that matter: a slack multiplier to absorb
// float rounding and scheduling jitter, and a hard displacement ceiling above
// which a single tick is treated as a teleport.
type MoveConfig struct {
	Tolerance         float64
	TeleportThreshold float64
}

func (c MoveConfig) withDefaults() MoveConfig {
	if c.Tolerance <= 0 {
		c.Tolerance = 1.1
	}
	if c.TeleportThreshold <= 0 {
		c.TeleportThreshold = 40
	}
	return c
}

// ValidateMove adjudicates one movement proposal. It is deliberately pure: same
// inputs, same verdict, no clock, no I/O. That is what makes it cheap enough to
// run on every move packet and trivial to unit-test.
func ValidateMove(p MoveProposal, cfg MoveConfig) MoveVerdict {
	cfg = cfg.withDefaults()

	dist := p.To.Sub(p.From).Len()
	if dist > cfg.TeleportThreshold {
		return MoveVerdict{Reason: "teleport: single-tick displacement over threshold"}
	}
	if p.Elapsed <= 0 {
		return MoveVerdict{Reason: "non-positive elapsed time"}
	}
	if p.Speed <= 0 {
		return MoveVerdict{Reason: "no server-known speed for entity"}
	}

	observed := dist / p.Elapsed.Seconds()
	ratio := observed / p.Speed
	if ratio > cfg.Tolerance {
		return MoveVerdict{Reason: "overspeed", SpeedRatio: ratio}
	}
	return MoveVerdict{Allowed: true, SpeedRatio: ratio}
}

// SkillUse is a client's claim that it cast a skill at a given time. The client
// supplies the skill id; the server supplies "now".
type SkillUse struct {
	SkillID uint16
	Caster  uint64
	Now     time.Time
}

// CooldownTracker enforces skill cooldowns on the server. The client UI greys a
// button out, but that is cosmetic: the authoritative "ready at" lives here, so
// a modified client that ignores its own UI gains nothing.
type CooldownTracker struct {
	dur     map[uint16]time.Duration
	readyAt map[uint32]time.Time
}

// NewCooldownTracker builds a tracker from the skill -> duration table.
func NewCooldownTracker(durations map[uint16]time.Duration) *CooldownTracker {
	return &CooldownTracker{
		dur:     durations,
		readyAt: make(map[uint32]time.Time),
	}
}

func cooldownKey(caster uint64, skill uint16) uint32 {
	return uint32(skill)<<16 | uint32(caster&0xffff)
}

// TryUse records a use if the skill is off cooldown. When the skill is still on
// cooldown it returns the remaining time and false, and does not mutate state.
// An unknown skill id is rejected as not-ready.
func (t *CooldownTracker) TryUse(caster uint64, skill uint16, now time.Time) (time.Duration, bool) {
	d, known := t.dur[skill]
	if !known {
		return 0, false
	}
	k := cooldownKey(caster, skill)
	if ready, ok := t.readyAt[k]; ok && now.Before(ready) {
		return ready.Sub(now), false
	}
	t.readyAt[k] = now.Add(d)
	return 0, true
}

// SolidFunc reports whether the grid cell containing (x, z) blocks movement.
type SolidFunc func(x, z int) bool

// PathBlocked samples the segment from -> to on a 1-unit grid and reports
// whether any sampled cell is solid. It is a coarse but cheap conservative
// check. Sampling finer than the wall thickness is what catches a client that
// tries to clip through a thin wall by reporting a straight line across it.
func PathBlocked(from, to Vec3, step float64, solid SolidFunc) bool {
	if step <= 0 {
		step = 1
	}
	d := to.Sub(from)
	dist := d.Len()
	if dist == 0 {
		return solid(int(math.Floor(from.X)), int(math.Floor(from.Z)))
	}
	n := int(dist/step) + 1
	for i := 0; i <= n; i++ {
		f := float64(i) / float64(n)
		x := from.X + d.X*f
		z := from.Z + d.Z*f
		if solid(int(math.Floor(x)), int(math.Floor(z))) {
			return true
		}
	}
	return false
}

有三点值得单独说。第一,ValidateMove 是纯函数:没有时钟、没有 I/O,同样的输入永远给同样的判定。这正是它能在每个移动包上跑的原因——没有锁、没有分配、可以随便压测。第二,容差 Tolerance 是唯一的「宽容度旋钮」,默认 1.1 意味着允许 10% 的超速余量,用来吸收浮点误差和 tick 抖动,而不是给作弊留口子。第三,PathBlocked 的采样步长必须小于地图最薄墙的厚度,否则一条斜线可以从两个采样点之间穿过薄墙;这也是它被标成「保守」的原因。

测试用一组边界值把这三条规则钉死:

// snippet: internal/authority/move_test.go(节选)

// 以最大速度行走、以及容差内的抖动,都必须放行。
{name: "walking at exactly max speed", to: Vec3{6, 0, 0}, elapsed: time.Second, wantOK: true},
{name: "jitter within tolerance", to: Vec3{6.3, 0, 0}, elapsed: time.Second, wantOK: true},
// 3 倍移速、跨图瞬移、零时间,都必须拒绝。
{name: "sprint cheat", to: Vec3{18, 0, 0}, elapsed: time.Second, wantOK: false, wantCause: "overspeed"},
{name: "teleport", to: Vec3{100, 0, 0}, elapsed: time.Second, wantOK: false, wantCause: "teleport"},

// 一条穿过实心墙的直线必须被判为 blocked。
if !PathBlocked(Vec3{0, 0, 0}, Vec3{10, 0, 0}, 0.25, wall) {
	t.Fatal("straight line through the wall must be blocked")
}

冷却与技能合法性

战斗侧最容易出错的不是伤害公式,而是冷却归属。很多项目把冷却写在客户端:客户端自己数秒,服务端收到「放技能」就执行。这等于把「能不能放」的裁决权交给了玩家——改一个客户端变量就能无限放技能。

正确做法是服务端持有每个 (施法者, 技能) 的「下次可用时间」。TryUse 在冷却未到时返回剩余时间且不改变状态,冷却到点才记一次使用。客户端 UI 把按钮置灰只是表现,服务端这份表才是权威。注意两个细节:

  • 未知技能 id 一律拒绝。如果 TryUse 对不在表里的技能返回「可用」,那么一个伪造的 id 就能绕过冷却——所以表里没有的,默认不可用。
  • 冷却的键是 (施法者, 技能)。用 64 位 caster 的低 16 位拼进 32 位 key 是有意为之:一个场景里活跃实体数量有限,低 16 位足够区分,同时把表压得足够小。真跨场景时要换成全局唯一的实体 id。

命中判定同理:服务端拿双方的服务端位置和朝向做判定,客户端的「我打中他了」只是提议。这条和 14.2 的延迟补偿裁决是同一件事的两面——补偿是为了公平,权威是为了不被篡改。

本节小结

  • 移动校验的输入要先约束干净:From 由服务端给,客户端只提供 To 和声称的时间。
  • 三条递进规则:瞬移(硬上限)→ 超速(比值 vs 服务端速度)→ 穿墙(线段采样)。便宜的先做。
  • 校验函数写成纯函数,无时钟无 I/O,才扛得住每个包都跑。
  • 冷却和命中判定必须由服务端独占;客户端 UI 只是表现。
  • 拒绝时记录 SpeedRatio——它是 15.3 嫌疑分的原料,不是丢弃的噪声。

阅读导航:上一节:15.1 权威边界与信任模型 · 下一节:15.3 异常行为信号与限速 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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