Commit 59309b8b authored by nanahira's avatar nanahira

add koishi

parent a2acc28c
Pipeline #43262 failed with stages
in 104 minutes and 47 seconds
This diff is collapsed.
...@@ -62,6 +62,7 @@ ...@@ -62,6 +62,7 @@
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"@koishijs/plugin-help": "^2.4.6",
"aragami": "^1.2.10", "aragami": "^1.2.10",
"axios": "^1.13.5", "axios": "^1.13.5",
"better-lock": "^3.2.0", "better-lock": "^3.2.0",
......
...@@ -53,8 +53,7 @@ export class ClientHandler { ...@@ -53,8 +53,7 @@ export class ClientHandler {
YGOProCtosBase, YGOProCtosBase,
async (msg, client, next) => { async (msg, client, next) => {
const bypassEstablished = const bypassEstablished =
msg instanceof YGOProCtosJoinGame && msg instanceof YGOProCtosJoinGame && msg.bypassEstablished;
msg.bypassEstablished;
if (bypassEstablished) { if (bypassEstablished) {
delete msg.bypassEstablished; delete msg.bypassEstablished;
return next(); return next();
......
...@@ -153,7 +153,7 @@ export class Client { ...@@ -153,7 +153,7 @@ export class Client {
if (this.isInternal) { if (this.isInternal) {
return; return;
} }
const locale = this.ctx.get(() => Chnroute).getLocale(this.ip); const locale = this.getLocale();
const lines = type <= NetPlayerType.OBSERVER ? [msg] : msg.split(/\r?\n/); const lines = type <= NetPlayerType.OBSERVER ? [msg] : msg.split(/\r?\n/);
const sendTasks: Promise<unknown>[] = []; const sendTasks: Promise<unknown>[] = [];
...@@ -209,6 +209,10 @@ export class Client { ...@@ -209,6 +209,10 @@ export class Client {
return this.ip || this.physicalIp() || 'unknown'; return this.ip || this.physicalIp() || 'unknown';
} }
getLocale() {
return this.ctx.get(() => Chnroute).getLocale(this.ip);
}
// in handshake // in handshake
hostname = ''; hostname = '';
name = ''; name = '';
......
...@@ -81,12 +81,14 @@ export const TRANSLATIONS = { ...@@ -81,12 +81,14 @@ export const TRANSLATIONS = {
unwelcome_warn_part1: 'If you keep doing ', unwelcome_warn_part1: 'If you keep doing ',
unwelcome_warn_part2: ', your opponent may leave you.', unwelcome_warn_part2: ', your opponent may leave you.',
unwelcome_tip_part1: 'Your opponent did ', unwelcome_tip_part1: 'Your opponent did ',
unwelcome_tip_part2: unwelcome_tip_part2: ', so you can leave the game without being punished.',
', so you can leave the game without being punished.',
banned_duel_tip: banned_duel_tip:
'You are banned from the random duel system for sending inappropriate messages.', 'You are banned from the random duel system for sending inappropriate messages.',
chat_banned: 'is banned from chat.', chat_banned: 'is banned from chat.',
surrender_denied: "Please don't surrender in the first 2 turns.", surrender_denied: "Please don't surrender in the first 2 turns.",
room_name: 'Room name is',
refresh_success: 'Refresh field succeeded.',
refresh_fail: 'Refresh field failed.',
chat_disabled: 'Chat is disabled in this room.', chat_disabled: 'Chat is disabled in this room.',
chat_warn_level1: 'Please avoid sensitive words.', chat_warn_level1: 'Please avoid sensitive words.',
chat_warn_level2: 'Your message contains blocked words.', chat_warn_level2: 'Your message contains blocked words.',
...@@ -103,6 +105,16 @@ export const TRANSLATIONS = { ...@@ -103,6 +105,16 @@ export const TRANSLATIONS = {
menu_single_random_duel: 'Single Random Duel', menu_single_random_duel: 'Single Random Duel',
menu_prev_page: 'Previous Page', menu_prev_page: 'Previous Page',
menu_next_page: 'Next Page', menu_next_page: 'Next Page',
koishi_cmd_tip_desc: 'Send a random tip.',
koishi_cmd_ai_desc: 'Add windbot to current room.',
koishi_cmd_surrender_desc: 'Surrender current duel.',
koishi_cmd_roomname_desc: 'Show current room name.',
koishi_cmd_refresh_desc: 'Refresh duel field state.',
koishi_cmd_ip_desc: 'Show your current IP.',
koishi_ai_only_host: 'Only room host can use /ai.',
koishi_ai_disabled: 'Windbot feature is disabled.',
koishi_ai_disabled_random_room: 'AI is disabled in random duel rooms.',
koishi_ai_room_full: 'Room is full, cannot add AI.',
}, },
'zh-CN': { 'zh-CN': {
update_required: '请更新你的客户端版本', update_required: '请更新你的客户端版本',
...@@ -183,6 +195,9 @@ export const TRANSLATIONS = { ...@@ -183,6 +195,9 @@ export const TRANSLATIONS = {
banned_duel_tip: '您的发言存在严重不适当的内容,禁止您使用随机对战功能!', banned_duel_tip: '您的发言存在严重不适当的内容,禁止您使用随机对战功能!',
chat_banned: '已被禁言!', chat_banned: '已被禁言!',
surrender_denied: '为保证双方玩家的游戏体验,随机对战中3回合后才能投降。', surrender_denied: '为保证双方玩家的游戏体验,随机对战中3回合后才能投降。',
room_name: '您当前的房间名是',
refresh_success: '刷新场面成功。',
refresh_fail: '刷新场面失败。',
chat_disabled: '本房间禁止聊天。', chat_disabled: '本房间禁止聊天。',
chat_warn_level1: '请注意发言,敏感词已被替换。', chat_warn_level1: '请注意发言,敏感词已被替换。',
chat_warn_level2: '消息包含敏感词,已被拦截。', chat_warn_level2: '消息包含敏感词,已被拦截。',
...@@ -199,5 +214,15 @@ export const TRANSLATIONS = { ...@@ -199,5 +214,15 @@ export const TRANSLATIONS = {
menu_single_random_duel: '随机对战(单局)', menu_single_random_duel: '随机对战(单局)',
menu_prev_page: '上一页', menu_prev_page: '上一页',
menu_next_page: '下一页', menu_next_page: '下一页',
koishi_cmd_tip_desc: '发送一条随机提示。',
koishi_cmd_ai_desc: '为当前房间添加AI。',
koishi_cmd_surrender_desc: '投降当前对局。',
koishi_cmd_roomname_desc: '显示当前房间名。',
koishi_cmd_refresh_desc: '刷新当前场面信息。',
koishi_cmd_ip_desc: '显示当前 IP。',
koishi_ai_only_host: '只有房主可以使用 /ai。',
koishi_ai_disabled: '人机功能未开启。',
koishi_ai_disabled_random_room: '随机对战房间不允许使用 /ai。',
koishi_ai_room_full: '房间已满,无法添加AI。',
}, },
}; };
...@@ -3,7 +3,7 @@ import { ClientVersionCheck } from './client-version-check'; ...@@ -3,7 +3,7 @@ import { ClientVersionCheck } from './client-version-check';
import { ContextState } from '../app'; 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, RefreshFieldService } from './reconnect';
import { WindbotModule } from './windbot'; import { WindbotModule } from './windbot';
import { SideTimeout } from './side-timeout'; import { SideTimeout } from './side-timeout';
import { RandomDuelModule } from './random-duel'; import { RandomDuelModule } from './random-duel';
...@@ -12,14 +12,18 @@ import { ResourceModule } from './resource'; ...@@ -12,14 +12,18 @@ import { ResourceModule } from './resource';
import { MenuManager } from './menu-manager'; import { MenuManager } from './menu-manager';
import { ClientKeyProvider } from './client-key-provider'; import { ClientKeyProvider } from './client-key-provider';
import { HidePlayerNameProvider } from './hide-player-name-provider'; import { HidePlayerNameProvider } from './hide-player-name-provider';
import { CommandsService, KoishiContextService } from '../koishi';
export const FeatsModule = createAppContext<ContextState>() export const FeatsModule = createAppContext<ContextState>()
.provide(ClientKeyProvider) .provide(ClientKeyProvider)
.provide(HidePlayerNameProvider) .provide(HidePlayerNameProvider)
.provide(KoishiContextService)
.provide(CommandsService)
.provide(MenuManager) .provide(MenuManager)
.provide(ClientVersionCheck) .provide(ClientVersionCheck)
.provide(Welcome) .provide(Welcome)
.provide(PlayerStatusNotify) .provide(PlayerStatusNotify)
.provide(RefreshFieldService)
.provide(Reconnect) .provide(Reconnect)
.provide(WaitForPlayerProvider) // chat refresh .provide(WaitForPlayerProvider) // chat refresh
.provide(SideTimeout) .provide(SideTimeout)
......
...@@ -51,7 +51,10 @@ export class MenuManager { ...@@ -51,7 +51,10 @@ export class MenuManager {
if (!client.currentMenu) { if (!client.currentMenu) {
return next(); return next();
} }
if (msg instanceof YGOProCtosHsToDuelist || msg instanceof YGOProCtosKick) { if (
msg instanceof YGOProCtosHsToDuelist ||
msg instanceof YGOProCtosKick
) {
return next(); return next();
} }
return undefined; return undefined;
......
...@@ -610,7 +610,10 @@ export class RandomDuelProvider { ...@@ -610,7 +610,10 @@ export class RandomDuelProvider {
if (!clientKey) { if (!clientKey) {
return empty; return empty;
} }
const data = await this.ctx.aragami.get(RandomDuelDisciplineCache, clientKey); const data = await this.ctx.aragami.get(
RandomDuelDisciplineCache,
clientKey,
);
const now = Date.now(); const now = Date.now();
const expireAt = Math.max(0, data?.expireAt || 0); const expireAt = Math.max(0, data?.expireAt || 0);
if (!data || expireAt <= now) { if (!data || expireAt <= now) {
...@@ -641,7 +644,10 @@ export class RandomDuelProvider { ...@@ -641,7 +644,10 @@ export class RandomDuelProvider {
return; return;
} }
const now = Date.now(); const now = Date.now();
const expireAt = Math.max(now + 1000, data.expireAt || now + RANDOM_DUEL_TTL); const expireAt = Math.max(
now + 1000,
data.expireAt || now + RANDOM_DUEL_TTL,
);
const ttl = Math.max(1000, expireAt - now); const ttl = Math.max(1000, expireAt - now);
await this.ctx.aragami.set( await this.ctx.aragami.set(
RandomDuelDisciplineCache, RandomDuelDisciplineCache,
......
import { import {
ChatColor, ChatColor,
NetPlayerType, NetPlayerType,
OcgcoreScriptConstants,
YGOProCtosBase, YGOProCtosBase,
YGOProCtosJoinGame, YGOProCtosJoinGame,
YGOProCtosUpdateDeck, YGOProCtosUpdateDeck,
YGOProMsgHint,
YGOProMsgNewPhase,
YGOProMsgNewTurn,
YGOProMsgStart, YGOProMsgStart,
YGOProMsgWaiting,
YGOProStocDuelStart, YGOProStocDuelStart,
YGOProStocGameMsg, YGOProStocGameMsg,
YGOProStocJoinGame,
YGOProStocTypeChange, YGOProStocTypeChange,
YGOProStocHsPlayerEnter, YGOProStocHsPlayerEnter,
YGOProStocHsPlayerChange, YGOProStocHsPlayerChange,
...@@ -30,6 +24,7 @@ import { YGOProCtosDisconnect } from '../../utility/ygopro-ctos-disconnect'; ...@@ -30,6 +24,7 @@ import { YGOProCtosDisconnect } from '../../utility/ygopro-ctos-disconnect';
import { isUpdateDeckPayloadEqual } from '../../utility/deck-compare'; import { isUpdateDeckPayloadEqual } from '../../utility/deck-compare';
import { CanReconnectCheck } from './can-reconnect-check'; import { CanReconnectCheck } from './can-reconnect-check';
import { ClientKeyProvider } from '../client-key-provider'; import { ClientKeyProvider } from '../client-key-provider';
import { RefreshFieldService } from './refresh-field-service';
interface DisconnectInfo { interface DisconnectInfo {
key: string; key: string;
...@@ -62,6 +57,7 @@ export class Reconnect { ...@@ -62,6 +57,7 @@ export class Reconnect {
private disconnectList = new Map<string, DisconnectInfo>(); private disconnectList = new Map<string, DisconnectInfo>();
private reconnectTimeout = this.ctx.config.getInt('RECONNECT_TIMEOUT'); // 超时时间,单位:毫秒(默认 180000ms = 3分钟) private reconnectTimeout = this.ctx.config.getInt('RECONNECT_TIMEOUT'); // 超时时间,单位:毫秒(默认 180000ms = 3分钟)
private clientKeyProvider = this.ctx.get(() => ClientKeyProvider); private clientKeyProvider = this.ctx.get(() => ClientKeyProvider);
private refreshFieldService = this.ctx.get(() => RefreshFieldService);
constructor(private ctx: Context) { constructor(private ctx: Context) {
// 检查是否启用断线重连(默认启用) // 检查是否启用断线重连(默认启用)
...@@ -513,65 +509,7 @@ export class Reconnect { ...@@ -513,65 +509,7 @@ export class Reconnect {
}), }),
); );
// 发送回合/阶段消息 await this.refreshFieldService.sendReconnectDuelingMessages(newClient, room);
await newClient.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgNewTurn().fromPartial({
player: room.turnIngamePos,
}),
}),
);
if (room.phase != null) {
await newClient.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgNewPhase().fromPartial({
phase: room.phase,
}),
}),
);
}
// 发送 MSG_RELOAD_FIELD(核心状态重建)
await newClient.send(await this.requestField(room));
// 发送刷新消息
await this.sendRefreshMessages(newClient, room);
// 判断是否需要重发响应请求
const needResendRequest =
room.hostinfo.time_limit > 0 && // 有计时器
this.isReconnectingPlayerOperating(newClient, room); // 重连玩家在操作
if (needResendRequest) {
// 重发 lastHintMsg(从 messages 找)
const lastHint = this.findLastHintForClient(newClient, room);
if (lastHint) {
await newClient.send(
new YGOProStocGameMsg().fromPartial({
msg: lastHint,
}),
);
}
// 重发 lastResponseRequestMsg
if (room.lastResponseRequestMsg) {
await newClient.send(
new YGOProStocGameMsg().fromPartial({
msg: room.lastResponseRequestMsg.playerView(
room.getIngameDuelPos(newClient),
),
}),
);
}
} else {
// 不是重连玩家操作,发送 WAITING
await newClient.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgWaiting(),
}),
);
}
} }
private importClientData(newClient: Client, oldClient: Client, room: Room) { private importClientData(newClient: Client, oldClient: Client, room: Room) {
...@@ -605,166 +543,6 @@ export class Reconnect { ...@@ -605,166 +543,6 @@ export class Reconnect {
} }
} }
private async requestField(room: Room): Promise<YGOProStocGameMsg> {
if (!room.ocgcore) {
throw new Error('OCGCore not initialized');
}
const info = await room.ocgcore.queryFieldInfo();
// info.field 已经是 YGOProMsgReloadField 对象
return new YGOProStocGameMsg().fromPartial({
msg: info.field,
});
}
private async sendRefreshMessages(client: Client, room: Room) {
// 参考 ygopro RequestField 的逻辑,刷新各个区域
// 使用 0xefffff queryFlag(重连专用,包含更完整的信息)
const queryFlag = 0xefffff;
// 按照 ygopro RequestField 的顺序刷新
// 先对方,后自己(使用 ingame pos)
const selfIngamePos = room.getIngameDuelPosByDuelPos(client.pos);
const opponentIngamePos = 1 - selfIngamePos;
// RefreshMzone
await room.refreshLocations(
{
player: opponentIngamePos,
location: OcgcoreScriptConstants.LOCATION_MZONE,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: selfIngamePos,
location: OcgcoreScriptConstants.LOCATION_MZONE,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
// RefreshSzone
await room.refreshLocations(
{
player: opponentIngamePos,
location: OcgcoreScriptConstants.LOCATION_SZONE,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: selfIngamePos,
location: OcgcoreScriptConstants.LOCATION_SZONE,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
// RefreshHand
await room.refreshLocations(
{
player: opponentIngamePos,
location: OcgcoreScriptConstants.LOCATION_HAND,
},
{ queryFlag, sendToClient: client },
);
await room.refreshLocations(
{ player: selfIngamePos, location: OcgcoreScriptConstants.LOCATION_HAND },
{ queryFlag, sendToClient: client, useCache: 0 },
);
// RefreshGrave
await room.refreshLocations(
{
player: opponentIngamePos,
location: OcgcoreScriptConstants.LOCATION_GRAVE,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: selfIngamePos,
location: OcgcoreScriptConstants.LOCATION_GRAVE,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
// RefreshExtra
await room.refreshLocations(
{
player: opponentIngamePos,
location: OcgcoreScriptConstants.LOCATION_EXTRA,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: selfIngamePos,
location: OcgcoreScriptConstants.LOCATION_EXTRA,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
// RefreshRemoved
await room.refreshLocations(
{
player: opponentIngamePos,
location: OcgcoreScriptConstants.LOCATION_REMOVED,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: selfIngamePos,
location: OcgcoreScriptConstants.LOCATION_REMOVED,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
}
private isReconnectingPlayerOperating(client: Client, room: Room): boolean {
// 检查重连玩家是否是当前操作玩家
const ingameDuelPos = room.getIngameDuelPosByDuelPos(client.pos);
const operatingPlayer = room.getIngameOperatingPlayer(ingameDuelPos);
return operatingPlayer === client;
}
private findLastHintForClient(
client: Client,
room: Room,
): YGOProMsgHint | undefined {
const messages = room.lastDuelRecord?.messages;
if (!messages) {
return undefined;
}
// 提前计算 ingame pos
const clientIngamePos = room.getIngameDuelPosByDuelPos(client.pos);
// 从后往前找
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
// 只找 Hint 消息
if (!(msg instanceof YGOProMsgHint)) {
continue;
}
// 检查 getSendTargets 是否包含重连玩家
try {
const targets = msg.getSendTargets(); // 返回 number[] (ingame pos 数组)
if (targets.includes(clientIngamePos)) {
return msg.playerView(clientIngamePos);
}
} catch {
// getSendTargets 可能失败,忽略
continue;
}
}
return undefined;
}
private getClientRoom(client: Client): Room | undefined { private getClientRoom(client: Client): Room | undefined {
if (!client.roomName) { if (!client.roomName) {
return undefined; return undefined;
...@@ -858,3 +636,4 @@ export class Reconnect { ...@@ -858,3 +636,4 @@ export class Reconnect {
} }
export * from './can-reconnect-check'; export * from './can-reconnect-check';
export * from './refresh-field-service';
import {
NetPlayerType,
OcgcoreScriptConstants,
YGOProMsgHint,
YGOProMsgNewPhase,
YGOProMsgNewTurn,
YGOProMsgWaiting,
YGOProStocGameMsg,
} from 'ygopro-msg-encode';
import { Context } from '../../app';
import { Client } from '../../client';
import { DuelStage, Room } from '../../room';
export class RefreshFieldService {
constructor(private ctx: Context) {}
async sendReconnectDuelingMessages(client: Client, room: Room) {
this.assertRefreshAllowed(client, room);
await this.sendNewTurnMessages(client, room);
await this.sendRefreshFieldMessages(client, room);
}
async sendRefreshFieldMessages(client: Client, room: Room) {
this.assertRefreshAllowed(client, room);
if (room.phase != null) {
await client.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgNewPhase().fromPartial({
phase: room.phase,
}),
}),
);
}
await client.send(await this.requestField(room));
await this.sendRefreshMessages(client, room);
const needResendRequest =
this.isReconnectingPlayerOperating(client, room);
if (needResendRequest) {
const lastHint = this.findLastHintForClient(client, room);
if (lastHint) {
await client.send(
new YGOProStocGameMsg().fromPartial({
msg: lastHint,
}),
);
}
if (room.lastResponseRequestMsg) {
await client.send(
new YGOProStocGameMsg().fromPartial({
msg: room.lastResponseRequestMsg.playerView(
room.getIngameDuelPos(client),
),
}),
);
await room.setResponseTimer(room.getDuelPos(client));
}
return;
}
await client.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgWaiting(),
}),
);
}
private assertRefreshAllowed(client: Client, room: Room) {
if (room.duelStage !== DuelStage.Dueling) {
throw new Error(`Room ${room.name} is not in dueling stage`);
}
if (client.pos >= NetPlayerType.OBSERVER) {
throw new Error(
`Client ${client.name || client.ip} is not an active duelist`,
);
}
}
private async sendNewTurnMessages(client: Client, room: Room) {
const turnCount = Math.max(1, room.turnCount || 0);
if (room.isTag) {
const newTurnCount = turnCount % 4 || 4;
for (let i = 0; i < newTurnCount; i += 1) {
await client.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgNewTurn().fromPartial({
player: i % 2,
}),
}),
);
}
return;
}
const newTurnCount = turnCount % 2 === 0 ? 2 : 1;
for (let i = 0; i < newTurnCount; i += 1) {
await client.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgNewTurn().fromPartial({
player: i,
}),
}),
);
}
}
private async requestField(room: Room): Promise<YGOProStocGameMsg> {
if (!room.ocgcore) {
throw new Error('OCGCore not initialized');
}
const info = await room.ocgcore.queryFieldInfo();
return new YGOProStocGameMsg().fromPartial({
msg: info.field,
});
}
private async sendRefreshMessages(client: Client, room: Room) {
const queryFlag = 0xefffff;
const selfIngamePos = room.getIngameDuelPos(client);
const opponentIngamePos = 1 - selfIngamePos;
await room.refreshLocations(
{
player: opponentIngamePos,
location: OcgcoreScriptConstants.LOCATION_MZONE,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: selfIngamePos,
location: OcgcoreScriptConstants.LOCATION_MZONE,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: opponentIngamePos,
location: OcgcoreScriptConstants.LOCATION_SZONE,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: selfIngamePos,
location: OcgcoreScriptConstants.LOCATION_SZONE,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: opponentIngamePos,
location: OcgcoreScriptConstants.LOCATION_HAND,
},
{ queryFlag, sendToClient: client },
);
await room.refreshLocations(
{ player: selfIngamePos, location: OcgcoreScriptConstants.LOCATION_HAND },
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: opponentIngamePos,
location: OcgcoreScriptConstants.LOCATION_GRAVE,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: selfIngamePos,
location: OcgcoreScriptConstants.LOCATION_GRAVE,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: opponentIngamePos,
location: OcgcoreScriptConstants.LOCATION_EXTRA,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: selfIngamePos,
location: OcgcoreScriptConstants.LOCATION_EXTRA,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: opponentIngamePos,
location: OcgcoreScriptConstants.LOCATION_REMOVED,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
await room.refreshLocations(
{
player: selfIngamePos,
location: OcgcoreScriptConstants.LOCATION_REMOVED,
},
{ queryFlag, sendToClient: client, useCache: 0 },
);
}
private isReconnectingPlayerOperating(client: Client, room: Room): boolean {
const ingameDuelPos = room.getIngameDuelPos(client);
const operatingPlayer = room.getIngameOperatingPlayer(ingameDuelPos);
return operatingPlayer === client;
}
private findLastHintForClient(
client: Client,
room: Room,
): YGOProMsgHint | undefined {
const messages = room.lastDuelRecord?.messages;
if (!messages) {
return undefined;
}
const clientIngamePos = room.getIngameDuelPos(client);
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i];
if (!(msg instanceof YGOProMsgHint)) {
continue;
}
try {
const targets = msg.getSendTargets();
if (targets.includes(clientIngamePos)) {
return msg.playerView(clientIngamePos);
}
} catch {
continue;
}
}
return undefined;
}
}
import { ChatColor } from 'ygopro-msg-encode'; import { ChatColor } from 'ygopro-msg-encode';
import { Context } from '../../app'; import { Context } from '../../app';
import { Chnroute, Client } from '../../client'; import { Chnroute, Client } from '../../client';
import { KoishiContextService } from '../../koishi';
import { DuelStage, OnRoomDuelStart, Room, RoomManager } from '../../room'; import { DuelStage, OnRoomDuelStart, Room, RoomManager } from '../../room';
import { ValueContainer } from '../../utility/value-container'; import { ValueContainer } from '../../utility/value-container';
import { pickRandom } from '../../utility/pick-random'; import { pickRandom } from '../../utility/pick-random';
...@@ -31,6 +32,7 @@ export class TipsProvider extends BaseResourceProvider<TipsData> { ...@@ -31,6 +32,7 @@ export class TipsProvider extends BaseResourceProvider<TipsData> {
); );
private chnroute = this.ctx.get(() => Chnroute); private chnroute = this.ctx.get(() => Chnroute);
private roomManager = this.ctx.get(() => RoomManager); private roomManager = this.ctx.get(() => RoomManager);
private koishiContextService = this.ctx.get(() => KoishiContextService);
private timersRegistered = false; private timersRegistered = false;
constructor(ctx: Context) { constructor(ctx: Context) {
...@@ -43,6 +45,20 @@ export class TipsProvider extends BaseResourceProvider<TipsData> { ...@@ -43,6 +45,20 @@ export class TipsProvider extends BaseResourceProvider<TipsData> {
return; return;
} }
const koishi = this.koishiContextService.instance;
this.koishiContextService.attachI18n('tip', {
description: 'koishi_cmd_tip_desc',
});
koishi.command('tip', '').action(async ({ session }) => {
const commandContext =
this.koishiContextService.resolveCommandContext(session);
if (!commandContext) {
return;
}
await this.sendRandomTip(commandContext.client, commandContext.room);
});
this.ctx.middleware(OnRoomDuelStart, async (event, _client, next) => { this.ctx.middleware(OnRoomDuelStart, async (event, _client, next) => {
await this.sendRandomTipToRoom(event.room); await this.sendRandomTipToRoom(event.room);
return next(); return next();
......
import cryptoRandomString from 'crypto-random-string'; import cryptoRandomString from 'crypto-random-string';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import { h } from 'koishi';
import { ChatColor } from 'ygopro-msg-encode'; import { ChatColor } from 'ygopro-msg-encode';
import WebSocket from 'ws'; import WebSocket from 'ws';
import { Context } from '../../app'; import { Context } from '../../app';
import { ClientHandler } from '../../client'; import { ClientHandler } from '../../client';
import { KoishiContextService } from '../../koishi';
import { OnRoomFinalize, Room } from '../../room'; import { OnRoomFinalize, Room } from '../../room';
import type { import type {
RequestWindbotJoinOptions, RequestWindbotJoinOptions,
...@@ -39,11 +41,59 @@ export class WindBotProvider { ...@@ -39,11 +41,59 @@ export class WindBotProvider {
private tokenDataMap = new Map<string, WindbotJoinTokenData>(); private tokenDataMap = new Map<string, WindbotJoinTokenData>();
private roomTokenMap = new Map<string, Set<string>>(); private roomTokenMap = new Map<string, Set<string>>();
private clientHandler = this.ctx.get(() => ClientHandler); private clientHandler = this.ctx.get(() => ClientHandler);
private koishiContextService = this.ctx.get(() => KoishiContextService);
private asRedError(message: string) {
return h('Chat', { color: 'Red' }, message);
}
constructor(private ctx: Context) { constructor(private ctx: Context) {
if (!this.enabled) { if (!this.enabled) {
return; return;
} }
const koishi = this.koishiContextService.instance;
this.koishiContextService.attachI18n('ai', {
description: 'koishi_cmd_ai_desc',
});
koishi.command('ai [name:text]', '').action(async ({ session }, name) => {
const commandContext =
this.koishiContextService.resolveCommandContext(session);
if (!commandContext) {
return;
}
const { room, client } = commandContext;
if (!client.isHost) {
return this.asRedError('#{koishi_ai_only_host}');
}
if (!this.enabled) {
return this.asRedError('#{koishi_ai_disabled}');
}
if (room.randomType) {
return this.asRedError('#{koishi_ai_disabled_random_room}');
}
let hasFreeSeat = false;
for (let i = 0; i < room.players.length; i += 1) {
if (!room.players[i]) {
hasFreeSeat = true;
break;
}
}
if (!hasFreeSeat) {
return this.asRedError('#{koishi_ai_room_full}');
}
const botName = (name || '').trim() || undefined;
if (botName && !this.getBotByNameOrDeck(botName)) {
return this.asRedError('#{windbot_deck_not_found}');
}
if (!botName && !this.getRandomBot()) {
return this.asRedError('#{windbot_deck_not_found}');
}
await this.requestWindbotJoin(room, botName);
});
this.ctx.middleware(OnRoomFinalize, async (event, _client, next) => { this.ctx.middleware(OnRoomFinalize, async (event, _client, next) => {
this.deleteRoomToken(event.room.name); this.deleteRoomToken(event.room.name);
return next(); return next();
......
...@@ -154,7 +154,8 @@ export class JoinBlankPassMenu { ...@@ -154,7 +154,8 @@ export class JoinBlankPassMenu {
private async dispatchJoinGameFromMenu(client: Client, pass: string) { private async dispatchJoinGameFromMenu(client: Client, pass: string) {
const joinMsg = new YGOProCtosJoinGame().fromPartial({ const joinMsg = new YGOProCtosJoinGame().fromPartial({
version: client.menuJoinVersion || this.ctx.config.getInt('YGOPRO_VERSION'), version:
client.menuJoinVersion || this.ctx.config.getInt('YGOPRO_VERSION'),
gameid: client.menuJoinGameId || 0, gameid: client.menuJoinGameId || 0,
pass, pass,
}); });
......
...@@ -3,6 +3,7 @@ import { ContextState } from '../app'; ...@@ -3,6 +3,7 @@ import { ContextState } from '../app';
import { ClientVersionCheck } from '../feats'; import { ClientVersionCheck } from '../feats';
import { JoinWindbotAi, JoinWindbotToken } from '../feats/windbot'; import { JoinWindbotAi, JoinWindbotToken } from '../feats/windbot';
import { JoinRoom } from './join-room'; import { JoinRoom } from './join-room';
import { JoinRoomIp } from './join-room-ip';
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';
...@@ -18,6 +19,7 @@ export const JoinHandlerModule = createAppContext<ContextState>() ...@@ -18,6 +19,7 @@ export const JoinHandlerModule = createAppContext<ContextState>()
.provide(BadwordPlayerInfoChecker) .provide(BadwordPlayerInfoChecker)
.provide(RandomDuelJoinHandler) .provide(RandomDuelJoinHandler)
.provide(JoinWindbotAi) .provide(JoinWindbotAi)
.provide(JoinRoomIp)
.provide(JoinRoom) .provide(JoinRoom)
.provide(JoinBlankPassMenu) .provide(JoinBlankPassMenu)
.provide(JoinBlankPassRandomDuel) .provide(JoinBlankPassRandomDuel)
......
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
export class JoinRoomIp {
constructor(private ctx: Context) {
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
const pass = (msg.pass || '').trim();
if (!pass) {
return next();
}
if (pass.toUpperCase() !== 'IP') {
return next();
}
const ip = client.ip || client.physicalIp() || 'unknown';
return client.die(`IP: ${ip}`, ChatColor.BABYBLUE);
});
}
}
import { h } from 'koishi';
import { YGOProCtosSurrender } from 'ygopro-msg-encode';
import { Context } from '../app';
import { RefreshFieldService } from '../feats';
import { KoishiContextService } from './koishi-context-service';
export class CommandsService {
private logger = this.ctx.createLogger(this.constructor.name);
private koishiContextService = this.ctx.get(() => KoishiContextService);
private refreshFieldService = this.ctx.get(() => RefreshFieldService);
constructor(private ctx: Context) {
const koishi = this.koishiContextService.instance;
this.koishiContextService
.attachI18n('surrender', {
description: 'koishi_cmd_surrender_desc',
})
.attachI18n('roomname', {
description: 'koishi_cmd_roomname_desc',
})
.attachI18n('refresh', {
description: 'koishi_cmd_refresh_desc',
})
.attachI18n('ip', {
description: 'koishi_cmd_ip_desc',
});
koishi
.command('surrender', '')
.alias('投降')
.action(async ({ session }) => {
const commandContext =
this.koishiContextService.resolveCommandContext(session);
if (!commandContext) {
return;
}
await this.ctx.dispatch(new YGOProCtosSurrender(), commandContext.client);
});
koishi.command('roomname', '').action(({ session }) => {
const commandContext =
this.koishiContextService.resolveCommandContext(session);
if (!commandContext) {
return;
}
return `#{room_name} ${commandContext.room.name}`;
});
koishi.command('refresh', '').action(async ({ session }) => {
const commandContext =
this.koishiContextService.resolveCommandContext(session);
if (!commandContext) {
return;
}
try {
await this.refreshFieldService.sendRefreshFieldMessages(
commandContext.client,
commandContext.room,
);
return '#{refresh_success}';
} catch (error) {
this.logger.warn(
{
roomName: commandContext.room.name,
clientName: commandContext.client.name,
error: (error as Error).toString(),
},
'Failed refreshing field by /refresh',
);
return h('Chat', { color: 'Red' }, '#{refresh_fail}');
}
});
koishi.command('ip', '').action(({ session }) => {
const commandContext =
this.koishiContextService.resolveCommandContext(session);
if (!commandContext) {
return;
}
const ip =
commandContext.client.ip ||
commandContext.client.physicalIp() ||
'unknown';
return `IP: ${ip}`;
});
}
}
export * from './koishi-context-service';
export * from './commands-service';
This diff is collapsed.
...@@ -1190,7 +1190,7 @@ export class Room { ...@@ -1190,7 +1190,7 @@ export class Room {
}); });
} }
private async setResponseTimer( async setResponseTimer(
originalDuelPos: number, originalDuelPos: number,
options: { options: {
settlePrevious?: boolean; settlePrevious?: boolean;
...@@ -1813,7 +1813,7 @@ export class Room { ...@@ -1813,7 +1813,7 @@ export class Room {
this.getIngameOperatingPlayer( this.getIngameOperatingPlayer(
this.getIngameDuelPosByDuelPos(this.responsePos), this.getIngameDuelPosByDuelPos(this.responsePos),
) || ) ||
!this.ocgcore !this.ocgcore // || this.timerState.awaitingConfirm
) { ) {
return; return;
} }
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"skipLibCheck": true,
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
"types": [ "types": [
......
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