18.2 《晨曦大陆》单服可玩
18.1 把玩法系统接进了 aegis:命令进了路由表、玩法进了 Server、帧走卷一的协议。但装配完成不等于能玩。一个服务端可能每一个单元测试都绿,装配起来却「能登录、能移动,但打完怪不掉东西」——因为掉落表没挂上;或者「能拾取、但交任务不发奖」——因为任务进度没接到拾取事件上。这些断裂只有一条端到端链路才能暴露。
本节给出一条可复现的验收链路,它把《晨曦大陆》的八个玩法串成一条:登录 → 进世界 → 移动 → AOI 广播 → 打怪 → 掉落 → 拾取入包 → 交任务 → 组队进副本。验收的判据不是「代码能编译」,而是这条链路每一步都能断言、且全部跑通。
验收的难点在「可复现」。掉落是随机的,如果验收用默认掉落表,今天跑出物品 A、明天跑出物品 B,任务进度就会时通时不通。所以验收必须固定随机:用一张只掉一种物品的确定表,把「随机」这个变量从验收里剔出去——随机本身由第 7 章的确定性随机保证,不需要在验收里再测一遍。同样,怪物的血量要卡到一次命中即死,把「打几下」也固定下来。
验收清单:八步链路
验收清单是这条链路的契约。每一步都有明确的输入与断言,任何一步断言失败,验收即失败:
| # | 步骤 | 断言 |
|---|---|---|
| 1 | 登录 | 三个机器人客户端拿到角色实体,落在同一实例 |
| 2 | 进世界 | 三个角色出现在同一场景实例,先入场者无邻居 |
| 3 | 移动 | 远离时 AOI 邻居被移除,走回时重新出现 |
| 4 | 打怪 | 技能命中、伤害结算、怪物死亡 |
| 5 | 掉落 | 掉落表按固定种子产出确定结果,落地 1 个掉落物 |
| 6 | 拾取入包 | 拾取成功并合并进背包,重复拾取失败 |
| 7 | 交任务 | 击杀 / 拾取事件推进进度,完成发奖,重复交任务失败 |
| 8 | 组队进副本 | 三人成队,队长掉线后迁移,全队进同一副本实例 |
清单里每一行的「断言」都是可失败的:第 6 步的「重复拾取失败」和第 7 步的「重复交任务失败」是幂等断言,第 3 步的「走回时重新出现」是 AOI 增量断言。验收脚本就是把这张表翻译成代码。
验收脚本:一条命令跑通
验收脚本是 cmd/dawnrealm,go run ./cmd/dawnrealm/ 可以直接跑。它按清单顺序走完整条链路,每一步打印 OK,任一步失败即退出码非零。下面这段是 L1,可以直接跑:
// file: cmd/dawnrealm/main.go
// tier: L1
// verify: go run ./cmd/dawnrealm/
//
// Command dawnrealm 是《晨曦大陆》单服可玩的验收脚本。它按验收清单的顺序
// 走完整条链路:登录 → 移动 → AOI 广播 → 打怪掉落 → 拾取入包 → 交任务 → 组队进副本。
// 每一步打印 OK/FAIL,任一步失败即退出码非零。
package main
import (
"fmt"
"os"
"aegis/internal/gameplay"
)
func main() {
srv := gameplay.NewServer(1, 1)
if err := srv.Wire(); err != nil {
fail("Wire", err)
}
// 为了让任务链可复现,验收用一张只掉 1001 的确定表。
srv.LootTable = gameplay.NewLootTable(gameplay.LootEntry{Item: 1001, Weight: 1})
p1, p2, p3 := gameplay.PlayerID(1), gameplay.PlayerID(2), gameplay.PlayerID(3)
e1, _ := srv.EnterWorld(p1, gameplay.Vec2{X: 0, Y: 0})
if _, nb := srv.EnterWorld(p2, gameplay.Vec2{X: 4, Y: 0}); len(nb) != 1 {
fail("2 号玩家进世界应看见 1 号", fmt.Errorf("邻居 = %d", len(nb)))
}
ok("登录 + 进世界")
if _, nb := srv.EnterWorld(p3, gameplay.Vec2{X: 8, Y: 0}); len(nb) != 2 {
fail("3 号玩家进世界应看见 2 人", fmt.Errorf("邻居 = %d", len(nb)))
}
ok("AOI 广播:三人同屏互见")
if _, rm := srv.Move(p1, gameplay.Vec2{X: 100, Y: 0}); len(rm) != 2 {
fail("1 号远离应移除 2 个邻居", fmt.Errorf("移除 = %d", len(rm)))
}
if add, _ := srv.Move(p1, gameplay.Vec2{X: 0, Y: 0}); len(add) != 2 {
fail("1 号走回应新增 2 个邻居", fmt.Errorf("新增 = %d", len(add)))
}
ok("移动 + AOI 增量")
mon := srv.SpawnMonster(gameplay.Vec2{X: 4, Y: 0}, 15)
sess := &gameplay.Session{PlayerID: p1, Entity: e1, Instance: 1}
resp, err := srv.Reg.Dispatch(sess, &gameplay.Frame{Cmd: gameplay.CmdSkillCastReq, Seq: 1, Payload: putU64(uint64(mon))})
if err != nil || resp.Payload[4] != 1 {
fail("打怪应击杀怪物", err)
}
ok("打怪:技能命中 + 伤害结算 + 击杀")
var dropID uint64
for _, id := range srv.Loot.DropIDs() {
dropID = id
}
resp, err = srv.Reg.Dispatch(sess, &gameplay.Frame{Cmd: gameplay.CmdItemUseReq, Seq: 2, Payload: putU64(dropID)})
if err != nil || resp.Payload[4] != 1 {
fail("拾取应成功", err)
}
if srv.Bag(p1).Count(1001) != 1 {
fail("背包里应有 1 件 1001", nil)
}
ok("掉落 + 拾取入包")
qlog := srv.QuestLog(p1)
qlog.Report(1001, 1)
if hit := qlog.Report(1001, 1); len(hit) != 1 {
fail("任务应完成", fmt.Errorf("完成数 = %d", len(hit)))
}
resp, err = srv.Reg.Dispatch(sess, &gameplay.Frame{Cmd: gameplay.CmdQuestFinishReq, Seq: 3, Payload: putU64(1)})
if err != nil || resp.Payload[4] != 1 {
fail("交任务应成功", err)
}
if _, again := qlog.Finish(1); again {
fail("重复交任务应失败", nil)
}
ok("交任务:进度推进 + 发奖 + 防重复")
party := srv.NewParty(1, p1)
srv.Reg.Dispatch(sess, &gameplay.Frame{Cmd: gameplay.CmdPartyInviteReq, Seq: 4, Payload: putU64(uint64(p2))})
srv.Reg.Dispatch(sess, &gameplay.Frame{Cmd: gameplay.CmdPartyInviteReq, Seq: 5, Payload: putU64(uint64(p3))})
if party.Size() != 3 {
fail("队伍应有 3 人", fmt.Errorf("人数 = %d", party.Size()))
}
if leader, empty := party.Leave(p1); empty || leader != p2 {
fail("队长应迁移给 2 号", nil)
}
if inst := party.EnterInstance(9); inst != 9 {
fail("进副本实例应为 9", nil)
}
ok("组队 + 队长迁移 + 进副本")
fmt.Println("ALL PASS: 《晨曦大陆》单服可玩验收通过")
}
func putU64(v uint64) []byte {
var b [8]byte
for i := 7; i >= 0; i-- {
b[i] = byte(v)
v >>= 8
}
return b[:]
}
func ok(step string) { fmt.Printf("OK %s\n", step) }
func fail(step string, err error) {
fmt.Printf("FAIL %s: %v\n", step, err)
os.Exit(1)
}
脚本里有两处刻意的设计。第一,掉落表被换成只掉 1001 的确定表,让第 6、7 步可复现;第二,怪物血量卡在 15,技能基础伤害 20、浮动 ±3,一次命中即死,让第 4、5 步可复现。把随机从验收里剔出去以后,这条链路就成了确定性的——同一份代码在任何机器上跑出同样的结果。
验收结果与失败排查
本机跑 go run ./cmd/dawnrealm/,八步全部通过:
$ go run ./cmd/dawnrealm/
OK 登录 + 进世界
OK AOI 广播:三人同屏互见
OK 移动 + AOI 增量
OK 打怪:技能命中 + 伤害结算 + 击杀
OK 掉落 + 拾取入包
OK 交任务:进度推进 + 发奖 + 防重复
OK 组队 + 队长迁移 + 进副本
ALL PASS: 《晨曦大陆》单服可玩验收通过
同一条链路也可以写成 go test 的断言版本,go test ./internal/gameplay/ 会把它纳入回归。下面这段是 L1,go test 可以直接跑:
// file: internal/gameplay/accept_test.go
// tier: L1
// verify: go test ./internal/gameplay/
package gameplay
import "testing"
// TestDawnRealmSingleServerAcceptance 是《晨曦大陆》单服可玩的端到端验收。
// 它按验收清单的顺序走完整条链路,并在每一步断言。
func TestDawnRealmSingleServerAcceptance(t *testing.T) {
srv := NewServer(1, 1)
if err := srv.Wire(); err != nil {
t.Fatalf("Wire: %v", err)
}
// 为了让任务链可复现,验收用一张只掉 1001 的确定表。
srv.LootTable = NewLootTable(LootEntry{Item: 1001, Weight: 1})
// 1. 登录:三个机器人拿到角色实体,落在同一实例。
p1, p2, p3 := PlayerID(1), PlayerID(2), PlayerID(3)
e1, nb1 := srv.EnterWorld(p1, Vec2{X: 0, Y: 0})
if _, nb := srv.EnterWorld(p2, Vec2{X: 4, Y: 0}); nb == nil {
t.Fatal("2 号玩家进世界后应看见 1 号")
}
if len(nb1) != 0 {
t.Fatalf("1 号先入场应无邻居,实际 %d", len(nb1))
}
// 2. 进世界 + AOI 广播:三人同屏互见。
_, nb := srv.EnterWorld(p3, Vec2{X: 8, Y: 0})
if len(nb) != 2 {
t.Fatalf("3 号进世界应看见 2 个邻居,实际 %d", len(nb))
}
// 3. 移动:1 号走到远处,邻居被移除。
adds, removes := srv.Move(p1, Vec2{X: 100, Y: 0})
if len(removes) != 2 || len(adds) != 0 {
t.Fatalf("移动后应移除 2 个邻居、新增 0,实际 -%d +%d", len(removes), len(adds))
}
// 走回来,邻居重新出现。
adds, _ = srv.Move(p1, Vec2{X: 0, Y: 0})
if len(adds) != 2 {
t.Fatalf("移动回来后应新增 2 个邻居,实际 %d", len(adds))
}
// 4. 打怪:技能命中、伤害结算、怪物死亡。
mon := srv.SpawnMonster(Vec2{X: 4, Y: 0}, 15)
sess := &Session{PlayerID: p1, Entity: e1, Instance: 1}
resp, err := srv.Reg.Dispatch(sess, &Frame{Cmd: CmdSkillCastReq, Seq: 1, Payload: putU64(uint64(mon))})
if err != nil {
t.Fatalf("技能分发:%v", err)
}
if resp.Payload[4] != 1 {
t.Fatal("怪物应被击杀")
}
if e, _ := srv.World.Get(mon); e.Alive {
t.Fatal("怪物应已死亡")
}
// 5. 掉落 + 拾取入包:掉落物被拾取并合并进背包。
if srv.Loot.Len() != 1 {
t.Fatalf("应落地 1 个掉落物,实际 %d", srv.Loot.Len())
}
var dropID uint64
for id := range srv.Loot.drops {
dropID = id
}
resp, err = srv.Reg.Dispatch(sess, &Frame{Cmd: CmdItemUseReq, Seq: 2, Payload: putU64(dropID)})
if err != nil {
t.Fatalf("拾取分发:%v", err)
}
if resp.Payload[4] != 1 {
t.Fatal("拾取应成功")
}
if n := srv.Bag(p1).Count(1001); n != 1 {
t.Fatalf("背包里 1001 数量 = %d,期望 1", n)
}
// 重复拾取同一个掉落物应失败(幂等)。
if _, ok := srv.Loot.Pickup(dropID); ok {
t.Fatal("重复拾取同一掉落物应失败")
}
// 6. 交任务:击杀 / 拾取事件推进进度,完成并发奖。
qlog := srv.QuestLog(p1)
if hit := qlog.Report(1001, 1); len(hit) != 0 {
t.Fatalf("进度 1/2 不应完成,实际完成 %d 个", len(hit))
}
if hit := qlog.Report(1001, 1); len(hit) != 1 {
t.Fatalf("进度 2/2 应完成 1 个任务,实际 %d", len(hit))
}
resp, err = srv.Reg.Dispatch(sess, &Frame{Cmd: CmdQuestFinishReq, Seq: 3, Payload: putU64(1)})
if err != nil {
t.Fatalf("交任务分发:%v", err)
}
if resp.Payload[4] != 1 {
t.Fatal("交任务应成功")
}
// 重复交任务应失败(防重复发奖)。
if _, ok := qlog.Finish(1); ok {
t.Fatal("重复交任务应失败")
}
// 7. 组队 + 进副本:三人成队,队长掉线后迁移。
party := srv.NewParty(1, p1)
srv.Reg.Dispatch(sess, &Frame{Cmd: CmdPartyInviteReq, Seq: 4, Payload: putU64(uint64(p2))})
srv.Reg.Dispatch(sess, &Frame{Cmd: CmdPartyInviteReq, Seq: 5, Payload: putU64(uint64(p3))})
if party.Size() != 3 {
t.Fatalf("队伍人数 = %d,期望 3", party.Size())
}
leader, empty := party.Leave(p1)
if empty || leader != p2 {
t.Fatalf("队长应迁移给 2 号,实际 leader=%d empty=%v", leader, empty)
}
if inst := party.EnterInstance(9); inst != 9 {
t.Fatalf("进副本实例 = %d,期望 9", inst)
}
}
验收失败时,按清单的顺序往前排查最有效:第 1、2 步失败查世界与 AOI;第 3 步失败查 AOI 增量;第 4 步失败查战斗管线;第 5 步失败查掉落表;第 6 步失败查任务进度;第 7 步失败查幂等;第 8 步失败查队伍。验收的价值不在「全绿」,而在「哪一步红、红在哪一层」——链路越长,这个定位能力越值钱。
本节小结 / 实测结论
- 《晨曦大陆》单服可玩是一条八步链路:登录 → 进世界 → 移动 → AOI 广播 → 打怪 → 掉落 → 拾取入包 → 交任务 → 组队进副本;每一步都有可失败的断言。
- 验收必须固定随机:用只掉一种物品的确定表、血量卡到一次命中即死,把随机从验收里剔出去;随机本身由第 7 章的确定性随机保证。
- 给出两个真跑块:
go run ./cmd/dawnrealm/的验收脚本,与go test ./internal/gameplay/的断言版本;两者都走同一条链路。 - 本机实测:八步全部通过,输出
ALL PASS: 《晨曦大陆》单服可玩验收通过。 - 验收的价值在定位:链路越长,「哪一步红、红在哪一层」的能力越值钱;装配完成不等于能玩,能跑通这条链路才算能玩。
阅读导航:上一节:18.1 把玩法系统接进 aegis · 下一节:18.3 单服承载数字 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。