Commit aff3079d authored by nanahira's avatar nanahira

add side timeout

parent 9e569115
Pipeline #43251 passed with stages
in 2 minutes and 1 second
...@@ -4,7 +4,8 @@ ...@@ -4,7 +4,8 @@
## 项目规范 ## 项目规范
- 非必要不要在 Room 和 Client 里面加字段或者方法。如果可以的话请使用定义 interface 进行依赖合并。 - 禁止在模块里面保存 `Client` 或者 `Room` 的强引用(包括 `Map` key/value、闭包长期持有等)。如需关联状态,优先使用 `pos``room.name` 等轻量标识。
- 禁止直接在 `Client``Room` 类里面添加耦合业务模块的字段或者方法。需要扩展时可通过定义 interface 做依赖合并;若扩展房间流程,允许在 `Room` 里新增并 `dispatch` 专用事件。
- 进行协议设计需要核对 ygopro 和 srvpro 的 coffee 和 cpp 的实现。 - 进行协议设计需要核对 ygopro 和 srvpro 的 coffee 和 cpp 的实现。
- 尽量定义新的模块实现功能,而不是在之前的方法上进行修改。 - 尽量定义新的模块实现功能,而不是在之前的方法上进行修改。
- 配置在 config.ts 里面写默认类型。注意所有类型必须是 string 并且全部大写字母。改了之后需要 npm run gen:config-example 生成 config.example.yaml - 配置在 config.ts 里面写默认类型。注意所有类型必须是 string 并且全部大写字母。改了之后需要 npm run gen:config-example 生成 config.example.yaml
......
...@@ -26,13 +26,14 @@ deckMaxCopies: 3 ...@@ -26,13 +26,14 @@ deckMaxCopies: 3
ocgcoreDebugLog: 0 ocgcoreDebugLog: 0
ocgcoreWasmPath: "" ocgcoreWasmPath: ""
welcome: "" welcome: ""
enableWindbot: 1 enableWindbot: 0
windbotBotlist: ./windbot/bots.json windbotBotlist: ./windbot/bots.json
windbotSpawn: 0 windbotSpawn: 0
windbotEndpoint: http://127.0.0.1:2399 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
sideTimeoutMinutes: 3
hostinfoLflist: 0 hostinfoLflist: 0
hostinfoRule: 0 hostinfoRule: 0
hostinfoMode: 0 hostinfoMode: 0
......
...@@ -9,6 +9,7 @@ import { JoinHandlerModule } from './join-handlers/join-handler-module'; ...@@ -9,6 +9,7 @@ import { JoinHandlerModule } from './join-handlers/join-handler-module';
import { RoomModule } from './room/room-module'; 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';
const core = createAppContext() const core = createAppContext()
.provide(ConfigService, { .provide(ConfigService, {
...@@ -16,6 +17,7 @@ const core = createAppContext() ...@@ -16,6 +17,7 @@ const core = createAppContext()
}) })
.provide(Logger, { merge: ['createLogger'] }) .provide(Logger, { merge: ['createLogger'] })
.provide(Emitter, { merge: ['dispatch', 'middleware', 'removeMiddleware'] }) .provide(Emitter, { merge: ['dispatch', 'middleware', 'removeMiddleware'] })
.provide(MiddlewareRx, { merge: ['event$'] })
.provide(HttpClient, { merge: ['http'] }) .provide(HttpClient, { merge: ['http'] })
.provide(AragamiService, { merge: ['aragami'] }) .provide(AragamiService, { merge: ['aragami'] })
.provide(SqljsLoader, { .provide(SqljsLoader, {
......
...@@ -79,6 +79,9 @@ export const defaultConfig = { ...@@ -79,6 +79,9 @@ 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',
// Side deck timeout in minutes during siding stage.
// Format: integer string. '0' or negative disables the feature.
SIDE_TIMEOUT_MINUTES: '3',
// Room hostinfo defaults expanded into HOSTINFO_* keys. // Room hostinfo defaults expanded into HOSTINFO_* keys.
// Format: each HOSTINFO_* value is a string; numeric fields use integer strings. // Format: each HOSTINFO_* value is a string; numeric fields use integer strings.
// Unit note: HOSTINFO_TIME_LIMIT is in seconds (s). // Unit note: HOSTINFO_TIME_LIMIT is in seconds (s).
......
...@@ -29,6 +29,12 @@ export const TRANSLATIONS = { ...@@ -29,6 +29,12 @@ export const TRANSLATIONS = {
deck_incorrect_reconnect: 'Please pick your previous deck.', deck_incorrect_reconnect: 'Please pick your previous deck.',
reconnect_failed: 'Reconnect failed.', reconnect_failed: 'Reconnect failed.',
reconnecting_to_room: 'Reconnecting to server...', reconnecting_to_room: 'Reconnecting to server...',
side_timeout_part1: 'Changing side time is limited to ',
side_timeout_part2: ' minutes.',
side_remain_part1: 'Remaining side changing time: ',
side_remain_part2: ' minutes.',
side_overtime: 'You exceeded side changing time and were kicked by system.',
side_overtime_room: ' exceeded side changing time and was kicked by system.',
}, },
'zh-CN': { 'zh-CN': {
update_required: '请更新你的客户端版本', update_required: '请更新你的客户端版本',
...@@ -57,5 +63,11 @@ export const TRANSLATIONS = { ...@@ -57,5 +63,11 @@ export const TRANSLATIONS = {
deck_incorrect_reconnect: '请选择你在本局决斗中使用的卡组。', deck_incorrect_reconnect: '请选择你在本局决斗中使用的卡组。',
reconnect_failed: '重新连接失败。', reconnect_failed: '重新连接失败。',
reconnecting_to_room: '正在重新连接到服务器……', reconnecting_to_room: '正在重新连接到服务器……',
side_timeout_part1: '你现在有',
side_timeout_part2: '分钟来更换副卡组。',
side_remain_part1: '更换副卡组剩余时间:',
side_remain_part2: '分钟。',
side_overtime: '你更换副卡组超时,已被系统踢出。',
side_overtime_room: '更换副卡组超时,已被系统踢出。',
}, },
}; };
...@@ -5,6 +5,7 @@ import { Welcome } from './welcome'; ...@@ -5,6 +5,7 @@ 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';
export const FeatsModule = createAppContext<ContextState>() export const FeatsModule = createAppContext<ContextState>()
.provide(ClientVersionCheck) .provide(ClientVersionCheck)
...@@ -12,4 +13,5 @@ export const FeatsModule = createAppContext<ContextState>() ...@@ -12,4 +13,5 @@ export const FeatsModule = createAppContext<ContextState>()
.provide(Welcome) .provide(Welcome)
.provide(PlayerStatusNotify) .provide(PlayerStatusNotify)
.provide(Reconnect) .provide(Reconnect)
.provide(SideTimeout)
.define(); .define();
import { ChatColor } from 'ygopro-msg-encode';
import { Context } from '../app';
import {
DuelStage,
OnRoomFinalize,
OnRoomGameStart,
OnRoomLeavePlayer,
OnRoomSidingReady,
OnRoomSidingStart,
Room,
} from '../room';
import { merge, Subscription, timer } from 'rxjs';
import { filter, finalize, share, take, takeUntil } from 'rxjs/operators';
declare module '../room' {
interface Room {
sideTimeoutSubscriptions?: Map<number, Subscription>;
sideTimeoutRemainMinutes?: Map<number, number>;
}
}
export class SideTimeout {
private logger = this.ctx.createLogger('SideTimeout');
private sideTimeoutMinutes = this.ctx.config.getInt('SIDE_TIMEOUT_MINUTES');
private onSidingReady$ = this.ctx.event$(OnRoomSidingReady).pipe(share());
private onLeavePlayer$ = this.ctx.event$(OnRoomLeavePlayer).pipe(share());
private onGameStart$ = this.ctx.event$(OnRoomGameStart).pipe(share());
private onFinalize$ = this.ctx.event$(OnRoomFinalize).pipe(share());
constructor(private ctx: Context) {
if (this.sideTimeoutMinutes <= 0) {
return;
}
this.ctx.event$(OnRoomSidingStart).subscribe(({ msg }) => {
void this.handleSidingStart(msg.room).catch((error) => {
this.logger.warn({ error }, 'Failed to start side timeout');
});
});
}
private async handleSidingStart(room: Room) {
if (room.duelStage !== DuelStage.Siding) {
return;
}
await Promise.all(
room.playingPlayers.map(async (player) => {
await this.startSideTimeout(room, player.pos);
}),
);
}
private getSubscriptions(room: Room): Map<number, Subscription> {
if (!room.sideTimeoutSubscriptions) {
room.sideTimeoutSubscriptions = new Map();
}
return room.sideTimeoutSubscriptions;
}
private getRemainMinutes(room: Room): Map<number, number> {
if (!room.sideTimeoutRemainMinutes) {
room.sideTimeoutRemainMinutes = new Map();
}
return room.sideTimeoutRemainMinutes;
}
private clearSideTimeout(room: Room, pos: number) {
const subscriptions = this.getSubscriptions(room);
const subscription = subscriptions.get(pos);
if (subscription) {
subscription.unsubscribe();
}
subscriptions.delete(pos);
this.getRemainMinutes(room).delete(pos);
}
private createStopSignal(room: Room, pos: number) {
return merge(
this.onSidingReady$.pipe(
filter((event) => event.msg.room === room && event.client.pos === pos),
),
this.onLeavePlayer$.pipe(
filter((event) => event.msg.room === room && event.msg.oldPos === pos),
),
this.onGameStart$.pipe(filter((event) => event.msg.room === room)),
this.onFinalize$.pipe(filter((event) => event.msg.room === room)),
).pipe(take(1));
}
private async startSideTimeout(room: Room, pos: number) {
const client = room.players[pos];
if (!client) {
return;
}
this.clearSideTimeout(room, pos);
this.getRemainMinutes(room).set(pos, this.sideTimeoutMinutes);
await client.sendChat(
`#{side_timeout_part1}${this.sideTimeoutMinutes}#{side_timeout_part2}`,
ChatColor.BABYBLUE,
);
const stopSignal = this.createStopSignal(room, pos);
const subscription = timer(60_000, 60_000)
.pipe(
takeUntil(stopSignal),
finalize(() => {
const subscriptions = this.getSubscriptions(room);
if (subscriptions.get(pos) === subscription) {
subscriptions.delete(pos);
}
this.getRemainMinutes(room).delete(pos);
}),
)
.subscribe(() => {
void this.tickSideTimeout(room, pos).catch((error) => {
this.logger.warn({ error }, 'Failed to process side timeout tick');
});
});
this.getSubscriptions(room).set(pos, subscription);
}
private async tickSideTimeout(room: Room, pos: number) {
if (room.finalizing || room.duelStage !== DuelStage.Siding) {
this.clearSideTimeout(room, pos);
return;
}
const remainMap = this.getRemainMinutes(room);
const remainMinutes = remainMap.get(pos);
if (!remainMinutes) {
this.clearSideTimeout(room, pos);
return;
}
const client = room.players[pos];
if (
!client ||
client.roomName !== room.name ||
client.disconnected ||
client.pos !== pos
) {
this.clearSideTimeout(room, pos);
return;
}
if (remainMinutes <= 1) {
this.clearSideTimeout(room, pos);
await room.sendChat(
`${client.name} #{side_overtime_room}`,
ChatColor.BABYBLUE,
);
await client.sendChat('#{side_overtime}', ChatColor.RED);
client.disconnect();
return;
}
const nextRemainMinutes = remainMinutes - 1;
remainMap.set(pos, nextRemainMinutes);
await client.sendChat(
`#{side_remain_part1}${nextRemainMinutes}#{side_remain_part2}`,
ChatColor.BABYBLUE,
);
}
}
export * from './room'; export * from './room';
export * from './room-manager'; export * from './room-manager';
export * from './duel-stage';
export * from './room-event/on-room-finalize'; export * from './room-event/on-room-finalize';
export * from './room-event/on-room-game-start';
export * from './room-event/on-room-leave-player';
export * from './room-event/on-room-siding-ready';
export * from './room-event/on-room-siding-start';
export * from './room-event/on-room-win';
export * from './default-hostinfo-provder'; export * from './default-hostinfo-provder';
import { RoomEvent } from './room-event';
export class OnRoomSidingReady extends RoomEvent {}
import { RoomEvent } from './room-event';
export class OnRoomSidingStart extends RoomEvent {}
...@@ -94,6 +94,8 @@ import { makeArray } from 'aragami/dist/src/utility/utility'; ...@@ -94,6 +94,8 @@ import { makeArray } from 'aragami/dist/src/utility/utility';
import path from 'path'; import path from 'path';
import { OnRoomCreate } from './room-event/on-room-create'; 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 { OnRoomSidingReady } from './room-event/on-room-siding-ready';
const { OcgcoreScriptConstants } = _OcgcoreConstants; const { OcgcoreScriptConstants } = _OcgcoreConstants;
...@@ -435,6 +437,7 @@ export class Room { ...@@ -435,6 +437,7 @@ 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]);
} }
get lastDuelRecord() { get lastDuelRecord() {
...@@ -806,6 +809,7 @@ export class Room { ...@@ -806,6 +809,7 @@ export class Room {
// 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
client.send(new YGOProStocDuelStart()); client.send(new YGOProStocDuelStart());
await this.ctx.dispatch(new OnRoomSidingReady(this), client);
// Check if all players have submitted their decks // Check if all players have submitted their decks
const allReady = this.playingPlayers.every((p) => p.deck); const allReady = this.playingPlayers.every((p) => p.deck);
......
import { AppContext, ClassType, Middleware, ProtoMiddlewareFunc } from 'nfkit';
import { Emitter } from './emitter';
import { Observable } from 'rxjs';
import { Client } from '../client';
export class MiddlewareRx {
constructor(private ctx: AppContext) {}
private emitter = this.ctx.get(() => Emitter);
event$<T>(cls: ClassType<T>, prior = false) {
return new Observable<{
msg: T;
client: Client;
}>((sub) => {
const handler: Middleware<ProtoMiddlewareFunc<[client: Client], T>> = (
msg,
client,
next,
) => {
sub.next({ msg, client });
return next();
};
this.emitter.middleware(cls, handler, prior);
return () => {
this.emitter.removeMiddleware(cls, handler);
}
});
}
}
...@@ -68,7 +68,7 @@ export class ReverseWsClient extends Client { ...@@ -68,7 +68,7 @@ export class ReverseWsClient extends Client {
} }
physicalIp(): string { physicalIp(): string {
return this.endpointIp; return this.ip;
} }
xffIp(): string | undefined { xffIp(): string | undefined {
......
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