Commit 19daed1d authored by nanahira's avatar nanahira

4 file related modules

parent 5f90cc89
...@@ -32,6 +32,20 @@ deckMaxCopies: 3 ...@@ -32,6 +32,20 @@ deckMaxCopies: 3
ocgcoreDebugLog: 0 ocgcoreDebugLog: 0
ocgcoreWasmPath: "" ocgcoreWasmPath: ""
welcome: "" welcome: ""
enableTips: 1
tipsGet: ""
tipsGetZh: ""
tipsSplitZh: 0
tipsPrefix: "Tip: "
tipsInterval: 30000
tipsIntervalIngame: 120000
enableWords: 1
wordsGet: ""
enableDialogues: 1
dialoguesGet: http://mercury233.me/ygosrv233/dialogues.json
dialoguesGetCustom: ""
enableBadwords: 1
badwordsGet: ""
enableWindbot: 0 enableWindbot: 0
windbotBotlist: ./windbot/bots.json windbotBotlist: ./windbot/bots.json
windbotSpawn: 0 windbotSpawn: 0
......
{
"badwords": {
"file": "./config/badwords.json",
"level0": ["滚", "衮", "操", "草", "艹", "狗", "日", "曰", "妈", "娘", "逼"],
"level1": ["傻逼", "鸡巴"],
"level2": ["死妈", "草你妈"],
"level3": ["迷奸", "仿真枪"]
},
"tips": {
"file": "./config/tips.json",
"tips": [
"欢迎来到本服务器",
"本服务器使用萌卡代码搭建"
],
"tips_zh": []
},
"words": {
"file": "./config/words.json",
"words": {
"test1": [
"test_word_1"
],
"test2": [
"test_word_2"
]
}
},
"dialogues": {
"file": "./config/dialogues.json",
"dialogues": {
"46986414": [
"出来吧,我最强的仆人,黑魔导!"
],
"58481572": [
"我们来做朋友吧!"
]
},
"dialogues_custom": {
"37564303": [
"泡沫君のようだ\n触れて壊さぬように\n刹那哀のまほろば\n見守るよ"
],
"37564765": [
"キラキラひかる空\n二つ並ぶ白い雲\nスカートなびかせて\n裾を掴む影ぼうし"
]
}
},
"users": {
"file": "./config/admin_user.json",
"permission_examples": {
"sudo": {
"get_rooms": true,
"duel_log": true,
"download_replay": true,
"clear_duel_log": true,
"deck_dashboard_read": true,
"deck_dashboard_write": true,
"shout": true,
"stop": true,
"change_settings": true,
"ban_user": true,
"kick_user": true,
"start_death": true,
"pre_dashboard": true,
"update_dashboard": true,
"vip": true
},
"judge": {
"get_rooms": true,
"duel_log": true,
"download_replay": true,
"deck_dashboard_read": true,
"deck_dashboard_write": true,
"shout": true,
"kick_user": true,
"start_death": true
},
"streamer": {
"get_rooms": true,
"duel_log": true,
"download_replay": true,
"deck_dashboard_read": true
}
},
"users": {
"root": {
"password": "123456",
"enabled": false,
"permissions": "sudo"
}
}
}
}
...@@ -27,10 +27,12 @@ export class ClientHandler { ...@@ -27,10 +27,12 @@ export class ClientHandler {
// ws/reverse-ws should already have IP from connection metadata, skip overwrite // ws/reverse-ws should already have IP from connection metadata, skip overwrite
return next(); return next();
} }
await this.ctx.get(() => IpResolver).setClientIp( await this.ctx
client, .get(() => IpResolver)
msg.real_ip === '0.0.0.0' ? undefined : msg.real_ip, .setClientIp(
); client,
msg.real_ip === '0.0.0.0' ? undefined : msg.real_ip,
);
client.hostname = msg.hostname?.split(':')[0] || ''; client.hostname = msg.hostname?.split(':')[0] || '';
return next(); return next();
}) })
......
import { filter, merge, Observable, of, Subject } from 'rxjs'; import {
import { map, share, take, takeUntil, tap } from 'rxjs/operators'; filter,
from,
lastValueFrom,
merge,
Observable,
of,
Subject,
} from 'rxjs';
import {
concatMap,
defaultIfEmpty,
ignoreElements,
map,
share,
take,
takeUntil,
tap,
} from 'rxjs/operators';
import { Context } from '../app'; import { Context } from '../app';
import { import {
YGOProCtos, YGOProCtos,
...@@ -113,7 +130,7 @@ export class Client { ...@@ -113,7 +130,7 @@ export class Client {
payload: JSON.stringify(logMsg), payload: JSON.stringify(logMsg),
}, },
'Sending message to client', 'Sending message to client',
) );
try { try {
await this._send(Buffer.from(data.toFullPayload())); await this._send(Buffer.from(data.toFullPayload()));
} catch (e) { } catch (e) {
...@@ -129,23 +146,43 @@ export class Client { ...@@ -129,23 +146,43 @@ export class Client {
if (this.isInternal) { if (this.isInternal) {
return; return;
} }
const locale = this.ctx.get(() => Chnroute).getLocale(this.ip);
const lines = type <= NetPlayerType.OBSERVER ? [msg] : msg.split(/\r?\n/);
const sendTasks: Promise<unknown>[] = [];
await lastValueFrom(
from(lines).pipe(
concatMap((rawLine) => this.resolveChatLine(rawLine, type, locale)),
tap((line: string) => {
const sendTask = this.send(
new YGOProStocChat().fromPartial({
msg: line,
player_type: type,
}),
);
if (sendTask) {
sendTasks.push(sendTask);
}
}),
ignoreElements(),
defaultIfEmpty(undefined),
),
);
await Promise.all(sendTasks);
}
private async resolveChatLine(rawLine: string, type: number, locale: string) {
let line = rawLine;
if (type >= ChatColor.RED) { if (type >= ChatColor.RED) {
msg = `[Server]: ${msg}`; line = `[Server]: ${line}`;
} }
return this.send( if (type > NetPlayerType.OBSERVER) {
new YGOProStocChat().fromPartial({ line = String(
msg: await this.ctx.get(() => I18nService).translate(locale, line),
type <= NetPlayerType.OBSERVER );
? msg }
: await this.ctx return line;
.get(() => I18nService)
.translate(
this.ctx.get(() => Chnroute).getLocale(this.ip),
msg,
),
player_type: type,
}),
);
} }
async die(msg?: string, type = ChatColor.BABYBLUE) { async die(msg?: string, type = ChatColor.BABYBLUE) {
......
export * from './client'; export * from './client';
export * from './client-handler'; export * from './client-handler';
export * from './chnroute';
...@@ -71,7 +71,9 @@ export class WsServer { ...@@ -71,7 +71,9 @@ export class WsServer {
req: IncomingMessage, req: IncomingMessage,
): Promise<void> { ): Promise<void> {
const client = new WsClient(this.ctx, ws, req); const client = new WsClient(this.ctx, ws, req);
if (await this.ctx.get(() => IpResolver).setClientIp(client, client.xffIp())) { if (
await this.ctx.get(() => IpResolver).setClientIp(client, client.xffIp())
) {
return; return;
} }
client.hostname = req.headers.host?.split(':')[0] || ''; client.hostname = req.headers.host?.split(':')[0] || '';
......
...@@ -73,6 +73,39 @@ export const defaultConfig = { ...@@ -73,6 +73,39 @@ export const defaultConfig = {
OCGCORE_WASM_PATH: '', OCGCORE_WASM_PATH: '',
// Welcome message sent when players join. Format: plain string. // Welcome message sent when players join. Format: plain string.
WELCOME: '', WELCOME: '',
// Enable tips feature.
// Boolean parse rule (default true): only '0'/'false'/'null' => false, otherwise true.
ENABLE_TIPS: '1',
// Remote URL for tips list. Empty means disabled.
TIPS_GET: '',
// Remote URL for zh tips list. Empty means disabled.
TIPS_GET_ZH: '',
// Use tips_zh for zh users when available.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
TIPS_SPLIT_ZH: '0',
// Prefix for tips messages.
TIPS_PREFIX: 'Tip: ',
// Interval for auto tips in non-dueling rooms. Format: integer string in milliseconds (ms). '0' disables.
TIPS_INTERVAL: '30000',
// Interval for auto tips in dueling rooms. Format: integer string in milliseconds (ms). '0' disables.
TIPS_INTERVAL_INGAME: '120000',
// Enable words feature.
// Boolean parse rule (default true): only '0'/'false'/'null' => false, otherwise true.
ENABLE_WORDS: '1',
// Remote URL for words data. Empty means disabled.
WORDS_GET: '',
// Enable dialogues feature.
// Boolean parse rule (default true): only '0'/'false'/'null' => false, otherwise true.
ENABLE_DIALOGUES: '1',
// Remote URL for dialogues.
DIALOGUES_GET: 'http://mercury233.me/ygosrv233/dialogues.json',
// Remote URL for custom dialogues. Empty means disabled.
DIALOGUES_GET_CUSTOM: '',
// Enable badwords feature.
// Boolean parse rule (default true): only '0'/'false'/'null' => false, otherwise true.
ENABLE_BADWORDS: '1',
// Remote URL for badwords data. Empty means disabled.
BADWORDS_GET: '',
// Enable windbot feature. // Enable windbot feature.
// Boolean parse rule (default true): only '0'/'false'/'null' => false, otherwise true. // Boolean parse rule (default true): only '0'/'false'/'null' => false, otherwise true.
ENABLE_WINDBOT: '0', ENABLE_WINDBOT: '0',
......
...@@ -8,6 +8,12 @@ export const TRANSLATIONS = { ...@@ -8,6 +8,12 @@ export const TRANSLATIONS = {
version_polyfilled: version_polyfilled:
'Temporary compatibility mode has been enabled for your version. We recommend updating your game to avoid potential compatibility issues in the future.', 'Temporary compatibility mode has been enabled for your version. We recommend updating your game to avoid potential compatibility issues in the future.',
bad_user_name: 'Please enter the correct ID', bad_user_name: 'Please enter the correct ID',
bad_name_level1: 'Your username contains blocked words.',
bad_name_level2: 'Your username contains blocked words.',
bad_name_level3: 'Your username contains blocked words.',
bad_roomname_level1: 'Your room name contains blocked words.',
bad_roomname_level2: 'Your room name contains blocked words.',
bad_roomname_level3: 'Your room name contains blocked words.',
blank_room_name: 'Blank room name is unallowed, please fill in something.', blank_room_name: 'Blank room name is unallowed, please fill in something.',
replay_hint_part1: 'Sending the replay of the duel number ', replay_hint_part1: 'Sending the replay of the duel number ',
replay_hint_part2: '.', replay_hint_part2: '.',
...@@ -34,7 +40,8 @@ export const TRANSLATIONS = { ...@@ -34,7 +40,8 @@ export const TRANSLATIONS = {
side_remain_part1: 'Remaining side changing time: ', side_remain_part1: 'Remaining side changing time: ',
side_remain_part2: ' minutes.', side_remain_part2: ' minutes.',
side_overtime: 'You exceeded side changing time and were kicked by system.', side_overtime: 'You exceeded side changing time and were kicked by system.',
side_overtime_room: ' exceeded side changing time and was kicked by system.', side_overtime_room:
' exceeded side changing time and was kicked by system.',
kicked_by_system: 'was evicted from the game by server.', kicked_by_system: 'was evicted from the game by server.',
kick_count_down: kick_count_down:
' seconds later this player will be evicted for not getting ready or starting the game.', ' seconds later this player will be evicted for not getting ready or starting the game.',
...@@ -49,6 +56,8 @@ export const TRANSLATIONS = { ...@@ -49,6 +56,8 @@ export const TRANSLATIONS = {
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.',
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_level2: 'Your message contains blocked words.',
}, },
'zh-CN': { 'zh-CN': {
update_required: '请更新你的客户端版本', update_required: '请更新你的客户端版本',
...@@ -58,6 +67,12 @@ export const TRANSLATIONS = { ...@@ -58,6 +67,12 @@ export const TRANSLATIONS = {
version_polyfilled: version_polyfilled:
'已为当前版本启用临时兼容模式。建议尽快更新游戏,以避免后续兼容性问题。', '已为当前版本启用临时兼容模式。建议尽快更新游戏,以避免后续兼容性问题。',
bad_user_name: '请输入正确的用户名', bad_user_name: '请输入正确的用户名',
bad_name_level1: '你的用户名包含敏感词,请修改后重试。',
bad_name_level2: '你的用户名包含敏感词,请修改后重试。',
bad_name_level3: '你的用户名包含敏感词,请修改后重试。',
bad_roomname_level1: '房间名包含敏感词,请修改后重试。',
bad_roomname_level2: '房间名包含敏感词,请修改后重试。',
bad_roomname_level3: '房间名包含敏感词,请修改后重试。',
blank_room_name: '房间名不能为空,请在主机密码处填写房间名', blank_room_name: '房间名不能为空,请在主机密码处填写房间名',
replay_hint_part1: '正在发送第', replay_hint_part1: '正在发送第',
replay_hint_part2: '局决斗的录像。', replay_hint_part2: '局决斗的录像。',
...@@ -96,5 +111,7 @@ export const TRANSLATIONS = { ...@@ -96,5 +111,7 @@ export const TRANSLATIONS = {
random_duel_enter_room_tag: random_duel_enter_room_tag:
'您进入了双打模式房间,密码输入 S 进入单局模式,输入 M 进入比赛模式。', '您进入了双打模式房间,密码输入 S 进入单局模式,输入 M 进入比赛模式。',
chat_disabled: '本房间禁止聊天。', chat_disabled: '本房间禁止聊天。',
chat_warn_level1: '请注意发言,敏感词已被替换。',
chat_warn_level2: '消息包含敏感词,已被拦截。',
}, },
}; };
...@@ -8,8 +8,10 @@ import { WindbotModule } from './windbot'; ...@@ -8,8 +8,10 @@ import { WindbotModule } from './windbot';
import { SideTimeout } from './side-timeout'; import { SideTimeout } from './side-timeout';
import { RandomDuelModule } from './random-duel'; import { RandomDuelModule } from './random-duel';
import { WaitForPlayerProvider } from './wait-for-player-provider'; import { WaitForPlayerProvider } from './wait-for-player-provider';
import { ResourceModule } from './resource';
export const FeatsModule = createAppContext<ContextState>() export const FeatsModule = createAppContext<ContextState>()
.use(ResourceModule)
.provide(ClientVersionCheck) .provide(ClientVersionCheck)
.provide(Welcome) .provide(Welcome)
.provide(PlayerStatusNotify) .provide(PlayerStatusNotify)
......
...@@ -2,3 +2,4 @@ export * from './client-version-check'; ...@@ -2,3 +2,4 @@ export * from './client-version-check';
export * from './random-duel'; export * from './random-duel';
export * from './reconnect'; export * from './reconnect';
export * from './wait-for-player-provider'; export * from './wait-for-player-provider';
export * from './resource';
...@@ -60,8 +60,7 @@ export class RandomDuelProvider { ...@@ -60,8 +60,7 @@ export class RandomDuelProvider {
private waitForPlayerReadyTimeoutMs = private waitForPlayerReadyTimeoutMs =
Math.max(0, this.ctx.config.getInt('RANDOM_DUEL_READY_TIME') || 0) * 1000; Math.max(0, this.ctx.config.getInt('RANDOM_DUEL_READY_TIME') || 0) * 1000;
private waitForPlayerHangTimeoutMs = private waitForPlayerHangTimeoutMs =
Math.max(0, this.ctx.config.getInt('RANDOM_DUEL_HANG_TIMEOUT') || 0) * Math.max(0, this.ctx.config.getInt('RANDOM_DUEL_HANG_TIMEOUT') || 0) * 1000;
1000;
private waitForPlayerLongAgoBackoffMs = Math.max( private waitForPlayerLongAgoBackoffMs = Math.max(
0, 0,
this.waitForPlayerHangTimeoutMs - 19_000, this.waitForPlayerHangTimeoutMs - 19_000,
...@@ -134,6 +133,7 @@ export class RandomDuelProvider { ...@@ -134,6 +133,7 @@ export class RandomDuelProvider {
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.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}';
...@@ -148,6 +148,7 @@ export class RandomDuelProvider { ...@@ -148,6 +148,7 @@ export class RandomDuelProvider {
} }
const room = await this.roomManager.findOrCreateByName(roomName); const room = await this.roomManager.findOrCreateByName(roomName);
room.randomType = randomType; room.randomType = randomType;
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}';
......
import { ChatColor, YGOProCtosChat } from 'ygopro-msg-encode';
import { Context } from '../../app';
import { Client } from '../../client';
import { Room, RoomManager } from '../../room';
import { escapeRegExp } from '../../utility/escape-regexp';
import { ValueContainer } from '../../utility/value-container';
import { BaseResourceProvider } from './base-resource-provider';
import { isObjectRecord } from './resource-util';
import { BadwordsData, EMPTY_BADWORDS_DATA } from './types';
declare module '../../room' {
interface Room {
checkChatBadword?: boolean;
}
}
export interface BadwordCheckResult {
level: number;
message?: string;
}
export class BadwordTextCheck extends ValueContainer<BadwordCheckResult> {
constructor(
public text: string,
public room?: Room,
public client?: Client,
) {
super({ level: -1 });
}
asLevel(level: number) {
return this.use({
...this.value,
level,
});
}
asMessage(message?: string) {
return this.use({
...this.value,
message,
});
}
}
export class BadwordProvider extends BaseResourceProvider<BadwordsData> {
enabled = this.ctx.config.getBoolean('ENABLE_BADWORDS');
private roomManager = this.ctx.get(() => RoomManager);
private level0Regex?: RegExp;
private level1Regex?: RegExp;
private level1GlobalRegex?: RegExp;
private level2Regex?: RegExp;
private level3Regex?: RegExp;
constructor(ctx: Context) {
super(ctx, {
resourceName: 'badwords',
emptyData: EMPTY_BADWORDS_DATA,
});
if (!this.enabled) {
return;
}
this.ctx.middleware(YGOProCtosChat, async (msg, client, next) => {
if (client.isInternal) {
return next();
}
const room = client.roomName
? this.roomManager.findByName(client.roomName)
: undefined;
const filtered = await this.filterText(msg.msg, room, client);
if (filtered.blocked) {
await client.sendChat('#{chat_warn_level2}', ChatColor.RED);
return;
}
if (filtered.message !== msg.msg) {
msg.msg = filtered.message;
await client.sendChat('#{chat_warn_level1}', ChatColor.BABYBLUE);
}
return next();
});
}
async refreshResources() {
if (!this.enabled) {
return false;
}
return this.refreshFromRemote();
}
async refreshFromRemote() {
if (!this.enabled) {
return false;
}
const url = this.ctx.config.getString('BADWORDS_GET').trim();
if (!url) {
return false;
}
try {
const body = (
await this.ctx.http.get(url, {
responseType: 'json',
})
).data;
const remoteData = this.resolveRemoteBadwordsData(body);
if (!remoteData) {
this.logger.warn({ url }, 'Remote badwords response is invalid');
return false;
}
await this.updateData(remoteData);
this.logger.info({ url }, 'Loaded remote resource');
return true;
} catch (error) {
this.logger.warn(
{
url,
error: (error as Error).toString(),
},
'Failed loading remote resource',
);
return false;
}
}
async getBadwordLevel(text: string, room?: Room, client?: Client) {
const checkResult = await this.getBadwordCheck(text, room, client);
return checkResult.level;
}
async getBadwordCheck(text: string, room?: Room, client?: Client) {
if (!this.enabled) {
return { level: -1 } as BadwordCheckResult;
}
const event = await this.ctx.dispatch(
new BadwordTextCheck(text, room, client),
client as any,
);
return event?.value ?? ({ level: -1 } as BadwordCheckResult);
}
async filterText(text: string, room?: Room, client?: Client) {
const checkResult = await this.getBadwordCheck(text, room, client);
const { level } = checkResult;
if (level >= 2) {
return {
blocked: true,
level,
message: text,
};
}
if (level === 1 && typeof checkResult.message === 'string') {
return {
blocked: false,
level,
message: checkResult.message,
};
}
if (level === 1) {
return {
blocked: false,
level,
message: text,
};
}
return {
blocked: false,
level,
message: text,
};
}
protected registerLookupMiddleware() {
this.ctx.middleware(BadwordTextCheck, async (event, _client, next) => {
if (event.room && !event.room.checkChatBadword) {
event.use({ level: -1 });
return next();
}
const level = this.resolveBadwordLevel(event.text);
if (level === 1 && this.level1GlobalRegex) {
event.use({
level,
message: event.text.replace(this.level1GlobalRegex, '**'),
});
} else {
event.use({ level });
}
return next();
});
}
protected onDataUpdated(nextData: BadwordsData): void {
this.level0Regex = this.buildRegex(nextData.level0, 'i');
this.level1Regex = this.buildRegex(nextData.level1, 'i');
this.level1GlobalRegex = this.buildRegex(nextData.level1, 'ig');
this.level2Regex = this.buildRegex(nextData.level2, 'i');
this.level3Regex = this.buildRegex(nextData.level3, 'i');
}
private resolveBadwordLevel(text: string) {
if (!text) {
return -1;
}
if (this.level3Regex?.test(text)) {
return 3;
}
if (this.level2Regex?.test(text)) {
return 2;
}
if (this.level1Regex?.test(text)) {
return 1;
}
if (this.level0Regex?.test(text)) {
return 0;
}
return -1;
}
private buildRegex(words: string[], flags: string) {
const escapedWords = words
.map((word) => word.trim())
.filter((word) => !!word)
.map((word) => escapeRegExp(word));
if (!escapedWords.length) {
return undefined;
}
return new RegExp(`(?:${escapedWords.join(')|(?:')})`, flags);
}
private resolveRemoteBadwordsData(
rawData: unknown,
): BadwordsData | undefined {
if (!isObjectRecord(rawData)) {
return undefined;
}
const level0 = this.ensureStringArray(rawData.level0);
const level1 = this.ensureStringArray(rawData.level1);
const level2 = this.ensureStringArray(rawData.level2);
const level3 = this.ensureStringArray(rawData.level3);
if (!level0 || !level1 || !level2 || !level3) {
return undefined;
}
return {
...this.getResourceData(),
level0,
level1,
level2,
level3,
};
}
private ensureStringArray(value: unknown) {
if (!Array.isArray(value)) {
return undefined;
}
return value.filter((item): item is string => typeof item === 'string');
}
protected isEnabled() {
return this.enabled;
}
}
import { Context } from '../../app';
import { ValueContainer } from '../../utility/value-container';
import { FileResourceService } from './file-resource-service';
import { cloneJson } from './resource-util';
type AnyObject = Record<string, unknown>;
type RemoteEntry<T extends object> = {
field: keyof T & string;
url: string;
};
export abstract class BaseResourceProvider<T extends object> {
protected logger = this.ctx.createLogger(this.constructor.name);
protected fileResourceService = this.ctx.get(() => FileResourceService);
protected data: ValueContainer<T>;
public resource: ValueContainer<T>;
constructor(
protected ctx: Context,
private options: {
resourceName: string;
emptyData: T;
},
) {
this.data = new ValueContainer(cloneJson(this.options.emptyData));
this.resource = this.data;
}
async init() {
await this.fileResourceService.ensureInitialized();
this.loadLocalData();
this.registerLookupMiddleware();
if (!this.isEnabled()) {
return;
}
await this.refreshFromRemote();
}
async refreshFromRemote() {
if (!this.isEnabled()) {
return false;
}
const entries = this.getRemoteLoadEntries().filter((entry) => !!entry.url);
if (!entries.length) {
return false;
}
const nextData = cloneJson(this.data.value);
for (const entry of entries) {
const fetched = await this.fetchRemoteData(entry.url);
if (fetched == null) {
return false;
}
(nextData as Record<string, unknown>)[entry.field] = fetched;
this.logger.info(
{
resource: this.options.resourceName,
field: entry.field,
url: entry.url,
},
'Loaded remote resource',
);
}
await this.updateData(nextData);
return true;
}
protected getResourceData() {
return this.data.value;
}
protected abstract registerLookupMiddleware(): void;
protected getRemoteLoadEntries(): RemoteEntry<T>[] {
return [];
}
protected onDataUpdated(_nextData: T): void {}
protected isEnabled() {
return true;
}
private loadLocalData() {
const localData = this.fileResourceService.getDataOrEmpty(
this.options.resourceName,
this.options.emptyData,
);
this.data.use(localData);
this.onDataUpdated(localData);
this.logger.info(
{ resource: this.options.resourceName },
'Loaded local resource',
);
}
protected async updateData(nextData: T) {
this.data.use(cloneJson(nextData));
this.onDataUpdated(this.data.value);
await this.fileResourceService.saveData(
this.options.resourceName,
nextData as AnyObject,
);
}
private async fetchRemoteData(url: string) {
try {
const body = (
await this.ctx.http.get(url, {
responseType: 'json',
})
).data;
if (!body || typeof body === 'string') {
this.logger.warn(
{
resource: this.options.resourceName,
url,
},
'Remote resource response is invalid',
);
return undefined;
}
return body as unknown;
} catch (error) {
this.logger.warn(
{
resource: this.options.resourceName,
url,
error: (error as Error).toString(),
},
'Failed loading remote resource',
);
return undefined;
}
}
}
import {
ChatColor,
OcgcoreCommonConstants,
OcgcoreScriptConstants,
YGOProMsgBase,
YGOProMsgChaining,
YGOProMsgPosChange,
YGOProMsgSpSummoning,
YGOProMsgSummoning,
YGOProMsgUpdateCard,
YGOProMsgWaiting,
} from 'ygopro-msg-encode';
import { Context } from '../../app';
import { Client } from '../../client';
import { Room, RoomManager } from '../../room';
import { ValueContainer } from '../../utility/value-container';
import { pickRandom } from '../../utility/pick-random';
import { BaseResourceProvider } from './base-resource-provider';
import { DialoguesData, EMPTY_DIALOGUES_DATA } from './types';
export class DialoguesLookup extends ValueContainer<string[]> {
constructor(
public room: Room,
public client: Client,
public cardCode: number,
) {
super([]);
}
}
export class DialoguesProvider extends BaseResourceProvider<DialoguesData> {
enabled = this.ctx.config.getBoolean('ENABLE_DIALOGUES');
private roomManager = this.ctx.get(() => RoomManager);
constructor(ctx: Context) {
super(ctx, {
resourceName: 'dialogues',
emptyData: EMPTY_DIALOGUES_DATA,
});
if (!this.enabled) {
return;
}
this.ctx.middleware(YGOProMsgBase, async (msg, client, next) => {
await this.handleDialogueMessage(msg, client);
return next();
});
}
async refreshResources() {
if (!this.enabled) {
return false;
}
return this.refreshFromRemote();
}
async getRandomDialogue(room: Room, client: Client, cardCode: number) {
if (!this.enabled) {
return undefined;
}
const event = await this.ctx.dispatch(
new DialoguesLookup(room, client, cardCode),
client,
);
const dialogues = (event?.value || []).filter((line) => !!line);
return pickRandom(dialogues);
}
protected registerLookupMiddleware() {
this.ctx.middleware(DialoguesLookup, async (event, _client, next) => {
const data = this.getResourceData();
const key = event.cardCode.toString();
event.use(data.dialogues[key] || data.dialogues_custom[key] || []);
return next();
});
}
protected getRemoteLoadEntries() {
return [
{
field: 'dialogues' as const,
url: this.ctx.config.getString('DIALOGUES_GET').trim(),
},
{
field: 'dialogues_custom' as const,
url: this.ctx.config.getString('DIALOGUES_GET_CUSTOM').trim(),
},
];
}
private async sendDialogueByCardCode(client: Client, cardCode: number) {
if (!client.roomName) {
return;
}
const room = this.roomManager.findByName(client.roomName);
if (!room) {
return;
}
const dialogue = await this.getRandomDialogue(room, client, cardCode);
if (!dialogue) {
return;
}
await room.sendChat(dialogue, ChatColor.PINK);
}
private async handleDialogueMessage(message: YGOProMsgBase, client: Client) {
if (message instanceof YGOProMsgSummoning) {
await this.sendDialogueByCardCode(client, message.code);
} else if (message instanceof YGOProMsgSpSummoning) {
await this.sendDialogueByCardCode(client, message.code);
} else if (message instanceof YGOProMsgChaining) {
if (this.canTriggerChainingDialogue(message, client)) {
await this.sendDialogueByCardCode(client, message.code);
}
}
this.updateReadyTrapState(client, message);
}
private canTriggerChainingDialogue(
message: YGOProMsgChaining,
client: Client,
) {
const fromSpellTrapZone =
(message.location & OcgcoreScriptConstants.LOCATION_SZONE) !== 0;
return fromSpellTrapZone && !!client.readyTrap;
}
private updateReadyTrapState(client: Client, message: YGOProMsgBase) {
if (message instanceof YGOProMsgPosChange) {
const isSpellTrapZone =
(message.card.location & OcgcoreScriptConstants.LOCATION_SZONE) !== 0;
const fromFacedown =
(message.previousPosition & OcgcoreCommonConstants.POS_FACEDOWN) !== 0;
const toFaceup =
(message.currentPosition & OcgcoreCommonConstants.POS_FACEUP) !== 0;
client.readyTrap = isSpellTrapZone && fromFacedown && toFaceup;
return;
}
if (
!(message instanceof YGOProMsgUpdateCard) &&
!(message instanceof YGOProMsgWaiting)
) {
client.readyTrap = false;
}
}
protected isEnabled() {
return this.enabled;
}
}
declare module '../../client' {
interface Client {
readyTrap?: boolean;
}
}
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { Context } from '../../app';
import { cloneJson, isObjectRecord } from './resource-util';
export class FileResourceService {
private logger = this.ctx.createLogger(this.constructor.name);
private readonly dataDir = path.resolve(process.cwd(), 'data');
private readonly defaultDataPath = path.resolve(
process.cwd(),
'resource',
'default_data.json',
);
private initialized = false;
private initTask?: Promise<void>;
private dataByName = new Map<string, Record<string, unknown>>();
private dataPathByName = new Map<string, string>();
constructor(private ctx: Context) {}
async init() {
await this.ensureInitialized();
}
async ensureInitialized() {
if (this.initialized) {
return;
}
if (!this.initTask) {
this.initTask = this.doInit();
}
await this.initTask;
}
getDataOrEmpty<T extends object>(name: string, emptyData: T): T {
if (!this.initialized) {
return cloneJson(emptyData);
}
const data = this.dataByName.get(name);
if (!data) {
return cloneJson(emptyData);
}
return cloneJson(data as T);
}
async saveData(name: string, data: Record<string, unknown>) {
await this.ensureInitialized();
const dataPath = this.dataPathByName.get(name);
if (!dataPath) {
return false;
}
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), 'utf-8');
this.dataByName.set(name, cloneJson(data));
return true;
}
private async doInit() {
await fs.mkdir(this.dataDir, { recursive: true });
const defaultData = await this.readJsonFile(this.defaultDataPath);
if (!isObjectRecord(defaultData)) {
this.logger.warn(
{ defaultDataPath: this.defaultDataPath },
'Failed to load resource/default_data.json',
);
this.initialized = true;
return;
}
for (const [name, data] of Object.entries(defaultData)) {
if (!isObjectRecord(data)) {
continue;
}
const resolvedData = this.resolveDefaultData(name, data);
const dataPath = this.resolveDataPath(name, data.file);
this.dataPathByName.set(name, dataPath);
const localData = await this.readJsonFile(dataPath);
if (isObjectRecord(localData)) {
this.dataByName.set(name, localData);
continue;
}
await fs.writeFile(
dataPath,
JSON.stringify(resolvedData, null, 2),
'utf-8',
);
this.dataByName.set(name, resolvedData);
}
this.initialized = true;
this.logger.info(
{ count: this.dataByName.size, dataDir: this.dataDir },
'File resources initialized',
);
}
private resolveDefaultData(name: string, data: Record<string, unknown>) {
const nextData = cloneJson(data);
const fileName = this.resolveFileName(name, data.file);
nextData.file = `./data/${fileName}`;
return nextData;
}
private resolveDataPath(name: string, filePath: unknown) {
const fileName = this.resolveFileName(name, filePath);
return path.join(this.dataDir, fileName);
}
private resolveFileName(name: string, filePath: unknown) {
if (typeof filePath === 'string' && filePath.trim()) {
return path.basename(filePath);
}
return `${name}.json`;
}
private async readJsonFile(filePath: string): Promise<unknown> {
try {
const text = await fs.readFile(filePath, 'utf-8');
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
}
export * from './module';
export * from './types';
export * from './file-resource-service';
export * from './tips-provider';
export * from './words-provider';
export * from './dialogues-provider';
export * from './badword-provider';
import { createAppContext } from 'nfkit';
import { ContextState } from '../../app';
import { BadwordProvider } from './badword-provider';
import { DialoguesProvider } from './dialogues-provider';
import { FileResourceService } from './file-resource-service';
import { TipsProvider } from './tips-provider';
import { WordsProvider } from './words-provider';
export const ResourceModule = createAppContext<ContextState>()
.provide(FileResourceService)
.provide(TipsProvider)
.provide(WordsProvider)
.provide(DialoguesProvider)
.provide(BadwordProvider)
.define();
export function isObjectRecord(
value: unknown,
): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
export function cloneJson<T>(value: T): T {
if (value == null) {
return value;
}
if (typeof globalThis.structuredClone === 'function') {
return globalThis.structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
import { ChatColor } from 'ygopro-msg-encode';
import { Context } from '../../app';
import { Chnroute, Client } from '../../client';
import { DuelStage, OnRoomDuelStart, Room, RoomManager } from '../../room';
import { ValueContainer } from '../../utility/value-container';
import { pickRandom } from '../../utility/pick-random';
import { BaseResourceProvider } from './base-resource-provider';
import { EMPTY_TIPS_DATA, TipsData } from './types';
export class TipsLookup extends ValueContainer<string[]> {
constructor(
public room: Room,
public client: Client,
) {
super([]);
}
}
export class TipsProvider extends BaseResourceProvider<TipsData> {
enabled = this.ctx.config.getBoolean('ENABLE_TIPS');
private splitZh = this.ctx.config.getBoolean('TIPS_SPLIT_ZH');
private prefix = this.ctx.config.getString('TIPS_PREFIX');
private intervalMs = Math.max(
0,
this.ctx.config.getInt('TIPS_INTERVAL') || 0,
);
private intervalIngameMs = Math.max(
0,
this.ctx.config.getInt('TIPS_INTERVAL_INGAME') || 0,
);
private chnroute = this.ctx.get(() => Chnroute);
private roomManager = this.ctx.get(() => RoomManager);
private timersRegistered = false;
constructor(ctx: Context) {
super(ctx, {
resourceName: 'tips',
emptyData: EMPTY_TIPS_DATA,
});
if (!this.enabled) {
return;
}
this.ctx.middleware(OnRoomDuelStart, async (event, _client, next) => {
await this.sendRandomTipToRoom(event.room);
return next();
});
}
async init() {
await super.init();
this.registerAutoTipTimers();
}
private registerAutoTipTimers() {
if (!this.enabled || this.timersRegistered) {
return;
}
this.timersRegistered = true;
if (this.intervalMs > 0) {
setInterval(() => {
this.sendTipsByDuelState(false).catch((error) => {
this.logger.warn(
{ error: (error as Error).toString() },
'Failed auto-sending non-duel tips',
);
});
}, this.intervalMs);
}
if (this.intervalIngameMs > 0) {
setInterval(() => {
this.sendTipsByDuelState(true).catch((error) => {
this.logger.warn(
{ error: (error as Error).toString() },
'Failed auto-sending ingame tips',
);
});
}, this.intervalIngameMs);
}
}
async refreshResources() {
if (!this.enabled) {
return false;
}
return this.refreshFromRemote();
}
async getRandomTip(room: Room, client: Client) {
if (!this.enabled) {
return undefined;
}
const event = await this.ctx.dispatch(new TipsLookup(room, client), client);
const tips = (event?.value || []).filter((tip) => !!tip);
return pickRandom(tips);
}
async sendRandomTip(client: Client, room?: Room) {
if (!this.enabled) {
return false;
}
const targetRoom = room || this.resolveClientRoom(client);
if (!targetRoom) {
return false;
}
try {
const tip = await this.getRandomTip(targetRoom, client);
if (!tip) {
return false;
}
await client.sendChat(`${this.prefix}${tip}`, ChatColor.LIGHTBLUE);
return true;
} catch (error) {
this.logger.warn(
{
roomName: targetRoom.name,
clientName: client.name,
error: (error as Error).toString(),
},
'Failed sending random tip',
);
return false;
}
}
async sendRandomTipToRoom(room: Room) {
if (!this.enabled) {
return false;
}
const tasks = room.allPlayers.map((player) =>
this.sendRandomTip(player, room).catch((error) => {
this.logger.warn(
{
roomName: room.name,
clientName: player.name,
error: (error as Error).toString(),
},
'Failed sending random tip to room player',
);
return false;
}),
);
await Promise.all(tasks);
return true;
}
protected registerLookupMiddleware() {
this.ctx.middleware(TipsLookup, async (event, _client, next) => {
const data = this.getResourceData();
const locale = this.chnroute.getLocale(event.client.ip).toLowerCase();
const isZh = locale.startsWith('zh');
if (this.splitZh && isZh && data.tips_zh.length) {
event.use(data.tips_zh);
} else {
event.use(data.tips);
}
return next();
});
}
protected getRemoteLoadEntries() {
return [
{
field: 'tips' as const,
url: this.ctx.config.getString('TIPS_GET').trim(),
},
{
field: 'tips_zh' as const,
url: this.ctx.config.getString('TIPS_GET_ZH').trim(),
},
];
}
protected isEnabled() {
return this.enabled;
}
private resolveClientRoom(client: Client) {
if (!client.roomName) {
return undefined;
}
return this.roomManager.findByName(client.roomName);
}
private async sendTipsByDuelState(dueling: boolean) {
if (!this.enabled) {
return;
}
const rooms = this.roomManager
.allRooms()
.filter((room) => !room.finalizing && room.duelStage !== DuelStage.End)
.filter((room) =>
dueling
? room.duelStage === DuelStage.Dueling
: room.duelStage !== DuelStage.Dueling,
);
await Promise.all(
rooms.map((room) =>
this.sendRandomTipToRoom(room).catch((error) => {
this.logger.warn(
{
roomName: room.name,
error: (error as Error).toString(),
},
'Failed auto-sending tips to room',
);
return false;
}),
),
);
}
}
export interface TipsData {
file: string;
tips: string[];
tips_zh: string[];
}
export interface WordsData {
file: string;
words: Record<string, string[]>;
}
export interface DialoguesData {
file: string;
dialogues: Record<string, string[]>;
dialogues_custom: Record<string, string[]>;
}
export interface BadwordsData {
file: string;
level0: string[];
level1: string[];
level2: string[];
level3: string[];
}
export const EMPTY_TIPS_DATA: TipsData = {
file: './data/tips.json',
tips: [],
tips_zh: [],
};
export const EMPTY_WORDS_DATA: WordsData = {
file: './data/words.json',
words: {},
};
export const EMPTY_DIALOGUES_DATA: DialoguesData = {
file: './data/dialogues.json',
dialogues: {},
dialogues_custom: {},
};
export const EMPTY_BADWORDS_DATA: BadwordsData = {
file: './data/badwords.json',
level0: [],
level1: [],
level2: [],
level3: [],
};
import { ChatColor } from 'ygopro-msg-encode';
import { Context } from '../../app';
import { Client } from '../../client';
import { OnRoomJoin, Room } from '../../room';
import { ValueContainer } from '../../utility/value-container';
import { pickRandom } from '../../utility/pick-random';
import { BaseResourceProvider } from './base-resource-provider';
import { EMPTY_WORDS_DATA, WordsData } from './types';
export class WordsLookup extends ValueContainer<string[]> {
constructor(
public room: Room,
public client: Client,
) {
super([]);
}
}
export class WordsProvider extends BaseResourceProvider<WordsData> {
enabled = this.ctx.config.getBoolean('ENABLE_WORDS');
constructor(ctx: Context) {
super(ctx, {
resourceName: 'words',
emptyData: EMPTY_WORDS_DATA,
});
if (!this.enabled) {
return;
}
this.ctx.middleware(OnRoomJoin, async (event, client, next) => {
const line = await this.getRandomWords(event.room, client);
if (line) {
await event.room.sendChat(line, ChatColor.PINK);
}
return next();
});
}
async refreshResources() {
if (!this.enabled) {
return false;
}
return this.refreshFromRemote();
}
async getRandomWords(room: Room, client: Client) {
if (!this.enabled) {
return undefined;
}
const event = await this.ctx.dispatch(
new WordsLookup(room, client),
client,
);
const words = (event?.value || []).filter((line) => !!line);
return pickRandom(words);
}
protected registerLookupMiddleware() {
this.ctx.middleware(WordsLookup, async (event, _client, next) => {
const data = this.getResourceData();
event.use(data.words[event.client.name] || []);
return next();
});
}
protected getRemoteLoadEntries() {
return [
{
field: 'words' as const,
url: this.ctx.config.getString('WORDS_GET').trim(),
},
];
}
protected isEnabled() {
return this.enabled;
}
}
...@@ -129,7 +129,11 @@ export class WaitForPlayerProvider { ...@@ -129,7 +129,11 @@ export class WaitForPlayerProvider {
YGOProCtosUpdateDeck, YGOProCtosUpdateDeck,
async (_msg, client, next) => { async (_msg, client, next) => {
const room = this.getRoom(client); const room = this.getRoom(client);
if (!room || !this.hasTickForRoom(room) || room.duelStage !== DuelStage.Begin) { if (
!room ||
!this.hasTickForRoom(room) ||
room.duelStage !== DuelStage.Begin
) {
return next(); return next();
} }
try { try {
...@@ -349,7 +353,8 @@ export class WaitForPlayerProvider { ...@@ -349,7 +353,8 @@ export class WaitForPlayerProvider {
if (room.waitForPlayerReadyTargetPos !== target.pos) { if (room.waitForPlayerReadyTargetPos !== target.pos) {
room.waitForPlayerReadyTargetPos = target.pos; room.waitForPlayerReadyTargetPos = target.pos;
room.waitForPlayerReadyDeadlineMs = nowMs + runtime.options.raadyTimeoutMs; room.waitForPlayerReadyDeadlineMs =
nowMs + runtime.options.raadyTimeoutMs;
room.waitForPlayerReadyWarnRemain = undefined; room.waitForPlayerReadyWarnRemain = undefined;
} }
...@@ -385,7 +390,10 @@ export class WaitForPlayerProvider { ...@@ -385,7 +390,10 @@ export class WaitForPlayerProvider {
) { ) {
return; return;
} }
await room.sendChat(`${latestTarget.name} #{kicked_by_system}`, ChatColor.RED); await room.sendChat(
`${latestTarget.name} #{kicked_by_system}`,
ChatColor.RED,
);
latestTarget.disconnect(); latestTarget.disconnect();
} }
......
import type { RequestWindbotJoinOptions } from './types'; import type { RequestWindbotJoinOptions } from './types';
import { parseRulePrefix } from './parse-rule-prefix'; import { parseRulePrefix } from './parse-rule-prefix';
export const parseWindbotOptions = (name: string): RequestWindbotJoinOptions => { export const parseWindbotOptions = (
name: string,
): RequestWindbotJoinOptions => {
const rule = parseRulePrefix(name); const rule = parseRulePrefix(name);
const options: RequestWindbotJoinOptions = {}; const options: RequestWindbotJoinOptions = {};
if (!rule) { if (!rule) {
......
...@@ -102,7 +102,10 @@ export class WindBotProvider { ...@@ -102,7 +102,10 @@ export class WindBotProvider {
consumeJoinToken(token: string) { consumeJoinToken(token: string) {
const data = this.tokenDataMap.get(token); const data = this.tokenDataMap.get(token);
this.logger.debug({ roomName: data?.roomName, token }, 'Consuming windbot join token'); this.logger.debug(
{ roomName: data?.roomName, token },
'Consuming windbot join token',
);
if (!data) { if (!data) {
return undefined; return undefined;
} }
......
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { BadwordProvider } from '../feats/resource';
export class BadwordPlayerInfoChecker {
private logger = this.ctx.createLogger(this.constructor.name);
private badwordProvider = this.ctx.get(() => BadwordProvider);
constructor(private ctx: Context) {
if (!this.badwordProvider.enabled) {
return;
}
this.ctx.middleware(YGOProCtosJoinGame, async (_msg, client, next) => {
if (client.isInternal) {
return next();
}
const userNameLevel = await this.badwordProvider.getBadwordLevel(
client.name,
undefined,
client,
);
if (userNameLevel >= 1) {
this.logger.warn(
{ level: userNameLevel, name: client.name, ip: client.ip },
'Blocked join due to bad username',
);
return client.die(`#{bad_name_level${userNameLevel}}`, ChatColor.RED);
}
return next();
});
}
}
...@@ -6,11 +6,13 @@ import { JoinRoom } from './join-room'; ...@@ -6,11 +6,13 @@ import { JoinRoom } from './join-room';
import { JoinFallback } from './fallback'; 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';
export const JoinHandlerModule = createAppContext<ContextState>() export const JoinHandlerModule = createAppContext<ContextState>()
.provide(ClientVersionCheck) .provide(ClientVersionCheck)
.provide(JoinPrechecks) .provide(JoinPrechecks)
.provide(JoinWindbotToken) .provide(JoinWindbotToken)
.provide(BadwordPlayerInfoChecker)
.provide(RandomDuelJoinHandler) .provide(RandomDuelJoinHandler)
.provide(JoinWindbotAi) .provide(JoinWindbotAi)
.provide(JoinRoom) .provide(JoinRoom)
......
...@@ -15,7 +15,8 @@ const setTagBit = (mode: number, isTag: boolean): number => ...@@ -15,7 +15,8 @@ const setTagBit = (mode: number, isTag: boolean): number =>
isTag ? mode | TAG_MODE_BIT : mode & ~TAG_MODE_BIT; isTag ? mode | TAG_MODE_BIT : mode & ~TAG_MODE_BIT;
const setWinMatchCountBits = (mode: number, winMatchCount: number): number => { const setWinMatchCountBits = (mode: number, winMatchCount: number): number => {
const nonTagBits = encodeWinMatchCountBits(winMatchCount) & MATCH_WINS_BITS_MASK; const nonTagBits =
encodeWinMatchCountBits(winMatchCount) & MATCH_WINS_BITS_MASK;
return (mode & TAG_MODE_BIT) | nonTagBits; return (mode & TAG_MODE_BIT) | nonTagBits;
}; };
......
export * from './room'; export * from './room';
export * from './room-manager'; export * from './room-manager';
export * from './duel-stage'; export * from './duel-stage';
export * from './room-event/on-room-create';
export * from './room-event/on-room-join';
export * from './room-event/on-room-finalize'; export * from './room-event/on-room-finalize';
export * from './room-event/on-room-join-observer';
export * from './room-event/on-room-leave';
export * from './room-event/on-room-leave-observer';
export * from './room-event/on-room-finger'; export * from './room-event/on-room-finger';
export * from './room-event/on-room-duel-start';
export * from './room-event/on-room-game-start'; export * from './room-event/on-room-game-start';
export * from './room-event/on-room-join-player'; export * from './room-event/on-room-join-player';
export * from './room-event/on-room-leave-player'; export * from './room-event/on-room-leave-player';
......
import { RoomEvent } from "./room-event"; import { RoomEvent } from './room-event';
export class OnRoomCreate extends RoomEvent {} export class OnRoomCreate extends RoomEvent {}
import { RoomEvent } from "./room-event"; import { RoomEvent } from './room-event';
export class OnRoomFinalize extends RoomEvent {} export class OnRoomFinalize extends RoomEvent {}
...@@ -3,7 +3,10 @@ import { Room } from '../room'; ...@@ -3,7 +3,10 @@ import { Room } from '../room';
import { RoomEvent } from './room-event'; import { RoomEvent } from './room-event';
export class OnRoomFinger extends RoomEvent { export class OnRoomFinger extends RoomEvent {
constructor(room: Room, public fingerPlayers: [Client, Client]) { constructor(
room: Room,
public fingerPlayers: [Client, Client],
) {
super(room); super(room);
} }
} }
...@@ -3,7 +3,10 @@ import { Room } from '../room'; ...@@ -3,7 +3,10 @@ import { Room } from '../room';
import { RoomEvent } from './room-event'; import { RoomEvent } from './room-event';
export class OnRoomSelectTp extends RoomEvent { export class OnRoomSelectTp extends RoomEvent {
constructor(room: Room, public selector: Client) { constructor(
room: Room,
public selector: Client,
) {
super(room); super(room);
} }
} }
...@@ -965,7 +965,10 @@ export class Room { ...@@ -965,7 +965,10 @@ export class Room {
} }
duelPos0.send(new YGOProStocSelectHand()); duelPos0.send(new YGOProStocSelectHand());
duelPos1.send(new YGOProStocSelectHand()); duelPos1.send(new YGOProStocSelectHand());
await this.ctx.dispatch(new OnRoomFinger(this, [duelPos0, duelPos1]), duelPos0); await this.ctx.dispatch(
new OnRoomFinger(this, [duelPos0, duelPos1]),
duelPos0,
);
} }
@RoomMethod({ allowInDuelStages: DuelStage.Finger }) @RoomMethod({ allowInDuelStages: DuelStage.Finger })
......
...@@ -4,6 +4,8 @@ import { configurer } from '../config'; ...@@ -4,6 +4,8 @@ import { configurer } from '../config';
async function main(): Promise<void> { async function main(): Promise<void> {
const exampleConfig = configurer.generateExampleObject(); const exampleConfig = configurer.generateExampleObject();
// Keep trailing space for tips prefix to match srvpro behavior.
exampleConfig.tipsPrefix = 'Tip: ';
const output = yaml.stringify(exampleConfig); const output = yaml.stringify(exampleConfig);
await fs.promises.writeFile('./config.example.yaml', output, 'utf-8'); await fs.promises.writeFile('./config.example.yaml', output, 'utf-8');
console.log('Generated config.example.yaml'); console.log('Generated config.example.yaml');
......
...@@ -6,6 +6,8 @@ import { ConfigService } from './config'; ...@@ -6,6 +6,8 @@ import { ConfigService } from './config';
export class HttpClient { export class HttpClient {
constructor(private ctx: AppContext) {} constructor(private ctx: AppContext) {}
http = axios.create({ http = axios.create({
...useProxy(this.ctx.get(() => ConfigService).config.getString('USE_PROXY')), ...useProxy(
this.ctx.get(() => ConfigService).config.getString('USE_PROXY'),
),
}); });
} }
...@@ -23,7 +23,7 @@ export class MiddlewareRx { ...@@ -23,7 +23,7 @@ export class MiddlewareRx {
this.emitter.middleware(cls, handler, prior); this.emitter.middleware(cls, handler, prior);
return () => { return () => {
this.emitter.removeMiddleware(cls, handler); this.emitter.removeMiddleware(cls, handler);
} };
}); });
} }
} }
export function escapeRegExp(text: string) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function pickRandom<T>(items: T[]) {
if (!items.length) {
return undefined;
}
const index = Math.floor(Math.random() * items.length);
return items[index];
}
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