431 lines
12 KiB
TypeScript
431 lines
12 KiB
TypeScript
import {Client, generateId, matchMaker, Room} from "colyseus";
|
|
import {BaseConst} from "../constants/BaseConst";
|
|
|
|
interface MatchmakingGroup {
|
|
averageRank: number;
|
|
clients: ClientStat[];
|
|
priority?: boolean;
|
|
|
|
ready?: boolean;
|
|
confirmed?: number;
|
|
count: number;
|
|
// cancelConfirmationTimeout?: Delayed;
|
|
}
|
|
|
|
interface ClientStat {
|
|
clients: Map<string, {client: Client, options: any, seat?: number}>;
|
|
waitingTime: number;
|
|
options?: any;
|
|
groupTag?: string;
|
|
group?: MatchmakingGroup;
|
|
rank: number;
|
|
confirmed?: boolean;
|
|
priority?: boolean;
|
|
}
|
|
|
|
export class RankedLobbyRoom extends Room {
|
|
/**
|
|
* If `allowUnmatchedGroups` is true, players inside an unmatched group (that
|
|
* did not reached `numClientsToMatch`, and `maxWaitingTime` has been
|
|
* reached) will be matched together. Your room should fill the remaining
|
|
* spots with "bots" on this case.
|
|
*/
|
|
allowUnmatchedGroups: boolean = true;
|
|
|
|
/**
|
|
* Evaluate groups for each client at interval
|
|
*/
|
|
evaluateGroupsInterval = 1000;
|
|
|
|
/**
|
|
* Groups of players per iteration
|
|
*/
|
|
groups: MatchmakingGroup[] = [];
|
|
|
|
/**
|
|
* name of the room to create
|
|
*/
|
|
roomToCreate = "general_room";
|
|
|
|
/**
|
|
* 最大匹配时间
|
|
*/
|
|
maxWaitingTime = 15 * 1000;
|
|
/**
|
|
* 超过该时间, 组队玩家可匹配单排玩家
|
|
* @type {number}
|
|
*/
|
|
groupAddOneTime = 10 * 1000;
|
|
|
|
/**
|
|
* after this time, try to fit this client with a not-so-compatible group
|
|
*/
|
|
maxWaitingTimeForPriority?: number = 10 * 1000;
|
|
|
|
/**
|
|
* number of players on each match
|
|
*/
|
|
numClientsToMatch = 4;
|
|
|
|
// /**
|
|
// * after a group is ready, clients have this amount of milliseconds to confirm
|
|
// * connection to the created room
|
|
// */
|
|
// cancelConfirmationAfter = 5000;
|
|
/**
|
|
* rank and group cache per-player
|
|
*/
|
|
stats: ClientStat[] = [];
|
|
rank_mpa = 0.2;
|
|
fair_ir = 1.2;
|
|
step_ir = 1.3;
|
|
|
|
onCreate(options: any) {
|
|
const fc = global.$cfg.get(BaseConst.FORMULA);
|
|
this.rank_mpa = fc.get(70028).number / 100;
|
|
this.fair_ir = fc.get(70029).number / 100 + 1;
|
|
this.step_ir = fc.get(70031).number / 100 + 1;
|
|
this.groupAddOneTime = fc.get(70023).number * 1000;
|
|
this.maxWaitingTime = (fc.get(70024).number + fc.get(70025).number) * 1000;
|
|
if (options.maxWaitingTime) {
|
|
this.maxWaitingTime = options.maxWaitingTime;
|
|
}
|
|
|
|
if (options.numClientsToMatch) {
|
|
this.numClientsToMatch = options.numClientsToMatch;
|
|
}
|
|
|
|
this.onMessage("bye", (client: Client, message: any) => {
|
|
const stat = this.stats.find(obj => obj.clients.has(client.sessionId));
|
|
|
|
if (stat && stat.group && typeof (stat.group.confirmed) === "number") {
|
|
stat.confirmed = true;
|
|
stat.group.confirmed++;
|
|
//stat.clients.delete(client.sessionId);
|
|
client.leave();
|
|
}
|
|
})
|
|
this.onMessage('gogogo', (client: Client, message: any) => {
|
|
const stat = this.stats.find(obj => obj.clients.has(client.sessionId));
|
|
stat.priority = true;
|
|
})
|
|
|
|
/**
|
|
* Redistribute clients into groups at every interval
|
|
*/
|
|
this.setSimulationInterval(() => this.redistributeGroups(), this.evaluateGroupsInterval);
|
|
}
|
|
|
|
onJoin(client: Client, options: any) {
|
|
/**
|
|
* 如果请求带有group, 那么查找当前队列中group相同的记录
|
|
* 如果没有, 就插一条记录
|
|
* 如果找到的记录, clients已经查过2条, 则也插入一条记录
|
|
* 如果找到的记录, clients是1, 则把当前client添加到该记录中, 同时更新rank
|
|
*/
|
|
if (options.group) {
|
|
let length = this.stats.length;
|
|
let groupData;
|
|
for (let i = 0; i < length; i++) {
|
|
if (this.stats[i].groupTag == options.group) {
|
|
groupData = this.stats[i];
|
|
break
|
|
}
|
|
}
|
|
if (!groupData) {
|
|
let clientMap = new Map();
|
|
clientMap.set(client.sessionId, {client, options});
|
|
groupData = {
|
|
clients: clientMap,
|
|
rank: options.rank,
|
|
groupTag: options.group,
|
|
waitingTime: 0,
|
|
options
|
|
}
|
|
this.stats.push(groupData);
|
|
} else {
|
|
if (groupData.clients.size >= 2) {
|
|
let clientMap = new Map();
|
|
options.group = generateId();
|
|
clientMap.set(client.sessionId, {client, options});
|
|
this.stats.push({
|
|
clients: clientMap,
|
|
rank: options.rank,
|
|
waitingTime: 0,
|
|
options
|
|
});
|
|
} else {
|
|
groupData.clients.set(client.sessionId, {client, options});
|
|
groupData.rank = (groupData.rank + options.rank) / 2 * (1 + this.rank_mpa);
|
|
}
|
|
}
|
|
client.send("clients", groupData.clients.size);
|
|
} else {
|
|
let clientMap = new Map();
|
|
clientMap.set(client.sessionId, {client, options});
|
|
this.stats.push({
|
|
clients: clientMap,
|
|
rank: options.rank,
|
|
waitingTime: 0,
|
|
options
|
|
});
|
|
}
|
|
|
|
|
|
client.send("clients", 1);
|
|
}
|
|
|
|
createGroup() {
|
|
let group: MatchmakingGroup = {clients: [], averageRank: 0, count: 0};
|
|
this.groups.push(group);
|
|
return group;
|
|
}
|
|
|
|
redistributeGroups() {
|
|
// re-set all groups
|
|
this.groups = [];
|
|
|
|
const stats = this.stats.sort((a, b) => a.rank - b.rank);
|
|
|
|
let currentGroup: MatchmakingGroup = this.createGroup();
|
|
let totalRank = 0;
|
|
// 先过滤一边组队的情况
|
|
for (let i = 0, l = stats.length; i < l; i++) {
|
|
if (stats[i].clients.size == 1) {
|
|
continue;
|
|
}
|
|
const stat = stats[i];
|
|
stat.waitingTime += this.clock.deltaTime;
|
|
if (stat.group && stat.group.ready) {
|
|
continue;
|
|
}
|
|
if (currentGroup.averageRank > 0) {
|
|
const diff = Math.abs(stat.rank - currentGroup.averageRank);
|
|
const diffRatio = (diff / currentGroup.averageRank);
|
|
|
|
/**
|
|
* figure out how to identify the diff ratio that makes sense
|
|
*/
|
|
if (diffRatio > this.step_ir) {
|
|
currentGroup = this.createGroup();
|
|
totalRank = 0;
|
|
}
|
|
}
|
|
|
|
stat.group = currentGroup;
|
|
currentGroup.clients.push(stat);
|
|
currentGroup.count += stat.clients.size;
|
|
|
|
totalRank += stat.rank;
|
|
currentGroup.averageRank = totalRank / currentGroup.clients.length;
|
|
if (currentGroup.count === this.numClientsToMatch) {
|
|
currentGroup.ready = true;
|
|
currentGroup = this.createGroup();
|
|
totalRank = 0;
|
|
}
|
|
}
|
|
totalRank = 0;
|
|
currentGroup = this.createGroup();
|
|
for (let i = 0, l = stats.length; i < l; i++) {
|
|
|
|
const stat = stats[i];
|
|
|
|
if (stats[i].clients.size == 1) {
|
|
stat.waitingTime += this.clock.deltaTime;
|
|
} else {
|
|
if (stat.waitingTime < this.groupAddOneTime) {
|
|
continue;
|
|
}
|
|
}
|
|
/**
|
|
* do not attempt to re-assign groups for clients inside "ready" groups
|
|
*/
|
|
if (stat.group && stat.group.ready) {
|
|
continue;
|
|
}
|
|
if (currentGroup.count + stat.clients.size > this.maxClients) {
|
|
continue;
|
|
}
|
|
if (stat.priority) {
|
|
currentGroup.priority = true;
|
|
}
|
|
|
|
/**
|
|
* Force this client to join a group, even if rank is incompatible
|
|
*/
|
|
// if (
|
|
// this.maxWaitingTimeForPriority !== undefined &&
|
|
// stat.waitingTime >= this.maxWaitingTimeForPriority
|
|
// ) {
|
|
// currentGroup.priority = true;
|
|
// }
|
|
if (
|
|
currentGroup.averageRank > 0 &&
|
|
!currentGroup.priority
|
|
) {
|
|
const diff = Math.abs(stat.rank - currentGroup.averageRank);
|
|
const diffRatio = (diff / currentGroup.averageRank);
|
|
|
|
/**
|
|
* figure out how to identify the diff ratio that makes sense
|
|
*/
|
|
if (diffRatio > this.fair_ir) {
|
|
currentGroup = this.createGroup();
|
|
totalRank = 0;
|
|
}
|
|
}
|
|
|
|
stat.group = currentGroup;
|
|
currentGroup.clients.push(stat);
|
|
currentGroup.count += stat.clients.size;
|
|
|
|
totalRank += stat.rank;
|
|
currentGroup.averageRank = totalRank / currentGroup.count;
|
|
|
|
if (
|
|
(currentGroup.count === this.numClientsToMatch) ||
|
|
|
|
/**
|
|
* Match long-waiting clients with bots
|
|
* FIXME: peers of this group may be entered short ago
|
|
*/
|
|
(stat.waitingTime >= this.maxWaitingTime && this.allowUnmatchedGroups) ||
|
|
stat.priority
|
|
) {
|
|
currentGroup.ready = true;
|
|
currentGroup = this.createGroup();
|
|
totalRank = 0;
|
|
}
|
|
}
|
|
|
|
this.checkGroupsReady();
|
|
}
|
|
|
|
generateSeat(index: number): number {
|
|
switch (index) {
|
|
case 0:
|
|
return 0;
|
|
case 1:
|
|
return 3;
|
|
case 2:
|
|
return 1;
|
|
case 3:
|
|
return 2;
|
|
}
|
|
}
|
|
async checkGroupsReady() {
|
|
await Promise.all(
|
|
this.groups
|
|
.map(async (group) => {
|
|
if (group.ready) {
|
|
group.confirmed = 0;
|
|
let score = 0;
|
|
let count = 0;
|
|
group.clients.map(client => {
|
|
for (let [,data] of client.clients) {
|
|
score += (data.options?.score || 0);
|
|
count ++;
|
|
}
|
|
})
|
|
let avaScore = score / count;
|
|
/**
|
|
* Create room instance in the server.
|
|
*/
|
|
const room = await matchMaker.createRoom(this.roomToCreate, {
|
|
match: true,
|
|
rank: group.averageRank,
|
|
score: avaScore,
|
|
count: this.numClientsToMatch - group.count
|
|
});
|
|
// 预处理数据, 确定座次
|
|
let hasGroup = false;
|
|
for (let client of group.clients) {
|
|
if (client.clients.size > 1) {
|
|
hasGroup = true;
|
|
break;
|
|
}
|
|
}
|
|
let seat = 0;
|
|
if (hasGroup) {
|
|
for (let client of group.clients) {
|
|
if (client.clients.size > 1) {
|
|
for (let [,sub] of client.clients) {
|
|
sub.seat = this.generateSeat(seat ++);
|
|
}
|
|
}
|
|
}
|
|
for (let client of group.clients) {
|
|
if (client.clients.size == 1) {
|
|
for (let [,sub] of client.clients) {
|
|
sub.seat = this.generateSeat(seat ++);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
group.clients.sort((a, b) => a.rank - b.rank);
|
|
for (let client of group.clients) {
|
|
for (let [,sub] of client.clients) {
|
|
sub.seat = seat ++;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
await Promise.all(group.clients.map(async (client) => {
|
|
|
|
/**
|
|
* Send room data for new WebSocket connection!
|
|
*/
|
|
for (let [,data] of client.clients) {
|
|
let matchOpt = {
|
|
seat: data.seat
|
|
}
|
|
Object.assign(matchOpt, data.options);
|
|
const matchData = await matchMaker.reserveSeatFor(room, matchOpt);
|
|
let options: any = {seat: data.seat, rank: data.options.rank};
|
|
Object.assign(options, matchData);
|
|
data.client.send("match_success", options);
|
|
}
|
|
}));
|
|
|
|
// /**
|
|
// * Cancel & re-enqueue clients if some of them couldn't confirm connection.
|
|
// */
|
|
// group.cancelConfirmationTimeout = this.clock.setTimeout(() => {
|
|
// group.clients.forEach(stat => {
|
|
// this.send(stat.client, 0);
|
|
// stat.group = undefined;
|
|
// stat.waitingTime = 0;
|
|
// });
|
|
// }, this.cancelConfirmationAfter);
|
|
|
|
} else {
|
|
/**
|
|
* Notify all clients within the group on how many players are in the queue
|
|
*/
|
|
group.clients.forEach(client => {
|
|
for (let [,data] of client.clients) {
|
|
data.client.send("clients", group.count);
|
|
}
|
|
});
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
onLeave(client: Client, consented: boolean) {
|
|
const stat = this.stats.find(obj => obj.clients.has(client.sessionId));
|
|
if (stat.clients.size > 1) {
|
|
stat.clients.delete(client.sessionId);
|
|
let data = [...stat.clients.values()][0];
|
|
stat.rank = data.options.rank;
|
|
} else {
|
|
this.stats.remove(stat);
|
|
}
|
|
}
|
|
|
|
onDispose() {
|
|
}
|
|
|
|
}
|