《MMO 服务器开发》12.1 队伍状态与入口控制

副本、排位、世界 Boss 都要先问一句:这个玩家能不能进这支队伍。本节把队伍建成一个带生命周期状态机的服务端对象,用状态、队长身份、成员集合三道闸把入口控制钉死,并用 Go 标准库写出可直接 go test 的 L1 实现,再把每道门逐一撞开。

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 满员判断与匹配锁定 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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