Commit 9c10c96b authored by nanahira's avatar nanahira

windbot

parent 4392e08d
...@@ -26,6 +26,11 @@ deckMaxCopies: 3 ...@@ -26,6 +26,11 @@ deckMaxCopies: 3
ocgcoreDebugLog: 0 ocgcoreDebugLog: 0
ocgcoreWasmPath: "" ocgcoreWasmPath: ""
welcome: "" welcome: ""
enableWindbot: 1
windbotBotlist: ./windbot/bots.json
windbotSpawn: 0
windbotEndpoint: http://127.0.0.1:2399
windbotMyIp: 127.0.0.1
enableReconnect: 1 enableReconnect: 1
reconnectTimeout: 180000 reconnectTimeout: 180000
hostinfoLflist: 0 hostinfoLflist: 0
......
...@@ -86,6 +86,7 @@ export class ClientHandler { ...@@ -86,6 +86,7 @@ export class ClientHandler {
{ {
msgName: msg.constructor.name, msgName: msg.constructor.name,
client: client.name || client.loggingIp(), client: client.name || client.loggingIp(),
payload: JSON.stringify(msg),
}, },
'Received client message', 'Received client message',
); );
......
...@@ -104,6 +104,14 @@ export class Client { ...@@ -104,6 +104,14 @@ export class Client {
return; return;
} }
return this.sendQueue.add(async () => { return this.sendQueue.add(async () => {
this.logger.debug(
{
msgName: data.constructor.name,
client: this.name || this.loggingIp(),
payload: JSON.stringify(data),
},
'Sending message to client',
)
try { try {
await this._send(Buffer.from(data.toFullPayload())); await this._send(Buffer.from(data.toFullPayload()));
} catch (e) { } catch (e) {
......
...@@ -60,6 +60,19 @@ export const defaultConfig = { ...@@ -60,6 +60,19 @@ 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 windbot feature.
// Boolean parse rule (default true): only '0'/'false'/'null' => false, otherwise true.
ENABLE_WINDBOT: '0',
// Windbot bot list path. Format: filesystem path string.
WINDBOT_BOTLIST: './windbot/bots.json',
// Spawn built-in windbot server mode process.
// Effective only when ENABLE_WINDBOT is true.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
WINDBOT_SPAWN: '0',
// Windbot HTTP endpoint. Format: URL string.
WINDBOT_ENDPOINT: 'http://127.0.0.1:2399',
// Public IP/host that windbot uses to connect back to this server.
WINDBOT_MY_IP: '127.0.0.1',
// Enable reconnect feature. // Enable reconnect 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.
// Note: with default-true parsing, empty string is treated as true. // Note: with default-true parsing, empty string is treated as true.
......
...@@ -7,6 +7,7 @@ export const TRANSLATIONS = { ...@@ -7,6 +7,7 @@ export const TRANSLATIONS = {
'Your client version is not fully supported. Please rejoin to enable temporary compatibility mode. For the best experience, we recommend updating your game to the latest version.', 'Your client version is not fully supported. Please rejoin to enable temporary compatibility mode. For the best experience, we recommend updating your game to the latest version.',
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',
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: '.',
...@@ -17,6 +18,12 @@ export const TRANSLATIONS = { ...@@ -17,6 +18,12 @@ export const TRANSLATIONS = {
reconnect_to_game: 'reconnected to the game', reconnect_to_game: 'reconnected to the game',
reconnect_kicked: reconnect_kicked:
"You are kicked out because you're logged in on other devices.", "You are kicked out because you're logged in on other devices.",
windbot_deck_not_found: 'Oops, AI or Deck not found',
windbot_name_too_long:
'Error occurs, please create a new game and enter /ai to summon an AI.',
create_room_failed: 'Game creation failed, please try again later.',
invalid_password_not_found: 'Password invalid (Not Found)',
add_windbot_failed: 'AI addition failed, enter /ai again.',
pre_reconnecting_to_room: pre_reconnecting_to_room:
'You will be reconnected to your previous game. Please pick your previous deck.', 'You will be reconnected to your previous game. Please pick your previous deck.',
deck_incorrect_reconnect: 'Please pick your previous deck.', deck_incorrect_reconnect: 'Please pick your previous deck.',
...@@ -30,6 +37,7 @@ export const TRANSLATIONS = { ...@@ -30,6 +37,7 @@ export const TRANSLATIONS = {
'当前客户端版本暂未完全支持。请重新加入以启用临时兼容模式。为获得更佳体验,建议尽快更新游戏版本。', '当前客户端版本暂未完全支持。请重新加入以启用临时兼容模式。为获得更佳体验,建议尽快更新游戏版本。',
version_polyfilled: version_polyfilled:
'已为当前版本启用临时兼容模式。建议尽快更新游戏,以避免后续兼容性问题。', '已为当前版本启用临时兼容模式。建议尽快更新游戏,以避免后续兼容性问题。',
bad_user_name: '请输入正确的用户名',
blank_room_name: '房间名不能为空,请在主机密码处填写房间名', blank_room_name: '房间名不能为空,请在主机密码处填写房间名',
replay_hint_part1: '正在发送第', replay_hint_part1: '正在发送第',
replay_hint_part2: '局决斗的录像。', replay_hint_part2: '局决斗的录像。',
...@@ -39,6 +47,11 @@ export const TRANSLATIONS = { ...@@ -39,6 +47,11 @@ export const TRANSLATIONS = {
disconnect_from_game: '断开了连接', disconnect_from_game: '断开了连接',
reconnect_to_game: '重新连接了', reconnect_to_game: '重新连接了',
reconnect_kicked: '你的账号已经在其他设备登录,你被迫下线。', reconnect_kicked: '你的账号已经在其他设备登录,你被迫下线。',
windbot_deck_not_found: '未找到该AI角色或卡组',
windbot_name_too_long: 'AI房间名过长,请在建立房间后输入 /ai 来添加AI',
create_room_failed: '建立房间失败,请重试',
invalid_password_not_found: '主机密码不正确 (Not Found)',
add_windbot_failed: '添加AI失败,可尝试输入 /ai 重新添加',
pre_reconnecting_to_room: pre_reconnecting_to_room:
'你有未完成的对局,即将重新连接,请选择你在本局决斗中使用的卡组并准备。', '你有未完成的对局,即将重新连接,请选择你在本局决斗中使用的卡组并准备。',
deck_incorrect_reconnect: '请选择你在本局决斗中使用的卡组。', deck_incorrect_reconnect: '请选择你在本局决斗中使用的卡组。',
......
...@@ -4,9 +4,11 @@ import { ContextState } from '../app'; ...@@ -4,9 +4,11 @@ import { ContextState } from '../app';
import { Welcome } from './welcome'; import { Welcome } from './welcome';
import { PlayerStatusNotify } from './player-status-notify'; import { PlayerStatusNotify } from './player-status-notify';
import { Reconnect } from './reconnect'; import { Reconnect } from './reconnect';
import { WindbotModule } from '../windbot';
export const FeatsModule = createAppContext<ContextState>() export const FeatsModule = createAppContext<ContextState>()
.provide(ClientVersionCheck) .provide(ClientVersionCheck)
.use(WindbotModule)
.provide(Welcome) .provide(Welcome)
.provide(PlayerStatusNotify) .provide(PlayerStatusNotify)
.provide(Reconnect) .provide(Reconnect)
......
...@@ -50,6 +50,12 @@ declare module '../client' { ...@@ -50,6 +50,12 @@ declare module '../client' {
} }
} }
declare module '../room' {
interface Room {
noReconnect?: boolean;
}
}
export class Reconnect { export class Reconnect {
private disconnectList = new Map<string, DisconnectInfo>(); private disconnectList = new Map<string, DisconnectInfo>();
private isLooseReconnectRule = false; // 宽松匹配模式,日后可能配置支持 private isLooseReconnectRule = false; // 宽松匹配模式,日后可能配置支持
...@@ -121,6 +127,7 @@ export class Reconnect { ...@@ -121,6 +127,7 @@ export class Reconnect {
return ( return (
!client.isInternal && // 不是内部虚拟客户端 !client.isInternal && // 不是内部虚拟客户端
!room.noReconnect &&
client.pos < NetPlayerType.OBSERVER && // 是玩家 client.pos < NetPlayerType.OBSERVER && // 是玩家
room.duelStage !== DuelStage.Begin // 游戏已开始 room.duelStage !== DuelStage.Begin // 游戏已开始
); );
......
import { createAppContext } from 'nfkit'; import { createAppContext } from 'nfkit';
import { ContextState } from '../app'; import { ContextState } from '../app';
import { ClientVersionCheck } from '../feats/client-version-check'; import { ClientVersionCheck } from '../feats/client-version-check';
import { JoinWindbotAi, JoinWindbotToken } from '../windbot';
import { JoinRoom } from './join-room'; import { JoinRoom } from './join-room';
import { JoinFallback } from './fallback'; import { JoinFallback } from './fallback';
import { JoinPrechecks } from './join-prechecks';
export const JoinHandlerModule = createAppContext<ContextState>() export const JoinHandlerModule = createAppContext<ContextState>()
.provide(ClientVersionCheck) .provide(ClientVersionCheck)
.provide(JoinPrechecks)
.provide(JoinWindbotToken)
.provide(JoinWindbotAi)
.provide(JoinRoom) .provide(JoinRoom)
.provide(JoinFallback) .provide(JoinFallback)
.define(); .define();
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
export class JoinPrechecks {
constructor(private ctx: Context) {
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
if (!client.name || !client.name.length) {
return client.die('#{bad_user_name}', ChatColor.RED);
}
return next();
});
}
}
...@@ -53,13 +53,6 @@ export class DefaultHostInfoProvider { ...@@ -53,13 +53,6 @@ export class DefaultHostInfoProvider {
hostinfo.start_lp = defaultHostinfo.start_lp * 2; hostinfo.start_lp = defaultHostinfo.start_lp * 2;
return hostinfo; return hostinfo;
} }
if (name.startsWith('AI#')) {
hostinfo.rule = 5;
hostinfo.lflist = -1;
hostinfo.time_limit = 0;
return hostinfo;
}
const compactParam = name.match( const compactParam = name.match(
/^(\d)(\d)([12345TF])(T|F)(T|F)(\d+),(\d+),(\d+)/i, /^(\d)(\d)([12345TF])(T|F)(T|F)(\d+),(\d+),(\d+)/i,
); );
......
export * from './room'; export * from './room';
export * from './room-manager';
export * from './room-event/on-room-finalize';
import { Context } from '../app'; import { Context } from '../app';
import { Room, RoomFinalizor } from './room'; import { Room, RoomFinalizor } from './room';
import BetterLock from 'better-lock'; import BetterLock from 'better-lock';
import { HostInfo } from 'ygopro-msg-encode';
export class RoomManager { export class RoomManager {
constructor(private ctx: Context) {} constructor(private ctx: Context) {}
...@@ -29,7 +30,7 @@ export class RoomManager { ...@@ -29,7 +30,7 @@ export class RoomManager {
private logger = this.ctx.createLogger('RoomManager'); private logger = this.ctx.createLogger('RoomManager');
async findOrCreateByName(name: string) { async findOrCreateByName(name: string, hostinfo?: Partial<HostInfo>) {
const existing = this.findByName(name); const existing = this.findByName(name);
if (existing) return existing; if (existing) return existing;
...@@ -37,7 +38,7 @@ export class RoomManager { ...@@ -37,7 +38,7 @@ export class RoomManager {
const existing = this.findByName(name); const existing = this.findByName(name);
if (existing) return existing; if (existing) return existing;
const room = new Room(this.ctx, name).addFinalizor((r) => { const room = new Room(this.ctx, name, hostinfo).addFinalizor((r) => {
this.rooms.delete(r.name); this.rooms.delete(r.name);
this.logger.debug( this.logger.debug(
{ room: r.name, roomCount: this.rooms.size }, { room: r.name, roomCount: this.rooms.size },
......
...@@ -309,8 +309,8 @@ export class Room { ...@@ -309,8 +309,8 @@ export class Room {
return this.getDuelPosPlayers(swappedDuelPos); return this.getDuelPosPlayers(swappedDuelPos);
} }
private sendPostWatchMessages(client: Client) { private async sendPostWatchMessages(client: Client) {
client.send(new YGOProStocDuelStart()); await client.send(new YGOProStocDuelStart());
// 在 SelectHand / SelectTp 阶段发送 DeckCount // 在 SelectHand / SelectTp 阶段发送 DeckCount
// Siding 阶段不发 DeckCount // Siding 阶段不发 DeckCount
...@@ -318,26 +318,26 @@ export class Room { ...@@ -318,26 +318,26 @@ export class Room {
this.duelStage === DuelStage.Finger || this.duelStage === DuelStage.Finger ||
this.duelStage === DuelStage.FirstGo this.duelStage === DuelStage.FirstGo
) { ) {
client.send(this.prepareStocDeckCount(client.pos)); await client.send(this.prepareStocDeckCount(client.pos));
} }
if (this.duelStage === DuelStage.Siding) { if (this.duelStage === DuelStage.Siding) {
client.send(new YGOProStocWaitingSide()); await client.send(new YGOProStocWaitingSide());
} else if (this.duelStage === DuelStage.Dueling) { } else if (this.duelStage === DuelStage.Dueling) {
// Dueling 阶段不发 DeckCount,直接发送观战消息 // Dueling 阶段不发 DeckCount,直接发送观战消息
this.lastDuelRecord?.messages const observerMessages =
.filter( this.lastDuelRecord?.messages.filter(
(msg) => (msg) =>
!(msg instanceof YGOProMsgResponseBase) && !(msg instanceof YGOProMsgResponseBase) &&
msg.getSendTargets().includes(NetPlayerType.OBSERVER), msg.getSendTargets().includes(NetPlayerType.OBSERVER),
) ) || [];
.forEach((message) => { for (const message of observerMessages) {
client.send( await client.send(
new YGOProStocGameMsg().fromPartial({ new YGOProStocGameMsg().fromPartial({
msg: message.observerView(), msg: message.observerView(),
}), }),
); );
}); }
} }
} }
...@@ -357,28 +357,28 @@ export class Room { ...@@ -357,28 +357,28 @@ export class Room {
} }
// send to client // send to client
client.send(this.joinGameMessage); await client.send(this.joinGameMessage);
client.sendTypeChange(); await client.sendTypeChange();
this.playingPlayers.forEach((p) => { for (const p of this.playingPlayers) {
client.send(p.prepareEnterPacket()); await client.send(p.prepareEnterPacket());
// p.send(client.prepareEnterPacket());
if (p.deck) { if (p.deck) {
client.send(p.prepareChangePacket()); await client.send(p.prepareChangePacket());
} }
}); }
if (this.watchers.size && this.duelStage === DuelStage.Begin) { if (this.watchers.size && this.duelStage === DuelStage.Begin) {
client.send(this.watcherSizeMessage); await client.send(this.watcherSizeMessage);
} }
// send to other players // send to other players
if (isPlayer) { if (isPlayer) {
this.allPlayers const enterMessage = client.prepareEnterPacket();
.filter((p) => p !== client) await Promise.all(
.forEach((p) => { this.allPlayers
p.send(client.prepareEnterPacket()); .filter((p) => p !== client)
}); .map((p) => p.send(enterMessage)),
);
} else if (this.watchers.size && this.duelStage === DuelStage.Begin) { } else if (this.watchers.size && this.duelStage === DuelStage.Begin) {
client.send(this.watcherSizeMessage); await client.send(this.watcherSizeMessage);
} }
await this.ctx.dispatch(new OnRoomJoin(this), client); await this.ctx.dispatch(new OnRoomJoin(this), client);
...@@ -391,7 +391,7 @@ export class Room { ...@@ -391,7 +391,7 @@ export class Room {
} }
if (this.duelStage !== DuelStage.Begin) { if (this.duelStage !== DuelStage.Begin) {
this.sendPostWatchMessages(client); await this.sendPostWatchMessages(client);
} }
return undefined; return undefined;
......
import cryptoRandomString from 'crypto-random-string';
export function fillRandomString(prefix: string, length: number): string {
if (prefix.length >= length) {
return prefix;
}
return `${prefix}${cryptoRandomString({
length: length - prefix.length,
type: 'alphanumeric',
})}`;
}
export * from './windbot-provider';
export * from './windbot-spawner';
export * from './join-windbot-ai';
export * from './join-windbot-token';
export * from './windbot-module';
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { WindBotProvider } from './windbot-provider';
import { RoomManager } from '../room';
import { fillRandomString } from '../utility/fill-random-string';
const getDisplayLength = (text: string) =>
text.replace(/[^\x00-\xff]/g, '00').length;
export class JoinWindbotAi {
private logger = this.ctx.createLogger(this.constructor.name);
private windbotProvider = this.ctx.get(() => WindBotProvider);
private roomManager = this.ctx.get(() => RoomManager);
constructor(private ctx: Context) {
if (!this.windbotProvider.enabled) {
return;
}
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
msg.pass = (msg.pass || '').trim();
if (!msg.pass || !msg.pass.toUpperCase().startsWith('AI')) {
return next();
}
const existingRoom = this.roomManager.findByName(msg.pass);
if (existingRoom) {
return existingRoom.join(client);
}
const requestedBotName = this.parseRequestedBotName(msg.pass);
if (
requestedBotName &&
!this.windbotProvider.getBotByNameOrDeck(requestedBotName)
) {
return client.die('#{windbot_deck_not_found}', ChatColor.RED);
}
const roomName = this.generateWindbotRoomName(msg.pass);
if (!roomName) {
return client.die('#{create_room_failed}', ChatColor.RED);
}
if (getDisplayLength(roomName) > 20) {
return client.die('#{windbot_name_too_long}', ChatColor.RED);
}
const room = await this.roomManager.findOrCreateByName(roomName, {
rule: 5,
lflist: -1,
time_limit: 0,
});
room.noReconnect = true;
room.windbot = {
name: '',
deck: '',
}
await room.join(client);
const requestOk = await this.windbotProvider.requestWindbotJoin(
room,
requestedBotName,
);
if (!requestOk) {
await room.finalize();
return;
}
this.logger.debug(
{
player: client.name,
roomName: room.name,
botName: room.windbot?.name,
},
'Created windbot room',
);
return;
});
}
private parseRequestedBotName(pass: string) {
const parts = pass.split('#');
if (parts.length > 1) {
return parts[parts.length - 1];
}
return undefined;
}
private generateWindbotRoomName(pass: string) {
for (let i = 0; i < 1000; i += 1) {
let prefix = '';
if (pass.toUpperCase() === 'AI') {
prefix = 'AI#';
} else if (pass.includes('#')) {
const roomPrefix = pass.split('#')[0]?.toUpperCase() || 'AI';
prefix = `${roomPrefix}#`;
} else {
prefix = `${pass}#`;
}
const roomName = fillRandomString(prefix, 19);
if (!this.roomManager.findByName(roomName)) {
return roomName;
}
}
return undefined;
}
}
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { WindBotProvider } from './windbot-provider';
import { RoomManager } from '../room';
export class JoinWindbotToken {
private windbotProvider = this.ctx.get(() => WindBotProvider);
private roomManager = this.ctx.get(() => RoomManager);
private logger = this.ctx.createLogger(this.constructor.name);
constructor(private ctx: Context) {
if (!this.windbotProvider.enabled) {
return;
}
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
msg.pass = (msg.pass || '').trim();
if (!msg.pass.startsWith('AIJOIN#')) {
return next();
}
const token = msg.pass.slice('AIJOIN#'.length);
const roomName = this.windbotProvider.consumeJoinToken(token);
if (!roomName) {
return client.die('#{invalid_password_not_found}', ChatColor.RED);
}
const room = this.roomManager.findByName(roomName);
if (!room) {
return client.die('#{invalid_password_not_found}', ChatColor.RED);
}
client.isInternal = true;
client.windbot = room.windbot;
return room.join(client);
});
}
}
import { createAppContext } from 'nfkit';
import { ContextState } from '../app';
import { WindBotProvider } from './windbot-provider';
import { WindbotSpawner } from './windbot-spawner';
export const WindbotModule = createAppContext<ContextState>()
.provide(WindBotProvider)
.provide(WindbotSpawner)
.define();
import cryptoRandomString from 'crypto-random-string';
import * as fs from 'node:fs/promises';
import { ChatColor } from 'ygopro-msg-encode';
import { Context } from '../app';
import { OnRoomFinalize, Room } from '../room';
export interface WindbotData {
name: string;
deck: string;
dialog?: string;
hidden?: boolean;
deckcode?: string;
}
declare module '../client' {
interface Client {
windbot?: WindbotData;
}
}
declare module '../room' {
interface Room {
windbot?: WindbotData;
}
}
export class WindBotProvider {
private logger = this.ctx.createLogger(this.constructor.name);
public enabled = this.ctx.config.getBoolean('ENABLE_WINDBOT');
public spawnEnabled = this.ctx.config.getBoolean('WINDBOT_SPAWN');
public endpoint = this.ctx.config.getString('WINDBOT_ENDPOINT');
public myIp = this.ctx.config.getString('WINDBOT_MY_IP');
public port = this.ctx.config.getString('PORT');
public version = this.ctx.config.getInt('YGOPRO_VERSION');
public botlistPath = this.ctx.config.getString('WINDBOT_BOTLIST');
private bots: WindbotData[] = [];
private tokenRoomMap = new Map<string, string>();
private roomTokenMap = new Map<string, string>();
constructor(private ctx: Context) {
if (!this.enabled) {
return;
}
this.ctx.middleware(OnRoomFinalize, async (event, _client, next) => {
this.deleteRoomToken(event.room.name);
return next();
});
}
async init() {
if (!this.enabled) {
return;
}
await this.loadBotList();
}
get isEnabled() {
return this.enabled;
}
getRandomBot() {
const visibleBots = this.bots.filter((bot) => !bot.hidden);
if (!visibleBots.length) {
return undefined;
}
const index = Math.floor(Math.random() * visibleBots.length);
return visibleBots[index];
}
getBotByNameOrDeck(name: string) {
return this.bots.find((bot) => bot.name === name || bot.deck === name);
}
issueJoinToken(roomName: string) {
const oldToken = this.roomTokenMap.get(roomName);
if (oldToken) {
this.tokenRoomMap.delete(oldToken);
}
let token = '';
do {
token = cryptoRandomString({
length: 12,
type: 'alphanumeric',
});
} while (this.tokenRoomMap.has(token));
this.logger.debug(
{ roomName, token },
'Issuing windbot join token for room',
);
this.tokenRoomMap.set(token, roomName);
this.roomTokenMap.set(roomName, token);
return token;
}
consumeJoinToken(token: string) {
const roomName = this.tokenRoomMap.get(token);
this.logger.debug({ roomName, token }, 'Consuming windbot join token');
if (!roomName) {
return undefined;
}
this.tokenRoomMap.delete(token);
const mappedToken = this.roomTokenMap.get(roomName);
if (mappedToken === token) {
this.roomTokenMap.delete(roomName);
}
return roomName;
}
deleteRoomToken(roomName: string) {
const token = this.roomTokenMap.get(roomName);
if (!token) {
return;
}
this.roomTokenMap.delete(roomName);
const mappedRoomName = this.tokenRoomMap.get(token);
if (mappedRoomName === roomName) {
this.tokenRoomMap.delete(token);
}
}
async requestWindbotJoin(room: Room, botname?: string) {
const roomWindbot =
this.isValidBot(room.windbot) && room.windbot ? room.windbot : undefined;
const bot =
(botname && this.getBotByNameOrDeck(botname)) || roomWindbot || this.getRandomBot();
if (!bot) {
await room.sendChat('#{windbot_deck_not_found}', ChatColor.RED);
return false;
}
if (!room.windbot) {
room.windbot = {
name: '',
deck: '',
};
}
Object.assign(room.windbot, bot);
const token = this.issueJoinToken(room.name);
let url: URL;
try {
url = new URL(this.endpoint);
} catch (error) {
this.logger.warn(
{ endpoint: this.endpoint, error: (error as Error).toString() },
'Invalid WINDBOT_ENDPOINT',
);
await room.sendChat('#{add_windbot_failed}', ChatColor.RED);
return false;
}
url.searchParams.set('name', bot.name);
url.searchParams.set('deck', bot.deck);
url.searchParams.set('host', this.myIp);
url.searchParams.set('port', this.port);
if (bot.dialog) {
url.searchParams.set('dialog', bot.dialog);
}
url.searchParams.set('version', this.version.toString());
url.searchParams.set('password', `AIJOIN#${token}`);
if (bot.deckcode) {
url.searchParams.set('deckcode', bot.deckcode);
}
this.logger.debug(
{ url: url.toString(), roomName: room.name },
'Requesting windbot join',
);
try {
await this.ctx.http.get(url.toString());
return true;
} catch (error) {
this.logger.warn(
{
roomToken: token,
botName: bot.name,
error: (error as Error).toString(),
},
'Windbot add request failed',
);
await room.sendChat('#{add_windbot_failed}', ChatColor.RED);
return false;
}
}
private isValidBot(
bot: WindbotData | undefined,
): bot is WindbotData {
return !!(
bot &&
typeof bot.name === 'string' &&
bot.name.length &&
typeof bot.deck === 'string' &&
bot.deck.length
);
}
private async loadBotList() {
try {
const text = await fs.readFile(this.botlistPath, 'utf-8');
const parsed = JSON.parse(text) as {
windbots?: WindbotData[];
};
const loadedBots = parsed?.windbots;
if (!Array.isArray(loadedBots)) {
this.logger.warn(
{ botlistPath: this.botlistPath },
'Windbot botlist format invalid',
);
return;
}
this.bots = loadedBots.filter(
(bot) =>
bot && typeof bot.name === 'string' && typeof bot.deck === 'string',
);
this.logger.info(
{ count: this.bots.length, botlistPath: this.botlistPath },
'Loaded windbot botlist',
);
} catch (error) {
this.logger.warn(
{ botlistPath: this.botlistPath, error: (error as Error).toString() },
'Failed to load windbot botlist',
);
}
}
}
import { ChildProcess, spawn } from 'node:child_process';
import { Context } from '../app';
import { WindBotProvider } from './windbot-provider';
export class WindbotSpawner {
private logger = this.ctx.createLogger(this.constructor.name);
private windbotProvider = this.ctx.get(() => WindBotProvider);
private loopLimit = 0;
private windbotProcess: ChildProcess | null = null;
private readonly maxLoopLimit = 1000;
private stopping = false;
constructor(private ctx: Context) {}
init() {
if (!this.windbotProvider.enabled || !this.windbotProvider.spawnEnabled) {
return;
}
this.spawnWindbot();
process.once('exit', () => {
this.stop();
});
process.once('SIGINT', () => {
this.stop();
});
process.once('SIGTERM', () => {
this.stop();
});
}
private resolveWindbotPort() {
try {
const endpointUrl = new URL(this.windbotProvider.endpoint);
const fallbackPort = endpointUrl.protocol === 'https:' ? '443' : '80';
return endpointUrl.port || fallbackPort;
} catch (error) {
this.logger.warn(
{
endpoint: this.windbotProvider.endpoint,
error: (error as Error).toString(),
},
'Invalid WINDBOT_ENDPOINT',
);
return null;
}
}
private spawnWindbot() {
const port = this.resolveWindbotPort();
if (!port) {
return;
}
const isWindows = /^win/.test(process.platform);
const windbotBin = isWindows ? 'WindBot.exe' : 'mono';
const windbotParameters = isWindows ? [] : ['WindBot.exe'];
windbotParameters.push('ServerMode=true');
windbotParameters.push(`ServerPort=${port}`);
const processHandle = spawn(windbotBin, windbotParameters, {
cwd: 'windbot',
});
this.windbotProcess = processHandle;
processHandle.on('error', (error) => {
this.logger.warn({ error }, 'WindBot ERROR');
this.respawnWindbot();
});
processHandle.on('exit', (code) => {
this.logger.warn({ code }, 'WindBot EXIT');
this.respawnWindbot();
});
processHandle.stdout?.setEncoding('utf8');
processHandle.stdout?.on('data', (data) => {
this.logger.info({ data }, 'WindBot');
});
processHandle.stderr?.setEncoding('utf8');
processHandle.stderr?.on('data', (data) => {
this.logger.warn({ data }, 'WindBot Error');
});
}
private respawnWindbot() {
if (this.stopping) {
return;
}
if (this.loopLimit >= this.maxLoopLimit) {
return;
}
this.loopLimit += 1;
this.spawnWindbot();
}
private stop() {
this.stopping = true;
if (this.windbotProcess) {
this.windbotProcess.kill();
this.windbotProcess = null;
}
}
}
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