《MMO 服务器开发》13.3 公会战报名与锁定

公会战的第一步是报名与锁定:报名只能在开窗时段内、由持有报名权限的成员发起;名单必须在截止时刻一次性冻结,之后入会或退会都不再改变参战名单。本节用 aegis 从零实现报名窗口、权限校验与名单快照,讲清锁定为什么必须幂等,并说明卷二只做单服内的报名与锁定,真正的跨服对战留给卷三,最后用真跑的 L1 测试钉死窗口边界、权限与冻结。

13.3 公会战报名与锁定

公会战是把公会状态导出给另一个系统的第一步。前三节里,公会是一坨活在单服内存里的、随时可变的状态:成员进出、职位升降、仓库增减。但公会战需要的是一个确定的参战名单——哪个公会、哪些人、在哪一刻确定。如果名单在开打前还在变,匹配规模、队伍分配、胜负结算全都会漂移。所以公会战真正的技术难点不是「怎么打」,而是名单在什么时刻被冻住,以及冻住之后谁都改不动。

第二个难点是幂等。锁定的触发者通常不是玩家点击,而是服务端调度器在截止时刻跑的一个任务。调度器可能重试、可能被重启后重跑、可能因为时钟抖动跑两次。如果每次跑都把「当前成员」重新快照一遍,那么一个在截止后、重跑前入会的人就会混进名单——冻结就失去了意义。锁定必须做到跑一次和跑一百次结果完全相同。

本节的做法:报名收敛成一个带窗口判定的 Signup,权限复用 13.1 的 PermWarSignup;锁定收敛成一个幂等的 Lock,它只在截止时刻做一次快照,之后任何人再调都直接返回。需要说清的是:卷二只做单服内的报名与锁定——报名名单、冻结、Enrolled 判定都在本服内存里完成;把这些名单送到另一个服务器、跨服匹配与结算,是卷三的主题。本节冻结出来的那份 Roster,正是卷三要发送出去的那个东西,所以它必须逐字节稳定。

报名:窗口、权限、一次

报名的三条规则缺一不可。第一是窗口:now 必须落在 [openAt, closeAt) 区间内,早于开窗或晚于关窗都拒绝。第二是权限:只有持有 PermWarSignup 的成员能发起——按 13.1 的 rankPerms 表,也就是副会长与会长,普通成员和官员都不行。第三是一次:一个公会在一轮里只能报名一次,重复报名返回 ErrSignupDup 而不是覆盖原记录(覆盖会把原报名时间也冲掉,影响排序)。

窗口用的是左闭右开:now.Before(openAt) 拒绝、!now.Before(closeAt) 也拒绝。这样 closeAt 那一刻正好落在窗口之外,和锁定的截止时刻严格对齐——同一个时间点,报名已关、锁定可开,不会出现「既不在报名窗内、又不能锁定」的空隙。

锁定:一次快照,永远冻结

Lock 是整个公会战里最该被反复审视的方法,因为它承担了「冻结」这个不可逆语义。它的规则是:先确认这个公会报过名,若已经锁过就直接返回(幂等),若还没到截止时刻就拒绝,若在册人数低于最小规模也拒绝,否则把当前的 Roster() 拷进 WarSignup.Roster 并置 Locked=true。

关键在于它只在第一次调用时读成员表。锁定之后,公会的成员表可以继续变——有人退会、有人入会——但 WarSignup.Roster 是那份冻结的拷贝,Enrolled 查的是拷贝而不是成员表。这就是「冻结」的全部含义。

下面这段是 L1,go test 可以直接跑:

// file: internal/guild/war.go
// tier: L1
// verify: go test ./internal/guild/ -run 'TestWar' -v

package guild

import (
	"errors"
	"time"
)

var (
	ErrSignupClosed   = errors.New("war signup window is closed")
	ErrSignupDup      = errors.New("guild already signed up")
	ErrNotSignedUp    = errors.New("guild has not signed up")
	ErrNotLockable    = errors.New("roster cannot be locked before the deadline")
	ErrRosterTooSmall = errors.New("roster below the minimum size")
)

// WarSignup is one guild's entry in the current war. Roster is empty until Lock
// fills it; after that it is frozen.
type WarSignup struct {
	GuildID  GuildID
	SignupAt time.Time
	Roster   []PlayerID // 锁定时冻结的参战名单
	Locked   bool
	LockedAt time.Time
}

// WarRegistry holds the signup window and the locked rosters for one single-server
// war cycle. 卷二 only does the in-server signup and lock; what travels to another
// server in 卷三 is exactly this frozen roster, nothing more.
type WarRegistry struct {
	openAt  time.Time
	closeAt time.Time
	minSize int
	signups map[GuildID]*WarSignup
}

func NewWarRegistry(openAt, closeAt time.Time, minSize int) *WarRegistry {
	if minSize <= 0 {
		minSize = 5
	}
	return &WarRegistry{openAt: openAt, closeAt: closeAt, minSize: minSize, signups: make(map[GuildID]*WarSignup)}
}

// Signup records a guild's intent inside [openAt, closeAt). Only a member holding
// PermWarSignup may do it, and a guild signs up at most once per cycle.
func (r *WarRegistry) Signup(g *Guild, actor PlayerID, now time.Time) error {
	if now.Before(r.openAt) || !now.Before(r.closeAt) {
		return ErrSignupClosed
	}
	if !g.can(actor, PermWarSignup) {
		return ErrPermissionDenied
	}
	if _, ok := r.signups[g.ID]; ok {
		return ErrSignupDup
	}
	r.signups[g.ID] = &WarSignup{GuildID: g.ID, SignupAt: now}
	return nil
}

// Lock freezes a signed-up guild's roster at the deadline. Before closeAt a
// roster would follow live membership; at the deadline it is snapshotted once and
// never changes again, so a member who joins after the lock cannot play this cycle.
// Locking twice is a no-op, not an error.
func (r *WarRegistry) Lock(g *Guild, now time.Time) error {
	s, ok := r.signups[g.ID]
	if !ok {
		return ErrNotSignedUp
	}
	if s.Locked {
		return nil
	}
	if now.Before(r.closeAt) {
		return ErrNotLockable
	}
	if len(g.Members) < r.minSize {
		return ErrRosterTooSmall
	}
	s.Roster = make([]PlayerID, 0, len(g.Members))
	for _, m := range g.Roster() {
		s.Roster = append(s.Roster, m.PlayerID)
	}
	s.Locked = true
	s.LockedAt = now
	return nil
}

// Roster returns the frozen roster, or nil while the guild is still unlocked.
func (r *WarRegistry) Roster(gid GuildID) []PlayerID {
	s, ok := r.signups[gid]
	if !ok || !s.Locked {
		return nil
	}
	out := make([]PlayerID, len(s.Roster))
	copy(out, s.Roster)
	return out
}

// Enrolled reports whether p is on the locked roster. A member who joined after
// the lock is deliberately excluded.
func (r *WarRegistry) Enrolled(gid GuildID, p PlayerID) bool {
	s, ok := r.signups[gid]
	if !ok || !s.Locked {
		return false
	}
	for _, id := range s.Roster {
		if id == p {
			return true
		}
	}
	return false
}

三个细节。第一,Lock 用 g.Roster() 而不是遍历 g.Members。这不是偷懒——13.1 里 Roster() 的排序是全确定的(职位降序、入会时间升序、ID 兜底),而 map 的遍历顺序在 Go 里是随机的。若这里直接遍历 map,同一个公会在两台机器上锁出的名单顺序可能不同,卷三拿去做跨服匹配时就会对不上。用确定序的 Roster() 是让「冻结」这件事可复现的前提。第二,Lock 的幂等分支在最前面:if s.Locked { return nil }。调度器重跑一次、十次,结果都一样,因为第二次进来时 s.Locked 已经是真,直接返回。第三,Roster(gid) 和 Enrolled 返回的都是拷贝或只读:Roster 显式 copy 一份出去,防止调用方改了切片内容而污染冻结名单。

卷二到此为止:真正的跨服在卷三

把整个流程画出来,能看清本节负责的边界在哪:

  玩家/调度器              本服(卷二)                        另一台服(卷三)
  ───────────             ─────────────                       ──────────────
  Signup(openAt..closeAt) ─▶ WarSignup{GuildID, SignupAt}
                            │
  调度器到 closeAt 触发 Lock ─▶ 快照 g.Roster() ─▶ Roster []PlayerID(冻结)
                            │
                            │  ── 这份冻结名单,逐字节稳定 ──▶  跨服匹配 / 对战 / 结算
                            │
  Enrolled(gid, p)         ─▶ 查冻结名单,而非实时成员表

这张图说明一件容易混淆的事:WarRegistry 里的 Roster 是单服内的权威快照,它不是「跨服状态」。卷三要做的,是把这份快照序列化发出去、在另一台服上重建、处理两边名单的差异与冲突。那些问题——跨服时钟对齐、名单传输的一致性、匹配失败的回滚——本节一概不碰,因为它们的前提(多个服务器)在单服里根本不存在。本节只保证一件事:冻结出来的名单是确定的、幂等的、可复现的。这是卷三能安全接手的最低条件。

真跑与实测结论

测试直接喂时间,不 sleep。窗口边界、权限、冻结三条边界各有用例:

// snippet: internal/guild/war_test.go(节选)

func TestWarSignupWindowAndPermission(t *testing.T) {
	open := base
	close := base.Add(2 * time.Hour)
	r := NewWarRegistry(open, close, 3)
	g := NewGuild(1, "g", 100, "leader", JoinOpen, 10, base)
	g.RequestJoin(101, "m", base)

	// Before the window opens.
	if err := r.Signup(g, 100, open.Add(-time.Minute)); !errors.Is(err, ErrSignupClosed) {
		t.Fatalf("early signup: want ErrSignupClosed, got %v", err)
	}
	// A plain member lacks PermWarSignup.
	if err := r.Signup(g, 101, open.Add(time.Minute)); !errors.Is(err, ErrPermissionDenied) {
		t.Fatalf("member signup: want ErrPermissionDenied, got %v", err)
	}
	if err := r.Signup(g, 100, open.Add(time.Minute)); err != nil {
		t.Fatalf("leader signup: %v", err)
	}
	if err := r.Signup(g, 100, open.Add(time.Minute)); !errors.Is(err, ErrSignupDup) {
		t.Fatalf("dup signup: want ErrSignupDup, got %v", err)
	}
}

func TestWarLockFreezesRoster(t *testing.T) {
	open := base
	close := base.Add(time.Hour)
	r := NewWarRegistry(open, close, 3)
	g := NewGuild(1, "g", 100, "leader", JoinOpen, 10, base)
	for _, id := range []PlayerID{101, 102} {
		g.RequestJoin(id, "m", base)
	}
	if err := r.Signup(g, 100, open); err != nil {
		t.Fatalf("signup: %v", err)
	}
	// Locking before the deadline is refused.
	if err := r.Lock(g, close.Add(-time.Minute)); !errors.Is(err, ErrNotLockable) {
		t.Fatalf("early lock: want ErrNotLockable, got %v", err)
	}
	if err := r.Lock(g, close); err != nil {
		t.Fatalf("lock: %v", err)
	}
	if got := len(r.Roster(1)); got != 3 {
		t.Fatalf("roster = %d, want 3", got)
	}
	// A member who joins after the lock must not be enrolled.
	g.RequestJoin(103, "late", base)
	if r.Enrolled(1, 103) {
		t.Fatal("late joiner enrolled after lock")
	}
	if !r.Enrolled(1, 102) {
		t.Fatal("original member missing from locked roster")
	}
	// Locking again is a no-op.
	if err := r.Lock(g, close.Add(time.Hour)); err != nil {
		t.Fatalf("relock: %v", err)
	}
}

本机(Go 1.26.0,macOS arm64)实测,go test ./internal/guild/ 全绿 13 个用例、go vet 无告警。同机相对量级,绝对数值不可移植:

断言结果说明
开窗前报名ErrSignupClosednow < openAt
普通成员报名ErrPermissionDenied无 PermWarSignup
会长重复报名ErrSignupDup一轮只报一次
截止前锁定ErrNotLockablenow < closeAt
锁定后名单长度3快照当时的全部成员
锁定后入会者 Enrolledfalse冻结名单不含迟到者
重复 Locknil,名单不变幂等,调度器可安全重跑

三个结论。第一,冻结的语义全靠「拷贝」:Lock 拷一份、Roster 拷一份,冻结名单与实时成员表从此分道扬镳,谁也不能通过改成员表影响战况。第二,幂等让锁定可以被调度器放心重试——TestWarLockFreezesRoster 最后一行故意在截止一小时后重锁,断言不报错且名单不变,这正是「跑一百次等于跑一次」。第三,确定序是可复现的前提:名单来自 Roster() 而非 map 遍历,卷三跨服重建时才有一致的基准。至此第 13 章的公会三件套(职位权限、仓库捐献、战事报名)收齐,它们共同复用同一套纪律:先判后写、权限查表、状态归属单执行体。下一章转向另一个高频读、低频写的系统——排行榜。

阅读导航:上一节:13.2 公会仓库与捐献流水 · 下一节:14.1 为什么不能只用一张表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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