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 无告警。同机相对量级,绝对数值不可移植:
| 断言 | 结果 | 说明 |
|---|---|---|
| 开窗前报名 | ErrSignupClosed | now < openAt |
| 普通成员报名 | ErrPermissionDenied | 无 PermWarSignup |
| 会长重复报名 | ErrSignupDup | 一轮只报一次 |
| 截止前锁定 | ErrNotLockable | now < closeAt |
| 锁定后名单长度 | 3 | 快照当时的全部成员 |
锁定后入会者 Enrolled | false | 冻结名单不含迟到者 |
重复 Lock | nil,名单不变 | 幂等,调度器可安全重跑 |
三个结论。第一,冻结的语义全靠「拷贝」:Lock 拷一份、Roster 拷一份,冻结名单与实时成员表从此分道扬镳,谁也不能通过改成员表影响战况。第二,幂等让锁定可以被调度器放心重试——TestWarLockFreezesRoster 最后一行故意在截止一小时后重锁,断言不报错且名单不变,这正是「跑一百次等于跑一次」。第三,确定序是可复现的前提:名单来自 Roster() 而非 map 遍历,卷三跨服重建时才有一致的基准。至此第 13 章的公会三件套(职位权限、仓库捐献、战事报名)收齐,它们共同复用同一套纪律:先判后写、权限查表、状态归属单执行体。下一章转向另一个高频读、低频写的系统——排行榜。
阅读导航:上一节:13.2 公会仓库与捐献流水 · 下一节:14.1 为什么不能只用一张表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。