Commit 9c10c96b authored by nanahira's avatar nanahira

windbot

parent 4392e08d
......@@ -26,6 +26,11 @@ deckMaxCopies: 3
ocgcoreDebugLog: 0
ocgcoreWasmPath: ""
welcome: ""
enableWindbot: 1
windbotBotlist: ./windbot/bots.json
windbotSpawn: 0
windbotEndpoint: http://127.0.0.1:2399
windbotMyIp: 127.0.0.1
enableReconnect: 1
reconnectTimeout: 180000
hostinfoLflist: 0
......
......@@ -86,6 +86,7 @@ export class ClientHandler {
{
msgName: msg.constructor.name,
client: client.name || client.loggingIp(),
payload: JSON.stringify(msg),
},
'Received client message',
);
......
......@@ -104,6 +104,14 @@ export class Client {
return;
}
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 {
await this._send(Buffer.from(data.toFullPayload()));
} catch (e) {
......
......@@ -60,6 +60,19 @@ export const defaultConfig = {
OCGCORE_WASM_PATH: '',
// Welcome message sent when players join. Format: plain string.
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.
// Boolean parse rule (default true): only '0'/'false'/'null' => false, otherwise true.
// Note: with default-true parsing, empty string is treated as true.
......
......@@ -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.',
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',
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: '.',
......@@ -17,6 +18,12 @@ export const TRANSLATIONS = {
reconnect_to_game: 'reconnected to the game',
reconnect_kicked:
"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:
'You will be reconnected to your previous game. Please pick your previous deck.',
deck_incorrect_reconnect: 'Please pick your previous deck.',
......@@ -30,6 +37,7 @@ export const TRANSLATIONS = {
'当前客户端版本暂未完全支持。请重新加入以启用临时兼容模式。为获得更佳体验,建议尽快更新游戏版本。',
version_polyfilled:
'已为当前版本启用临时兼容模式。建议尽快更新游戏,以避免后续兼容性问题。',
bad_user_name: '请输入正确的用户名',
blank_room_name: '房间名不能为空,请在主机密码处填写房间名',
replay_hint_part1: '正在发送第',
replay_hint_part2: '局决斗的录像。',
......@@ -39,6 +47,11 @@ export const TRANSLATIONS = {
disconnect_from_game: '断开了连接',
reconnect_to_game: '重新连接了',
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:
'你有未完成的对局,即将重新连接,请选择你在本局决斗中使用的卡组并准备。',
deck_incorrect_reconnect: '请选择你在本局决斗中使用的卡组。',
......
......@@ -4,9 +4,11 @@ import { ContextState } from '../app';
import { Welcome } from './welcome';
import { PlayerStatusNotify } from './player-status-notify';
import { Reconnect } from './reconnect';
import { WindbotModule } from '../windbot';
export const FeatsModule = createAppContext<ContextState>()
.provide(ClientVersionCheck)
.use(WindbotModule)
.provide(Welcome)
.provide(PlayerStatusNotify)
.provide(Reconnect)
......
......@@ -50,6 +50,12 @@ declare module '../client' {
}
}
declare module '../room' {
interface Room {
noReconnect?: boolean;
}
}
export class Reconnect {
private disconnectList = new Map<string, DisconnectInfo>();
private isLooseReconnectRule = false; // 宽松匹配模式,日后可能配置支持
......@@ -121,6 +127,7 @@ export class Reconnect {
return (
!client.isInternal && // 不是内部虚拟客户端
!room.noReconnect &&
client.pos < NetPlayerType.OBSERVER && // 是玩家
room.duelStage !== DuelStage.Begin // 游戏已开始
);
......
import { createAppContext } from 'nfkit';
import { ContextState } from '../app';
import { ClientVersionCheck } from '../feats/client-version-check';
import { JoinWindbotAi, JoinWindbotToken } from '../windbot';
import { JoinRoom } from './join-room';
import { JoinFallback } from './fallback';
import { JoinPrechecks } from './join-prechecks';
export const JoinHandlerModule = createAppContext<ContextState>()
.provide(ClientVersionCheck)
.provide(JoinPrechecks)
.provide(JoinWindbotToken)
.provide(JoinWindbotAi)
.provide(JoinRoom)
.provide(JoinFallback)
.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 {
hostinfo.start_lp = defaultHostinfo.start_lp * 2;
return hostinfo;
}
if (name.startsWith('AI#')) {
hostinfo.rule = 5;
hostinfo.lflist = -1;
hostinfo.time_limit = 0;
return hostinfo;
}
const compactParam = name.match(
/^(\d)(\d)([12345TF])(T|F)(T|F)(\d+),(\d+),(\d+)/i,
);
......
export * from './room';
export * from './room-manager';
export * from './room-event/on-room-finalize';
import { Context } from '../app';
import { Room, RoomFinalizor } from './room';
import BetterLock from 'better-lock';
import { HostInfo } from 'ygopro-msg-encode';
export class RoomManager {
constructor(private ctx: Context) {}
......@@ -29,7 +30,7 @@ export class RoomManager {
private logger = this.ctx.createLogger('RoomManager');
async findOrCreateByName(name: string) {
async findOrCreateByName(name: string, hostinfo?: Partial<HostInfo>) {
const existing = this.findByName(name);
if (existing) return existing;
......@@ -37,7 +38,7 @@ export class RoomManager {
const existing = this.findByName(name);
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.logger.debug(
{ room: r.name, roomCount: this.rooms.size },
......
......@@ -309,8 +309,8 @@ export class Room {
return this.getDuelPosPlayers(swappedDuelPos);
}
private sendPostWatchMessages(client: Client) {
client.send(new YGOProStocDuelStart());
private async sendPostWatchMessages(client: Client) {
await client.send(new YGOProStocDuelStart());
// 在 SelectHand / SelectTp 阶段发送 DeckCount
// Siding 阶段不发 DeckCount
......@@ -318,26 +318,26 @@ export class Room {
this.duelStage === DuelStage.Finger ||
this.duelStage === DuelStage.FirstGo
) {
client.send(this.prepareStocDeckCount(client.pos));
await client.send(this.prepareStocDeckCount(client.pos));
}
if (this.duelStage === DuelStage.Siding) {
client.send(new YGOProStocWaitingSide());
await client.send(new YGOProStocWaitingSide());
} else if (this.duelStage === DuelStage.Dueling) {
// Dueling 阶段不发 DeckCount,直接发送观战消息
this.lastDuelRecord?.messages
.filter(
const observerMessages =
this.lastDuelRecord?.messages.filter(
(msg) =>
!(msg instanceof YGOProMsgResponseBase) &&
msg.getSendTargets().includes(NetPlayerType.OBSERVER),
)
.forEach((message) => {
client.send(
) || [];
for (const message of observerMessages) {
await client.send(
new YGOProStocGameMsg().fromPartial({
msg: message.observerView(),
}),
);
});
}
}
}
......@@ -357,28 +357,28 @@ export class Room {
}
// send to client
client.send(this.joinGameMessage);
client.sendTypeChange();
this.playingPlayers.forEach((p) => {
client.send(p.prepareEnterPacket());
// p.send(client.prepareEnterPacket());
await client.send(this.joinGameMessage);
await client.sendTypeChange();
for (const p of this.playingPlayers) {
await client.send(p.prepareEnterPacket());
if (p.deck) {
client.send(p.prepareChangePacket());
await client.send(p.prepareChangePacket());
}
});
}
if (this.watchers.size && this.duelStage === DuelStage.Begin) {
client.send(this.watcherSizeMessage);
await client.send(this.watcherSizeMessage);
}
// send to other players
if (isPlayer) {
this.allPlayers
.filter((p) => p !== client)
.forEach((p) => {
p.send(client.prepareEnterPacket());
});
const enterMessage = client.prepareEnterPacket();
await Promise.all(
this.allPlayers
.filter((p) => p !== client)
.map((p) => p.send(enterMessage)),
);
} 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);
......@@ -391,7 +391,7 @@ export class Room {
}
if (this.duelStage !== DuelStage.Begin) {
this.sendPostWatchMessages(client);
await this.sendPostWatchMessages(client);
}
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