《MMO 服务器开发》18.2 《晨曦大陆》单服可玩

装配完成不等于能玩。本节给出一条可复现的验收链路:登录 → 移动 → AOI 广播 → 打怪掉落 → 拾取入包 → 交任务 → 组队进副本,每一步都带断言。给出验收脚本 cmd/dawnrealm 与端到端测试两个真跑块,以及本机跑通的完整输出,最后交代验收失败时该往哪一层排查。

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 单服承载数字 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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