12.1 队伍状态与入口控制
组队看起来是社交功能,实际上它是很多玩法的入口控制器。副本要问「队伍满没满」,排位要问「是不是所有人都在线」,世界 Boss 要问「队长是谁、还能不能带人进来」,语音房要问「这个玩家属于哪支队」。这些问题的答案必须来自同一份权威状态,而不是各自去猜。
问题在于,队伍状态天然是会被并发写的:队长一边邀请,被邀请的人一边点接受,掉线的人一边触发迁移,匹配服务一边来查满员。只要「谁能改成员集合」这件事没有单一归属,事故就来了——玩家被踢后仍进了副本、邀请过期了还能加入、队伍锁定了还在进人。这些都不是偶发 bug,而是状态归属不清的必然结果。
本节先解决最底层的一件事:把队伍建成一个有生命周期状态机的服务端对象,并把入口控制的判据收敛到一个函数里。命令号沿用卷二共享约定,组队域用 CmdPartyInviteReq 0x0701(邀请入队)与 CmdPartyInfoSync 0x0702(队伍信息同步)。
本节与站内既有文章的分工
站内已有一篇从架构视角写的组队服务文章,负责「方案与取舍」;本书负责「从零实现 + 真跑 + 端到端验收」。两者互补,不重复:
| 既有文章 | 它负责 | 本书本节负责 |
|---|---|---|
/game-server-party-team-service-architecture/ | 组队服务的控制面/执行面划分、跨服与可见性、生命周期与状态机的架构级取舍 | 把状态机落成一个可 go test 的 Go 对象,把入口控制写成四道可验证的闸门 |
如果你还没想清楚「组队服务该不该独立部署、要不要跨服」,先读那篇;如果你已经定了边界、想知道代码长什么样、并发下会不会漏,就往下读。
队伍是一台状态机,不是一个列表
最容易写错的建模方式是把队伍当成一个「玩家列表」:加人就 append,减人就 delete。列表没有状态,所以「队伍正在匹配中,别再放人进来」这种规则只能靠调用方自觉遵守——而调用方永远不可信。
正确的做法是给队伍一个显式的生命周期状态,让状态本身成为入口控制的一部分:
StateForming ── LockForMatch(且必须满员)──▶ StateLocked
StateForming ◀── Unlock(匹配失败/取消)──── StateLocked
StateLocked ── EnterDungeon ─────────────▶ StateInDungeon
StateInDungeon ── 结算回退 ────────────────▶ StateForming
三态各自对应一组允许的操作:
| 状态 | 谁能改成员 | 能否匹配 | 典型时刻 |
|---|---|---|---|
StateForming | 队长 | 否 | 组队大厅里慢慢拉人 |
StateLocked | 无人 | 是 | 已提交匹配,等房间 |
StateInDungeon | 无人 | 否 | 副本内,只等结算 |
状态机带来的直接好处是:「锁定期间不能改成员」不再是一条要背的约定,而是 entryAllowedLocked 里的一次判断。谁想绕过它,都得先改状态——而改状态的路径只有 setStateLocked 一条,转换合法性也在那里统一校验。
入口控制:四道门
下面这段是 L1,go test 可以直接跑。它把「谁能改成员集合」的所有规则收进两个函数:entryAllowedLocked(状态 + 身份)与 AddMember(成员集合本身):
// file: internal/party/party.go
// tier: L1
// verify: go test ./internal/party/
package party
import (
"errors"
"sync"
)
// PartyID 与 PlayerID 是卷二共享的服务端内部类型,全卷口径一致。
type (
PartyID uint64
PlayerID uint64
)
// Role 只有两种:队长是唯一的「入口控制者」。
type Role uint8
const (
RoleMember Role = iota
RoleLeader
)
// State 是队伍的生命周期状态,决定谁能改队伍、队伍能不能被匹配。
type State uint8
const (
StateForming State = iota // 自由组建,队长可邀请/踢人
StateLocked // 已锁定匹配,成员集合冻结
StateInDungeon // 已进入副本,仅允许结算后回退
)
// MaxMembers 是《晨曦大陆》小队上限,满员判断与容量预留都以它为准。
const MaxMembers = 4
// 入口控制的拒绝原因保持独立,方便网关按码回包而不是回一句含糊的失败。
var (
ErrPartyFull = errors.New("party: party is full")
ErrNotLeader = errors.New("party: caller is not the leader")
ErrPartyLocked = errors.New("party: party is locked")
ErrAlreadyMember = errors.New("party: player is already a member")
ErrNotMember = errors.New("party: player is not a member")
ErrNoLeader = errors.New("party: no eligible leader")
)
// Member 是一个座位。Online 由会话层在掉线/重连时改写,不是队伍服务自己猜的。
type Member struct {
PlayerID PlayerID
Role Role
Online bool
}
// Party 是单服内一支队伍的全部权威状态。所有字段都在 mu 之下,调用方绝不
// 在持有 *Party 的情况下发起网络调用。
type Party struct {
mu sync.Mutex
id PartyID
state State
leader PlayerID
order []PlayerID // 稳定加入顺序,队长迁移按它挑选
members map[PlayerID]*Member
}
// NewParty 建一支只有队长一人的队伍。队长天然在线。
func NewParty(id PartyID, leader PlayerID) *Party {
return &Party{
id: id,
state: StateForming,
leader: leader,
order: []PlayerID{leader},
members: map[PlayerID]*Member{leader: {PlayerID: leader, Role: RoleLeader, Online: true}},
}
}
func (p *Party) ID() PartyID { return p.id }
// State 返回当前状态快照。
func (p *Party) State() State {
p.mu.Lock()
defer p.mu.Unlock()
return p.state
}
// Leader 返回当前队长。
func (p *Party) Leader() PlayerID {
p.mu.Lock()
defer p.mu.Unlock()
return p.leader
}
// Count 只是读快照,不能拿它做「先判断再写入」的满员判断——那正是 12.2
// 要修的竞态。写路径一律走 TryJoin。
func (p *Party) Count() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.members)
}
// Snapshot 复制一份成员列表,按加入顺序返回。调用方拿到的切片可以随意
// 排序、序列化、发给客户端,不会影响队伍内部状态。
func (p *Party) Snapshot() []Member {
p.mu.Lock()
defer p.mu.Unlock()
return p.snapshotLocked()
}
func (p *Party) snapshotLocked() []Member {
out := make([]Member, 0, len(p.order))
for _, id := range p.order {
if m, ok := p.members[id]; ok {
out = append(out, *m)
}
}
return out
}
// entryAllowedLocked 是入口控制的唯一判据:谁、在什么状态下、能不能改成员
// 集合。所有写方法都先过它,避免规则散落在七八个 if 里各写一遍。
func (p *Party) entryAllowedLocked(actor PlayerID) error {
if p.state != StateForming {
return ErrPartyLocked
}
if actor != p.leader {
return ErrNotLeader
}
return nil
}
// AddMember 是队长邀请入队。四道门依次过:状态可写、调用者是队长、目标
// 不在队里、未满员。任何一道不过,队伍状态一个字节都不改。
func (p *Party) AddMember(actor, target PlayerID) error {
p.mu.Lock()
defer p.mu.Unlock()
if err := p.entryAllowedLocked(actor); err != nil {
return err
}
// 先判重复再判满员:重复邀请的语义是「已入队」而非「满了」。
if _, ok := p.members[target]; ok {
return ErrAlreadyMember
}
if len(p.members) >= MaxMembers {
return ErrPartyFull
}
p.members[target] = &Member{PlayerID: target, Role: RoleMember, Online: true}
p.order = append(p.order, target)
return nil
}
// RemoveMember 覆盖两种语义:队长踢人(actor 是队长),以及成员自己退出
// (actor == target)。队长不能踢自己——退位必须走迁移,否则队伍会没有队长。
func (p *Party) RemoveMember(actor, target PlayerID) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.state != StateForming {
return ErrPartyLocked
}
if actor != p.leader && actor != target {
return ErrNotLeader
}
if actor == target && actor == p.leader {
return ErrNotLeader
}
if _, ok := p.members[target]; !ok {
return ErrNotMember
}
p.removeLocked(target)
return nil
}
func (p *Party) removeLocked(target PlayerID) {
delete(p.members, target)
for i, id := range p.order {
if id == target {
p.order = append(p.order[:i], p.order[i+1:]...)
break
}
}
}
// setStateLocked 是状态机的唯一入口:组建 -> 锁定 -> 副本,以及回退。
func (p *Party) setStateLocked(next State) error {
switch p.state {
case StateForming:
if next != StateLocked {
return ErrPartyLocked
}
case StateLocked:
if next != StateInDungeon && next != StateForming {
return ErrPartyLocked
}
case StateInDungeon:
if next != StateForming {
return ErrPartyLocked
}
}
p.state = next
return nil
}
这里有两个刻意的设计选择,值得说清楚。
第一,order 与 members 并存。members 是 map,用来 O(1) 判断「这个人在不在队里」;order 是切片,用来记住加入顺序。为什么需要顺序?因为队长迁移、CmdPartyInfoSync 0x0702 给客户端展示的队伍列表、以及匹配票据里的名单,都需要一个稳定顺序。如果只留 map,Go 的 map 迭代顺序是随机的,队友看到的队伍列表会每次刷新都不一样——这是个真实且高频的体验缺陷。
第二,「先判重复、再判满员」。两句话的顺序是有语义的:一个人重复点邀请,客户端应该提示「对方已在队伍中」,而不是「队伍已满」。判据顺序决定了错误码,错误码决定了文案。
测试:把每道门都撞一遍
入口控制的正确性不能靠读代码确认,要把它写成断言。下面这段是测试节选(// snippet:,不参与独立验证,但它属于上表那份跑通的测试文件):
// snippet: internal/party/party_test.go(节选)
// TestEntryControl 钉死 12.1 的四道门:非队长不能改、满员不能进、重复不能进、
// 锁定后一律不能进。任何一条被绕过,都会让「谁能进副本」失控。
func TestEntryControl(t *testing.T) {
leader, a, b, c, d := PlayerID(1), PlayerID(2), PlayerID(3), PlayerID(4), PlayerID(5)
p := NewParty(PartyID(7), leader)
if err := p.AddMember(a, b); !errors.Is(err, ErrNotLeader) {
t.Fatalf("non-leader add: want ErrNotLeader, got %v", err)
}
if err := p.AddMember(leader, a); err != nil {
t.Fatalf("leader add: %v", err)
}
if err := p.AddMember(leader, a); !errors.Is(err, ErrAlreadyMember) {
t.Fatalf("duplicate add: want ErrAlreadyMember, got %v", err)
}
// ... 补满 b、c 后,第四个 d 必须被 ErrPartyFull 挡住
// 队长不能把自己踢掉——退位必须走迁移
if err := p.RemoveMember(leader, leader); !errors.Is(err, ErrNotLeader) {
t.Fatalf("leader self-remove: want ErrNotLeader, got %v", err)
}
}
跑它的命令是 go test ./internal/party/ -run TestEntryControl -v,六个用例全绿。
本节小结
| 想做的事 | 该走的路径 | 被谁拦 |
|---|---|---|
| 邀请入队 | AddMember / TryJoin | 非队长 → ErrNotLeader;满员 → ErrPartyFull |
| 重复邀请 | 同上 | ErrAlreadyMember |
| 锁定后改成员 | 任何写方法 | ErrPartyLocked |
| 队长退位 | 队长迁移(12.3) | 直接 RemoveMember 自己 → ErrNotLeader |
| 查满员 | 只读 Count / Full | 不能用来做写判断(12.2) |
把状态、身份、成员集合三样东西的判据收敛到 entryAllowedLocked 一处之后,「谁能改队伍」就只剩一个答案。下一节我们要处理这个答案在并发下最容易翻车的地方:满员判断与匹配锁定。
阅读导航:上一节:目录 · 下一节:12.2 满员判断与匹配锁定 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。