Commit 4af43c04 authored by nanahira's avatar nanahira

rework random duel

parent 19daed1d
Pipeline #43256 passed with stages
in 2 minutes and 39 seconds
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
- 目录外引用:必须通过 index.ts 引用,如 `'../room'` `'../client'`(指向 index.ts) - 目录外引用:必须通过 index.ts 引用,如 `'../room'` `'../client'`(指向 index.ts)
- 禁止直接引用具体文件:不要使用 `'../room/room'` `'../client/client'` 这样的路径 - 禁止直接引用具体文件:不要使用 `'../room/room'` `'../client/client'` 这样的路径
- 如果正在写的算法代码与 this 和业务无关,那么不要放在类方法里面,而是在 utility 目录新开一个 ts 文件放进去 - 如果正在写的算法代码与 this 和业务无关,那么不要放在类方法里面,而是在 utility 目录新开一个 ts 文件放进去
- 如果正在移植 srvpro 的功能,那么 i18n 必须严格和 srvpro 保持一致,不能改动 i18n 的 key 和 value。新功能或者原有功能的额外部分可以写新的。
## 模块结构 ## 模块结构
......
...@@ -45,6 +45,10 @@ export class ClientHandler { ...@@ -45,6 +45,10 @@ export class ClientHandler {
client.vpass = vpass || ''; client.vpass = vpass || '';
return next(); return next();
}) })
.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
client.roompass = msg.pass || '';
return next();
})
.middleware( .middleware(
YGOProCtosBase, YGOProCtosBase,
async (msg, client, next) => { async (msg, client, next) => {
......
...@@ -213,6 +213,8 @@ export class Client { ...@@ -213,6 +213,8 @@ export class Client {
@ClientRoomField() @ClientRoomField()
roomName?: string; roomName?: string;
@ClientRoomField() @ClientRoomField()
roompass = '';
@ClientRoomField()
isHost = false; isHost = false;
@ClientRoomField() @ClientRoomField()
pos = -1; pos = -1;
......
...@@ -55,6 +55,30 @@ export const TRANSLATIONS = { ...@@ -55,6 +55,30 @@ export const TRANSLATIONS = {
'Match mode room. Password S for single mode, T for tag mode.', 'Match mode room. Password S for single mode, T for tag mode.',
random_duel_enter_room_tag: random_duel_enter_room_tag:
'Tag mode room. Password S for single mode, M for match mode.', 'Tag mode room. Password S for single mode, M for match mode.',
random_banned_part1: 'You have been banned from the game due to ',
random_banned_part2: ', it will last until ',
random_banned_part3: '.',
random_deprecated_part1: 'Because of your ',
random_deprecated_part2:
', in recent games, you will only suffer other players who are under same punishment as you in ',
random_deprecated_part3: '.',
random_warn_part1: 'The system detects that recently you had ',
random_warn_part2:
' in recent games, you will be penalized after 3 offences',
random_ban_reason_separator: '/',
random_ban_reason_flee: 'Flee',
random_ban_reason_AFK: 'AFK',
random_ban_reason_abuse: 'Abusing',
random_ban_reason_zombie: 'Zombie',
unwelcome_warn_part1: 'If you keep doing ',
unwelcome_warn_part2: ', your opponent may leave you.',
unwelcome_tip_part1: 'Your opponent did ',
unwelcome_tip_part2:
', so you can leave the game without being punished.',
banned_duel_tip:
'You are banned from the random duel system for sending inappropriate messages.',
chat_banned: 'is banned from chat.',
surrender_denied: "Please don't surrender in the first 2 turns.",
chat_disabled: 'Chat is disabled in this room.', chat_disabled: 'Chat is disabled in this room.',
chat_warn_level1: 'Please avoid sensitive words.', chat_warn_level1: 'Please avoid sensitive words.',
chat_warn_level2: 'Your message contains blocked words.', chat_warn_level2: 'Your message contains blocked words.',
...@@ -110,6 +134,26 @@ export const TRANSLATIONS = { ...@@ -110,6 +134,26 @@ export const TRANSLATIONS = {
'您进入了比赛模式房间,密码输入 S 进入单局模式,输入 T 进入双打模式。', '您进入了比赛模式房间,密码输入 S 进入单局模式,输入 T 进入双打模式。',
random_duel_enter_room_tag: random_duel_enter_room_tag:
'您进入了双打模式房间,密码输入 S 进入单局模式,输入 M 进入比赛模式。', '您进入了双打模式房间,密码输入 S 进入单局模式,输入 M 进入比赛模式。',
random_banned_part1: '因为您近期在游戏中多次',
random_banned_part2: ',您已被禁止使用随机对战功能,将在',
random_banned_part3: '后解封',
random_deprecated_part1: '因为您近期在游戏中',
random_deprecated_part2: ',在',
random_deprecated_part3: '内您随机对战时只能遇到其他违规玩家。',
random_warn_part1: '系统检测到您近期在游戏中',
random_warn_part2: ',若您违规超过3次,将受到惩罚',
random_ban_reason_separator: '',
random_ban_reason_flee: '强退',
random_ban_reason_AFK: '挂机',
random_ban_reason_abuse: '发言违规',
random_ban_reason_zombie: '挂房间',
unwelcome_warn_part1: '如果您经常',
unwelcome_warn_part2: ',您的对手可能会离你而去。',
unwelcome_tip_part1: '因为您的对手有',
unwelcome_tip_part2: '行为,现在您可以直接离开游戏或投降,不视为强退。',
banned_duel_tip: '您的发言存在严重不适当的内容,禁止您使用随机对战功能!',
chat_banned: '已被禁言!',
surrender_denied: '为保证双方玩家的游戏体验,随机对战中3回合后才能投降。',
chat_disabled: '本房间禁止聊天。', chat_disabled: '本房间禁止聊天。',
chat_warn_level1: '请注意发言,敏感词已被替换。', chat_warn_level1: '请注意发言,敏感词已被替换。',
chat_warn_level2: '消息包含敏感词,已被拦截。', chat_warn_level2: '消息包含敏感词,已被拦截。',
......
import { Client } from '../client';
import { Room } from '../room';
export type RandomDuelWaitTimeoutType = 'ready' | 'hang';
export class OnClientWaitTimeout {
constructor(
public room: Room,
public client: Client,
public type: RandomDuelWaitTimeoutType,
) {}
}
export class OnClientBadwordViolation {
constructor(
public client: Client,
public room: Room | undefined,
public message: string,
public level: number,
public replacedMessage?: string,
) {}
}
import { CacheKey } from 'aragami'; import { CacheKey } from 'aragami';
import { ChatColor, YGOProCtosChat } from 'ygopro-msg-encode'; import {
ChatColor,
NetPlayerType,
YGOProCtosChat,
YGOProCtosSurrender,
} from 'ygopro-msg-encode';
import { Context } from '../../app'; import { Context } from '../../app';
import { Client } from '../../client'; import { Client } from '../../client';
import { MAX_ROOM_NAME_LENGTH } from '../../constants/room'; import { MAX_ROOM_NAME_LENGTH } from '../../constants/room';
...@@ -7,15 +12,32 @@ import { ...@@ -7,15 +12,32 @@ import {
DuelStage, DuelStage,
OnRoomFinalize, OnRoomFinalize,
OnRoomJoinPlayer, OnRoomJoinPlayer,
OnRoomLeavePlayer,
RoomLeavePlayerReason,
Room, Room,
RoomManager, RoomManager,
} from '../../room'; } from '../../room';
import { fillRandomString } from '../../utility/fill-random-string'; import { fillRandomString } from '../../utility/fill-random-string';
import {
OnClientBadwordViolation,
OnClientWaitTimeout,
} from '../random-duel-events';
import { CanReconnectCheck } from '../reconnect'; import { CanReconnectCheck } from '../reconnect';
import { WaitForPlayerProvider } from '../wait-for-player-provider'; import { WaitForPlayerProvider } from '../wait-for-player-provider';
import { RandomDuelScore } from './score.entity'; import { RandomDuelScore } from './score.entity';
import {
buildFleeFreeKey,
formatRemainText,
RandomDuelPunishReason,
renderReasonText,
} from './utility/random-duel-discipline';
const RANDOM_DUEL_TTL = 24 * 60 * 60 * 1000; const RANDOM_DUEL_TTL = 24 * 60 * 60 * 1000;
const RANDOM_DUEL_WARN_COUNT = 2;
const RANDOM_DUEL_DEPRECATED_COUNT = 3;
const RANDOM_DUEL_BANNED_COUNT = 6;
const RANDOM_DUEL_EARLY_SURRENDER_TURN = 3;
const BUILTIN_RANDOM_TYPES = [ const BUILTIN_RANDOM_TYPES = [
'S', 'S',
'M', 'M',
...@@ -39,13 +61,43 @@ class RandomDuelOpponentCache { ...@@ -39,13 +61,43 @@ class RandomDuelOpponentCache {
opponentIp = ''; opponentIp = '';
} }
class RandomDuelDisciplineCache {
@CacheKey()
ip!: string;
count = 0;
reasons: RandomDuelPunishReason[] = [];
needTip = false;
abuseCount = 0;
expireAt = 0;
}
class RandomDuelFleeFreeCache {
@CacheKey()
key!: string;
enabled = false;
}
declare module '../../room' { declare module '../../room' {
interface Room { interface Room {
randomType?: string; randomType?: string;
randomDuelMaxPlayer?: number; randomDuelMaxPlayer?: number;
randomDuelDeprecated?: boolean;
randomDuelScoreHandled?: boolean;
} }
} }
interface RandomDuelJoinState {
deprecated: boolean;
errorMessage?: string;
}
interface FindOrCreateRandomRoomResult {
room?: Room;
errorMessage?: string;
}
export class RandomDuelProvider { export class RandomDuelProvider {
private logger = this.ctx.createLogger(this.constructor.name); private logger = this.ctx.createLogger(this.constructor.name);
private roomManager = this.ctx.get(() => RoomManager); private roomManager = this.ctx.get(() => RoomManager);
...@@ -83,20 +135,32 @@ export class RandomDuelProvider { ...@@ -83,20 +135,32 @@ export class RandomDuelProvider {
'RANDOM_DUEL_RECORD_MATCH_SCORES is enabled but database is unavailable', 'RANDOM_DUEL_RECORD_MATCH_SCORES is enabled but database is unavailable',
); );
} }
this.ctx.middleware(CanReconnectCheck, async (msg, _client, next) => { this.ctx.middleware(CanReconnectCheck, async (msg, _client, next) => {
if (msg.room.randomType && this.getDisconnectedCount(msg.room) > 1) { if (msg.room.randomType && this.getDisconnectedCount(msg.room) > 1) {
return msg.no(); return msg.no();
} }
return next(); return next();
}); });
this.ctx.middleware(OnRoomJoinPlayer, async (event, client, next) => { this.ctx.middleware(OnRoomJoinPlayer, async (event, client, next) => {
if (event.room.randomType) {
await this.setAbuseCount(client.ip, 0);
}
await this.updateOpponentRelation(event.room, client); await this.updateOpponentRelation(event.room, client);
return next(); return next();
}); });
this.ctx.middleware(OnRoomLeavePlayer, async (event, client, next) => {
await this.handlePlayerLeave(event, client);
return next();
});
this.ctx.middleware(OnRoomFinalize, async (event, _client, next) => { this.ctx.middleware(OnRoomFinalize, async (event, _client, next) => {
await this.recordMatchResult(event.room); await this.recordMatchResult(event.room);
return next(); return next();
}); });
this.ctx.middleware(YGOProCtosChat, async (msg, client, next) => { this.ctx.middleware(YGOProCtosChat, async (msg, client, next) => {
if (!this.disableChat || !client.roomName) { if (!this.disableChat || !client.roomName) {
return next(); return next();
...@@ -108,6 +172,38 @@ export class RandomDuelProvider { ...@@ -108,6 +172,38 @@ export class RandomDuelProvider {
await client.sendChat('#{chat_disabled}', ChatColor.BABYBLUE); await client.sendChat('#{chat_disabled}', ChatColor.BABYBLUE);
return; return;
}); });
this.ctx.middleware(YGOProCtosSurrender, async (_msg, client, next) => {
if (client.isInternal || !client.roomName) {
return next();
}
const room = this.roomManager.findByName(client.roomName);
if (!room?.randomType) {
return next();
}
if (
room.turnCount >= RANDOM_DUEL_EARLY_SURRENDER_TURN ||
(room.randomType === 'M' && this.recordMatchScoresEnabled) ||
(await this.isFleeFree(room.name, client.ip))
) {
return next();
}
await client.sendChat('#{surrender_denied}', ChatColor.BABYBLUE);
return;
});
this.ctx.middleware(OnClientWaitTimeout, async (event, _client, next) => {
await this.handleWaitTimeout(event);
return next();
});
this.ctx.middleware(
OnClientBadwordViolation,
async (event, _client, next) => {
await this.handleBadwordViolation(event);
return next();
},
);
} }
get defaultType() { get defaultType() {
...@@ -128,32 +224,46 @@ export class RandomDuelProvider { ...@@ -128,32 +224,46 @@ export class RandomDuelProvider {
return undefined; return undefined;
} }
async findOrCreateRandomRoom(type: string, playerIp: string) { async findOrCreateRandomRoom(
const found = await this.findRandomRoom(type, playerIp); type: string,
playerIp: string,
): Promise<FindOrCreateRandomRoomResult> {
const joinState = await this.resolveJoinState(type, playerIp);
if (joinState.errorMessage) {
return { errorMessage: joinState.errorMessage };
}
const found = await this.findRandomRoom(
type,
playerIp,
joinState.deprecated,
);
if (found) { if (found) {
const foundType = found.randomType || type || this.defaultType; const foundType = found.randomType || type || this.defaultType;
found.randomType = foundType; found.randomType = foundType;
found.randomDuelDeprecated = joinState.deprecated;
found.checkChatBadword = true; found.checkChatBadword = true;
found.noHost = true; found.noHost = true;
found.randomDuelMaxPlayer = this.resolveRandomDuelMaxPlayer(foundType); found.randomDuelMaxPlayer = this.resolveRandomDuelMaxPlayer(foundType);
found.welcome = '#{random_duel_enter_room_waiting}'; found.welcome = '#{random_duel_enter_room_waiting}';
this.applyWelcomeType(found, foundType); this.applyWelcomeType(found, foundType);
return found; return { room: found };
} }
const randomType = type || this.defaultType; const randomType = type || this.defaultType;
const roomName = this.generateRandomRoomName(randomType); const roomName = this.generateRandomRoomName(randomType);
if (!roomName) { if (!roomName) {
return undefined; return {};
} }
const room = await this.roomManager.findOrCreateByName(roomName); const room = await this.roomManager.findOrCreateByName(roomName);
room.randomType = randomType; room.randomType = randomType;
room.randomDuelDeprecated = joinState.deprecated;
room.checkChatBadword = true; room.checkChatBadword = true;
room.noHost = true; room.noHost = true;
room.randomDuelMaxPlayer = this.resolveRandomDuelMaxPlayer(randomType); room.randomDuelMaxPlayer = this.resolveRandomDuelMaxPlayer(randomType);
room.welcome = '#{random_duel_enter_room_new}'; room.welcome = '#{random_duel_enter_room_new}';
this.applyWelcomeType(room, randomType); this.applyWelcomeType(room, randomType);
return room; return { room };
} }
private resolveBlankPassModes() { private resolveBlankPassModes() {
...@@ -189,7 +299,59 @@ export class RandomDuelProvider { ...@@ -189,7 +299,59 @@ export class RandomDuelProvider {
return room.playingPlayers.filter((player) => !!player.disconnected).length; return room.playingPlayers.filter((player) => !!player.disconnected).length;
} }
private async findRandomRoom(type: string, playerIp: string) { private async resolveJoinState(
type: string,
playerIp: string,
): Promise<RandomDuelJoinState> {
if (!playerIp) {
return { deprecated: false };
}
const discipline = await this.getDiscipline(playerIp);
const reasonsText = renderReasonText(discipline.reasons);
const remainText = formatRemainText(discipline.expireAt);
const deprecated = discipline.count > RANDOM_DUEL_DEPRECATED_COUNT;
if (discipline.count > RANDOM_DUEL_BANNED_COUNT) {
return {
deprecated,
errorMessage: `#{random_banned_part1}${reasonsText}#{random_banned_part2}${remainText}#{random_banned_part3}`,
};
}
if (
discipline.count > RANDOM_DUEL_DEPRECATED_COUNT &&
discipline.needTip &&
type !== 'T'
) {
discipline.needTip = false;
await this.setDiscipline(playerIp, discipline);
return {
deprecated,
errorMessage: `#{random_deprecated_part1}${reasonsText}#{random_deprecated_part2}${remainText}#{random_deprecated_part3}`,
};
}
if (discipline.needTip) {
discipline.needTip = false;
await this.setDiscipline(playerIp, discipline);
return {
deprecated,
errorMessage: `#{random_warn_part1}${reasonsText}#{random_warn_part2}`,
};
}
if (discipline.count > RANDOM_DUEL_WARN_COUNT && !discipline.needTip) {
discipline.needTip = true;
await this.setDiscipline(playerIp, discipline);
}
return { deprecated };
}
private async findRandomRoom(
type: string,
playerIp: string,
playerDeprecated: boolean,
) {
for (const room of this.roomManager.allRooms()) { for (const room of this.roomManager.allRooms()) {
if ( if (
!room.randomType || !room.randomType ||
...@@ -202,6 +364,9 @@ export class RandomDuelProvider { ...@@ -202,6 +364,9 @@ export class RandomDuelProvider {
if (!this.canMatchType(room.randomType, type)) { if (!this.canMatchType(room.randomType, type)) {
continue; continue;
} }
if (type !== 'T' && !!room.randomDuelDeprecated !== !!playerDeprecated) {
continue;
}
const maxPlayer = const maxPlayer =
room.randomDuelMaxPlayer || room.randomDuelMaxPlayer ||
this.resolveRandomDuelMaxPlayer(room.randomType); this.resolveRandomDuelMaxPlayer(room.randomType);
...@@ -250,6 +415,114 @@ export class RandomDuelProvider { ...@@ -250,6 +415,114 @@ export class RandomDuelProvider {
room.welcome2 = ''; room.welcome2 = '';
} }
private async handlePlayerLeave(event: OnRoomLeavePlayer, client: Client) {
const room = event.room;
if (
!room.randomType ||
client.isInternal ||
event.reason !== RoomLeavePlayerReason.Disconnect ||
event.bySystem ||
event.oldPos >= NetPlayerType.OBSERVER ||
room.duelStage === DuelStage.Begin ||
(await this.isFleeFree(room.name, client.ip))
) {
return;
}
await this.punishPlayer(client, 'FLEE');
if (
this.recordMatchScoresEnabled &&
room.randomType === 'M' &&
!room.randomDuelScoreHandled
) {
await this.recordFleeResult(room, client);
room.randomDuelScoreHandled = true;
}
}
private async handleWaitTimeout(event: OnClientWaitTimeout) {
if (!event.room.randomType || event.client.isInternal) {
return;
}
const reason: RandomDuelPunishReason =
event.type === 'ready' ? 'ZOMBIE' : 'AFK';
await this.punishPlayer(event.client, reason);
}
private async handleBadwordViolation(event: OnClientBadwordViolation) {
const room = event.room;
const client = event.client;
if (!room?.randomType || client.isInternal || !client.ip) {
return;
}
let abuseCount = await this.getAbuseCount(client.ip);
if (event.level >= 3) {
if (abuseCount > 0) {
await client.sendChat('#{banned_duel_tip}', ChatColor.RED);
await this.punishPlayer(client, 'ABUSE');
await this.punishPlayer(client, 'ABUSE', 3);
client.disconnect();
return;
}
abuseCount += 4;
} else if (event.level === 2) {
abuseCount += 3;
} else if (event.level === 1) {
abuseCount += 1;
} else {
return;
}
await this.setAbuseCount(client.ip, abuseCount);
if (abuseCount >= 2) {
await this.unwelcome(room, client);
}
if (abuseCount >= 5) {
await room.sendChat(`${client.name} #{chat_banned}`, ChatColor.RED);
await this.punishPlayer(client, 'ABUSE');
client.disconnect();
}
}
private async unwelcome(room: Room, badPlayer: Client) {
await Promise.all(
room.playingPlayers.map(async (player) => {
if (player === badPlayer) {
await player.sendChat(
'#{unwelcome_warn_part1}#{random_ban_reason_abuse}#{unwelcome_warn_part2}',
ChatColor.RED,
);
return;
}
if (player.pos >= NetPlayerType.OBSERVER || player.isInternal) {
return;
}
await this.setFleeFree(room.name, player.ip, true);
await player.sendChat(
'#{unwelcome_tip_part1}#{random_ban_reason_abuse}#{unwelcome_tip_part2}',
ChatColor.BABYBLUE,
);
}),
);
}
private async recordFleeResult(room: Room, loser: Client) {
const loserName = loser.name_vpass || loser.name;
if (loserName) {
await this.recordFlee(loserName);
}
const winner = room
.getOpponents(loser)
.find((player) => player.pos < NetPlayerType.OBSERVER);
const winnerName = winner?.name_vpass || winner?.name;
if (winnerName) {
await this.recordWin(winnerName);
}
}
private async updateOpponentRelation(room: Room, client: Client) { private async updateOpponentRelation(room: Room, client: Client) {
if (!room.randomType || !client.ip) { if (!room.randomType || !client.ip) {
return; return;
...@@ -269,6 +542,9 @@ export class RandomDuelProvider { ...@@ -269,6 +542,9 @@ export class RandomDuelProvider {
} }
private async setLastOpponent(ip: string, opponentIp: string) { private async setLastOpponent(ip: string, opponentIp: string) {
if (!ip) {
return;
}
await this.ctx.aragami.set( await this.ctx.aragami.set(
RandomDuelOpponentCache, RandomDuelOpponentCache,
{ {
...@@ -282,12 +558,154 @@ export class RandomDuelProvider { ...@@ -282,12 +558,154 @@ export class RandomDuelProvider {
); );
} }
private async punishPlayer(
client: Client,
reason: RandomDuelPunishReason,
countAdd = 1,
) {
if (!client.ip) {
return;
}
const discipline = await this.getDiscipline(client.ip);
discipline.count += Math.max(0, countAdd);
if (!discipline.reasons.includes(reason)) {
discipline.reasons = [...discipline.reasons, reason].slice(-16);
}
discipline.needTip = true;
discipline.expireAt = Date.now() + RANDOM_DUEL_TTL;
await this.setDiscipline(client.ip, discipline);
this.logger.info(
{
name: client.name,
ip: client.ip,
reason,
countAdd,
count: discipline.count,
},
'Recorded random duel punishment',
);
}
private async getDiscipline(ip: string) {
const empty = {
count: 0,
reasons: [] as RandomDuelPunishReason[],
needTip: false,
abuseCount: 0,
expireAt: 0,
};
if (!ip) {
return empty;
}
const data = await this.ctx.aragami.get(RandomDuelDisciplineCache, ip);
const now = Date.now();
const expireAt = Math.max(0, data?.expireAt || 0);
if (!data || expireAt <= now) {
return empty;
}
return {
count: Math.max(0, data?.count || 0),
reasons: [...(data?.reasons || [])].filter((reason) =>
['AFK', 'ABUSE', 'FLEE', 'ZOMBIE'].includes(reason),
) as RandomDuelPunishReason[],
needTip: !!data?.needTip,
abuseCount: Math.max(0, data?.abuseCount || 0),
expireAt,
};
}
private async setDiscipline(
ip: string,
data: {
count: number;
reasons: RandomDuelPunishReason[];
needTip: boolean;
abuseCount: number;
expireAt: number;
},
) {
if (!ip) {
return;
}
const now = Date.now();
const expireAt = Math.max(now + 1000, data.expireAt || now + RANDOM_DUEL_TTL);
const ttl = Math.max(1000, expireAt - now);
await this.ctx.aragami.set(
RandomDuelDisciplineCache,
{
ip,
count: Math.max(0, data.count || 0),
reasons: [...(data.reasons || [])].slice(-16),
needTip: !!data.needTip,
abuseCount: Math.max(0, data.abuseCount || 0),
expireAt,
},
{
key: ip,
ttl,
},
);
}
private async getAbuseCount(ip: string) {
const discipline = await this.getDiscipline(ip);
return discipline.abuseCount;
}
private async setAbuseCount(ip: string, abuseCount: number) {
if (!ip) {
return;
}
const discipline = await this.getDiscipline(ip);
if (
discipline.count <= 0 &&
discipline.reasons.length <= 0 &&
!discipline.needTip &&
abuseCount <= 0
) {
return;
}
discipline.abuseCount = Math.max(0, abuseCount);
await this.setDiscipline(ip, discipline);
}
private async isFleeFree(roomName: string, ip: string) {
if (!roomName || !ip) {
return false;
}
const key = buildFleeFreeKey(roomName, ip);
const data = await this.ctx.aragami.get(RandomDuelFleeFreeCache, key);
return !!data?.enabled;
}
private async setFleeFree(roomName: string, ip: string, enabled: boolean) {
if (!roomName || !ip) {
return;
}
const key = buildFleeFreeKey(roomName, ip);
await this.ctx.aragami.set(
RandomDuelFleeFreeCache,
{
key,
enabled: !!enabled,
},
{
key,
ttl: RANDOM_DUEL_TTL,
},
);
}
private get recordMatchScoresEnabled() { private get recordMatchScoresEnabled() {
return this.recordMatchScoresConfigured && !!this.ctx.database; return this.recordMatchScoresConfigured && !!this.ctx.database;
} }
private async recordMatchResult(room: Room) { private async recordMatchResult(room: Room) {
if (!this.recordMatchScoresEnabled || room.randomType !== 'M') { if (
!this.recordMatchScoresEnabled ||
room.randomType !== 'M' ||
room.randomDuelScoreHandled
) {
return; return;
} }
const duelPos0Player = room.getDuelPosPlayers(0)[0]; const duelPos0Player = room.getDuelPosPlayers(0)[0];
...@@ -351,4 +769,20 @@ export class RandomDuelProvider { ...@@ -351,4 +769,20 @@ export class RandomDuelProvider {
score.lose(); score.lose();
await repo.save(score); await repo.save(score);
} }
private async recordFlee(name: string) {
if (!name) {
return;
}
const repo = this.ctx.database?.getRepository(RandomDuelScore);
if (!repo) {
return;
}
const score = await this.getOrCreateScore(name);
if (!score) {
return;
}
score.flee();
await repo.save(score);
}
} }
export type RandomDuelPunishReason = 'AFK' | 'ABUSE' | 'FLEE' | 'ZOMBIE';
export const punishReasonToI18nKey = (reason: RandomDuelPunishReason) => {
if (reason === 'AFK') {
return 'random_ban_reason_AFK';
}
if (reason === 'ABUSE') {
return 'random_ban_reason_abuse';
}
if (reason === 'FLEE') {
return 'random_ban_reason_flee';
}
return 'random_ban_reason_zombie';
};
export const renderReasonText = (reasons: RandomDuelPunishReason[]) => {
const entries = [...new Set(reasons)].map(
(reason) => `#{${punishReasonToI18nKey(reason)}}`,
);
if (!entries.length) {
return `#{${punishReasonToI18nKey('ABUSE')}}`;
}
return entries.join('#{random_ban_reason_separator}');
};
export const formatRemainText = (expireAt: number) => {
const remainMs = Math.max(0, expireAt - Date.now());
const remainMinutes = Math.max(1, Math.ceil(remainMs / 60_000));
if (remainMinutes >= 60) {
const hours = Math.floor(remainMinutes / 60);
const minutes = remainMinutes % 60;
if (minutes <= 0) {
return `${hours}h`;
}
return `${hours}h ${minutes}m`;
}
return `${remainMinutes}m`;
};
export const buildFleeFreeKey = (roomName: string, ip: string) =>
`${roomName}:${ip}`;
...@@ -838,7 +838,9 @@ export class Reconnect { ...@@ -838,7 +838,9 @@ export class Reconnect {
const matchCondition = const matchCondition =
room.isLooseReconnectRule || room.isLooseReconnectRule ||
player.ip === newClient.ip || player.ip === newClient.ip ||
(newClient.vpass && newClient.vpass === player.vpass); (newClient.vpass &&
newClient.vpass === player.vpass &&
newClient.roompass === player.roompass);
if (matchCondition) { if (matchCondition) {
return player; return player;
......
...@@ -4,6 +4,7 @@ import { Client } from '../../client'; ...@@ -4,6 +4,7 @@ import { Client } from '../../client';
import { Room, RoomManager } from '../../room'; import { Room, RoomManager } from '../../room';
import { escapeRegExp } from '../../utility/escape-regexp'; import { escapeRegExp } from '../../utility/escape-regexp';
import { ValueContainer } from '../../utility/value-container'; import { ValueContainer } from '../../utility/value-container';
import { OnClientBadwordViolation } from '../random-duel-events';
import { BaseResourceProvider } from './base-resource-provider'; import { BaseResourceProvider } from './base-resource-provider';
import { isObjectRecord } from './resource-util'; import { isObjectRecord } from './resource-util';
import { BadwordsData, EMPTY_BADWORDS_DATA } from './types'; import { BadwordsData, EMPTY_BADWORDS_DATA } from './types';
...@@ -71,7 +72,21 @@ export class BadwordProvider extends BaseResourceProvider<BadwordsData> { ...@@ -71,7 +72,21 @@ export class BadwordProvider extends BaseResourceProvider<BadwordsData> {
const room = client.roomName const room = client.roomName
? this.roomManager.findByName(client.roomName) ? this.roomManager.findByName(client.roomName)
: undefined; : undefined;
const filtered = await this.filterText(msg.msg, room, client); const originalMessage = msg.msg;
const filtered = await this.filterText(originalMessage, room, client);
if (filtered.level >= 0) {
await this.ctx.dispatch(
new OnClientBadwordViolation(
client,
room,
originalMessage,
filtered.level,
filtered.message !== originalMessage ? filtered.message : undefined,
),
client as any,
);
}
if (filtered.blocked) { if (filtered.blocked) {
await client.sendChat('#{chat_warn_level2}', ChatColor.RED); await client.sendChat('#{chat_warn_level2}', ChatColor.RED);
......
...@@ -19,6 +19,7 @@ import { ...@@ -19,6 +19,7 @@ import {
Room, Room,
RoomManager, RoomManager,
} from '../room'; } from '../room';
import { OnClientWaitTimeout } from './random-duel-events';
export interface WaitForPlayerConfig { export interface WaitForPlayerConfig {
roomFilter: (room: Room) => boolean; roomFilter: (room: Room) => boolean;
...@@ -394,6 +395,10 @@ export class WaitForPlayerProvider { ...@@ -394,6 +395,10 @@ export class WaitForPlayerProvider {
`${latestTarget.name} #{kicked_by_system}`, `${latestTarget.name} #{kicked_by_system}`,
ChatColor.RED, ChatColor.RED,
); );
await this.ctx.dispatch(
new OnClientWaitTimeout(room, latestTarget, 'ready'),
latestTarget,
);
latestTarget.disconnect(); latestTarget.disconnect();
} }
...@@ -441,6 +446,10 @@ export class WaitForPlayerProvider { ...@@ -441,6 +446,10 @@ export class WaitForPlayerProvider {
`${waitingPlayer.name} #{kicked_by_system}`, `${waitingPlayer.name} #{kicked_by_system}`,
ChatColor.RED, ChatColor.RED,
); );
await this.ctx.dispatch(
new OnClientWaitTimeout(room, waitingPlayer, 'hang'),
waitingPlayer,
);
waitingPlayer.disconnect(); waitingPlayer.disconnect();
return; return;
} }
......
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode'; import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../../app'; import { Context } from '../../app';
import { Client } from '../../client';
import { WindBotProvider } from './windbot-provider'; import { WindBotProvider } from './windbot-provider';
import { RoomManager } from '../../room'; import { RoomManager } from '../../room';
import { MAX_ROOM_NAME_LENGTH } from '../../constants/room'; import { MAX_ROOM_NAME_LENGTH } from '../../constants/room';
...@@ -19,31 +20,47 @@ export class JoinWindbotAi { ...@@ -19,31 +20,47 @@ export class JoinWindbotAi {
return; return;
} }
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => { this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
msg.pass = (msg.pass || '').trim(); if (!(await this.joinByPass(msg.pass, client))) {
if (!msg.pass || !msg.pass.toUpperCase().startsWith('AI')) {
return next(); return next();
} }
return;
});
}
async joinByPass(pass: string, client: Client) {
const normalizedPass = (pass || '').trim();
if (
!this.windbotProvider.enabled ||
!normalizedPass ||
!normalizedPass.toUpperCase().startsWith('AI')
) {
return false;
}
const existingRoom = this.roomManager.findByName(msg.pass); const existingRoom = this.roomManager.findByName(normalizedPass);
if (existingRoom) { if (existingRoom) {
existingRoom.noHost = true; existingRoom.noHost = true;
return existingRoom.join(client); await existingRoom.join(client);
return true;
} }
const requestedBotName = this.parseRequestedBotName(msg.pass); const requestedBotName = this.parseRequestedBotName(normalizedPass);
if ( if (
requestedBotName && requestedBotName &&
!this.windbotProvider.getBotByNameOrDeck(requestedBotName) !this.windbotProvider.getBotByNameOrDeck(requestedBotName)
) { ) {
return client.die('#{windbot_deck_not_found}', ChatColor.RED); await client.die('#{windbot_deck_not_found}', ChatColor.RED);
return true;
} }
const roomName = this.generateWindbotRoomName(msg.pass); const roomName = this.generateWindbotRoomName(normalizedPass);
if (!roomName) { if (!roomName) {
return client.die('#{create_room_failed}', ChatColor.RED); await client.die('#{create_room_failed}', ChatColor.RED);
return true;
} }
if (getDisplayLength(roomName) > 20) { if (getDisplayLength(roomName) > 20) {
return client.die('#{windbot_name_too_long}', ChatColor.RED); await client.die('#{windbot_name_too_long}', ChatColor.RED);
return true;
} }
const room = await this.roomManager.findOrCreateByName(roomName, { const room = await this.roomManager.findOrCreateByName(roomName, {
...@@ -69,7 +86,7 @@ export class JoinWindbotAi { ...@@ -69,7 +86,7 @@ export class JoinWindbotAi {
); );
if (!requestOk) { if (!requestOk) {
await room.finalize(); await room.finalize();
return; return true;
} }
} }
...@@ -82,8 +99,7 @@ export class JoinWindbotAi { ...@@ -82,8 +99,7 @@ export class JoinWindbotAi {
}, },
'Created windbot room', 'Created windbot room',
); );
return; return true;
});
} }
private parseRequestedBotName(pass: string) { private parseRequestedBotName(pass: string) {
......
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { RandomDuelProvider } from '../feats';
export class JoinBlankPassRandomDuel {
private randomDuelProvider = this.ctx.get(() => RandomDuelProvider);
constructor(private ctx: Context) {
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
msg.pass = (msg.pass || '').trim();
if (msg.pass || !this.randomDuelProvider.enabled) {
return next();
}
const result = await this.randomDuelProvider.findOrCreateRandomRoom(
'',
client.ip,
);
if (result.errorMessage) {
return client.die(result.errorMessage, ChatColor.RED);
}
if (!result.room) {
return client.die('#{create_room_failed}', ChatColor.RED);
}
return result.room.join(client);
});
}
}
import { YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { JoinWindbotAi } from '../feats/windbot';
export class JoinBlankPassWindbotAi {
private joinWindbotAi = this.ctx.get(() => JoinWindbotAi);
constructor(private ctx: Context) {
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
msg.pass = (msg.pass || '').trim();
if (msg.pass) {
return next();
}
if (await this.joinWindbotAi.joinByPass('AI', client)) {
return;
}
return next();
});
}
}
...@@ -7,6 +7,8 @@ import { JoinFallback } from './fallback'; ...@@ -7,6 +7,8 @@ import { JoinFallback } from './fallback';
import { JoinPrechecks } from './join-prechecks'; import { JoinPrechecks } from './join-prechecks';
import { RandomDuelJoinHandler } from './random-duel-join-handler'; import { RandomDuelJoinHandler } from './random-duel-join-handler';
import { BadwordPlayerInfoChecker } from './badword-player-info-checker'; import { BadwordPlayerInfoChecker } from './badword-player-info-checker';
import { JoinBlankPassRandomDuel } from './join-blank-pass-random-duel';
import { JoinBlankPassWindbotAi } from './join-blank-pass-windbot-ai';
export const JoinHandlerModule = createAppContext<ContextState>() export const JoinHandlerModule = createAppContext<ContextState>()
.provide(ClientVersionCheck) .provide(ClientVersionCheck)
...@@ -16,5 +18,7 @@ export const JoinHandlerModule = createAppContext<ContextState>() ...@@ -16,5 +18,7 @@ export const JoinHandlerModule = createAppContext<ContextState>()
.provide(RandomDuelJoinHandler) .provide(RandomDuelJoinHandler)
.provide(JoinWindbotAi) .provide(JoinWindbotAi)
.provide(JoinRoom) .provide(JoinRoom)
.provide(JoinBlankPassRandomDuel)
.provide(JoinBlankPassWindbotAi)
.provide(JoinFallback) .provide(JoinFallback)
.define(); .define();
...@@ -11,18 +11,24 @@ export class RandomDuelJoinHandler { ...@@ -11,18 +11,24 @@ export class RandomDuelJoinHandler {
} }
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => { this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
msg.pass = (msg.pass || '').trim(); msg.pass = (msg.pass || '').trim();
if (!msg.pass) {
return next();
}
const type = this.randomDuelProvider.resolveRandomType(msg.pass); const type = this.randomDuelProvider.resolveRandomType(msg.pass);
if (type == null) { if (type == null) {
return next(); return next();
} }
const room = await this.randomDuelProvider.findOrCreateRandomRoom( const result = await this.randomDuelProvider.findOrCreateRandomRoom(
type, type,
client.ip, client.ip,
); );
if (!room) { if (result.errorMessage) {
return client.die(result.errorMessage, ChatColor.RED);
}
if (!result.room) {
return client.die('#{create_room_failed}', ChatColor.RED); return client.die('#{create_room_failed}', ChatColor.RED);
} }
return room.join(client); return result.room.join(client);
}); });
} }
} }
import { Room } from '../room';
import { RoomEvent } from './room-event'; import { RoomEvent } from './room-event';
export class OnRoomLeaveObserver extends RoomEvent {} export enum RoomLeaveObserverReason {
Disconnect = 'disconnect',
ToDuelist = 'to_duelist',
}
export class OnRoomLeaveObserver extends RoomEvent {
constructor(
room: Room,
public reason: RoomLeaveObserverReason,
public bySystem = false,
) {
super(room);
}
}
import { Room } from '../room'; import { Room } from '../room';
import { RoomEvent } from './room-event'; import { RoomEvent } from './room-event';
export enum RoomLeavePlayerReason {
Disconnect = 'disconnect',
ToObserver = 'to_observer',
SwitchPosition = 'switch_position',
}
export class OnRoomLeavePlayer extends RoomEvent { export class OnRoomLeavePlayer extends RoomEvent {
constructor( constructor(
room: Room, room: Room,
public oldPos: number, public oldPos: number,
public reason: RoomLeavePlayerReason,
public bySystem = false,
) { ) {
super(room); super(room);
} }
......
...@@ -71,8 +71,14 @@ import { OnRoomLeave } from './room-event/on-room-leave'; ...@@ -71,8 +71,14 @@ import { OnRoomLeave } from './room-event/on-room-leave';
import { OnRoomWin } from './room-event/on-room-win'; import { OnRoomWin } from './room-event/on-room-win';
import { OnRoomJoinPlayer } from './room-event/on-room-join-player'; import { OnRoomJoinPlayer } from './room-event/on-room-join-player';
import { OnRoomJoinObserver } from './room-event/on-room-join-observer'; import { OnRoomJoinObserver } from './room-event/on-room-join-observer';
import { OnRoomLeavePlayer } from './room-event/on-room-leave-player'; import {
import { OnRoomLeaveObserver } from './room-event/on-room-leave-observer'; OnRoomLeavePlayer,
RoomLeavePlayerReason,
} from './room-event/on-room-leave-player';
import {
OnRoomLeaveObserver,
RoomLeaveObserverReason,
} from './room-event/on-room-leave-observer';
import { OnRoomMatchStart } from './room-event/on-room-match-start'; import { OnRoomMatchStart } from './room-event/on-room-match-start';
import { OnRoomGameStart } from './room-event/on-room-game-start'; import { OnRoomGameStart } from './room-event/on-room-game-start';
import YGOProDeck from 'ygopro-deck-encode'; import YGOProDeck from 'ygopro-deck-encode';
...@@ -590,9 +596,24 @@ export class Room { ...@@ -590,9 +596,24 @@ export class Room {
// 触发具体的离开事件 // 触发具体的离开事件
if (wasObserver) { if (wasObserver) {
await this.ctx.dispatch(new OnRoomLeaveObserver(this), client); await this.ctx.dispatch(
new OnRoomLeaveObserver(
this,
RoomLeaveObserverReason.Disconnect,
_msg.bySystem,
),
client,
);
} else { } else {
await this.ctx.dispatch(new OnRoomLeavePlayer(this, oldPos), client); await this.ctx.dispatch(
new OnRoomLeavePlayer(
this,
oldPos,
RoomLeavePlayerReason.Disconnect,
_msg.bySystem,
),
client,
);
} }
client.roomName = undefined; client.roomName = undefined;
...@@ -638,7 +659,10 @@ export class Room { ...@@ -638,7 +659,10 @@ export class Room {
this.allPlayers.forEach((p) => p.send(this.watcherSizeMessage)); this.allPlayers.forEach((p) => p.send(this.watcherSizeMessage));
// 触发事件 // 触发事件
await this.ctx.dispatch(new OnRoomLeavePlayer(this, oldPos), client); await this.ctx.dispatch(
new OnRoomLeavePlayer(this, oldPos, RoomLeavePlayerReason.ToObserver),
client,
);
await this.ctx.dispatch(new OnRoomJoinObserver(this), client); await this.ctx.dispatch(new OnRoomJoinObserver(this), client);
} }
...@@ -675,7 +699,10 @@ export class Room { ...@@ -675,7 +699,10 @@ export class Room {
this.allPlayers.forEach((p) => p.send(this.watcherSizeMessage)); this.allPlayers.forEach((p) => p.send(this.watcherSizeMessage));
// 触发事件 // 触发事件
await this.ctx.dispatch(new OnRoomLeaveObserver(this), client); await this.ctx.dispatch(
new OnRoomLeaveObserver(this, RoomLeaveObserverReason.ToDuelist),
client,
);
await this.ctx.dispatch(new OnRoomJoinPlayer(this), client); await this.ctx.dispatch(new OnRoomJoinPlayer(this), client);
} else if (this.isTag) { } else if (this.isTag) {
// TAG 模式下,已经是玩家,切换到另一个空位 // TAG 模式下,已经是玩家,切换到另一个空位
...@@ -708,7 +735,14 @@ export class Room { ...@@ -708,7 +735,14 @@ export class Room {
await client.sendTypeChange(); await client.sendTypeChange();
// 触发事件 (玩家切换位置) // 触发事件 (玩家切换位置)
await this.ctx.dispatch(new OnRoomLeavePlayer(this, oldPos), client); await this.ctx.dispatch(
new OnRoomLeavePlayer(
this,
oldPos,
RoomLeavePlayerReason.SwitchPosition,
),
client,
);
await this.ctx.dispatch(new OnRoomJoinPlayer(this), client); await this.ctx.dispatch(new OnRoomJoinPlayer(this), client);
} }
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment