Commit 5f90cc89 authored by nanahira's avatar nanahira

random duel things

parent aff3079d
Pipeline #43255 failed with stages
in 74 minutes and 37 seconds
FROM node:lts-trixie-slim as base FROM node:lts-trixie-slim as base
LABEL Author="Nanahira <nanahira@momobako.com>" LABEL Author="Nanahira <nanahira@momobako.com>"
RUN apt update && apt -y install python3 build-essential && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/* RUN apt update && apt -y install python3 build-essential libpq-dev && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/*
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY ./package*.json ./ COPY ./package*.json ./
...@@ -12,7 +12,8 @@ RUN npm run build ...@@ -12,7 +12,8 @@ RUN npm run build
FROM base FROM base
ENV NODE_ENV production ENV NODE_ENV production
RUN npm ci && npm cache clean --force RUN npm ci && npm install --no-save pg-native && npm cache clean --force
COPY --from=builder /usr/src/app/dist ./dist COPY --from=builder /usr/src/app/dist ./dist
ENV NODE_PG_FORCE_NATIVE=true
CMD [ "npm", "start" ] CMD [ "npm", "start" ]
host: "::" host: "::"
port: 7911 port: 7911
dbHost: ""
dbPort: 5432
dbUser: postgres
dbPass: ""
dbName: srvpro2
dbNoInit: 0
redisUrl: "" redisUrl: ""
logLevel: info logLevel: info
wsPort: 0 wsPort: 0
...@@ -33,6 +39,15 @@ windbotEndpoint: http://127.0.0.1:2399 ...@@ -33,6 +39,15 @@ windbotEndpoint: http://127.0.0.1:2399
windbotMyIp: 127.0.0.1 windbotMyIp: 127.0.0.1
enableReconnect: 1 enableReconnect: 1
reconnectTimeout: 180000 reconnectTimeout: 180000
enableRandomDuel: 1
randomDuelBlankPassModes:
- S
- M
randomDuelNoRematchCheck: 0
randomDuelRecordMatchScores: 1
randomDuelDisableChat: 0
randomDuelReadyTime: 20
randomDuelHangTimeout: 90
sideTimeoutMinutes: 3 sideTimeoutMinutes: 3
hostinfoLflist: 0 hostinfoLflist: 0
hostinfoRule: 0 hostinfoRule: 0
......
This diff is collapsed.
...@@ -10,6 +10,7 @@ import { RoomModule } from './room/room-module'; ...@@ -10,6 +10,7 @@ import { RoomModule } from './room/room-module';
import { SqljsFactory, SqljsLoader } from './services/sqljs'; import { SqljsFactory, SqljsLoader } from './services/sqljs';
import { FeatsModule } from './feats/feats-module'; import { FeatsModule } from './feats/feats-module';
import { MiddlewareRx } from './services/middleware-rx'; import { MiddlewareRx } from './services/middleware-rx';
import { TypeormFactory, TypeormLoader } from './services/typeorm';
const core = createAppContext() const core = createAppContext()
.provide(ConfigService, { .provide(ConfigService, {
...@@ -24,6 +25,10 @@ const core = createAppContext() ...@@ -24,6 +25,10 @@ const core = createAppContext()
useFactory: SqljsFactory, useFactory: SqljsFactory,
merge: ['SQL'], merge: ['SQL'],
}) })
.provide(TypeormLoader, {
useFactory: TypeormFactory,
merge: ['database'],
})
.define(); .define();
export type Context = typeof core; export type Context = typeof core;
......
...@@ -27,18 +27,16 @@ export class ClientHandler { ...@@ -27,18 +27,16 @@ export class ClientHandler {
// ws/reverse-ws should already have IP from connection metadata, skip overwrite // ws/reverse-ws should already have IP from connection metadata, skip overwrite
return next(); return next();
} }
this.ctx await this.ctx.get(() => IpResolver).setClientIp(
.get(() => IpResolver) client,
.setClientIp( msg.real_ip === '0.0.0.0' ? undefined : msg.real_ip,
client, );
msg.real_ip === '0.0.0.0' ? undefined : msg.real_ip,
);
client.hostname = msg.hostname?.split(':')[0] || ''; client.hostname = msg.hostname?.split(':')[0] || '';
return next(); return next();
}) })
.middleware(YGOProCtosPlayerInfo, async (msg, client, next) => { .middleware(YGOProCtosPlayerInfo, async (msg, client, next) => {
if (!client.ip) { if (!client.ip) {
this.ctx.get(() => IpResolver).setClientIp(client); await this.ctx.get(() => IpResolver).setClientIp(client);
} }
const [name, vpass] = msg.name.split('$'); const [name, vpass] = msg.name.split('$');
client.name = name; client.name = name;
......
import { CacheKey } from 'aragami';
import { Context } from '../app'; import { Context } from '../app';
import { Client } from './client'; import { Client } from './client';
import * as ipaddr from 'ipaddr.js'; import * as ipaddr from 'ipaddr.js';
import { YGOProCtosDisconnect } from '../utility/ygopro-ctos-disconnect';
const IP_RESOLVER_TTL = 24 * 60 * 60 * 1000;
class ConnectedIpCountCache {
@CacheKey()
ip!: string;
count = 0;
}
class BadIpCountCache {
@CacheKey()
ip!: string;
count = 0;
}
export class IpResolver { export class IpResolver {
private logger = this.ctx.createLogger('IpResolver'); private logger = this.ctx.createLogger('IpResolver');
private connectedIpCount = new Map<string, number>();
private badIpCount = new Map<string, number>();
private trustedProxies: Array<[ipaddr.IPv4 | ipaddr.IPv6, number]> = []; private trustedProxies: Array<[ipaddr.IPv4 | ipaddr.IPv6, number]> = [];
constructor(private ctx: Context) { constructor(private ctx: Context) {
...@@ -26,6 +42,11 @@ export class IpResolver { ...@@ -26,6 +42,11 @@ export class IpResolver {
{ count: this.trustedProxies.length }, { count: this.trustedProxies.length },
'Trusted proxies initialized', 'Trusted proxies initialized',
); );
this.ctx.middleware(YGOProCtosDisconnect, async (_msg, client, next) => {
await this.releaseClientIp(client);
return next();
});
} }
toIpv4(ip: string): string { toIpv4(ip: string): string {
...@@ -73,7 +94,7 @@ export class IpResolver { ...@@ -73,7 +94,7 @@ export class IpResolver {
* @param xffIp Optional X-Forwarded-For IP * @param xffIp Optional X-Forwarded-For IP
* @returns true if client should be rejected (bad IP or too many connections) * @returns true if client should be rejected (bad IP or too many connections)
*/ */
setClientIp(client: Client, xffIp?: string): boolean { async setClientIp(client: Client, xffIp?: string): Promise<boolean> {
const prevIp = client.ip; const prevIp = client.ip;
// Priority: passed xffIp > client.xffIp() > client.physicalIp() // Priority: passed xffIp > client.xffIp() > client.physicalIp()
...@@ -89,13 +110,9 @@ export class IpResolver { ...@@ -89,13 +110,9 @@ export class IpResolver {
// Decrement count for previous IP // Decrement count for previous IP
if (prevIp) { if (prevIp) {
const prevCount = this.connectedIpCount.get(prevIp) || 0; const prevCount = await this.getConnectedIpCount(prevIp);
if (prevCount > 0) { if (prevCount > 0) {
if (prevCount === 1) { await this.setConnectedIpCount(prevIp, prevCount - 1);
this.connectedIpCount.delete(prevIp);
} else {
this.connectedIpCount.set(prevIp, prevCount - 1);
}
} }
} }
...@@ -110,7 +127,7 @@ export class IpResolver { ...@@ -110,7 +127,7 @@ export class IpResolver {
const noConnectCountLimit = this.ctx.config.getBoolean( const noConnectCountLimit = this.ctx.config.getBoolean(
'NO_CONNECT_COUNT_LIMIT', 'NO_CONNECT_COUNT_LIMIT',
); );
let connectCount = this.connectedIpCount.get(newIp) || 0; let connectCount = await this.getConnectedIpCount(newIp);
if ( if (
!noConnectCountLimit && !noConnectCountLimit &&
...@@ -119,13 +136,11 @@ export class IpResolver { ...@@ -119,13 +136,11 @@ export class IpResolver {
!this.isTrustedProxy(newIp) !this.isTrustedProxy(newIp)
) { ) {
connectCount++; connectCount++;
this.connectedIpCount.set(newIp, connectCount);
} else {
this.connectedIpCount.set(newIp, connectCount);
} }
await this.setConnectedIpCount(newIp, connectCount);
// Check if IP should be rejected // Check if IP should be rejected
const badCount = this.badIpCount.get(newIp) || 0; const badCount = await this.getBadIpCount(newIp);
if (badCount > 5 || connectCount > 10) { if (badCount > 5 || connectCount > 10) {
this.logger.info( this.logger.info(
{ ip: newIp, badCount, connectCount }, { ip: newIp, badCount, connectCount },
...@@ -142,9 +157,9 @@ export class IpResolver { ...@@ -142,9 +157,9 @@ export class IpResolver {
* Mark an IP as bad (increment bad count) * Mark an IP as bad (increment bad count)
* @param ip The IP address to mark as bad * @param ip The IP address to mark as bad
*/ */
addBadIp(ip: string): void { async addBadIp(ip: string): Promise<void> {
const currentCount = this.badIpCount.get(ip) || 0; const currentCount = await this.getBadIpCount(ip);
this.badIpCount.set(ip, currentCount + 1); await this.setBadIpCount(ip, currentCount + 1);
this.logger.warn( this.logger.warn(
{ ip, count: currentCount + 1 }, { ip, count: currentCount + 1 },
'Bad IP count incremented', 'Bad IP count incremented',
...@@ -154,30 +169,80 @@ export class IpResolver { ...@@ -154,30 +169,80 @@ export class IpResolver {
/** /**
* Get the current connection count for an IP * Get the current connection count for an IP
*/ */
getConnectedIpCount(ip: string): number { async getConnectedIpCount(ip: string): Promise<number> {
return this.connectedIpCount.get(ip) || 0; const data = await this.ctx.aragami.get(ConnectedIpCountCache, ip);
return data?.count || 0;
} }
/** /**
* Get the bad count for an IP * Get the bad count for an IP
*/ */
getBadIpCount(ip: string): number { async getBadIpCount(ip: string): Promise<number> {
return this.badIpCount.get(ip) || 0; const data = await this.ctx.aragami.get(BadIpCountCache, ip);
return data?.count || 0;
} }
/** /**
* Clear all connection counts (useful for testing or maintenance) * Clear all connection counts (useful for testing or maintenance)
*/ */
clearConnectionCounts(): void { async clearConnectionCounts(): Promise<void> {
this.connectedIpCount.clear(); await this.ctx.aragami.clear(ConnectedIpCountCache);
this.logger.debug('Connection counts cleared'); this.logger.debug('Connection counts cleared');
} }
/** /**
* Clear all bad IP counts (useful for testing or maintenance) * Clear all bad IP counts (useful for testing or maintenance)
*/ */
clearBadIpCounts(): void { async clearBadIpCounts(): Promise<void> {
this.badIpCount.clear(); await this.ctx.aragami.clear(BadIpCountCache);
this.logger.debug('Bad IP counts cleared'); this.logger.debug('Bad IP counts cleared');
} }
private async setConnectedIpCount(ip: string, count: number) {
if (count <= 0) {
await this.ctx.aragami.del(ConnectedIpCountCache, ip);
return;
}
await this.ctx.aragami.set(
ConnectedIpCountCache,
{
ip,
count,
},
{
key: ip,
ttl: IP_RESOLVER_TTL,
},
);
}
private async setBadIpCount(ip: string, count: number) {
if (count <= 0) {
await this.ctx.aragami.del(BadIpCountCache, ip);
return;
}
await this.ctx.aragami.set(
BadIpCountCache,
{
ip,
count,
},
{
key: ip,
ttl: IP_RESOLVER_TTL,
},
);
}
private async releaseClientIp(client: Client) {
const ip = client.ip;
if (!ip) {
return;
}
const currentCount = await this.getConnectedIpCount(ip);
if (currentCount <= 0) {
return;
}
await this.setConnectedIpCount(ip, currentCount - 1);
}
} }
...@@ -45,7 +45,9 @@ export class WsServer { ...@@ -45,7 +45,9 @@ export class WsServer {
this.wss = new WebSocketServer({ server: this.httpServer }); this.wss = new WebSocketServer({ server: this.httpServer });
this.wss.on('connection', (ws, req) => { this.wss.on('connection', (ws, req) => {
this.handleConnection(ws, req); this.handleConnection(ws, req).catch((err) => {
this.logger.error({ err }, 'Error handling WebSocket connection');
});
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
...@@ -64,13 +66,17 @@ export class WsServer { ...@@ -64,13 +66,17 @@ export class WsServer {
}); });
} }
private handleConnection(ws: WebSocket, req: IncomingMessage): void { private async handleConnection(
ws: WebSocket,
req: IncomingMessage,
): Promise<void> {
const client = new WsClient(this.ctx, ws, req); const client = new WsClient(this.ctx, ws, req);
if (this.ctx.get(() => IpResolver).setClientIp(client, client.xffIp())) if (await this.ctx.get(() => IpResolver).setClientIp(client, client.xffIp())) {
return; return;
}
client.hostname = req.headers.host?.split(':')[0] || ''; client.hostname = req.headers.host?.split(':')[0] || '';
const handler = this.ctx.get(() => ClientHandler); const handler = this.ctx.get(() => ClientHandler);
handler.handleClient(client).catch((err) => { await handler.handleClient(client).catch((err) => {
this.logger.error({ err }, 'Error handling client'); this.logger.error({ err }, 'Error handling client');
}); });
} }
......
...@@ -12,6 +12,19 @@ export const defaultConfig = { ...@@ -12,6 +12,19 @@ export const defaultConfig = {
HOST: '::', HOST: '::',
// Main server port for YGOPro clients. Format: integer string. // Main server port for YGOPro clients. Format: integer string.
PORT: '7911', PORT: '7911',
// PostgreSQL host. Empty means database disabled.
DB_HOST: '',
// PostgreSQL port. Format: integer string.
DB_PORT: '5432',
// PostgreSQL username.
DB_USER: 'postgres',
// PostgreSQL password.
DB_PASS: '',
// PostgreSQL database name.
DB_NAME: 'srvpro2',
// Skip schema initialization/synchronize when set to '1'.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
DB_NO_INIT: '0',
// Redis connection URL. Format: URL string. Empty means disabled. // Redis connection URL. Format: URL string. Empty means disabled.
REDIS_URL: '', REDIS_URL: '',
// Log level. Format: lowercase string (e.g. info/debug/warn/error). // Log level. Format: lowercase string (e.g. info/debug/warn/error).
...@@ -79,6 +92,27 @@ export const defaultConfig = { ...@@ -79,6 +92,27 @@ export const defaultConfig = {
ENABLE_RECONNECT: '1', ENABLE_RECONNECT: '1',
// Reconnect timeout after disconnect. Format: integer string in milliseconds (ms). // Reconnect timeout after disconnect. Format: integer string in milliseconds (ms).
RECONNECT_TIMEOUT: '180000', RECONNECT_TIMEOUT: '180000',
// Enable random duel feature.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
ENABLE_RANDOM_DUEL: '1',
// Random duel modes that can be matched by blank pass.
// Format: comma-separated mode names. The first item is used as default type.
RANDOM_DUEL_BLANK_PASS_MODES: 'S,M',
// Disable rematch checking for random duel.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
RANDOM_DUEL_NO_REMATCH_CHECK: '0',
// Record random match scores (effective only when database is enabled).
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
RANDOM_DUEL_RECORD_MATCH_SCORES: '1',
// Disable chat in random duel rooms.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
RANDOM_DUEL_DISABLE_CHAT: '0',
// Random duel ready countdown before kicking the only unready player in Begin stage.
// Format: integer string in seconds (s). '0' or negative disables the feature.
RANDOM_DUEL_READY_TIME: '20',
// Random duel AFK timeout while waiting for player action.
// Format: integer string in seconds (s). '0' or negative disables the feature.
RANDOM_DUEL_HANG_TIMEOUT: '90',
// Side deck timeout in minutes during siding stage. // Side deck timeout in minutes during siding stage.
// Format: integer string. '0' or negative disables the feature. // Format: integer string. '0' or negative disables the feature.
SIDE_TIMEOUT_MINUTES: '3', SIDE_TIMEOUT_MINUTES: '3',
......
export const MAX_ROOM_NAME_LENGTH = 19;
...@@ -35,6 +35,20 @@ export const TRANSLATIONS = { ...@@ -35,6 +35,20 @@ export const TRANSLATIONS = {
side_remain_part2: ' minutes.', side_remain_part2: ' minutes.',
side_overtime: 'You exceeded side changing time and were kicked by system.', side_overtime: 'You exceeded side changing time and were kicked by system.',
side_overtime_room: ' exceeded side changing time and was kicked by system.', side_overtime_room: ' exceeded side changing time and was kicked by system.',
kicked_by_system: 'was evicted from the game by server.',
kick_count_down:
' seconds later this player will be evicted for not getting ready or starting the game.',
afk_warn_part1: 'no operation too long, will be disconnected after ',
afk_warn_part2: ' seconds',
random_duel_enter_room_waiting: 'Your opponent is ready, start now!',
random_duel_enter_room_new: 'Game created, waiting for random opponent.',
random_duel_enter_room_single:
'Single mode room. Password M for match mode, T for tag mode.',
random_duel_enter_room_match:
'Match mode room. Password S for single mode, T for tag mode.',
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.',
}, },
'zh-CN': { 'zh-CN': {
update_required: '请更新你的客户端版本', update_required: '请更新你的客户端版本',
...@@ -69,5 +83,18 @@ export const TRANSLATIONS = { ...@@ -69,5 +83,18 @@ export const TRANSLATIONS = {
side_remain_part2: '分钟。', side_remain_part2: '分钟。',
side_overtime: '你更换副卡组超时,已被系统踢出。', side_overtime: '你更换副卡组超时,已被系统踢出。',
side_overtime_room: '更换副卡组超时,已被系统踢出。', side_overtime_room: '更换副卡组超时,已被系统踢出。',
kicked_by_system: '被系统请出了房间',
kick_count_down: '秒后若不准备或开始游戏将被请出房间',
afk_warn_part1: '已经很久没有操作了,若继续挂机,将于',
afk_warn_part2: '秒后被请出房间',
random_duel_enter_room_waiting: '对手已经在等你了,开始决斗吧!',
random_duel_enter_room_new: '已建立随机对战房间,正在等待对手!',
random_duel_enter_room_single:
'您进入了单局模式房间,密码输入 M 进入比赛模式,输入 T 进入双打模式。',
random_duel_enter_room_match:
'您进入了比赛模式房间,密码输入 S 进入单局模式,输入 T 进入双打模式。',
random_duel_enter_room_tag:
'您进入了双打模式房间,密码输入 S 进入单局模式,输入 M 进入比赛模式。',
chat_disabled: '本房间禁止聊天。',
}, },
}; };
...@@ -4,14 +4,18 @@ import { ContextState } from '../app'; ...@@ -4,14 +4,18 @@ 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'; import { WindbotModule } from './windbot';
import { SideTimeout } from './side-timeout'; import { SideTimeout } from './side-timeout';
import { RandomDuelModule } from './random-duel';
import { WaitForPlayerProvider } from './wait-for-player-provider';
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)
.provide(WaitForPlayerProvider)
.provide(SideTimeout) .provide(SideTimeout)
.use(RandomDuelModule)
.use(WindbotModule)
.define(); .define();
export * from './client-version-check';
export * from './random-duel';
export * from './reconnect';
export * from './wait-for-player-provider';
export * from './module';
export * from './provider';
export * from './score.entity';
import { createAppContext } from 'nfkit';
import { ContextState } from '../../app';
import { RandomDuelProvider } from './provider';
export const RandomDuelModule = createAppContext<ContextState>()
.provide(RandomDuelProvider)
.define();
This diff is collapsed.
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity('random_duel_score')
export class RandomDuelScore {
@PrimaryColumn({ type: 'varchar', length: 64 })
name!: string;
@Index()
@Column('int', { default: 0 })
winCount = 0;
@Index()
@Column('int', { default: 0 })
loseCount = 0;
@Index()
@Column('int', { default: 0 })
fleeCount = 0;
@Column('int', { default: 0 })
winCombo = 0;
win() {
this.winCount += 1;
this.winCombo += 1;
}
lose() {
this.loseCount += 1;
this.winCombo = 0;
}
flee() {
this.fleeCount += 1;
this.lose();
}
}
import { Client } from '../../client';
import { Room } from '../../room';
import { ValueContainer } from '../../utility/value-container';
export class CanReconnectCheck extends ValueContainer<boolean> {
constructor(
public client: Client,
public room: Room,
) {
super(true);
}
get canReconnect() {
return this.value;
}
no() {
return this.use(false);
}
}
...@@ -22,16 +22,16 @@ import { ...@@ -22,16 +22,16 @@ import {
ErrorMessageType, ErrorMessageType,
YGOProStocErrorMsg, YGOProStocErrorMsg,
} from 'ygopro-msg-encode'; } from 'ygopro-msg-encode';
import { Context } from '../app'; import { Context } from '../../app';
import { Client } from '../client'; import { Client } from '../../client';
import { DuelStage } from '../room/duel-stage'; import { DuelStage, Room, RoomManager } from '../../room';
import { Room } from '../room'; import { getSpecificFields } from '../../utility/metadata';
import { RoomManager } from '../room/room-manager'; import { YGOProCtosDisconnect } from '../../utility/ygopro-ctos-disconnect';
import { getSpecificFields } from '../utility/metadata'; import { isUpdateDeckPayloadEqual } from '../../utility/deck-compare';
import { YGOProCtosDisconnect } from '../utility/ygopro-ctos-disconnect'; import { CanReconnectCheck } from './can-reconnect-check';
import { isUpdateDeckPayloadEqual } from '../utility/deck-compare';
interface DisconnectInfo { interface DisconnectInfo {
key: string;
roomName: string; roomName: string;
clientPos: number; clientPos: number;
playerName: string; playerName: string;
...@@ -42,23 +42,24 @@ interface DisconnectInfo { ...@@ -42,23 +42,24 @@ interface DisconnectInfo {
type ReconnectType = 'normal' | 'kick'; type ReconnectType = 'normal' | 'kick';
declare module '../client' { declare module '../../client' {
interface Client { interface Client {
preReconnecting?: boolean; preReconnecting?: boolean;
reconnectType?: ReconnectType; reconnectType?: ReconnectType;
preReconnectRoomName?: string; // 临时保存重连的目标房间名 preReconnectRoomName?: string; // 临时保存重连的目标房间名
preReconnectDisconnectKey?: string;
} }
} }
declare module '../room' { declare module '../../room' {
interface Room { interface Room {
noReconnect?: boolean; noReconnect?: boolean;
isLooseReconnectRule?: boolean;
} }
} }
export class Reconnect { export class Reconnect {
private disconnectList = new Map<string, DisconnectInfo>(); private disconnectList = new Map<string, DisconnectInfo>();
private isLooseReconnectRule = false; // 宽松匹配模式,日后可能配置支持
private reconnectTimeout = this.ctx.config.getInt('RECONNECT_TIMEOUT'); // 超时时间,单位:毫秒(默认 180000ms = 3分钟) private reconnectTimeout = this.ctx.config.getInt('RECONNECT_TIMEOUT'); // 超时时间,单位:毫秒(默认 180000ms = 3分钟)
constructor(private ctx: Context) { constructor(private ctx: Context) {
...@@ -93,11 +94,16 @@ export class Reconnect { ...@@ -93,11 +94,16 @@ export class Reconnect {
return next(); // 正常断线处理 return next(); // 正常断线处理
} }
if (!this.canReconnect(client)) { const room = this.getClientRoom(client);
if (!room) {
return next();
}
if (!(await this.canReconnect(client, room))) {
return next(); // 正常断线处理 return next(); // 正常断线处理
} }
await this.registerDisconnect(client); await this.registerDisconnect(client, room);
// 不调用 next(),阻止踢人 // 不调用 next(),阻止踢人
}); });
...@@ -119,23 +125,24 @@ export class Reconnect { ...@@ -119,23 +125,24 @@ export class Reconnect {
}); });
} }
private canReconnect(client: Client): boolean { private async canReconnect(client: Client, room: Room): Promise<boolean> {
const room = this.getClientRoom(client); const canReconnect =
if (!room) {
return false;
}
return (
!client.isInternal && // 不是内部虚拟客户端 !client.isInternal && // 不是内部虚拟客户端
!room.noReconnect && !room.noReconnect &&
client.pos < NetPlayerType.OBSERVER && // 是玩家 client.pos < NetPlayerType.OBSERVER && // 是玩家
room.duelStage !== DuelStage.Begin // 游戏已开始 room.duelStage !== DuelStage.Begin; // 游戏已开始
if (!canReconnect) {
return false;
}
const check = await this.ctx.dispatch(
new CanReconnectCheck(client, room),
client,
); );
return !!check?.canReconnect;
} }
private async registerDisconnect(client: Client) { private async registerDisconnect(client: Client, room: Room) {
const room = this.getClientRoom(client)!; const key = this.getAuthorizeKey(client, room);
const key = this.getAuthorizeKey(client);
// 通知房间 // 通知房间
await room.sendChat( await room.sendChat(
...@@ -149,6 +156,7 @@ export class Reconnect { ...@@ -149,6 +156,7 @@ export class Reconnect {
}, this.reconnectTimeout); }, this.reconnectTimeout);
this.disconnectList.set(key, { this.disconnectList.set(key, {
key,
roomName: room.name, roomName: room.name,
clientPos: client.pos, clientPos: client.pos,
playerName: client.name, playerName: client.name,
...@@ -162,20 +170,14 @@ export class Reconnect { ...@@ -162,20 +170,14 @@ export class Reconnect {
newClient: Client, newClient: Client,
msg: YGOProCtosJoinGame, msg: YGOProCtosJoinGame,
): Promise<boolean> { ): Promise<boolean> {
const key = this.getAuthorizeKey(newClient);
const disconnectInfo = this.disconnectList.get(key);
let room: Room | undefined; let room: Room | undefined;
let oldClient: Client | undefined; let oldClient: Client | undefined;
let reconnectType: ReconnectType | undefined; let reconnectType: ReconnectType | undefined;
let disconnectInfo: DisconnectInfo | undefined;
// 1. 尝试正常断线重连 // 1. 尝试正常断线重连
disconnectInfo = this.findDisconnectInfo(newClient, msg.pass);
if (disconnectInfo) { if (disconnectInfo) {
// 验证房间名(msg.pass 就是房间名)
if (msg.pass !== disconnectInfo.roomName) {
return false;
}
// 获取房间 // 获取房间
const roomManager = this.ctx.get(() => RoomManager); const roomManager = this.ctx.get(() => RoomManager);
room = roomManager.findByName(disconnectInfo.roomName); room = roomManager.findByName(disconnectInfo.roomName);
...@@ -191,7 +193,7 @@ export class Reconnect { ...@@ -191,7 +193,7 @@ export class Reconnect {
// 2. 尝试踢人重连 // 2. 尝试踢人重连
if (!room) { if (!room) {
const kickTarget = this.findKickReconnectTarget(newClient); const kickTarget = await this.findKickReconnectTarget(newClient);
if (kickTarget) { if (kickTarget) {
room = this.getClientRoom(kickTarget)!; room = this.getClientRoom(kickTarget)!;
oldClient = kickTarget; oldClient = kickTarget;
...@@ -204,7 +206,13 @@ export class Reconnect { ...@@ -204,7 +206,13 @@ export class Reconnect {
} }
// 进入 pre_reconnect 阶段 // 进入 pre_reconnect 阶段
await this.sendPreReconnectInfo(newClient, room, oldClient, reconnectType); await this.sendPreReconnectInfo(
newClient,
room,
oldClient,
reconnectType,
disconnectInfo?.key,
);
return true; return true;
} }
...@@ -253,16 +261,20 @@ export class Reconnect { ...@@ -253,16 +261,20 @@ export class Reconnect {
client.preReconnecting = false; client.preReconnecting = false;
client.reconnectType = undefined; client.reconnectType = undefined;
client.preReconnectRoomName = undefined; client.preReconnectRoomName = undefined;
client.preReconnectDisconnectKey = undefined;
return client.disconnect(); return client.disconnect();
} }
client.preReconnecting = false; client.preReconnecting = false;
client.reconnectType = undefined; client.reconnectType = undefined;
client.preReconnectRoomName = undefined; client.preReconnectRoomName = undefined;
const preReconnectDisconnectKey = client.preReconnectDisconnectKey;
client.preReconnectDisconnectKey = undefined;
if (reconnectType === 'normal') { if (reconnectType === 'normal') {
const key = this.getAuthorizeKey(client); const disconnectInfo = preReconnectDisconnectKey
const disconnectInfo = this.disconnectList.get(key); ? this.disconnectList.get(preReconnectDisconnectKey)
: undefined;
if (!disconnectInfo) { if (!disconnectInfo) {
await client.sendChat('#{reconnect_failed}', ChatColor.RED); await client.sendChat('#{reconnect_failed}', ChatColor.RED);
return client.disconnect(); return client.disconnect();
...@@ -317,11 +329,13 @@ export class Reconnect { ...@@ -317,11 +329,13 @@ export class Reconnect {
room: Room, room: Room,
oldClient: Client, oldClient: Client,
reconnectType: ReconnectType, reconnectType: ReconnectType,
disconnectKey?: string,
) { ) {
// 设置 pre_reconnecting 状态 // 设置 pre_reconnecting 状态
client.preReconnecting = true; client.preReconnecting = true;
client.reconnectType = reconnectType; client.reconnectType = reconnectType;
client.preReconnectRoomName = room.name; // 保存目标房间名 client.preReconnectRoomName = room.name; // 保存目标房间名
client.preReconnectDisconnectKey = disconnectKey;
client.pos = oldClient.pos; client.pos = oldClient.pos;
// 发送房间信息 // 发送房间信息
...@@ -358,8 +372,8 @@ export class Reconnect { ...@@ -358,8 +372,8 @@ export class Reconnect {
): Promise<boolean> { ): Promise<boolean> {
if (reconnectType === 'normal') { if (reconnectType === 'normal') {
// 正常重连:验证 disconnectInfo 中的 startDeck // 正常重连:验证 disconnectInfo 中的 startDeck
const key = this.getAuthorizeKey(client); const key = client.preReconnectDisconnectKey;
const disconnectInfo = this.disconnectList.get(key); const disconnectInfo = key ? this.disconnectList.get(key) : undefined;
if (!disconnectInfo) { if (!disconnectInfo) {
return false; return false;
} }
...@@ -750,15 +764,15 @@ export class Reconnect { ...@@ -750,15 +764,15 @@ export class Reconnect {
return undefined; return undefined;
} }
private getAuthorizeKey(client: Client): string { private getAuthorizeKey(client: Client, room?: Room): string {
// 参考 srvpro 逻辑 // 参考 srvpro 逻辑
// 如果有 vpass 且不是宽松匹配模式,优先用 name_vpass // 如果有 vpass 且不是宽松匹配模式,优先用 name_vpass
if (!this.isLooseReconnectRule && client.vpass) { if (!room?.isLooseReconnectRule && client.vpass) {
return client.name_vpass; return client.name_vpass;
} }
// 宽松匹配模式或内部客户端 // 宽松匹配模式或内部客户端
if (this.isLooseReconnectRule) { if (room?.isLooseReconnectRule) {
return client.name || client.ip || 'undefined'; return client.name || client.ip || 'undefined';
} }
...@@ -791,11 +805,12 @@ export class Reconnect { ...@@ -791,11 +805,12 @@ export class Reconnect {
private clearDisconnectInfo(disconnectInfo: DisconnectInfo) { private clearDisconnectInfo(disconnectInfo: DisconnectInfo) {
clearTimeout(disconnectInfo.timeout); clearTimeout(disconnectInfo.timeout);
const key = this.getAuthorizeKey(disconnectInfo.oldClient); this.disconnectList.delete(disconnectInfo.key);
this.disconnectList.delete(key);
} }
private findKickReconnectTarget(newClient: Client): Client | undefined { private async findKickReconnectTarget(
newClient: Client,
): Promise<Client | undefined> {
const roomManager = this.ctx.get(() => RoomManager); const roomManager = this.ctx.get(() => RoomManager);
const allRooms = roomManager.allRooms(); const allRooms = roomManager.allRooms();
...@@ -807,6 +822,9 @@ export class Reconnect { ...@@ -807,6 +822,9 @@ export class Reconnect {
// 查找符合条件的在线玩家 // 查找符合条件的在线玩家
for (const player of room.playingPlayers) { for (const player of room.playingPlayers) {
if (!(await this.canReconnect(player, room))) {
continue;
}
// if (player.disconnected) { // if (player.disconnected) {
// continue; // 跳过已断线的玩家 // continue; // 跳过已断线的玩家
// } // }
...@@ -818,7 +836,7 @@ export class Reconnect { ...@@ -818,7 +836,7 @@ export class Reconnect {
// 宽松模式或匹配条件 // 宽松模式或匹配条件
const matchCondition = const matchCondition =
this.isLooseReconnectRule || room.isLooseReconnectRule ||
player.ip === newClient.ip || player.ip === newClient.ip ||
(newClient.vpass && newClient.vpass === player.vpass); (newClient.vpass && newClient.vpass === player.vpass);
...@@ -830,4 +848,29 @@ export class Reconnect { ...@@ -830,4 +848,29 @@ export class Reconnect {
return undefined; return undefined;
} }
private findDisconnectInfo(
newClient: Client,
roomName: string,
): DisconnectInfo | undefined {
const roomManager = this.ctx.get(() => RoomManager);
for (const disconnectInfo of this.disconnectList.values()) {
if (disconnectInfo.roomName !== roomName) {
continue;
}
const room = roomManager.findByName(disconnectInfo.roomName);
if (!room) {
this.clearDisconnectInfo(disconnectInfo);
continue;
}
const key = this.getAuthorizeKey(newClient, room);
if (key !== disconnectInfo.key) {
continue;
}
return disconnectInfo;
}
return undefined;
}
} }
export * from './can-reconnect-check';
This diff is collapsed.
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode'; import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app'; import { Context } from '../../app';
import { WindBotProvider } from './windbot-provider'; import { WindBotProvider } from './windbot-provider';
import { RoomManager } from '../room'; import { RoomManager } from '../../room';
import { fillRandomString } from '../utility/fill-random-string'; import { MAX_ROOM_NAME_LENGTH } from '../../constants/room';
import { fillRandomString } from '../../utility/fill-random-string';
import { parseWindbotOptions } from './utility'; import { parseWindbotOptions } from './utility';
const getDisplayLength = (text: string) => const getDisplayLength = (text: string) =>
...@@ -25,6 +26,7 @@ export class JoinWindbotAi { ...@@ -25,6 +26,7 @@ export class JoinWindbotAi {
const existingRoom = this.roomManager.findByName(msg.pass); const existingRoom = this.roomManager.findByName(msg.pass);
if (existingRoom) { if (existingRoom) {
existingRoom.noHost = true;
return existingRoom.join(client); return existingRoom.join(client);
} }
...@@ -49,6 +51,7 @@ export class JoinWindbotAi { ...@@ -49,6 +51,7 @@ export class JoinWindbotAi {
lflist: -1, lflist: -1,
time_limit: 0, time_limit: 0,
}); });
room.noHost = true;
room.noReconnect = true; room.noReconnect = true;
room.windbot = { room.windbot = {
name: '', name: '',
...@@ -102,7 +105,7 @@ export class JoinWindbotAi { ...@@ -102,7 +105,7 @@ export class JoinWindbotAi {
} else { } else {
prefix = `${pass}#`; prefix = `${pass}#`;
} }
const roomName = fillRandomString(prefix, 19); const roomName = fillRandomString(prefix, MAX_ROOM_NAME_LENGTH);
if (!this.roomManager.findByName(roomName)) { if (!this.roomManager.findByName(roomName)) {
return roomName; return roomName;
} }
......
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode'; import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app'; import { Context } from '../../app';
import { WindBotProvider } from './windbot-provider'; import { WindBotProvider } from './windbot-provider';
import { RoomManager } from '../room'; import { RoomManager } from '../../room';
export class JoinWindbotToken { export class JoinWindbotToken {
private windbotProvider = this.ctx.get(() => WindBotProvider); private windbotProvider = this.ctx.get(() => WindBotProvider);
......
import { Observable, fromEvent, merge } from 'rxjs'; import { Observable, fromEvent, merge } from 'rxjs';
import { map, take } from 'rxjs/operators'; import { map, take } from 'rxjs/operators';
import WebSocket, { RawData } from 'ws'; import WebSocket, { RawData } from 'ws';
import { Context } from '../app'; import { Context } from '../../app';
import { Client } from '../client'; import { Client } from '../../client';
export class ReverseWsClient extends Client { export class ReverseWsClient extends Client {
constructor( constructor(
......
import { createAppContext } from 'nfkit'; import { createAppContext } from 'nfkit';
import { ContextState } from '../app'; import { ContextState } from '../../app';
import { WindBotProvider } from './windbot-provider'; import { WindBotProvider } from './windbot-provider';
import { WindbotSpawner } from './windbot-spawner'; import { WindbotSpawner } from './windbot-spawner';
......
...@@ -2,9 +2,9 @@ import cryptoRandomString from 'crypto-random-string'; ...@@ -2,9 +2,9 @@ import cryptoRandomString from 'crypto-random-string';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
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 { OnRoomFinalize, Room } from '../room'; import { OnRoomFinalize, Room } from '../../room';
import type { import type {
RequestWindbotJoinOptions, RequestWindbotJoinOptions,
WindbotData, WindbotData,
...@@ -12,13 +12,13 @@ import type { ...@@ -12,13 +12,13 @@ import type {
} from './utility'; } from './utility';
import { ReverseWsClient } from './reverse-ws-client'; import { ReverseWsClient } from './reverse-ws-client';
declare module '../client' { declare module '../../client' {
interface Client { interface Client {
windbot?: WindbotData; windbot?: WindbotData;
} }
} }
declare module '../room' { declare module '../../room' {
interface Room { interface Room {
windbot?: WindbotData; windbot?: WindbotData;
} }
......
import { ChildProcess, spawn } from 'node:child_process'; import { ChildProcess, spawn } from 'node:child_process';
import { Context } from '../app'; import { Context } from '../../app';
import { WindBotProvider } from './windbot-provider'; import { WindBotProvider } from './windbot-provider';
export class WindbotSpawner { export class WindbotSpawner {
......
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';
import { JoinWindbotAi, JoinWindbotToken } from '../windbot'; import { JoinWindbotAi, JoinWindbotToken } from '../feats/windbot';
import { JoinRoom } from './join-room'; import { JoinRoom } from './join-room';
import { JoinFallback } from './fallback'; import { JoinFallback } from './fallback';
import { JoinPrechecks } from './join-prechecks'; import { JoinPrechecks } from './join-prechecks';
import { RandomDuelJoinHandler } from './random-duel-join-handler';
export const JoinHandlerModule = createAppContext<ContextState>() export const JoinHandlerModule = createAppContext<ContextState>()
.provide(ClientVersionCheck) .provide(ClientVersionCheck)
.provide(JoinPrechecks) .provide(JoinPrechecks)
.provide(JoinWindbotToken) .provide(JoinWindbotToken)
.provide(RandomDuelJoinHandler)
.provide(JoinWindbotAi) .provide(JoinWindbotAi)
.provide(JoinRoom) .provide(JoinRoom)
.provide(JoinFallback) .provide(JoinFallback)
......
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { RandomDuelProvider } from '../feats';
export class RandomDuelJoinHandler {
private randomDuelProvider = this.ctx.get(() => RandomDuelProvider);
constructor(private ctx: Context) {
if (!this.randomDuelProvider.enabled) {
return;
}
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
msg.pass = (msg.pass || '').trim();
const type = this.randomDuelProvider.resolveRandomType(msg.pass);
if (type == null) {
return next();
}
const room = await this.randomDuelProvider.findOrCreateRandomRoom(
type,
client.ip,
);
if (!room) {
return client.die('#{create_room_failed}', ChatColor.RED);
}
return room.join(client);
});
}
}
...@@ -2,8 +2,11 @@ export * from './room'; ...@@ -2,8 +2,11 @@ export * from './room';
export * from './room-manager'; export * from './room-manager';
export * from './duel-stage'; export * from './duel-stage';
export * from './room-event/on-room-finalize'; export * from './room-event/on-room-finalize';
export * from './room-event/on-room-finger';
export * from './room-event/on-room-game-start'; export * from './room-event/on-room-game-start';
export * from './room-event/on-room-join-player';
export * from './room-event/on-room-leave-player'; export * from './room-event/on-room-leave-player';
export * from './room-event/on-room-select-tp';
export * from './room-event/on-room-siding-ready'; export * from './room-event/on-room-siding-ready';
export * from './room-event/on-room-siding-start'; export * from './room-event/on-room-siding-start';
export * from './room-event/on-room-win'; export * from './room-event/on-room-win';
......
import { Client } from '../../client';
import { Room } from '../room';
import { RoomEvent } from './room-event';
export class OnRoomFinger extends RoomEvent {
constructor(room: Room, public fingerPlayers: [Client, Client]) {
super(room);
}
}
import { Client } from '../../client';
import { Room } from '../room';
import { RoomEvent } from './room-event';
export class OnRoomSelectTp extends RoomEvent {
constructor(room: Room, public selector: Client) {
super(room);
}
}
...@@ -52,6 +52,7 @@ import { ...@@ -52,6 +52,7 @@ import {
YGOProCtosTimeConfirm, YGOProCtosTimeConfirm,
YGOProMsgWaiting, YGOProMsgWaiting,
YGOProStocTimeLimit, YGOProStocTimeLimit,
YGOProMsgMatchKill,
} from 'ygopro-msg-encode'; } from 'ygopro-msg-encode';
import { DefaultHostInfoProvider } from './default-hostinfo-provder'; import { DefaultHostInfoProvider } from './default-hostinfo-provder';
import { import {
...@@ -96,6 +97,8 @@ import { OnRoomCreate } from './room-event/on-room-create'; ...@@ -96,6 +97,8 @@ import { OnRoomCreate } from './room-event/on-room-create';
import { OnRoomFinalize } from './room-event/on-room-finalize'; import { OnRoomFinalize } from './room-event/on-room-finalize';
import { OnRoomSidingStart } from './room-event/on-room-siding-start'; import { OnRoomSidingStart } from './room-event/on-room-siding-start';
import { OnRoomSidingReady } from './room-event/on-room-siding-ready'; import { OnRoomSidingReady } from './room-event/on-room-siding-ready';
import { OnRoomFinger } from './room-event/on-room-finger';
import { OnRoomSelectTp } from './room-event/on-room-select-tp';
const { OcgcoreScriptConstants } = _OcgcoreConstants; const { OcgcoreScriptConstants } = _OcgcoreConstants;
...@@ -124,6 +127,7 @@ export class Room { ...@@ -124,6 +127,7 @@ export class Room {
return (this.hostinfo.mode & 0x2) !== 0; return (this.hostinfo.mode & 0x2) !== 0;
} }
noHost = false;
players = new Array<Client | undefined>(this.isTag ? 4 : 2); players = new Array<Client | undefined>(this.isTag ? 4 : 2);
watchers = new Set<Client>(); watchers = new Set<Client>();
get playingPlayers() { get playingPlayers() {
...@@ -335,17 +339,17 @@ export class Room { ...@@ -335,17 +339,17 @@ export class Room {
) || []; ) || [];
for (const message of observerMessages) { for (const message of observerMessages) {
await client.send( await client.send(
new YGOProStocGameMsg().fromPartial({ new YGOProStocGameMsg().fromPartial({
msg: message.observerView(), msg: message.observerView(),
}), }),
); );
} }
} }
} }
async join(client: Client) { async join(client: Client) {
client.roomName = this.name; client.roomName = this.name;
client.isHost = !this.allPlayers.length; client.isHost = this.noHost ? false : !this.allPlayers.length;
const firstEmptyPlayerSlot = this.players.findIndex((p) => !p); const firstEmptyPlayerSlot = this.players.findIndex((p) => !p);
const isPlayer = const isPlayer =
firstEmptyPlayerSlot >= 0 && this.duelStage === DuelStage.Begin; firstEmptyPlayerSlot >= 0 && this.duelStage === DuelStage.Begin;
...@@ -401,10 +405,27 @@ export class Room { ...@@ -401,10 +405,27 @@ export class Room {
duelStage = DuelStage.Begin; duelStage = DuelStage.Begin;
duelRecords: DuelRecord[] = []; duelRecords: DuelRecord[] = [];
private overrideScore?: [number | undefined, number | undefined];
setOverrideScore(duelPos: 0 | 1, value: number) {
this.overrideScore = this.overrideScore || [undefined, undefined];
this.overrideScore[duelPos] = value;
}
get score() { get score() {
return [0, 1].map( const score: [number, number] = [0, 0];
(p) => this.duelRecords.filter((d) => d.winPosition === p).length, for (const duelRecord of this.duelRecords) {
); if (duelRecord.winPosition === 0 || duelRecord.winPosition === 1) {
score[duelRecord.winPosition] += 1;
}
}
for (const duelPos of [0, 1] as const) {
const override = this.overrideScore?.[duelPos];
if (override != null) {
score[duelPos] = override;
}
}
return score;
} }
private async sendReplays(client: Client) { private async sendReplays(client: Client) {
...@@ -437,7 +458,10 @@ export class Room { ...@@ -437,7 +458,10 @@ export class Room {
for (const p of this.watchers) { for (const p of this.watchers) {
p.send(new YGOProStocWaitingSide()); p.send(new YGOProStocWaitingSide());
} }
await this.ctx.dispatch(new OnRoomSidingStart(this), this.playingPlayers[0]); await this.ctx.dispatch(
new OnRoomSidingStart(this),
this.playingPlayers[0],
);
} }
get lastDuelRecord() { get lastDuelRecord() {
...@@ -451,7 +475,7 @@ export class Room { ...@@ -451,7 +475,7 @@ export class Room {
} catch {} } catch {}
} }
async win(winMsg: Partial<YGOProMsgWin>, forceWinMatch = false) { async win(winMsg: Partial<YGOProMsgWin>, forceWinMatch?: number) {
this.resetResponseState(); this.resetResponseState();
this.disposeOcgcore(); this.disposeOcgcore();
this.ocgcore = undefined; this.ocgcore = undefined;
...@@ -481,10 +505,16 @@ export class Room { ...@@ -481,10 +505,16 @@ export class Room {
if (lastDuelRecord) { if (lastDuelRecord) {
lastDuelRecord.winPosition = duelPos; lastDuelRecord.winPosition = duelPos;
} }
if (typeof forceWinMatch === 'number') {
const loseDuelPos = (1 - duelPos) as 0 | 1;
this.setOverrideScore(loseDuelPos, -Math.abs(forceWinMatch));
}
const score = this.score;
this.logger.debug( this.logger.debug(
`Player ${duelPos} wins the duel. Current score: ${this.score.join('-')}`, `Player ${duelPos} wins the duel. Current score: ${score.join('-')}`,
); );
const winMatch = forceWinMatch || this.score[duelPos] >= this.winMatchCount; const winMatch =
forceWinMatch != null || score[duelPos] >= this.winMatchCount;
if (!winMatch) { if (!winMatch) {
await this.changeSide(); await this.changeSide();
} }
...@@ -533,10 +563,9 @@ export class Room { ...@@ -533,10 +563,9 @@ export class Room {
p.send(client.prepareChangePacket(PlayerChangeState.LEAVE)); p.send(client.prepareChangePacket(PlayerChangeState.LEAVE));
}); });
} else { } else {
this.score[this.getDuelPos(client)] = -9;
await this.win( await this.win(
{ player: 1 - this.getIngameDuelPos(client), type: 0x4 }, { player: 1 - this.getIngameDuelPos(client), type: 0x4 },
true, 9,
); );
} }
if (client.isHost) { if (client.isHost) {
...@@ -805,6 +834,12 @@ export class Room { ...@@ -805,6 +834,12 @@ export class Room {
// Auto-ready: send PlayerChange READY to all players (client.deck 已设置,自动为 READY) // Auto-ready: send PlayerChange READY to all players (client.deck 已设置,自动为 READY)
const changeMsg = client.prepareChangePacket(); const changeMsg = client.prepareChangePacket();
this.allPlayers.forEach((p) => p.send(changeMsg)); this.allPlayers.forEach((p) => p.send(changeMsg));
if (this.noHost) {
const allReadyAndFull = this.players.every((player) => !!player?.deck);
if (allReadyAndFull) {
await this.startGame();
}
}
} else if (this.duelStage === DuelStage.Siding) { } else if (this.duelStage === DuelStage.Siding) {
// In Siding stage, send DUEL_START to the player who submitted deck // In Siding stage, send DUEL_START to the player who submitted deck
// Siding 阶段不发 DeckCount // Siding 阶段不发 DeckCount
...@@ -910,16 +945,27 @@ export class Room { ...@@ -910,16 +945,27 @@ export class Room {
this.firstgoPos = firstgoPos; this.firstgoPos = firstgoPos;
this.duelStage = DuelStage.FirstGo; this.duelStage = DuelStage.FirstGo;
const firstgoPlayer = this.getDuelPosPlayers(firstgoPos)[0]; const firstgoPlayer = this.getDuelPosPlayers(firstgoPos)[0];
if (!firstgoPlayer) {
return;
}
firstgoPlayer.send(new YGOProStocSelectTp()); firstgoPlayer.send(new YGOProStocSelectTp());
await this.ctx.dispatch(
new OnRoomSelectTp(this, firstgoPlayer),
firstgoPlayer,
);
} }
private async toFinger() { private async toFinger() {
this.duelStage = DuelStage.Finger; this.duelStage = DuelStage.Finger;
// 只有每方的第一个玩家猜拳 // 只有每方的第一个玩家猜拳
const fingerPlayers = [0, 1].map((p) => this.getDuelPosPlayers(p)[0]); const duelPos0 = this.getDuelPosPlayers(0)[0];
fingerPlayers.forEach((p) => { const duelPos1 = this.getDuelPosPlayers(1)[0];
p.send(new YGOProStocSelectHand()); if (!duelPos0 || !duelPos1) {
}); return;
}
duelPos0.send(new YGOProStocSelectHand());
duelPos1.send(new YGOProStocSelectHand());
await this.ctx.dispatch(new OnRoomFinger(this, [duelPos0, duelPos1]), duelPos0);
} }
@RoomMethod({ allowInDuelStages: DuelStage.Finger }) @RoomMethod({ allowInDuelStages: DuelStage.Finger })
...@@ -1618,8 +1664,13 @@ export class Room { ...@@ -1618,8 +1664,13 @@ export class Room {
); );
} }
return next(); return next();
})
.middleware(YGOProMsgMatchKill, async (message, next) => {
this.matchKilled = true;
return next();
}); });
private matchKilled = false;
private responsePos?: number; private responsePos?: number;
private async advance() { private async advance() {
...@@ -1660,7 +1711,7 @@ export class Room { ...@@ -1660,7 +1711,7 @@ export class Room {
const handled = await this.dispatchGameMsg(message); const handled = await this.dispatchGameMsg(message);
if (handled instanceof YGOProMsgWin) { if (handled instanceof YGOProMsgWin) {
return this.win(handled); return this.win(handled, this.matchKilled ? 1 : undefined);
} }
await this.routeGameMsg(handled); await this.routeGameMsg(handled);
} }
......
import { AppContext } from 'nfkit';
import { DataSource } from 'typeorm';
import { ConfigService } from './config';
import { Logger } from './logger';
import { RandomDuelScore } from '../feats/random-duel';
export class TypeormLoader {
constructor(private ctx: AppContext) {}
database: DataSource | undefined;
setDatabase(database: DataSource | undefined) {
this.database = database;
return this;
}
}
export const TypeormFactory = async (ctx: AppContext) => {
const loader = new TypeormLoader(ctx);
const config = ctx.get(ConfigService).config;
const logger = ctx.get(Logger).createLogger('TypeormLoader');
const host = config.getString('DB_HOST');
if (!host) {
logger.info('database disabled because DB_HOST is empty');
return loader.setDatabase(undefined);
}
const port = config.getInt('DB_PORT') || 5432;
const username = config.getString('DB_USER');
const password = config.getString('DB_PASS');
const database = config.getString('DB_NAME');
const synchronize = !config.getBoolean('DB_NO_INIT');
const dataSource = new DataSource({
type: 'postgres',
host,
port,
username,
password,
database,
synchronize,
entities: [RandomDuelScore],
});
try {
await dataSource.initialize();
logger.info(
{
host,
port,
database,
synchronize,
},
'Database initialized',
);
return loader.setDatabase(dataSource);
} catch (error) {
logger.error(
{
host,
port,
database,
err: error,
},
'Database initialization failed',
);
throw error;
}
};
export class ValueContainer<T> {
constructor(public value: T) {}
use(value: T) {
this.value = value;
return this;
}
}
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