《MMO 服务器开发》13.1 入会、职位与权限

公会系统是 MMO 里第一个跨玩家的持久组织:入会有自由与审批两种策略,职位有会长到成员五级,权限是一张职位到动作的位掩码表。本节用 aegis 从零实现单服公会模型——把权限收敛成一张可审计的表、让入会与审批幂等、把职位变更约束在层级之内,并用真跑的 L1 测试钉死满员竞态、重复申请与越级任免三条边界。

13.1 入会、职位与权限

第 12 章的队伍是临时的:满员即锁、掉线即走、打完即散,它的整个生命周期都在一个副本里。公会不一样。公会是 MMO 里第一个跨玩家的持久组织——它跨登录、跨地图、跨副本,成员今天加入可能三个月后还在,会长离线一周公会也照常运转。这种「持久 + 有层级 + 多人可操作」的组合,是玩法系统里第一处真正需要认真对待权限的地方。

难在哪?难在权限不是一个布尔值,而是「职位 × 动作 × 对象」的乘积。会长能踢人,官员能不能?官员能不能踢副会长?副会长能不能把别人提成会长?入会也一样:自由加入的公会点一下就在名单里,审批制的公会要先排队、再由有权限的人放行,而放行的瞬间公会可能已经满了。这些规则如果散落在几十个 if 里,每加一个职位就得全仓搜一遍,漏一处就是一个越权漏洞。

本节的做法是把复杂度收敛到两个地方:权限收敛成一张「职位 → 动作位掩码」的表,读表即知谁能在哪;入会收敛成「策略分派 + 幂等」两个入口,把满员竞态和重复申请在写名单之前挡掉。职位变更则用「不能碰大于等于自己等级的职位」这一条铁律封住越权。下面的代码是 L1,go test 直接能跑。

CmdGuildJoinReq  0x0801  客户端申请入会 / 审批请求
CmdGuildInfoSync 0x0802  服务端下发公会快照(成员、职位、仓库、战况)

GuildID 是全服唯一的 uint64,PlayerID 也是 uint64;这两个类型在本章 internal/guild/ 包内自定义一份最小版本,与卷二其它章自包含。

与站内实战文章的分工

站内 game/server/practice/ 里有一篇公会后端设计的架构文章。它负责「线上怎么设计、怎么取舍」,本节负责「从零写出可运行的那一层」:

站内既有文章它负责的问题本节负责的部分
/game-server-guild-system-backend-design/公会后端整体架构、存储选型、跨服公会与容量规划单服内从零实现:职位/权限表、入会幂等、审批时重查容量

一句话:要看架构取舍去读那篇,要一份能 go test 通过的职位与入会实现读本节。

职位与权限:一张表定死

权限模型最容易写坏的方式,是把判断写进业务分支:if m.Rank == RankLeader || m.Rank == RankViceLeader { ... }。这样写能跑,但两周后没人说得清「官员到底能不能改公告」。我们改成一张表:Rank 是低到高的有序枚举,Permission 是位掩码,rankPerms 把「哪个职位有哪些权限」一次说清,业务只调 Can。

下面这段是 L1 的核心(摘自 internal/guild/model.go),go test 可以直接跑:

// snippet: internal/guild/model.go(职位与权限部分)

// Rank is a guild member position, ordered from low to high. The numeric order
// is load-bearing: the rules in guild.go compare ranks directly.
type Rank uint8

const (
	RankMember     Rank = iota // 普通成员
	RankElite                  // 精英
	RankOfficer                // 官员
	RankViceLeader             // 副会长
	RankLeader                 // 会长
)

// Permission is a bitmask of actions a rank may perform.
type Permission uint32

const (
	PermInvite Permission = 1 << iota
	PermKick
	PermEditNotice
	PermStorageDeposit
	PermStorageWithdraw
	PermWarSignup
	PermPromote
	PermDisband
)

// rankPerms is the single source of truth for "which rank may do what". Keeping
// it in one table instead of scattering checks across call sites is what makes
// the permission model auditable: a reviewer reads one map, not forty branches.
var rankPerms = map[Rank]Permission{
	RankMember:     PermStorageDeposit,
	RankElite:      PermStorageDeposit,
	RankOfficer:    PermStorageDeposit | PermInvite | PermStorageWithdraw | PermEditNotice,
	RankViceLeader: PermStorageDeposit | PermInvite | PermStorageWithdraw | PermEditNotice | PermKick | PermPromote | PermWarSignup,
	RankLeader:     PermStorageDeposit | PermInvite | PermStorageWithdraw | PermEditNotice | PermKick | PermPromote | PermWarSignup | PermDisband,
}

// Can reports whether the rank holds perm.
func (r Rank) Can(perm Permission) bool { return rankPerms[r]&perm != 0 }

三个设计点。第一,职位是有序枚举,RankMember 是 0、RankLeader 是 4。后面所有「不能踢比自己高的人」「不能提别人到自己等级」的判断,都是拿这个序号直接比大小——不需要再写一张「谁比谁大」的邻接表,序号本身就是层级。第二,权限用位掩码而不是权限列表:Can 是一次按位与,O(1)、无分配、可内联;一个成员的总权限就是一个 uint32,可以随 CmdGuildInfoSync 一起下发,客户端不用自己算。第三,越高职位是越低职位的超集:从表里能一眼看出会长拥有官员的全部权限再加两项。这不是巧合,是刻意的——权限若做成互不相交的集合,运营配错一次就会出现「会长改不了公告」这种荒唐 bug。

入会:策略分派与幂等

入会只有两个入口:自由加入与申请审批。我们把它们合并到一个 RequestJoin,按 Policy 分派,返回值里 joined 说明「这次是不是直接进了名单」。关键在于两条路径都必须幂等:已经在名单里的人再点一次入会、申请还在队列里的人再点一次申请,都不能产生第二条记录。

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

// file: internal/guild/guild.go
// tier: L1
// verify: go test ./internal/guild/ -run 'TestJoin|TestKick|TestRank|TestLeader' -v

package guild

import (
	"sort"
	"time"
)

// Guild holds the roster and the admission state of one guild. Every method is
// called from the guild's own single-threaded logic goroutine, so there is no
// mutex here on purpose: the locking discipline is "one guild, one executor",
// the same ownership rule as 卷二第 1 章.
type Guild struct {
	ID       GuildID
	Name     string
	Leader   PlayerID
	Policy   JoinPolicy
	Capacity int
	Members  map[PlayerID]*Member
	Pending  map[PlayerID]time.Time // 待审批申请:玩家 -> 申请时间
}

// NewGuild creates a guild whose founder is the first leader.
func NewGuild(id GuildID, name string, leader PlayerID, leaderName string, policy JoinPolicy, capacity int, now time.Time) *Guild {
	if capacity <= 0 {
		capacity = 50
	}
	g := &Guild{
		ID:       id,
		Name:     name,
		Leader:   leader,
		Policy:   policy,
		Capacity: capacity,
		Members:  make(map[PlayerID]*Member),
		Pending:  make(map[PlayerID]time.Time),
	}
	g.Members[leader] = &Member{PlayerID: leader, Name: leaderName, Rank: RankLeader, JoinedAt: now}
	return g
}

// can is the internal permission gate; it also rejects non-members.
func (g *Guild) can(actor PlayerID, perm Permission) bool {
	m, ok := g.Members[actor]
	if !ok {
		return false
	}
	return m.Rank.Can(perm)
}

// RequestJoin dispatches on policy: an open guild admits immediately and reports
// joined=true; an apply-only guild queues the request and reports joined=false.
// Both paths are idempotent — a duplicate never creates a second roster entry or
// a second pending row.
func (g *Guild) RequestJoin(p PlayerID, name string, now time.Time) (joined bool, err error) {
	if _, ok := g.Members[p]; ok {
		return false, ErrAlreadyMember
	}
	if len(g.Members) >= g.Capacity {
		return false, ErrGuildFull
	}
	if g.Policy == JoinOpen {
		g.Members[p] = &Member{PlayerID: p, Name: name, Rank: RankMember, JoinedAt: now}
		return true, nil
	}
	if _, ok := g.Pending[p]; ok {
		return false, ErrAlreadyApplied
	}
	g.Pending[p] = now
	return false, nil
}

// Approve admits a pending applicant. The actor must hold PermInvite, and the
// room is re-checked here because members may have filled the guild while the
// application sat in the queue.
func (g *Guild) Approve(actor, applicant PlayerID, name string, now time.Time) error {
	if !g.can(actor, PermInvite) {
		return ErrPermissionDenied
	}
	if _, ok := g.Pending[applicant]; !ok {
		return ErrNoApplication
	}
	if len(g.Members) >= g.Capacity {
		return ErrGuildFull
	}
	delete(g.Pending, applicant)
	g.Members[applicant] = &Member{PlayerID: applicant, Name: name, Rank: RankMember, JoinedAt: now}
	return nil
}

// Leave removes a member. The leader may not leave while others remain, or the
// guild would be headless — transferring leadership is a separate operation.
func (g *Guild) Leave(p PlayerID) error {
	m, ok := g.Members[p]
	if !ok {
		return ErrNotMember
	}
	if m.Rank == RankLeader && len(g.Members) > 1 {
		return ErrHeadlessGuild
	}
	delete(g.Members, p)
	return nil
}

// Kick removes target. The actor needs PermKick and may not remove a rank at or
// above their own — a vice-leader cannot kick a peer or the leader.
func (g *Guild) Kick(actor, target PlayerID) error {
	if !g.can(actor, PermKick) {
		return ErrPermissionDenied
	}
	t, ok := g.Members[target]
	if !ok {
		return ErrNotMember
	}
	if t.Rank >= g.Members[actor].Rank {
		return ErrBadRankChange
	}
	delete(g.Members, target)
	return nil
}

// SetRank changes target's rank. Two rules keep the hierarchy sane: the actor may
// not touch a rank at or above their own, and nobody may mint a second leader.
func (g *Guild) SetRank(actor, target PlayerID, r Rank) error {
	if !g.can(actor, PermPromote) {
		return ErrPermissionDenied
	}
	a, ok := g.Members[actor]
	if !ok {
		return ErrNotMember
	}
	t, ok := g.Members[target]
	if !ok {
		return ErrNotMember
	}
	if target == actor {
		return ErrBadRankChange
	}
	if t.Rank >= a.Rank || r >= a.Rank {
		return ErrBadRankChange
	}
	t.Rank = r
	return nil
}

// Roster returns members sorted by rank desc, then join time asc, then id. The
// order is fully deterministic so the client can render it without re-sorting,
// and 13.3 relies on it to snapshot a stable war roster.
func (g *Guild) Roster() []*Member {
	out := make([]*Member, 0, len(g.Members))
	for _, m := range g.Members {
		out = append(out, m)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Rank != out[j].Rank {
			return out[i].Rank > out[j].Rank
		}
		if !out[i].JoinedAt.Equal(out[j].JoinedAt) {
			return out[i].JoinedAt.Before(out[j].JoinedAt)
		}
		return out[i].PlayerID < out[j].PlayerID
	})
	return out
}

四个必须点破的细节。第一,Guild 故意没有互斥锁。这不是省事,而是延续卷二第 1 章的状态归属:一个公会绑定一个逻辑执行体,所有变更都在那一个 goroutine 里串行跑,于是「检查容量」和「写入名单」之间不可能被别的协程插进来。真正的满员竞态,是跨请求的:甲申请时还有空位,乙先被批准把位置占了,等甲被审批时已经满了——所以 Approve 里再查一次 Capacity,这就是那第二道门。第二,Kick 和 SetRank 共用 t.Rank >= a.Rank 这条判据:动不了大于等于自己的职位,一个副会长既踢不掉同级,也踢不掉会长,更提不出第二个会长。第三,Leave 里会长不能裸退——公会的 Leader 字段必须始终指向一个在册成员,否则「谁有权解散公会」就成了悬空问题;转让会长是独立操作,本节不展开。第四,Roster 的排序是全确定的:职位降序、入会时间升序、ID 兜底,客户端拿来即渲染;13.3 冻结公会战名单时会直接复用这个顺序,保证同一份名单每次快照逐字节一致。

真跑与实测结论

测试不 mock 任何东西,直接构造公会喂进去。三条边界各一个用例:

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

func TestJoinOpenRespectsCapacity(t *testing.T) {
	g := NewGuild(1, "晨曦骑士团", 100, "阿澈", JoinOpen, 3, base)
	for _, id := range []PlayerID{101, 102} {
		joined, err := g.RequestJoin(id, "p", base)
		if err != nil || !joined {
			t.Fatalf("join %d: joined=%v err=%v", id, joined, err)
		}
	}
	if _, err := g.RequestJoin(103, "p", base); !errors.Is(err, ErrGuildFull) {
		t.Fatalf("want ErrGuildFull, got %v", err)
	}
}

func TestApplyDedup(t *testing.T) {
	g := NewGuild(1, "g", 100, "leader", JoinApply, 10, base)
	if joined, err := g.RequestJoin(200, "p", base); err != nil || joined {
		t.Fatalf("first apply: joined=%v err=%v", joined, err)
	}
	if _, err := g.RequestJoin(200, "p", base); !errors.Is(err, ErrAlreadyApplied) {
		t.Fatalf("want ErrAlreadyApplied, got %v", err)
	}
	if len(g.Pending) != 1 {
		t.Fatalf("pending = %d, want 1", len(g.Pending))
	}
	if err := g.Approve(100, 200, "p", base); err != nil {
		t.Fatalf("approve: %v", err)
	}
	if _, err := g.RequestJoin(200, "p", base); !errors.Is(err, ErrAlreadyMember) {
		t.Fatalf("rejoin after approval: want ErrAlreadyMember, got %v", err)
	}
}

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

断言结果说明
自由加入的第 3 人ErrGuildFull容量 3、已有 3 人时第 4 次入会失败
审批后重复申请ErrAlreadyMember已在名单里,不再排队
申请期内重复申请ErrAlreadyAppliedPending 只留一行
副会长踢会长 / 成员踢副会长ErrBadRankChange / ErrPermissionDenied层级与权限两道门
会长带人离会ErrHeadlessGuild防止公会无主

两个结论。其一,权限的正确性来自「一张表」而非「一堆 if」:TestRankPerms 直接断言 RankOfficer.Can(PermKick)==false、RankMember.Can(PermStorageWithdraw)==false,任何一处误配立刻红。其二,入会的所有拒绝都发生在写名单之前:满员、重复、无权限,三条路径都不会留下半条脏记录——这是「状态归属」在公会上的具体落地。下一节把仓库与捐献接进来,你会看到同一个「先判后写」的纪律如何在幂等上再咬一口。

阅读导航:上一节:目录 · 下一节:13.2 公会仓库与捐献流水 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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