匹配系统

匹配系统概览

多人在线游戏的游戏内容可以粗略的分为两类:

  1. player versus environment, 也就是常说的PVE,游戏玩家负责与游戏程序设定好的非玩家角色执行互动,MMO中的副本玩法可以归为此类
  2. player versus player,也就是常说的PVP,游戏玩家与游戏玩家在游戏内执行亲切友好的交流,Dota/LOL/CS/PUBG等游戏可以归为此类

PVE玩法由于其都是预设好的玩法流程,在玩家尝试过几次之后都会厌倦,所以这类玩法需要不断的更新内容、添加不同等级的难度来维护用户粘性。而PVP游戏则只需要提供一个完善的游戏玩法机制,再加上一个合适的玩家匹配系统,就可以在低频的内容更新下吸引大量的玩家在此游戏中流连忘返。毕竟与人斗其乐无穷,如果在深夜最后一把经历了一个棋逢对手并最终在被破三路的情况下翻盘,我想此时的玩家应该会在后面的一个多月内不断的回味这盘比赛。

为了做到棋逢对手,我们首先要为参与匹配的每个游戏玩家的游戏技术来做一个量化,专业数据叫MatchMaking Rating。在不同的游戏中这个量化指标会以不同的名词来体现,例如等级分、天梯分、金银铜铁段位。

dota2中的段位

有些游戏里使用了不止一个分数来决定匹配决策,例如行为分、英雄分、位置分等等。有了这些量化分数之后,玩家申请匹配游戏的时候就会将自己以及相关的分数加入到匹配池,匹配池通过内部的撮合算法来生成两边分数比较均衡的队伍,来作为一局游戏的人员配置,通知对局服务器开启一局新的游戏。下图中就是一局Dota2游戏中的人员配置,玩家名字附近的数字就代表了其天梯分数的排名,底色则代表了其排行所属的段位。

dota2中的对局

当一场游戏结束之后,会根据比赛的输赢以及每个玩家的局内表现,执行一下分数的调整。玩家使用更新后的分数再次进入匹配池,在这样的循环机制下玩家的分数将趋于稳定,同时其匹配到的对手与队友也逐渐棋逢对手。

综上,一个匹配系统主要需要做到两个部分:对局撮合,分数调整。下面将对这两部分进行分别介绍。

评分系统

ELO评分系统

国际象棋比赛中使用的ELO评分系统在匹配系统中极为出名。这个ELO评分系统对于参赛选手的实力有一个假设:其实际发挥出来的实力遵从一个正态分布函数:

这里的代表了玩家的平均实力,而则代表实力分布的标准差,整个正态分布的图形描述见下:

正态分布的图形

假如两个玩家X,Y的实力分布都服从这个假设,即的同时。在某一局中XY的概率等于的概率。由于两个正态分布相减得到的结果仍然是正态分布,即。所以等于下面这个积分:

其中

由于这个公式有点过于复杂,在常规情况下可以做一个近似,用来替换,此时的概率函数可以近似表示为,下面是更加容易理解的图形化表示:

elo胜率函数近似

对于两个ELO分数为R1,R2的玩家来说,其赢的概率分别为。我们可以验证这两个概率的和为1:

在一个对局结束后,两个玩家的分数都需要进行更新,分数更新的公式为R=R+K*(ActualScore - ExpectedScore)。在大多数游戏中,ActualScore根据对局的输赢分别设置为01,如果平局则设置为0.5,而ExpectedScore则设置为其在这个对剧中应该赢的概率。这里的K则是预先设置好的一个常数,其数值的选择将显著的影响分数的变化。

举例来说,两个玩家的ELO分数分别是1200,1000,则各自赢的概率分别为:

假设此时常数K设置为30,对局结束之后的分数变化如下:

  1. 1200分数的玩家赢的时候,赢家分数将更新为,输家分数将更新为
  2. 1000分数的玩家赢的时候,赢家分数将更新为,输家分数将更新为

在这两种情况下可以看出ELO有这样的性质,对局结束之后玩家的总分不变,分数高的玩家赢下之后得到的分数远小于输了之后扣掉的分数。分数的增减与分差之间的对应曲线见下图:

elo分数更新曲线

从这张图中可以观察到:玩家分数差距越大时,赢了得到的分数越小,输了减去的分数越大,单局最大分数更新值将无限趋近于预设的常数。整体分数更新逻辑可以用下面的简单代码流程来阐明:

#include <bits/stdc++.h>
using namespace std;

// Function to calculate the Probability
float Probability(int rating1, int rating2)
{
    // Calculate and return the expected score
    return 1.0 / (1 + pow(10, (rating1 - rating2) / 400.0));
}

// Function to calculate Elo rating
// K is a constant.
// outcome determines the outcome: 1 for Player A win, 0 for Player B win, 0.5 for draw.
void EloRating(float Ra, float Rb, int K, float outcome)
{
    // Calculate the Winning Probability of Player B
    float Pb = Probability(Ra, Rb);

    // Calculate the Winning Probability of Player A
    float Pa = Probability(Rb, Ra);

    // Update the Elo Ratings
    Ra = Ra + K * (outcome - Pa);
    Rb = Rb + K * ((1 - outcome) - Pb);

    // Print updated ratings
    cout << "Updated Ratings:-\n";
    cout << "Ra = " << Ra << " Rb = " << Rb << endl;
}

// Driver code
int main()
{
    // Current ELO ratings
    float Ra = 1200, Rb = 1000;

    // K is a constant
    int K = 30;

    // Outcome: 1 for Player A win, 0 for Player B win, 0.5 for draw
    float outcome = 1;

    // Function call
    EloRating(Ra, Rb, K, outcome);

    return 0;
}

前述的规则只适用于1v1类型的比赛,现在的游戏大多都是多人竞技类型,最常见的就是3v3,5v5的队伍配置,为此需要将ELO评分系统做多人模式的扩展。比较简单的一种扩展就是队伍总体的ELO分数等于队伍内所有人的ELO加权平均或者几何平均,一个玩家的局后分数更新值等于自己与敌方阵营中每个选手计算出来的单人ELO更新值的总和。同时为了限制单局引发的分数变化幅度,会将K值除以敌方阵营内人数。至于不怎么常见的1v1v1v1这种多阵营且每个阵营中只有一个人的情况,也可以采用对应的分别计算之后进行累加的机制。

Glicko评分系统

不同于传统的棋类游戏,现在的多人竞技网络游戏里玩家的角色特征比较复杂,单纯的使用ELO评分系统有很多缺点。特别是在处理新玩家和回流玩家的时候有很大的缺陷:

  1. 对于一个新号,ELO会给予其一个基础分,如1000。假如一个真实实力为2000分的玩家创建一个新号,在单局分数变动上限为20的情况下,能够很轻松的在1000-1600分数段实现三十连胜。这种现象就是俗称的小号炸鱼。而如果一个完全意义上的新手玩家,其真实实力可能只有200,他将经受无限的连败,直到其ELO被矫正到200,这种玩家就是俗称的鱼苗。

  2. 对于一个回流玩家,其由于长时间没有练习以及游戏版本更新的原因,其实际水平其实比之前的ELO分数低很多。此时使用其以往的ELO分数来执行匹配的话,其对手和队友会明显察觉出此人已经不适合这个版本了,自己输的概率非常大,导致好不容易回流的玩家由于无法跟上比赛节奏而被迫再次流失

为了修正这两个缺陷,ELO系统中一般会调整用来更新分数的K值,对于新手玩家和回流玩家其K值将会显著的增大,以方便这些玩家能够快速的矫正到其真实实力。为了更加自动化的来执行这个K值的调整,Mark Glickman在其1995年的论文The Glicko system中提出了Glicko系统。在Glicko系统中,一个玩家的实力除了原来的均值之外,还有一个偏差值ratings deviation(RD)来描述。一个更高的偏差值代表这个玩家近期比赛次数少或者比赛总数少,低偏差值则代表这个玩家近期很活跃。偏差值大的时候玩家的分数调整幅度就大,反之则小。由于偏差值的存在,导致输赢两边的分数调整总和将不再永远是0。同时由于偏差值的存在,描述一个玩家的分数的时候,单变量已经不够用了,一般使用95%置信区间来作为玩家的分数范围,这个分数范围的差值就是偏差值的两倍。举个例子来说,一个玩家的评分是1850同时其偏差值为50,则其分数的95%置信区间就是[1750,1950]

在了解偏差值的概念之后,我们再来解释一下Glicko系统的运作过程。首先我们需要对新手玩家和回流玩家执行一些初始化:

  1. 对于新手玩家,初始化其基础分为1500,同时偏差值设置为350
  2. 对于回流玩家,设置其偏差值为,这里的c是一个预先设置的常量,t是不活跃时间

在玩家完成了m场比赛之后,其分数通过下面的公式来执行更新:

其中代表每个对手的分数,代表每个对手的偏差值,代表每个比赛这个玩家的输赢,则是ELO规则下对局获胜的概率,其他几个变量的定义见下:

在原始的论文中作者提供了一个具体的数据更新例子,提供了计算过程,来加深一下对整个系统的了解。在这个例子中,为了方便描述一个玩家的数据特征,我们使用这个二元组来表征。主角的数据为, 与三个对手进行了三次比赛的对应结果为赢、输、输。在这样的三个对局过后,系统开始执行新一轮的分数与偏差值矫正:

11400300.99550.6391
215501000.95310.4320
317003000.72420.3030

有了这些基础对局数据之后,可以计算出:

有了之后,就可以计算出:

所以最终这名玩家的分数被更新为

由于Glicko显著的比ELO系统更加优秀,很多游戏都逐渐的切换到Glicko系统中,典型样例就是Dota2 7.33更新所带来的积分系统修改:

dota2的天梯分致信度

原作者在Glicko的基础上在2022年提出了Glicko2,主要的改进是考虑到玩家的实力发挥的稳定性。为此在Glicko2中又加入了一个波动性volatility参数,用来描述实力的稳定性。偏差作为评分变化的乘数;偏差较大意味着你的评分增减会被放大。每次比赛后,偏差也会发生变化;这一变化由你的波动性驱动。如果你的偏差相对于波动性较高,它会下降;如果偏差较低,它会增加。最后,你的波动性将根据比赛结果进行更新。极端的成绩,比如5-0或1-7,会使波动性上升,而3-2或2-3的成绩则会使波动性下降。

至于Glicko2的细节我就不在这里描述了,有兴趣的读者可以根据提供的连接去阅读原文。这里我们只介绍一下大家更加喜闻乐见的基于Glicko2评分系统的漏洞利用问题,最早出现漏洞利用的是知名游戏Pokemon Go BattleLeague(GBL)。有GBL的玩家发现自己会出现异常的高评分变动,在多名玩家都汇报了此类情况之后,社区总结出这些玩家的共性:都是在赛季初期故意输掉多场比赛的玩家。

根据更加深入的探究之后,相关人员最终发现了Glicko2系统存在一个巨大的缺陷,这个评分系统可以被利用来暂时达到非常高的评分,具体过程如下:

  1. 通过故意输掉比赛,玩家将自己的评分降低到远低于真实技能水平。
  2. 玩家与同样低评分的对手进行多场比赛。与比自己弱得多的对手对战时,玩家可以选择按需赢或输。通过这样做,他强迫自己进行极端的比赛;要么在一组比赛中赢得所有游戏,要么输掉所有游戏。玩家的波动性会稳步增加;偏差也随之变化。
  3. 通过按需要交替赢输比赛,玩家可以保持评分相对稳定,让他可以继续这个过程,持续时间没有限制。
  4. 在波动性和偏差被培养到足够高后,玩家开始恢复正常比赛,逐渐将评分恢复到自己的真实技能水平。
  5. 评分调整的速度比波动性大很多,所以即使波动性和偏差在恢复评分过程中下降,波动性仍然会很高。
  6. 玩家现在处于自己的真实评分,但在游戏中的得失会被严重放大。现在他正常比赛,直到获得一次好的连胜,将评分推到顶峰。
  7. 由于玩家的偏差极高,这个评分顶峰会比正常情况下应有的要高得多。

所以基于这样的严重缺陷,不推荐使用Glicko2,直接使用Glicko作为评分系统更加合适。

TrueSkill 系统

TrueSkill 是由微软开发的一种玩家匹配和排名系统,最早用于 Xbox Live 平台,类似于Elo评级系统,但更适用于多人游戏和团队对战。它的核心思想是用贝叶斯推理来估计玩家的真实实力,并动态调整他们的排名。这个机制的细节里充满了各种统计学公式,不是非常好理解,因此这里就直接掠过不去介绍了。有兴趣的读者可以去阅读微软提供的TrueSkill官方网站

撮合系统

撮合系统规则

在明确了玩家的分数表征之后,就可以开始执行玩家之间的匹配工作。为了让比赛呈现势均力敌卧龙凤雏的效果,撮合系统需要让队伍两边的分数差尽可能的小。最简单的实现方法就是将分数切分为多个等距离的分数段,每个参与匹配的玩家投递到对应的段中,执行撮合时直接从同一段中的所有玩家随机选择所需的人数来开启一个对局。如果有些分数段由于人太少导致无法出现满足条件的对局人数,这个简单的策略将导致这个分数段的玩家永远都匹配不到其他玩家。此时的一个缓解措施就是将一个玩家投递到一定区间[A-B, A+B]内的所有段中去参与匹配,这里的A是玩家的天梯分数,B是匹配容差,这个B随着等待时间的增长而变大。

但是撮合系统不仅仅需要考虑分数差这个因素,还有很多其他因素要注意,常见的其他因素包括:

  1. 玩家的等待时间,对于节奏快二十分钟左右就结束的游戏来说,玩家不想浪费十来分钟去等待一个完美的匹配。他们只想快速的开一把游戏,装模做样的对线,到六就开始启动,打不过就投。所以快节奏的游戏中撮合机制会将匹配容差B快速的拉大,以尽可能的快速撮合出队伍。即使三局里面只能赢一局,那这个玩家一个小时内还是能高兴一段时间的。而对于把把六十多分钟的游戏来说,一个尽可能平衡的队伍则是非常必要的,否则十分钟三路炸然后再坐牢等待半小时高地才被拆的游戏毫无体验。
  2. 玩家的延迟,玩家希望游戏与服务器之间的延迟尽可能低,同时希望与具有相同低延迟的玩家匹配在一起。当年的南方电信北方网通时代每个玩家必须选择好自己要匹配的哪个区,选错了那就是毫无游戏体验

dota2匹配区域选择

实际被使用的成熟的商业匹配系统考虑的因素会更加多,所以他们的匹配规则就更加的灵活。假设要为一款团队射击游戏做匹配系统,要求每支队伍的玩家数量相等,且每队至少 4 人,最多 8 人。为了让两只队伍看上去势均力敌,两支队伍的平均玩家技能水平需要相差不超过 10 分。此外,为了避免玩家等待时间过长,希望在 5 秒后放宽技能匹配规则,使队伍间的平均技能差距上限提高到 50分,并在 15 秒后进一步放宽至 100 分。下面是匹配规则在aws中的FlexMatch匹配配置样例:

{
    "name": "aliens_vs_cowboys",
    "ruleLanguageVersion": "1.0",
    "playerAttributes": [{
        "name": "skill",
        "type": "number",
        "default": 10
    }],
    "teams": [{
        "name": "cowboys",
        "maxPlayers": 8,
        "minPlayers": 4
    }, {
        "name": "aliens",
        "maxPlayers": 8,
        "minPlayers": 4
    }],
    "rules": [{
        "name": "FairTeamSkill",
        "description": "The average skill of players in each team is within 10 points from the average skill of players in the match",
        "type": "distance",
        // get skill values for players in each team and average separately to produce list of two numbers
        "measurements": [ "avg(teams[*].players.attributes[skill])" ],
        // get skill values for players in each team, flatten into a single list, and average to produce an overall average
        "referenceValue": "avg(flatten(teams[*].players.attributes[skill]))",
        "maxDistance": 10 // minDistance would achieve the opposite result
    }, {
        "name": "EqualTeamSizes",
        "description": "Only launch a game when the number of players in each team matches, e.g. 4v4, 5v5, 6v6, 7v7, 8v8",
        "type": "comparison",
        "measurements": [ "count(teams[cowboys].players)" ],
        "referenceValue": "count(teams[aliens].players)",
        "operation": "=" // other operations: !=, <, <=, >, >=
    }],
    "expansions": [{
        "target": "rules[FairTeamSkill].maxDistance",
        "steps": [{
            "waitTimeSeconds": 5,
            "value": 50
        }, {
            "waitTimeSeconds": 15,
            "value": 100
        }]
    }]
}

mosaic_game中使用了一个非常简单的撮合系统,这部分的代码作为一个独立模块开源在huangfeidian/match_maker,这里就简单介绍一下这个撮合系统如何使用的。

首先我们需要明确匹配撮合的目标:产生这个对局需要几个阵营,每个阵营中需要几个人。这些信息我们使用match_base_config来描述:

struct match_base_config
{
    std::uint32_t faction_num;// 阵营数量
    std::uint32_t faction_player_sz; // 阵营内玩家数量
    std::uint32_t min_team_player_sz; // 阵营内单一队伍最小玩家数量
    std::uint32_t max_team_player_sz; // 阵营内单一队伍最大玩家数量
};

我们会以这个结构体来初始化match_maker_base,这个类型负责管理单一目标的匹配撮合。如果游戏内同时可能有多种匹配,如普通模式、天梯模式、快速模式,则需要创建对应数量的match_maker_base

接下来需要明确参与匹配的单元,我们用team_info来描述,内部可能包含一个或多个玩家,同时携带这个队伍的整体积分:

struct player_info
{
    std::string pid;
};
struct team_info
{
    std::vector<player_info> players;
    float rank_score;
    std::string tid;
};

匹配产生的结果就是一组阵营,每个阵营内有多个参与匹配的队伍单元:

struct faction_group
{
    std::vector<team_info> teams;
};
struct match_result
{
    std::vector<faction_group> factions;
};

在明确好了输入与输出的结构定义之后,我们再来解释match_maker_base如何构造出符合规则的对局阵营:

virtual std::vector<match_result> make_matchs() = 0;

无视分数的撮合

首先来介绍一下完全无视队伍分数的撮合系统naive_set_match_maker,这个撮合系统只负责构造出符合人数规则的对局。在这个匹配系统的make_matchs函数中,首先构造出一个数组team_ptr_by_sz,这个数组内的元素也是一个数组,次级数组里存储了所有拥有指定人员数量的candidate_team的指针:

std::vector<std::vector<candidate_team*>> faction_result;
std::vector<candidate_team*> temp_faction;
std::vector<std::vector< candidate_team*>> team_ptrs_by_sz(m_teams_by_sz.size());
for (std::uint32_t i = 1; i < team_ptrs_by_sz.size(); i++)
{
	std::sort(m_teams_by_sz[i].begin(), m_teams_by_sz[i].end(), [](const candidate_team& team_a, const candidate_team& team_b)
		{
			return team_a.apply_ts < team_b.apply_ts;
		});
	team_ptrs_by_sz[i].resize(m_teams_by_sz[i].size());
	for (std::uint32_t j = 0; j < m_teams_by_sz[i].size(); j++)
	{
		team_ptrs_by_sz[i][j] = m_teams_by_sz[i].data() + j;
	}
}

这里candidate_team继承自team_info,多谢带了两个字段,分别是当前的匹配状态和进入匹配池的时间:

enum class basic_match_state
{
    idle = 0,
    candidate_for_faction,
    in_result

};
struct candidate_team : public team_info
{
    std::uint32_t match_state = 0;
    std::uint64_t apply_ts = 0;
};

这里将参与匹配的队伍按照进入匹配池的时间顺序进行排序,这样做的目的是让更早进入匹配池的有更高的优先级被撮合成为对局。

然后撮合流程就比较简单了,两层遍历,第一层从人员数量大的队伍遍历到只有一个人的队伍,第二层遍历次级队伍数组中的所有没有被撮合的队伍,以这个队伍为开始的搜索条件来执行深度优先遍历,尝试去生成一个可行的阵营。这里优先挑选人数多的队伍是因为队伍越大越不好撮合,优先选择大队伍就可以显著的降低这些队伍的等待时间。

for (std::uint32_t i = team_ptrs_by_sz.size() - 1; i > 0; i--)
{
    for (std::uint32_t j = 0; j < team_ptrs_by_sz[i].size(); j++)
    {
        
        if (team_ptrs_by_sz[i][j]->match_state != std::uint32_t(basic_match_state::idle))
        {
            continue;
        }
        temp_faction.clear();
        team_ptrs_by_sz[i][j]->match_state = std::uint32_t(basic_match_state::candidate_for_faction);
        temp_faction.push_back(team_ptrs_by_sz[i][j]);
        if (search_for_faction(team_ptrs_by_sz, temp_faction, m_base_config.faction_player_sz - i, i, j))
        {
            faction_result.push_back({});
            faction_result.back().reserve(temp_faction.size());
            for (auto one_team : temp_faction)
            {
                one_team->match_state = std::uint32_t(basic_match_state::in_result);
                faction_result.back().push_back(one_team);
            }
        }
        else
        {
            temp_faction.pop_back();
            team_ptrs_by_sz[i][j]->match_state = std::uint32_t(basic_match_state::idle);
            break;
        }
    }
}

这里的search_for_faction就是以当前team_ptrs_by_sz[i][j]为种子来执行撮合搜索的函数,这个函数负责寻找一些人员数量不大于cur_team_sz的其他队伍来生成一个阵营撮合。这个阵营撮合的过程是一个递归搜索的过程,每个被搜索到的队伍都会修改其状态为candidate_for_faction。如果发现递归搜索失败,需要将构造为临时匹配阵营的队伍状态切换为idle

bool match_maker_base::search_for_faction_recursive(const std::vector<std::vector< candidate_team*>>& team_ptrs_by_sz, std::vector<candidate_team*>& cur_faction_group, const std::uint32_t remain_capacity, const std::uint32_t cur_team_sz, const std::uint32_t last_choose_idx)
{
    if (remain_capacity == 0) // 剩余需要的队伍人数为0 阵营撮合成功
    {
        return true;
    }
    if (remain_capacity >= cur_team_sz)
    {
        for (std::uint32_t i = last_choose_idx + 1; i < team_ptrs_by_sz[cur_team_sz].size(); i++)
        {
            auto cur_team_ptr = team_ptrs_by_sz[cur_team_sz][i];
            if (cur_team_ptr->match_state != std::uint32_t(basic_match_state::idle))
            {
                continue;
            }
            // 切换为已经进入阵营状态
            cur_team_ptr->match_state = std::uint32_t(basic_match_state::candidate_for_faction); 
            cur_faction_group.push_back(cur_team_ptr);
            if (search_for_faction_recursive(team_ptrs_by_sz, cur_faction_group, remain_capacity - cur_team_sz, cur_team_sz, i))
            {// 阵营成功 直接返回
                return true;
            }
            else
            {   // 阵营撮合失败 回退状态 放弃在当前队伍数组中继续寻找队伍 
                cur_faction_group.pop_back();
                cur_team_ptr->match_state = std::uint32_t(basic_match_state::idle);
                break;
            }

        }
    }

    // 走到这里说明继续添加一个同样大小的队伍会导致阵营无法被撮合,因此需要往更小的队伍里开始寻找
    for (std::uint32_t i = std::min(cur_team_sz - 1, remain_capacity); i > 0; i--)
    {
        for (std::uint32_t j = 0; j < team_ptrs_by_sz[i].size(); j++)
        {
            auto cur_team_ptr = team_ptrs_by_sz[i][j];
            if (cur_team_ptr->match_state != std::uint32_t(basic_match_state::idle))
            {
                continue;
            }
            cur_team_ptr->match_state = std::uint32_t(basic_match_state::candidate_for_faction);
            cur_faction_group.push_back(cur_team_ptr);
            if (search_for_faction_recursive(team_ptrs_by_sz, cur_faction_group, remain_capacity - i, i, j ))
            {
                return true;
            }
            else
            {
                cur_faction_group.pop_back();
                cur_team_ptr->match_state = std::uint32_t(basic_match_state::idle);
                break;
            }
        }

    }
    return false;

}

每个被撮合成功的阵营都会放到faction_result之中,该阵营中的每个candidate_team队伍的状态都会被切换为candidate_for_faction

当多个阵营被生成之后,接下来就简单了,挑选指定数量的阵营来生成一个对局:

std::vector<match_result> match_results(faction_result.size() / m_base_config.faction_num);
for (std::uint32_t i = 0; i < match_results.size(); i++)
{
    match_results[i].factions.resize(m_base_config.faction_num);
    for (std::uint32_t j = 0; j < m_base_config.faction_num; j++)
    {
        match_results[i].factions[j].teams.reserve(faction_result[i * m_base_config.faction_num + j].size());
        for (auto one_team_ptr : faction_result[i * m_base_config.faction_num + j])
        {
            match_results[i].factions[j].teams.push_back(*one_team_ptr);
            m_sz_for_team.erase(one_team_ptr->tid);
        }
        
    }
}

对于那些没有进入对局的队伍,将其状态切换为idle状态,等待下一次匹配来撮合。对于那些撮合成功的队伍,从匹配池中删除掉。

for (std::uint32_t i = match_results.size() * m_base_config.faction_num; i < faction_result.size(); i++)
{
    for (auto one_team_ptr : faction_result[i])
    {
        one_team_ptr->match_state = std::uint32_t(basic_match_state::idle);
    }
}
std::vector<candidate_team> remain_teams;
remain_teams.reserve(8);
for (std::uint32_t i = 1; i < m_teams_by_sz.size(); i++)
{
    if (m_teams_by_sz[i].empty())
    {
        continue;
    }
    remain_teams.clear();
    for (const auto& one_team : m_teams_by_sz[i])
    {
        if (one_team.match_state == std::uint32_t(basic_match_state::idle))
        {
            remain_teams.push_back(one_team);
        }
    }
    std::swap(remain_teams, m_teams_by_sz[i]);
}

这个的实现既有两重循环,又有递归,复杂度还是比较高的。在没有一次触发搜索回溯的情况下,整体的复杂度大概是m_teams_by_sz这个数组里每个非空元素的队伍数量的乘积。

考虑分数的撮合

前面介绍的撮合系统具体实现是非常简陋的,为了撮合而撮合,忽略了最重要的一个维度:分数平衡。 当然绝对的分数平衡计算起来代价是很高的,类似于装箱问题,是个NP-Hard复杂度的。所以一般来说会将分数按照指定间隔划分为多个分数段,将参与匹配的团队按照其分数投递到分数段中。每个分数段内自己执行前述的无视分数的撮合,如果一个队伍长时间没有被匹配成功,则允许其参与附近的相关分数段里的匹配,这就是naive_ranked_match_maker::make_matchs的执行过程。在创建这个naive_ranked_match_maker的时候,需要传入分数段相关的配置项:

struct ranked_match_config
{
    float rank_level_gap_score; // 分数段的分数间隔
    std::uint64_t extend_level_tolerance_time_gap; // 随着等待时间变长而扩充的分数段匹配容差系数
    std::uint32_t max_level_diff_tolerance; // 最大允许的分数段差距
};

在开头,首先获取参与匹配的所有队伍的最大分数与最小分数,从而来创建分数段:

float min_score, max_score;
bool score_set = false;
for (const auto& one_team_vec : m_teams_by_sz)
{
    for (const auto& one_team : one_team_vec)
    {
        if (!score_set)
        {
            score_set = true;
            min_score = one_team.rank_score;
            max_score = one_team.rank_score;
        }
        else
        {
            min_score = std::min(min_score, one_team.rank_score);
            max_score = std::max(max_score, one_team.rank_score);
        }
        
    }
}
if (!score_set)
{
    return {};
}

int min_score_level = int(std::floor(min_score / m_ranked_config.rank_level_gap_score));
int max_score_level = int(std::ceil(max_score / m_ranked_config.rank_level_gap_score));
min_score = min_score_level * m_ranked_config.rank_level_gap_score;
max_score = max_score_level * m_ranked_config.rank_level_gap_score;
std::uint32_t level_range_sz = max_score_level - min_score_level + 1;
std::vector<std::vector<std::vector< candidate_team*>>> team_ptrs_by_sz_and_level(level_range_sz, std::vector<std::vector< candidate_team*>>(m_teams_by_sz.size()));
std::vector<match_result> final_match_results;

这里的team_ptrs_by_sz_and_level就是每个分数段里的队伍匹配池,接下来要遍历所有的队伍,根据其分数以及分数段容差,投递到一个或者多个分数段匹配池中:


for (std::uint32_t i = 1; i < m_teams_by_sz.size(); i++)
{
    std::sort(m_teams_by_sz[i].begin(), m_teams_by_sz[i].end(), [](const candidate_team& team_a, const candidate_team& team_b)
        {
            return team_a.apply_ts < team_b.apply_ts;
        });

    for (std::uint32_t j = 0; j < m_teams_by_sz[i].size(); j++)
    {
        candidate_team* cur_team_ptr = &m_teams_by_sz[i][j];
        auto cur_team_match_level_tolerance = int((m_now_ts - cur_team_ptr->apply_ts) / m_ranked_config.extend_level_tolerance_time_gap);
        cur_team_match_level_tolerance = std::min(cur_team_match_level_tolerance, int(m_ranked_config.max_level_diff_tolerance));
        int cur_team_match_level = int(cur_team_ptr->rank_score / m_ranked_config.rank_level_gap_score);
        for (int k = std::max(min_score_level, cur_team_match_level - cur_team_match_level_tolerance); k <= std::min(max_score_level, cur_team_match_level + cur_team_match_level_tolerance); k++)
        {
            auto& cur_team_vec = team_ptrs_by_sz_and_level[k - min_score_level][cur_team_ptr->players.size()];
            if (cur_team_vec.capacity() < 8)
            {
                cur_team_vec.reserve(8);
            }
            cur_team_vec.push_back(cur_team_ptr);
        }
    }
}

接下来的流程就很好理解了,基本照抄自前述的无视分数的撮合,只不过外部加了一个遍历所有分数段的循环:

for (std::uint32_t k = 0; k < team_ptrs_by_sz_and_level.size(); k++)
{
    // 循环体内执行每个分数段内无视分数的撮合
}

剩下的没有进入撮合对局的队伍,重新构成匹配池,这里的逻辑与之前一样:

std::vector<candidate_team> remain_teams;
remain_teams.reserve(8);
for (std::uint32_t i = 1; i < m_teams_by_sz.size(); i++)
{
    if (m_teams_by_sz[i].empty())
    {
        continue;
    }
    remain_teams.clear();
    for (const auto& one_team : m_teams_by_sz[i])
    {
        if (one_team.match_state == std::uint32_t(basic_match_state::idle))
        {
            remain_teams.push_back(one_team);
        }
    }
    std::swap(remain_teams, m_teams_by_sz[i]);
}

虽说这个匹配流程相对于之前的无视分数的流程多加了一轮循环,但是实际上由于拆分成了多个独立的分数段执行撮合,整体的计算复杂度其实是降低的。同时按照分数段隔离的匹配保证了两边的分差不会太离谱,因此实际中这个匹配的实现是更优的。

Mosaic Game 中的匹配

match_service上的匹配管理

mosaic_game中接入了huangfeidian/match_maker来做匹配服务match_service。在match_service上的接口数量不多,提供了加入匹配池、退出匹配池、确认匹配结果这几个rpc:

Meta(rpc) void apply_matchmaking(const utility::rpc_msg& msg, const std::uint32_t match_index, const std::string& tid, const std::vector<std::string>& pids, const float score);
Meta(rpc) void leave_matchmaking(const utility::rpc_msg& msg, const std::uint32_t match_index, const std::string& tid);

match_service中会维护系统中所有的匹配,不同的匹配目标由一个唯一整数match_index来控制,这个match_index就是匹配数据表utility::typed_matrix_data_manager::instance()->get("match_list")中的索引值。在匹配数据表中会定义每种匹配项目的阵营数量、阵营内玩家数量、最大队伍大小、最小队伍大小等限制,用来初始化每个不同的match_maker。然后在match_servicetick中会遍历所有的match_maker来执行撮合逻辑:

void match_service::tick()
{
    auto cur_ts = utility::timer_manager::now_ts();
    for(auto& one_match_maker: m_match_makers)
    {
        if(one_match_maker.next_match_ts > cur_ts)
        {
            continue;
            
        }
        one_match_maker.next_match_ts = cur_ts + one_match_maker.match_make_gap;
        auto cur_match_results = one_match_maker.match_maker_impl->make_matchs();
        // 暂时省略后续代码
    }
    
}

match_service的设计中,每次对局撮合成功之后需要通知相关的玩家与队伍来确认,只有都确认接受此对局之后,才能真正的算对局开启。所以会对make_matchs的结果执行遍历,收集当前撮合的所有阵营信息,并将这些信息通过team_match_init_notify这个rpc发送到team_service,让team_service去通知相关队伍内的所有人来处理这个撮合成功消息,:

for(const auto& one_match_result: cur_match_results)
{
    m_match_result_counter++;
    waiting_confirm_matchs cur_confirm_match;
    cur_confirm_match.match_index = one_match_maker.match_index;
    cur_confirm_match.expire_ts = cur_ts + one_match_maker.confirm_gap;
    cur_confirm_match.factions = one_match_result.factions;
    cur_confirm_match.all_tids.reserve(8);
    cur_confirm_match.match_uid = m_match_result_counter;
    std::map<std::string, json::object_t> cur_team_infos;
    std::uint32_t faction_idx = 0;
    for(const auto& one_faction: cur_confirm_match.factions)
    {
        for(const auto& one_team: one_faction.teams)
        {
            cur_confirm_match.all_tids.push_back(one_team.tid);
            json::object_t temp_team_info;
            temp_team_info["faction"] = faction_idx;
            std::vector<std::string> players_in_team;
            for(const auto& one_player: one_team.players)
            {
                players_in_team.push_back(one_player.pid);
            }
            temp_team_info["pids"] = players_in_team;
            temp_team_info["score"] = one_team.rank_score;
            cur_team_infos[one_team.tid] = temp_team_info;
        }
        faction_idx++;
    }
    
    utility::rpc_msg sync_msg;
    sync_msg.cmd = "team_match_init_notify";
    sync_msg.set_args(cur_confirm_match.all_tids,  one_match_maker.match_index, m_match_result_counter, cur_confirm_match.expire_ts, cur_team_infos);
    get_server()->call_service("team_service", sync_msg);
    m_waiting_confirm_matchs[m_match_result_counter] = cur_confirm_match;

}

每个对局都会有其唯一标识符,由m_match_result_counter++生成,等待确认撮合的对局会存储在m_waiting_confirm_matchs中,key就是这个唯一标识符。被撮合的阵营内所有玩家与队伍都可以接受或者拒绝当前撮合,所以match_service提供了这样的接口来确认参与方的选择,这里的result_match_uid就是在撮合成功后生成的当前撮合唯一id

Meta(rpc) void accept_matchmaking(const utility::rpc_msg& msg, const std::uint32_t result_match_uid, const std::string& tid);
Meta(rpc) void refuse_matchmaking(const utility::rpc_msg& msg, const std::uint32_t result_match_uid, const std::string& tid);

对局内任意一方执行了接受或者拒绝之后,都会将这个信息通过组队服务广播到对局内的所有玩家身上:

// accept时的广播处理
cur_match.accept_tids.push_back(tid);
utility::rpc_msg sync_msg;
sync_msg.cmd = "team_match_ack_notify";
sync_msg.set_args(temp_iter->second.all_tids, result_match_uid, tid, true);
get_server()->call_service("team_service", sync_msg);
// refuse时的广播处理
utility::rpc_msg sync_msg;
sync_msg.cmd = "team_match_ack_notify";
sync_msg.set_args(temp_iter->second.all_tids, result_match_uid, tid, false);
get_server()->call_service("team_service", sync_msg);

当所有队伍都确认接受撮合之后,才会执行对局开始的处理:

if(cur_match.accept_tids.size() == cur_match.all_tids.size())
{
    do_match_start(temp_iter->second);
    m_waiting_confirm_matchs.erase(temp_iter);
}

而一旦有一方拒绝了这个对局,则当前对局被取消,除了对局内的所有队伍重新进入匹配池,除了当前拒绝的队伍:

auto cur_match_maker = get_match_maker(cur_match.match_index);

for(const auto& one_faction: cur_match.factions)
{
    for(const auto& one_team: one_faction.teams)
    {
        if(one_team.tid == tid)
        {
            continue;
        }
        cur_match_maker->add_candidate(one_team);
    }
}
m_waiting_confirm_matchs.erase(temp_iter);

每个队伍撮合成功之后,都会设立一个确认超时计时器,然后在tick中检查这个计时器是否超时。如果在这个计时器超时的时候仍然没有得到所有队伍同意,则当前对局也算撮合失败,此时会将所有发出了确认的队伍重新放到匹配池中:

// void match_service::tick()
std::vector<std::uint32_t> expired_match_uids;
for(const auto& one_match_pair: m_waiting_confirm_matchs)
{
    if(one_match_pair.second.expire_ts > cur_ts)
    {
        continue;
    }
    expired_match_uids.push_back(one_match_pair.first);
    utility::rpc_msg sync_msg;
    sync_msg.cmd = "team_match_expire_notify";
    sync_msg.set_args(one_match_pair.second.all_tids, one_match_pair.first);
    get_server()->call_service("team_service", sync_msg);
    const auto& cur_factions = one_match_pair.second;
    for(const auto& one_faction_group: cur_factions.factions)
    {
        for(const auto& one_team: one_faction_group.teams)
        {
            if(std::find(cur_factions.accept_tids.begin(), cur_factions.accept_tids.end(), one_team.tid) != cur_factions.accept_tids.end())
            {
                get_match_maker(one_match_pair.second.match_index)->add_candidate(one_team);
            }
        }
    }
}
for(const auto& one_match_uid: expired_match_uids)
{
    m_waiting_confirm_matchs.erase(one_match_uid);
}

每个对局被完全确认后,都会通知场景服务去创建一个专用的比赛场景:

void match_service::do_match_start(const  waiting_confirm_matchs& cur_match)
{
    auto cur_match_space = m_match_makers[cur_match.match_index].match_space;
    std::map<std::string, std::uint32_t> player_factions;
    for(std::uint32_t i = 0; i< cur_match.factions.size(); i++)
    {
        for(const auto& one_team: cur_match.factions[i].teams)
        {
            for(const auto& one_player: one_team.players)
            {
                player_factions[one_player.pid] = i;
            }
        }
    }
    
    auto cur_space_id = get_server()->gen_unique_str();
    json::object_t space_init_info;
    space_init_info["player_factions"] = player_factions;
    space_init_info["match_uid"] = cur_match.match_uid;
    space_init_info["match_index"] = cur_match.match_index;
    utility::rpc_msg create_space_msg;
    create_space_msg.cmd = "request_create_space";
    create_space_msg.set_args(cur_match_space, cur_space_id, space_init_info);
    create_space_msg.from = m_local_anchor;
    get_server()->call_service("space_service", create_space_msg);
    on_going_matchs cur_ongoing_match;
    cur_ongoing_match.factions = cur_match.factions;
    cur_ongoing_match.match_index = cur_match.match_index;
    cur_ongoing_match.space_no = cur_match_space;
    cur_ongoing_match.space_id = cur_space_id;
    cur_ongoing_match.match_uid = cur_match.match_uid;
    cur_ongoing_match.all_tids = cur_match.all_tids;

    m_on_going_matchs[cur_match.match_uid] = cur_ongoing_match;
    utility::rpc_msg start_msg;
    start_msg.cmd = "team_match_start_notify";
    start_msg.set_args(cur_match.all_tids, cur_match.match_uid, cur_match_space, cur_space_id);
    get_server()->call_service("team_service", start_msg);
    m_logger->info("match uid {} index {} create_space no {} id {}", cur_match.match_uid, cur_match.match_index, cur_match_space, cur_space_id);

}

在场景服务器创建好这个场景之后,会反向通知匹配服务:

Meta(rpc) void reply_create_space(const utility::rpc_msg& msg,std::uint32_t space_no, const std::string& space_id, const json::object_t& init_info);

此时匹配服务会通过login_service来通知对局内的所有人去进入这个比赛场景:

// void match_service::reply_create_space(const utility::rpc_msg& msg,std::uint32_t space_no, const std::string& space_id, const json::object_t& init_info)
utility::rpc_msg notify_enter_msg;
notify_enter_msg.cmd = "request_call_multi_online";
std::vector<json> enter_args;
std::vector<std::string> players;
players.reserve(10);
for(const auto& one_faction: temp_iter2->second.factions)
{
    for(const auto& one_team: one_faction.teams)
    {
        for(const auto& one_player: one_team.players)
        {
            players.push_back(one_player.pid);
        }
    }
}
enter_args.reserve(3);
enter_args.push_back(cur_match_uid);
enter_args.push_back(space_no);
enter_args.push_back(space_id);
notify_enter_msg.set_args(players, "notify_match_space_created", enter_args);
get_server()->call_service("login_service", notify_enter_msg);

当所有玩家都进入比赛场景后,局内就完全被场景所托管。在对局结束之后,比赛场景会将对局结果发回match_service,然后由match_service做后续的处理。这部分的逻辑也需要rpc来做场景与match_service之间的消息传递:

Meta(rpc) void report_match_finish(const utility::rpc_msg& msg,std::uint32_t match_uid, std::uint32_t winner_faction, const std::map<std::string, float>& delta_scores)
{
    auto temp_iter = m_on_going_matchs.find(match_uid);
    if(temp_iter == m_on_going_matchs.end())
    {
        return;
    }
    temp_iter->second.result_score_delta = delta_scores;
    temp_iter->second.winner_faction = winner_faction;
    on_match_finish(temp_iter->second);
    m_on_going_matchs.erase(temp_iter);
}

这里的on_match_finish实现的比较简单,根据场景发回来的对局分数修改执行相关的离线消息推送:

void match_service::on_match_finish(const on_going_matchs& cur_match)
{
    utility::rpc_msg sync_msg;
    sync_msg.cmd = "team_match_finish_notify";
    sync_msg.set_args(cur_match.all_tids,  cur_match.match_uid, cur_match.winner_faction, cur_match.result_score_delta);
    get_server()->call_service("team_service", sync_msg);
    auto& cur_offline_msg_mgr = server::offline_msg_manager::instance();
    for(const auto& one_pair: cur_match.result_score_delta)
    {
        std::vector<json> msg_args;
        msg_args.reserve(3);
        msg_args.push_back(cur_match.match_index);
        msg_args.push_back(cur_match.match_uid);
        msg_args.push_back(one_pair.second);
        json::object_t temp_doc;
        temp_doc["cmd"] = "notify_match_score_delta";
        temp_doc["args"] = msg_args;
        cur_offline_msg_mgr.add_msg(one_pair.first, temp_doc);

    }
}

space上的对局管理

space_service接收到建立比赛场景的请求之后,会将这个对局的信息打包到场景创建参数中,这样场景自己就知道当前对局的所有信息:

// space_entity上的一些字段
std::uint32_t m_match_index = 0;
std::uint32_t m_match_uid = 0;
std::map<std::string, std::uint32_t> m_player_factions; // 限定的玩家阵营

// bool space_entity::init(const json::object_t& data)
// 在场景被创建的时候去解析创建参数
try
{
    data.at("space_no").get_to(m_space_no);
    data.at("union_space_id").get_to(m_union_space_id);
    auto temp_iter_1 = data.find("player_id");
    if(temp_iter_1 != data.end())
    {
        temp_iter_1->second.get_to(m_player_id);
    }
    temp_iter_1 = data.find("team_id");
    if(temp_iter_1 != data.end())
    {
        temp_iter_1->second.get_to(m_team_id);
    }
    temp_iter_1 = data.find("match_index");
    if(temp_iter_1 != data.end())
    {
        temp_iter_1->second.get_to(m_match_index);
    }
    temp_iter_1 = data.find("match_uid");
    if(temp_iter_1 != data.end())
    {
        temp_iter_1->second.get_to(m_match_uid);
    }
    temp_iter_1 = data.find("player_factions");
    if(temp_iter_1 != data.end())
    {
        temp_iter_1->second.get_to(m_player_factions);
    }
}

在比赛场景创建完成之后,后续的比赛流程由space_match_component来托管,主要是根据比赛的类型来确认比赛的结束规则以及超时时间:

bool space_match_component::init(const json& data)
{
    if(m_owner->match_index())
    {
        auto cur_match_table = utility::typed_matrix_data_manager::instance()->get("match_list");
        auto cur_match_sysd = cur_match_table->get_row(m_owner->match_index());
        if(!cur_match_sysd.valid())
        {
            return false;
        }
        std::string temp_match_finish_type;
        if(!cur_match_sysd.expect_value(std::string("match_finish_type"), temp_match_finish_type))
        {
            return false;
        }
        auto temp_match_finish_type_opt = magic_enum::enum_cast<enums::match_finish_type>(temp_match_finish_type);
        if(!temp_match_finish_type_opt)
        {
            return false;
        }
        m_match_finish_type = temp_match_finish_type_opt.value();
        cur_match_sysd.expect_value(std::string("faction_num"), m_faction_num);
        cur_match_sysd.expect_value(std::string("faction_player_size"), m_faction_player_size);
        cur_match_sysd.expect_value(std::string("match_duration"), m_match_duration);
        cur_match_sysd.expect_value(std::string("support_respawn"), m_support_respawn);
        m_faction_death_counter.resize(m_faction_num, 0);
        m_faction_kill_counter.resize(m_faction_num, 0);
        m_owner->get_space_data_entity()->prop_proxy().faction_death_counter().set(m_faction_death_counter);
        m_owner->get_space_data_entity()->prop_proxy().faction_kill_counter().set(m_faction_kill_counter);
        m_owner->get_space_data_entity()->prop_proxy().match_winner_faction().set(m_faction_num);
        m_match_finish_timer = m_owner->add_timer_with_gap(std::chrono::milliseconds(m_match_duration*1000), [=]()
        {
            finish_match();
        });
    }
    return true;
}

目前支持的比赛胜利判定模式只有两种:

enum class match_finish_type
{
    invalid = 0,
    most_kill =1, // 击杀最多
    min_death = 2// 死亡最少
};
  1. 死亡次数最少的成为胜者,判定函数为std::uint32_t space_match_component::check_winner_min_death() const
  2. 击杀最多的成为胜者,判定函数为std::uint32_t space_match_component::check_winner_most_kill() const

在比赛设置为死亡后不可复活时,最后剩下的阵营将自动成为胜者, 判定函数为std::uint32_t space_match_component::check_winner_no_respawn() const,每次有玩家死亡的时候都会触发这个函数的执行。

在场景初始化的时候,会根据场景最长比赛时间来添加一个结算用的计时器。当计时器超时的时候自动调用finish_match来生成对局结果,然后将结果发送回match_service,并开启场景的自动销毁:

void space_match_component::finish_match()
{
    m_owner->cancel_timer(m_match_finish_timer);
    m_match_finish_timer.reset();
    m_is_match_finish = true;
    m_owner->get_space_data_entity()->prop_proxy().match_finish().set(true);
    std::uint32_t cur_winner_faction = m_faction_num;
    if(m_match_finish_type == enums::match_finish_type::min_death)
    {
        cur_winner_faction = check_winner_min_death();
    }
    else if(m_match_finish_type == enums::match_finish_type::most_kill)
    {
        cur_winner_faction = check_winner_most_kill();
    }
    m_owner->get_space_data_entity()->prop_proxy().match_winner_faction().set(cur_winner_faction);
    std::map<std::string, float> delta_scores;
    for(const auto& one_pair: m_owner->player_factions())
    {
        if(one_pair.second == cur_winner_faction)
        {
            delta_scores[one_pair.first] = 20;
        }
        else
        {
            delta_scores[one_pair.first] = -10;
        }
    }
    utility::rpc_msg finish_msg;
    finish_msg.cmd = "report_match_finish";
    finish_msg.set_args(m_owner->match_uid(), cur_winner_faction, delta_scores);
    m_owner->call_service("match_service", finish_msg);
    utility::rpc_msg countdown_msg;
    countdown_msg.cmd = "request_countdown_destroy";
    std::uint32_t countdown_ts = 10; // 10s之后自动销毁
    countdown_msg.set_args(m_owner->entity_id(), countdown_ts);
    m_owner->call_service("space_service", countdown_msg);
    m_owner->get_space_data_entity()->prop_proxy().destroy_ts().set(utility::timer_manager::now_ts() + 1000 * countdown_ts);

}

这里对局结算计算分数增减的时候用了一个非常简单的规则,胜者加20分,败者减10分。实际情况下肯定不能这么处理,具体可以参考前述的ELO或者Glicko评分机制。