目标:
- 数据库
room管理移交给后端执行,避免让前端鉴权- 一场比赛分为多个
docker并行,每个队伍对应一个docker- 架构的可扩展性和动态性,支持比赛队伍数可变、每个队伍的角色数可变、每支队伍的代码数可变、每个角色可选的技能可变、比赛的地图可变
天梯逻辑
流程描述
- 选手在完成队伍和代码准备后,打开
天梯试炼,向心仪的队伍开战!。- 谨防战争狂。同一队伍不能同时打多于6场的比赛。前端需要从数据库上查询比赛状态,若有相同配置的比赛已经在开战,则给用户发出提示信息:
已有一场相同的比赛正在开战。是否继续?。 - 后端也要检查,限制一支队伍的开战频率。
- 谨防战争狂。同一队伍不能同时打多于6场的比赛。前端需要从数据库上查询比赛状态,若有相同配置的比赛已经在开战,则给用户发出提示信息:
- 前端请求后端
/arena/create路由- 前端需要检查数据库上的代码编译状态和角色代码分配状态,若队伍角色未分配代码,或代码未编译,则在页面报错而不请求路由。
- 后端也要检查数据库上的代码编译状态和角色代码分配状态,还要检查
contest表中的arena_switch是否为true,都正常的情况下再继续下一步。
- 后端在数据表
contest_room中创建room,更新status为Waiting,并在contest_room_team中绑定room和team。 - 选手代码的编译文件在
cos中,后端需要从cos上临时下载队伍的代码(如 python 代码)或编译文件(如 c++ 编译后的可执行文件)到后端服务器上。- 后端服务器存储空间有限,需要定期清理下载的队伍代码和文件。
- 后端服务器与
docker服务器之间通过NFS进行文件共享,因此docker服务器自动同步了队伍文件。(备注:建议提前服务器之间组内网减少流量费。)
- 前两步都执行成功的前提下,后端创建
docker并入队docker_queue,向前端返回创建是否成功的结果。- 对于一场比赛(两队参与为例),后端需要先后创建4个
docker:一个server镜像对应的比赛逻辑服务器、两个client镜像对应的选手代码执行客户端(每队共用一个),一个envoy镜像对应的grpc-web与grpc转发服务器(用于前端直播,暂不急于实现)。 - 比赛状态显示。后端创建
docker分为两步:【第一步】是将比赛放入队列docker_queue尾,此时room->status为Waiting;【第二步】是docker_cron定时程序从队列中抽取队首的比赛进行,如果比赛启动成功,此时room->status为Running。 - 比赛期间,用户可通过特定端口观看直播。后端在上面所述启动比赛的【第二步】时分配好一个端口。如果端口数量不足,则不启动比赛。如果成功分配端口并启动比赛,则应同时更新数据库
contest_room表中的port字段。 - 前端应当使用
subscription实时更新比赛状态和直播观看端口。
- 对于一场比赛(两队参与为例),后端需要先后创建4个
docker服务器结束比赛后请求后端/arena/finish路由。- 后端更新数据库,更新
contest_room表中的status为Finished、Timeout或Crashed、更新port为NULL;更新contest_room_team表中的score字段,为这场比赛的每个队伍记录分数 - 后端将比赛回放文件以及日志文件(如有)上传至
cos,具体路径参考COS存储桶访问路径。 - 后端向参与这场比赛的队伍队员发送
Web Push订阅通知(暂不急于实现)。 - 后端会设置最大运行时间(位于数据库内,每届比赛有不同的运行时间),若超过最大运行时间,则后端会强制停止所有相关容器的运行,并更新
room状态为Timeout,释放port。
- 后端更新数据库,更新
- 比赛结束后,前端提供下载和在线观看回放的功能,直接按照COS存储桶访问路径中约定的路径从
cos下载对应的文件即可。
接口描述
新版天梯接口的前缀为/arena。
/arena/create:创建比赛。数据库中插入room,并将比赛加入队列中。- 请求方法:
POST - 请求:
body中有{contest_name: string, team_labels: TeamLabelBind[], map_id: uuid},其中contest_name是数据库中的name、用于确定用于执行比赛的镜像,team_labels的类型定义见下方附录、包含了参加比赛的队伍uuid、队伍顺序和队伍执方(如Student或Tricker,见contest_player表),map_id代表选择的地图uuid。(请求同时携带了包含用户信息的token) - 响应:
200:Arena created! - 工作过程:
- 鉴权。检查登录状态,及用户是否在队伍中。
- 限制开战频率。同一队伍不能同时打多于6场的比赛。
- 接下来检查代码和队伍是否准备完成,若队伍角色未分配代码,或代码未编译,则报错。
- 后端需要从
cos上临时下载队伍的代码或编译文件到服务器上。文件路径参考COS存储桶访问路径。服务器存储空间有限,需要定期清理下载的队伍代码和文件。如果cos上找不到对应的编译文件,则报错。 - 后端在数据表
contest_room中创建room,更新status为Waiting,并在contest_room_team中绑定room和team,并返回创建是否成功的结果,以及room_id。 - 后端将比赛数据存入
docker_queue中,等待docker_cron发起比赛。
- 错误:
400:400 Bad Request: Contest not found400:400 Bad Request: Players_label not found401:401 Unauthorized: Missing token(未登录)403:403 Forbidden: User not in team(用户不在队伍中)403:403 Forbidden: Arena is not open403:403 Forbidden: Team player not assigned(队伍角色未分配代码)403:403 Forbidden: Team code not compiled(代码未通过编译)403:403 Forbidden: Team code language not supported422:422 Unprocessable Entity: Duplicate team labels(team labels不能重复的原因在于会与 docker 内文件命名规则冲突 )422:422 Unprocessable Entity: Missing credentials(请求缺失参数)423:423 Locked: Request arena too frequently(比赛次数过多)500:undefined(其他内部错误)
- 请求方法:
/arena/get-score:docker服务器比赛结束后,用于查询参战队伍现有天梯分数的路由,拿来计算本场对战的得分。后端查询数据库即可。- 请求方法:
POST - 请求:在
headers里传回创建docker时设置的TOKEN。 - 响应:
body中包含ContestResult。顺序与TOKEN.team_label_binds中的顺序一致。 - 错误:
500:undefined,返回报错信息
- 请求方法:
/arena/finish:docker服务器比赛结束的hook。更新比赛结果,更新天梯分数,将比赛回放和日志文件上传至COS。- 请求方法:
POST - 请求:
body中包含ContestResult,类型定义见下方附录。同时在headers里传回创建docker时设置的TOKEN。如果docker未能正常运行比赛,body.status设置为Crashed,不会更新分数;否则body.status设置为Finished,正常更新分数。 - 响应:
200:Update OK! - 错误:
500:undefined,返回报错信息
- 请求方法:
/arena/playback/:room_id:用于获取回放的路由,直接返回文件。- 请求方法:
GET - 响应:
200:playback.thuaipb文件 - 错误:
404:404 Not Found: Playback not found
- 请求方法:
比赛逻辑
流程描述
比赛的流程与天梯非常相近,几大区别在于:
- 比赛由前端先写入数据库的
contest_round表,用于记录这轮比赛的一些基本设置和uuid。一轮比赛(round)指的是所有队伍全循环一次的比赛之总和,一轮比赛包含多场对战。 - 后端需要对比赛队伍、队伍执方、地图进行全循环,每个循环体发起一场对战,流程和天梯中的一场对战大致相同。
- 后端需要在插入
contest_room表时额外写入round_id从而与天梯区分。 - 比赛暂时默认不暴露端口,不需要更新
port字段。 - 比赛结束时不向选手发送
Web Push订阅通知。
具体流程如下:
- 比赛管理员在前端页面上发起比赛。
- 前端在数据库
contest_round里插入一行,包含了这轮比赛的名称(仅展示用)和使用的地图map_id。 - 前端请求后端路由
/competition/start-all。 - 后端获取所有队伍数据。检查队伍代码是否完整,角色是否分配,如果不完整则跳过此队伍。
- 后端对于正常队伍开启循环赛,将比赛全部加入
docker_queue中。 docker比赛结束后向后端通信,后端在数据表上更新比赛分数。
接口描述
新版比赛接口的前缀为/competition。
/competition/start-all:管理员专用。后端可以按contest_round表中的信息设置所有队伍之间的完整比赛,全部队伍的比赛合起来称为一个round,对应一个round_id。设置room发起对战的流程跟天梯逻辑类似,需要在contest_room里额外加round_id标识。- 请求方法:
POST - 请求:
{round_id: uuid}。(请求同时携带了包含用户信息的token) - 响应:
200:Competition Created! - 错误:
422:422 Unprocessable Entity: Missing credentials(请求缺失参数)403:403 Forbidden: Not a manager500:undefined,返回报错信息
- 请求方法:
/competition/start-one:管理员专用,用于重新发起round中某一场特定的比赛。后端需要先删除这场比赛的已有记录(包括数据库中、cos中),然后将比赛加入队列中。设置room发起对战的过程跟天梯逻辑一致,只需要在contest_room里额外加round_id标识即可。- 请求方法:
POST - 请求:
{team_labels: TeamLabelBind[], round_id: uuid}。(请求同时携带了包含用户信息的token) - 响应:
200:Room Created! - 错误:
400:400 Bad Request: Contest not found400:400 Bad Request: Players_label not found401:401 Unauthorized: Missing token(未登录)403:403 Forbidden: Not a manager403:403 Forbidden: Team player not assigned(队伍角色未分配代码)403:403 Forbidden: Team code not compiled(代码未通过编译)403:403 Forbidden: Team code language not supported422:422 Unprocessable Entity: Duplicate team labels422:422 Unprocessable Entity: Missing credentials500:undefined,返回报错信息
- 请求方法:
/competition/get-score:docker服务器比赛结束后,用于查询参战队伍现有比赛分数的路由,拿来计算本场对战的得分。后端查询数据库即可。- 请求方法:
POST - 请求:在
headers里传回创建docker时设置的TOKEN(内部包含round_id)。 - 响应:
body中包含ContestResult。顺序与TOKEN.team_label_binds中的顺序一致。 - 错误:
500:undefined,返回报错信息
- 请求方法:
/competition/finish-one:docker服务器比赛结束的hook。更新比赛结果,更新比赛分数,将比赛回放和日志文件上传至COS。如果docker未能正常运行比赛,body.status设置为Crashed,不会更新分数;否则body.status设置为Finished,正常更新分数。- 请求方法:
POST - 请求:
body中包含ContestResult,类型定义见下方附录。同时在headers里传回创建docker时设置的TOKEN。 - 响应:
200:Update OK! - 错误:
500:undefined,返回报错信息
- 请求方法:
/competition/playback/:room_id:用于获取回放的路由,直接返回文件。- 请求方法:
GET - 响应:
200:playback.thuaipb文件 - 错误:
404:404 Not Found: Playback not found
- 请求方法:
与赛事组的约定
- 一场比赛对应两个
docker镜像、多个docker并行。其中server镜像为比赛逻辑服务器,client镜像为选手代码执行客户端(一队共用)。 - 队式应当关注上面的
/arena/finish、/arena/get-score和/competition/finish-one、/competition/get-score路由参数信息。server镜像启动时会设置环境变量SCORE_URL(即/arena/get-score或/competition/get-score)、FINISH_URL(即/arena/finish或/competition/finish-one)、TOKEN。- 比赛结束后先请求
SCORE_URL,获取参战队伍在天梯/比赛中的现有分数,请求时需要在headers中加上TOKEN。 - 获得现有分数后,
docker应当据此计算出本场对战的得分(增量,而非更新后的总分) - 完成后再请求
FINISH_URL,在请求的body中传回ContestResult(即上面计算出的增量得分),请求时需要在headers中加上TOKEN。
- 比赛结束后先请求
client镜像启动时会设置环境变量TEAM_LABEL和TEAM_SEQ_ID,供容器得知该队比赛执方和序号。- 队式 docker 不需要关注
team_uuid,这对于队式而言是不可见的,队式 docker 可见的只有TEAM_LABELS和TEAM_SEQ_ID,并且分数信息须与传入的TEAM_SEQ_ID的顺序相同。
- 后端提供的环境变量说明。
- 客户端:
TERMINAL: 取值为SERVER或者CLIENT,表明当前比赛 docker 是客户端还是服务器。TEAM_LABEL: 客户端队伍标签。对应TeamLabelBind中的label字段。TEAM_SEQ_ID: 客户端使用,当前客户端的队伍序号,所有队伍从 0 开始顺序编号,与服务端TEAM_LABELS的顺序对应。PORT: 服务器开放的端口,客户端需通过此端口加入服务器的比赛。
- 服务端:
TERMINAL: 取值为SERVER或者CLIENT,表明当前比赛 docker 是客户端还是服务器。MODE: 判断当前比赛是天梯还是最终比赛。取值为ARENA(天梯)或者COMPETITION(决赛)。TOKEN: 服务端验证身份的 token。发送请求时需带上。TEAM_LABELS: 全局信息。本场比赛的所有队伍标签。用:分隔,其中的每个元素对应TeamLabelBind中的label字段,位序对应客户端TEAM_SEQ_ID的顺序,也对应SCORE_URL和FINISH_URL的分数信息scores的顺序。MAX_GAME_TIME: 比赛持续的时间,单位为秒。若超过这个时间,后端会强制停止所有相关容器。MAP_ID: 地图 id。SCORE_URL: 获取当前天梯分数的 url 路径。请求时需带上TOKEN。FINISH_URL: 结束比赛时更新分数的 url 路径。请求时需带上TOKEN。EXPOSED: 决定是否开放端口的环境变量,1表示开放,0表示不开放。
- 客户端:
docker目录绑定。- 对于
server镜像,地图文件在/usr/local/map下,命名为${map_id}.txt,回放文件请放在在/usr/local/output下,命名为playback.thuaipb。如果需要上传日志文件,同样放在此目录下,命名为xxx.log。TEAM_LABELS中传入了所有队伍的label。 - 对于
client镜像,队伍代码在/usr/local/code下,命名为${player_label}.${suffix}(player_label为在数据库存储的字符串标签,可供赛事组预先定义,如Student1)。对于suffix的说明:对于python代码,suffix为py;对于cpp代码,没有suffix,文件命名就是${player_label}。日志文件可以放在/usr/local/output下,命名为xxx.log。TEAM_LABEL中传入了当前队伍的label,TEAM_SEQ_ID是当前队伍的序号,编号从 0 开始。
- 对于
附录
数据结构定义
interface ContestResult {
status: string; // `Finished`、`Timeout` 或 `Crashed`。
scores: number[]; // 每个队伍的分数,顺序与 TEAM_LABELS 一致。
player_roles: string[][]; // 数组第一维代表两个队伍,顺序与 TEAM_LABELS 一致,第二维代表某个队伍选用的 player_role,顺序与 player_label 默认顺序一致
extra: string | string[]; // 额外的统计信息
};
interface TeamLabelBind {
team_id: uuid;
label: string;
}
interface ServerToken {
contest_id: string;
round_id: string?;
room_id: string;
team_label_binds: TeamLabelBind[];
}