15.2 移动与战斗合法性校验
上一节把边界画在了「客户端提议、服务端裁决」上。这一节把这条边界落到最频繁、也最容易被滥用的两条路径上:移动和战斗。它们之所以是重灾区,是因为它们每帧都在发生——一个玩家的移动包一秒钟来十几条,攻击包也几乎不断,任何一条没校验的路径都会被脚本放大成灾难。
移动校验的难点在于「合法」没有唯一答案。一个玩家从 A 走到 B,可能是正常走路、可能是被位移技能推过去的、可能是在延迟补偿窗口里被回溯的。如果校验只认「直线距离 ÷ 时间 ≤ 最大速度」,它会把一堆合法的位移判成作弊。所以这一节不追求「完美判定」,而是给一组保守、可解释、可调参的规则:宁可放过一个慢速作弊者,也不要误伤一个被技能推飞的正常玩家。
战斗侧的重点则更简单:服务端独占冷却和命中判定。客户端可以表演,但「能不能放」和「打没打中」由服务端说了算。
移动校验:把「提议」变成可判定的数
先把输入约束干净。From 不是客户端说的,是服务端上一次确认的位置。客户端只能提供 To(目的地)和 elapsed(它声称过去的时间)。这两个都是提议:
To是Advisory:可以接受,但要过校验。elapsed是Untrusted:客户端说「过了 100ms」不算数,服务端用自己的 tick 间隔或收到包的时间差。
然后有三条递进的规则:
- 瞬移(teleport):单帧位移超过一个硬上限,直接拒。这条最粗暴也最有效——正常玩家的单帧位移有物理上限,任何超过它的都是传送。
- 超速(overspeed):位移 ÷ 时间 = 观测速度,再除以角色的服务端已知最大速度,得到比值。比值超过容差(默认 1.1)就拒。容差是给浮点误差和调度抖动留的余量。
- 穿墙(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 异常行为信号与限速 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。