Commit 19daed1d authored by nanahira's avatar nanahira

4 file related modules

parent 5f90cc89
......@@ -32,6 +32,20 @@ deckMaxCopies: 3
ocgcoreDebugLog: 0
ocgcoreWasmPath: ""
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
windbotBotlist: ./windbot/bots.json
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 {
// ws/reverse-ws should already have IP from connection metadata, skip overwrite
return next();
}
await this.ctx.get(() => IpResolver).setClientIp(
client,
msg.real_ip === '0.0.0.0' ? undefined : msg.real_ip,
);
await this.ctx
.get(() => IpResolver)
.setClientIp(
client,
msg.real_ip === '0.0.0.0' ? undefined : msg.real_ip,
);
client.hostname = msg.hostname?.split(':')[0] || '';
return next();
})
......
import { filter, merge, Observable, of, Subject } from 'rxjs';
import { map, share, take, takeUntil, tap } from 'rxjs/operators';
import {
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 {
YGOProCtos,
......@@ -113,7 +130,7 @@ export class Client {
payload: JSON.stringify(logMsg),
},
'Sending message to client',
)
);
try {
await this._send(Buffer.from(data.toFullPayload()));
} catch (e) {
......@@ -129,23 +146,43 @@ export class Client {
if (this.isInternal) {
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) {
msg = `[Server]: ${msg}`;
line = `[Server]: ${line}`;
}
return this.send(
new YGOProStocChat().fromPartial({
msg:
type <= NetPlayerType.OBSERVER
? msg
: await this.ctx
.get(() => I18nService)
.translate(
this.ctx.get(() => Chnroute).getLocale(this.ip),
msg,
),
player_type: type,
}),
);
if (type > NetPlayerType.OBSERVER) {
line = String(
await this.ctx.get(() => I18nService).translate(locale, line),
);
}
return line;
}
async die(msg?: string, type = ChatColor.BABYBLUE) {
......
export * from './client';
export * from './client-handler';
export * from './chnroute';
......@@ -71,7 +71,9 @@ export class WsServer {
req: IncomingMessage,
): Promise<void> {
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;
}
client.hostname = req.headers.host?.split(':')[0] || '';
......
......@@ -73,6 +73,39 @@ export const defaultConfig = {
OCGCORE_WASM_PATH: '',
// Welcome message sent when players join. Format: plain string.
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.
// Boolean parse rule (default true): only '0'/'false'/'null' => false, otherwise true.
ENABLE_WINDBOT: '0',
......
......@@ -8,6 +8,12 @@ export const TRANSLATIONS = {
version_polyfilled:
'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_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.',
replay_hint_part1: 'Sending the replay of the duel number ',
replay_hint_part2: '.',
......@@ -34,7 +40,8 @@ export const TRANSLATIONS = {
side_remain_part1: 'Remaining side changing time: ',
side_remain_part2: ' minutes.',
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.',
kick_count_down:
' seconds later this player will be evicted for not getting ready or starting the game.',
......@@ -49,6 +56,8 @@ export const TRANSLATIONS = {
random_duel_enter_room_tag:
'Tag mode room. Password S for single mode, M for match mode.',
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': {
update_required: '请更新你的客户端版本',
......@@ -58,6 +67,12 @@ export const TRANSLATIONS = {
version_polyfilled:
'已为当前版本启用临时兼容模式。建议尽快更新游戏,以避免后续兼容性问题。',
bad_user_name: '请输入正确的用户名',
bad_name_level1: '你的用户名包含敏感词,请修改后重试。',
bad_name_level2: '你的用户名包含敏感词,请修改后重试。',
bad_name_level3: '你的用户名包含敏感词,请修改后重试。',
bad_roomname_level1: '房间名包含敏感词,请修改后重试。',
bad_roomname_level2: '房间名包含敏感词,请修改后重试。',
bad_roomname_level3: '房间名包含敏感词,请修改后重试。',
blank_room_name: '房间名不能为空,请在主机密码处填写房间名',
replay_hint_part1: '正在发送第',
replay_hint_part2: '局决斗的录像。',
......@@ -96,5 +111,7 @@ export const TRANSLATIONS = {
random_duel_enter_room_tag:
'您进入了双打模式房间,密码输入 S 进入单局模式,输入 M 进入比赛模式。',
chat_disabled: '本房间禁止聊天。',
chat_warn_level1: '请注意发言,敏感词已被替换。',
chat_warn_level2: '消息包含敏感词,已被拦截。',
},
};
......@@ -8,8 +8,10 @@ import { WindbotModule } from './windbot';
import { SideTimeout } from './side-timeout';
import { RandomDuelModule } from './random-duel';
import { WaitForPlayerProvider } from './wait-for-player-provider';
import { ResourceModule } from './resource';
export const FeatsModule = createAppContext<ContextState>()
.use(ResourceModule)
.provide(ClientVersionCheck)
.provide(Welcome)
.provide(PlayerStatusNotify)
......
......@@ -2,3 +2,4 @@ export * from './client-version-check';
export * from './random-duel';
export * from './reconnect';
export * from './wait-for-player-provider';
export * from './resource';
......@@ -60,8 +60,7 @@ export class RandomDuelProvider {
private waitForPlayerReadyTimeoutMs =
Math.max(0, this.ctx.config.getInt('RANDOM_DUEL_READY_TIME') || 0) * 1000;
private waitForPlayerHangTimeoutMs =
Math.max(0, this.ctx.config.getInt('RANDOM_DUEL_HANG_TIMEOUT') || 0) *
1000;
Math.max(0, this.ctx.config.getInt('RANDOM_DUEL_HANG_TIMEOUT') || 0) * 1000;
private waitForPlayerLongAgoBackoffMs = Math.max(
0,
this.waitForPlayerHangTimeoutMs - 19_000,
......@@ -134,6 +133,7 @@ export class RandomDuelProvider {
if (found) {
const foundType = found.randomType || type || this.defaultType;
found.randomType = foundType;
found.checkChatBadword = true;
found.noHost = true;
found.randomDuelMaxPlayer = this.resolveRandomDuelMaxPlayer(foundType);
found.welcome = '#{random_duel_enter_room_waiting}';
......@@ -148,6 +148,7 @@ export class RandomDuelProvider {
}
const room = await this.roomManager.findOrCreateByName(roomName);
room.randomType = randomType;
room.checkChatBadword = true;
room.noHost = true;
room.randomDuelMaxPlayer = this.resolveRandomDuelMaxPlayer(randomType);
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 {
YGOProCtosUpdateDeck,
async (_msg, client, next) => {
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();
}
try {
......@@ -349,7 +353,8 @@ export class WaitForPlayerProvider {
if (room.waitForPlayerReadyTargetPos !== target.pos) {
room.waitForPlayerReadyTargetPos = target.pos;
room.waitForPlayerReadyDeadlineMs = nowMs + runtime.options.raadyTimeoutMs;
room.waitForPlayerReadyDeadlineMs =
nowMs + runtime.options.raadyTimeoutMs;
room.waitForPlayerReadyWarnRemain = undefined;
}
......@@ -385,7 +390,10 @@ export class WaitForPlayerProvider {
) {
return;
}
await room.sendChat(`${latestTarget.name} #{kicked_by_system}`, ChatColor.RED);
await room.sendChat(
`${latestTarget.name} #{kicked_by_system}`,
ChatColor.RED,
);
latestTarget.disconnect();
}
......
import type { RequestWindbotJoinOptions } from './types';
import { parseRulePrefix } from './parse-rule-prefix';
export const parseWindbotOptions = (name: string): RequestWindbotJoinOptions => {
export const parseWindbotOptions = (
name: string,
): RequestWindbotJoinOptions => {
const rule = parseRulePrefix(name);
const options: RequestWindbotJoinOptions = {};
if (!rule) {
......
......@@ -102,7 +102,10 @@ export class WindBotProvider {
consumeJoinToken(token: string) {
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) {
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';
import { JoinFallback } from './fallback';
import { JoinPrechecks } from './join-prechecks';
import { RandomDuelJoinHandler } from './random-duel-join-handler';
import { BadwordPlayerInfoChecker } from './badword-player-info-checker';
export const JoinHandlerModule = createAppContext<ContextState>()
.provide(ClientVersionCheck)
.provide(JoinPrechecks)
.provide(JoinWindbotToken)
.provide(BadwordPlayerInfoChecker)
.provide(RandomDuelJoinHandler)
.provide(JoinWindbotAi)
.provide(JoinRoom)
......
......@@ -15,7 +15,8 @@ const setTagBit = (mode: number, isTag: boolean): number =>
isTag ? mode | TAG_MODE_BIT : mode & ~TAG_MODE_BIT;
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;
};
......
export * from './room';
export * from './room-manager';
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-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-duel-start';
export * from './room-event/on-room-game-start';
export * from './room-event/on-room-join-player';
export * from './room-event/on-room-leave-player';
......
import { RoomEvent } from "./room-event";
import { RoomEvent } from './room-event';
export class OnRoomCreate extends RoomEvent {}
import { RoomEvent } from "./room-event";
import { RoomEvent } from './room-event';
export class OnRoomFinalize extends RoomEvent {}
......@@ -3,7 +3,10 @@ import { Room } from '../room';
import { RoomEvent } from './room-event';
export class OnRoomFinger extends RoomEvent {
constructor(room: Room, public fingerPlayers: [Client, Client]) {
constructor(
room: Room,
public fingerPlayers: [Client, Client],
) {
super(room);
}
}
......@@ -3,7 +3,10 @@ import { Room } from '../room';
import { RoomEvent } from './room-event';
export class OnRoomSelectTp extends RoomEvent {
constructor(room: Room, public selector: Client) {
constructor(
room: Room,
public selector: Client,
) {
super(room);
}
}
......@@ -965,7 +965,10 @@ export class Room {
}
duelPos0.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 })
......
......@@ -4,6 +4,8 @@ import { configurer } from '../config';
async function main(): Promise<void> {
const exampleConfig = configurer.generateExampleObject();
// Keep trailing space for tips prefix to match srvpro behavior.
exampleConfig.tipsPrefix = 'Tip: ';
const output = yaml.stringify(exampleConfig);
await fs.promises.writeFile('./config.example.yaml', output, 'utf-8');
console.log('Generated config.example.yaml');
......
......@@ -6,6 +6,8 @@ import { ConfigService } from './config';
export class HttpClient {
constructor(private ctx: AppContext) {}
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 {
this.emitter.middleware(cls, handler, prior);
return () => {
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