# SuperSorry match_mode 字段 Bug 案例研究

**发现日期**: 2026-03-17
**游戏**: SuperSorry（超级跪）
**问题类型**: 服务端埋点 - 枚举字段错误
**严重程度**: Critical
**状态**: 已修复

---

## 执行摘要

### 问题描述

SuperSorry 游戏的 `match_mode` 字段在**免费场匹配**场景下上报错误：
- **预期值**: 1（匹配模式）
- **实际值**: 2（自建房模式）

导致数据分析时无法正确区分免费场的匹配和自建房数据。

### 根本原因

免费场匹配器代码中，调用 `CreateFullMatchRoom` 时传递了**硬编码空数组**而非计算好的 `matchTeamList`：

```go
// 免费场 (supersorry_matchmaker_free.go:76) - Bug
CreateFullMatchRoom(..., []int32{})  // 传空数组

// 金币场 (supersorry_matchmaker_coin.go:82) - 正确
CreateFullMatchRoom(..., matchTeamList)  // 传计算好的变量
```

### 为什么测试没发现

**测试盲区**：只测试了金币场，假设免费场也正确。

**错误假设**：
- 金币场的 match_mode 正确 → 认为所有模式都正确
- 没有意识到不同模式可能有独立实现

### 影响范围

- 影响：免费场匹配的所有埋点事件（GameStartWaitDuration、GameLeaveWaitDuration、EndGame 等）
- 时间：从功能上线到修复期间的所有数据
- 数据污染：无法区分免费场的匹配和自建房用户行为

---

## 案例价值

### 引发的核心教训

这个 bug 引发了项目中最重要的测试原则之一：

> **核心原则**：游戏有 N 个模式，就必须测试 N 个模式。没有例外，没有假设，必须全覆盖。

### 系统性改进

1. **新增测试覆盖原则** - 写入 MEMORY.md 和 tracking-test SKILL
2. **强制测试矩阵** - 所有游戏模式 × 所有进入方式
3. **代码审查警示标志** - 识别"声明变量但传硬编码值"的模式

---

## 技术细节

### 代码对比

#### 金币场（正确实现）

```go
// wegame/app/matchmaker/matcher/supersorry_matchmaker_coin.go:69-82
func (mm *SuperSorryMatchmakerCoinMatch) Assign(match core.Match) (core.Assignment, error) {
    // ... 其他代码 ...

    var matchTeamList []int32  // 声明变量
    for _, tickets := range match.TeamTickets {
        for _, ticket := range tickets {
            if ticket.GroupLeaderUid == 0 {
                matchTeamList = append(matchTeamList, int32(len(ticket.GroupTickets)+1))  // 计算
            }
        }
    }

    rid, host, port, err := rpcRoom.CreateFullMatchRoom(config.ServerMatchMaker, int32(mm.GameType),
        int32(mm.GameMode), "", playerInfos, config.GetRoomConf().DefaultVoiceType, matchTeamList)
    //                                                                                    ^^^^^^^^^^^^^ ✅ 传变量
    // ...
}
```

#### 免费场（Bug 实现 - 修复前）

```go
// wegame/app/matchmaker/matcher/supersorry_matchmaker_free.go:63-76
func (mm *SuperSorryMatchmakerFreeMatch) Assign(match core.Match) (core.Assignment, error) {
    // ... 其他代码 ...

    var matchTeamList []int32  // 🚨 声明了变量
    for _, tickets := range match.TeamTickets {
        for _, ticket := range tickets {
            if ticket.GroupLeaderUid == 0 {
                matchTeamList = append(matchTeamList, int32(len(ticket.GroupTickets)+1))  // 🚨 计算了
            }
        }
    }

    rid, host, port, err := rpcRoom.CreateFullMatchRoom(config.ServerMatchMaker, int32(mm.GameType),
        int32(mm.GameMode), "", playerInfos, config.GetRoomConf().DefaultVoiceType, []int32{})
    //                                                                                    ^^^^^^^^ ❌ 但传的是空数组！
    // ...
}
```

**危险信号**：
1. 声明了 `matchTeamList` 变量
2. 有代码计算 `matchTeamList`
3. 但最后传的是硬编码 `[]int32{}`
4. 典型的"复制粘贴忘记修改"错误

#### 免费场（修复后）

```go
// wegame/app/matchmaker/matcher/supersorry_matchmaker_free.go:76 - 修复后
rid, host, port, err := rpcRoom.CreateFullMatchRoom(config.ServerMatchMaker, int32(mm.GameType),
    int32(mm.GameMode), "", playerInfos, config.GetRoomConf().DefaultVoiceType, matchTeamList)
//                                                                                    ^^^^^^^^^^^^^ ✅ 传变量
```

---

### match_mode 计算逻辑

```go
// wegame/app/room/service/room_rpc.go:144-149
func (s *Service) getMatchMode(matchTeamList []int32) shared.MatchModeType {
    if len(matchTeamList) > 0 {
        return shared.MatchModeType_MatchModeMatch  // 1 - 匹配模式
    }
    return shared.MatchModeType_MatchModeCustom     // 2 - 自建房模式
}
```

**逻辑**：
- `matchTeamList` 非空 → match_mode = 1（匹配）
- `matchTeamList` 为空 → match_mode = 2（自建房）

**Bug 效果**：
- 金币场：传 `matchTeamList`（非空）→ match_mode = 1 ✅
- 免费场：传 `[]int32{}`（空）→ match_mode = 2 ❌（应该是 1）

---

### 数据流追踪

```
免费场匹配器 (supersorry_matchmaker_free.go)
  ↓
计算 matchTeamList = [2] (假设2个玩家)
  ↓
❌ 调用 CreateFullMatchRoom(..., []int32{})  // Bug：传空数组而非 matchTeamList
  ↓
Room RPC (room_rpc.go:144)
  ↓
getMatchMode([]int32{})  // 收到空数组
  ↓
len(matchTeamList) = 0 → 返回 MatchModeCustom (2)  // ❌ 错误
  ↓
埋点上报 match_mode = 2  // 应该是 1
```

---

## 测试盲区分析

### 测试时的错误假设

**测试过程**：
1. 测试金币场匹配 → match_mode = 1 ✅
2. 测试金币场自建房 → match_mode = 2 ✅
3. **假设**：免费场应该也正确 ❌
4. **跳过**：免费场测试

**问题**：
- 没有意识到免费场和金币场有**独立的实现**
- 认为"同一个字段在不同模式下的逻辑应该一样"

### 应该怎么测试

**正确的测试矩阵**：

| 游戏模式 | 进入方式 | match_mode 预期 | 是否测试 | 结果 |
|---------|---------|----------------|----------|------|
| 免费场   | 匹配    | 1              | ❌ 未测试 | ❌ Bug |
| 免费场   | 自建房  | 2              | ❌ 未测试 | ? |
| 金币场   | 匹配    | 1              | ✅ 已测试 | ✅ 正确 |
| 金币场   | 自建房  | 2              | ✅ 已测试 | ✅ 正确 |

**覆盖率**: 2/4 = 50%

**结论**：只测了一半场景，遗漏了 bug。

---

## 为什么代码会这样写

### 可能的开发过程推测

1. **第一步**：实现金币场匹配器（正确）
   ```go
   CreateFullMatchRoom(..., matchTeamList)  // ✅
   ```

2. **第二步**：复制代码到免费场匹配器
   ```go
   // 复制了完整的代码，包括 matchTeamList 计算
   var matchTeamList []int32
   for ... {
       matchTeamList = append(...)
   }
   ```

3. **第三步**：修改某些参数时，误操作
   ```go
   // 可能想测试"不传 matchTeamList 会怎样"
   CreateFullMatchRoom(..., []int32{})  // ❌ 忘记改回来
   ```

4. **第四步**：提交代码，未发现问题
   - 本地测试可能只测了金币场
   - Code Review 没有发现（变量声明了但未使用）

---

## 修复验证

### 修复前后对比

**修复前**：
```bash
# 免费场匹配
$ grep "match_mode" tracking_data.log | grep "game_mode=1"
match_mode=2  # ❌ 错误
match_mode=2
match_mode=2
```

**修复后**：
```bash
# 免费场匹配
$ grep "match_mode" tracking_data.log | grep "game_mode=1"
match_mode=1  # ✅ 正确
match_mode=1
match_mode=1
```

### 完整验证清单

- [x] 免费场匹配 → match_mode = 1 ✅
- [x] 免费场自建房 → match_mode = 2 ✅
- [x] 金币场匹配 → match_mode = 1 ✅（回归测试）
- [x] 金币场自建房 → match_mode = 2 ✅（回归测试）

**覆盖率**: 4/4 = 100%

---

## 经验教训

### 教训 1：代码相似 ≠ 逻辑相同

**错误认知**：
> "金币场和免费场都是匹配，逻辑应该一样，测一个就够了。"

**现实**：
- 不同模式可能有**独立的实现文件**
- 复制粘贴代码时容易引入 bug
- 每个实现都需要**独立验证**

---

### 教训 2：声明但未使用的变量是危险信号

**代码警示标志**：
```go
var matchTeamList []int32  // 🚨 声明了
// ... 计算 matchTeamList ...
CreateFullMatchRoom(..., []int32{})  // 🚨 但没用
```

**预防方法**：
- 启用 `unused variable` 编译器警告
- Code Review 时特别关注这类模式
- 使用 golangci-lint 等静态分析工具

---

### 教训 3：枚举字段必须逐值验证

**match_mode 有 2 个值**：
- 1 = 匹配模式
- 2 = 自建房模式

**必须验证**：
- 每个值在什么情况下出现
- 每个游戏模式下是否正确

**不能假设**：
- "看代码逻辑应该对"
- "其他模式测过了，这个应该也对"

---

### 教训 4：测试矩阵是强制要求

**测试公式**：

```
游戏模式数 × 进入方式数 = 必须测试的场景数

SuperSorry: 2 模式 × 2 方式 = 4 个场景必测
```

**不允许**：
- ❌ 只测 1 个模式
- ❌ 只测匹配不测自建房
- ❌ 假设"应该都对"

**强制要求**：
- ✅ 所有组合都要测
- ✅ 没有例外

---

## 预防措施

### 1. Code Review 检查清单

在审查匹配器代码时，必须检查：

- [ ] 是否声明了 `matchTeamList` 变量？
- [ ] 是否有计算 `matchTeamList` 的逻辑？
- [ ] `CreateFullMatchRoom` 调用时是否传递了 `matchTeamList`？
- [ ] 是否传递了硬编码空数组 `[]int32{}`？
- [ ] 是否有未使用的变量警告？

### 2. 静态分析规则

```bash
# 检查未使用的变量
golangci-lint run --enable=unused

# 检查硬编码空数组（自定义规则）
grep -r "\[\]int32{}" wegame/app/matchmaker/
```

### 3. 测试模板

对于所有有多模式的游戏，必须使用测试矩阵：

```markdown
## match_mode 测试矩阵

| 游戏模式 | 进入方式 | match_mode 预期 | 实际值 | 状态 |
|---------|---------|----------------|--------|------|
| 模式1    | 匹配    | 1              | ?      | [ ]  |
| 模式1    | 自建房  | 2              | ?      | [ ]  |
| 模式2    | 匹配    | 1              | ?      | [ ]  |
| 模式2    | 自建房  | 2              | ?      | [ ]  |

**测试完成标准**：所有格子都填写并通过
```

### 4. 自动化测试

```go
// 建议添加单元测试
func TestMatchModeForAllGameModes(t *testing.T) {
    testCases := []struct {
        gameMode  int32
        matchTeamList []int32
        expected  shared.MatchModeType
    }{
        {1, []int32{2}, shared.MatchModeType_MatchModeMatch},     // 免费场匹配
        {1, []int32{}, shared.MatchModeType_MatchModeCustom},     // 免费场自建房
        {2, []int32{2}, shared.MatchModeType_MatchModeMatch},     // 金币场匹配
        {2, []int32{}, shared.MatchModeType_MatchModeCustom},     // 金币场自建房
    }

    for _, tc := range testCases {
        actual := getMatchMode(tc.matchTeamList)
        assert.Equal(t, tc.expected, actual)
    }
}
```

---

## 影响评估

### 数据影响

**污染期间**：从功能上线到修复

**受影响的埋点**：
- GameStartWaitDuration - 游戏开始等待时长
- GameLeaveWaitDuration - 游戏离开等待时长
- EndGame - 游戏结束
- 所有包含 match_mode 的事件

**数据修复**：
```sql
-- 需要根据其他字段反推正确的 match_mode
UPDATE tracking_events
SET match_mode = 1  -- 修正为匹配模式
WHERE event_name IN ('GameStartWaitDuration', 'EndGame')
  AND game_type = 1080
  AND game_mode = 1  -- 免费场
  AND match_mode = 2  -- 错误的值
  AND created_at BETWEEN '2026-XX-XX' AND '2026-03-17'  -- 污染期间
  -- AND 其他条件判断是否真的是匹配（如 rid 来自匹配系统）
```

---

## 总结

### 案例意义

这是一个**教科书级别的案例**，展示了：

1. **测试覆盖的重要性** - 50% 覆盖率 = 遗漏 bug
2. **代码审查的价值** - "声明但未使用"是危险信号
3. **假设的危险性** - "应该都对"往往是错的
4. **系统性改进** - 从一个 bug 提炼出通用原则

### 核心原则（再次强调）

> **游戏有 N 个模式，就必须测试 N 个模式。**
>
> 没有例外，没有假设，必须全覆盖。

### 传播这个教训

这个案例已经整合到：
- ✅ 项目 MEMORY.md - 作为强制测试规则
- ✅ tracking-test SKILL - 作为测试覆盖原则
- ✅ game-tracking-audit SKILL - 作为核心教训

**目标**：让每个团队成员都知道这个教训，避免重复犯错。

---

**案例记录日期**: 2026-03-17
**记录人**: Claude (AI Assistant)
**案例状态**: 已修复，已转化为系统性改进

---

**记住**：代码可能在一个模式下正确，在另一个模式下错误。永远不要假设。
