Commit 29cd7800 authored by nanahira's avatar nanahira

add rewind

parent 50dfa86d
...@@ -119,6 +119,7 @@ export const TRANSLATIONS = { ...@@ -119,6 +119,7 @@ export const TRANSLATIONS = {
menu_next_page: 'Next Page', menu_next_page: 'Next Page',
koishi_cmd_tip_desc: 'Send a random tip.', koishi_cmd_tip_desc: 'Send a random tip.',
koishi_cmd_ai_desc: 'Add windbot to current room.', koishi_cmd_ai_desc: 'Add windbot to current room.',
koishi_cmd_rewind_desc: 'Rewind, only available in AI rooms.',
koishi_cmd_surrender_desc: 'Surrender current duel.', koishi_cmd_surrender_desc: 'Surrender current duel.',
koishi_cmd_roomname_desc: 'Show current room name.', koishi_cmd_roomname_desc: 'Show current room name.',
koishi_cmd_refresh_desc: 'Refresh duel field state.', koishi_cmd_refresh_desc: 'Refresh duel field state.',
...@@ -127,6 +128,13 @@ export const TRANSLATIONS = { ...@@ -127,6 +128,13 @@ export const TRANSLATIONS = {
koishi_ai_disabled: 'Windbot feature is disabled.', koishi_ai_disabled: 'Windbot feature is disabled.',
koishi_ai_disabled_random_room: 'AI is disabled in random duel rooms.', koishi_ai_disabled_random_room: 'AI is disabled in random duel rooms.',
koishi_ai_room_full: 'Room is full, cannot add AI.', koishi_ai_room_full: 'Room is full, cannot add AI.',
koishi_rewind_duel_not_started: 'Duel has not started, cannot rewind.',
koishi_rewind_not_ai_room: 'Not an AI duel room, cannot rewind.',
koishi_rewind_no_response_found: 'No rewind point found.',
koishi_rewind_duel_not_self_responsing:
'It is not your turn to respond, cannot rewind.',
koishi_rewind_failed: 'Rewind failed.',
koishi_rewind_success: 'Rewind succeeded.',
cloud_replay_no: 'Replay not found.', cloud_replay_no: 'Replay not found.',
cloud_replay_error: 'Replay opening failed.', cloud_replay_error: 'Replay opening failed.',
cloud_replay_playing: 'Accessing cloud replay', cloud_replay_playing: 'Accessing cloud replay',
...@@ -292,6 +300,7 @@ export const TRANSLATIONS = { ...@@ -292,6 +300,7 @@ export const TRANSLATIONS = {
menu_next_page: '下一页', menu_next_page: '下一页',
koishi_cmd_tip_desc: '发送一条随机提示。', koishi_cmd_tip_desc: '发送一条随机提示。',
koishi_cmd_ai_desc: '为当前房间添加AI。', koishi_cmd_ai_desc: '为当前房间添加AI。',
koishi_cmd_rewind_desc: '悔棋,仅 AI 房间可用',
koishi_cmd_surrender_desc: '投降当前对局。', koishi_cmd_surrender_desc: '投降当前对局。',
koishi_cmd_roomname_desc: '显示当前房间名。', koishi_cmd_roomname_desc: '显示当前房间名。',
koishi_cmd_refresh_desc: '刷新当前场面信息。', koishi_cmd_refresh_desc: '刷新当前场面信息。',
...@@ -300,6 +309,12 @@ export const TRANSLATIONS = { ...@@ -300,6 +309,12 @@ export const TRANSLATIONS = {
koishi_ai_disabled: '人机功能未开启。', koishi_ai_disabled: '人机功能未开启。',
koishi_ai_disabled_random_room: '随机对战房间不允许使用 /ai。', koishi_ai_disabled_random_room: '随机对战房间不允许使用 /ai。',
koishi_ai_room_full: '房间已满,无法添加AI。', koishi_ai_room_full: '房间已满,无法添加AI。',
koishi_rewind_duel_not_started: '决斗未开始,无法悔棋',
koishi_rewind_not_ai_room: '非 AI 决斗房间,无法悔棋',
koishi_rewind_no_response_found: '未找到悔棋时点。',
koishi_rewind_duel_not_self_responsing: '未轮到自己操作,无法悔棋',
koishi_rewind_failed: '悔棋失败。',
koishi_rewind_success: '悔棋成功。',
cloud_replay_no: '没有找到录像', cloud_replay_no: '没有找到录像',
cloud_replay_error: '播放录像出错', cloud_replay_error: '播放录像出错',
cloud_replay_playing: '正在观看云录像', cloud_replay_playing: '正在观看云录像',
......
...@@ -119,7 +119,7 @@ export class DuelRecordEntity extends BaseTimeEntity { ...@@ -119,7 +119,7 @@ export class DuelRecordEntity extends BaseTimeEntity {
duelRecord.winPosition = this.resolveWinPosition(); duelRecord.winPosition = this.resolveWinPosition();
duelRecord.winReason = this.winReason; duelRecord.winReason = this.winReason;
duelRecord.messages = decodeMessagesBase64(this.messages).map( duelRecord.messages = decodeMessagesBase64(this.messages).map(
(packet) => packet.msg, (packet) => packet.msg!,
); );
duelRecord.responses = decodeResponsesBase64(this.responses); duelRecord.responses = decodeResponsesBase64(this.responses);
return duelRecord; return duelRecord;
......
...@@ -489,28 +489,6 @@ export class Reconnect { ...@@ -489,28 +489,6 @@ export class Reconnect {
// Dueling 阶段:决斗中 // Dueling 阶段:决斗中
// 这是原来的完整重连逻辑 // 这是原来的完整重连逻辑
await newClient.send(new YGOProStocDuelStart()); await newClient.send(new YGOProStocDuelStart());
// Dueling 阶段不发 DeckCount
// 发送 MSG_START,卡组数量全部为 0(重连时不显示卡组数量)
const playerType = room.getIngameDuelPos(newClient);
await newClient.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgStart().fromPartial({
playerType,
duelRule: room.hostinfo.duel_rule,
startLp0: room.hostinfo.start_lp,
startLp1: room.hostinfo.start_lp,
player0: {
deckCount: 0,
extraCount: 0,
},
player1: {
deckCount: 0,
extraCount: 0,
},
}),
}),
);
await this.refreshFieldService.sendReconnectDuelingMessages( await this.refreshFieldService.sendReconnectDuelingMessages(
newClient, newClient,
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
YGOProMsgNewPhase, YGOProMsgNewPhase,
YGOProMsgNewTurn, YGOProMsgNewTurn,
YGOProMsgReverseDeck, YGOProMsgReverseDeck,
YGOProMsgStart,
YGOProMsgWaiting, YGOProMsgWaiting,
YGOProStocGameMsg, YGOProStocGameMsg,
} from 'ygopro-msg-encode'; } from 'ygopro-msg-encode';
...@@ -43,6 +44,7 @@ export class RefreshFieldService { ...@@ -43,6 +44,7 @@ export class RefreshFieldService {
async sendReconnectDuelingMessages(client: Client, room: Room) { async sendReconnectDuelingMessages(client: Client, room: Room) {
this.assertRefreshAllowed(client, room); this.assertRefreshAllowed(client, room);
await this.sendMsgStart(client, room);
await this.sendNewTurnMessages(client, room); await this.sendNewTurnMessages(client, room);
await this.sendRefreshFieldMessages(client, room); await this.sendRefreshFieldMessages(client, room);
} }
...@@ -145,7 +147,29 @@ export class RefreshFieldService { ...@@ -145,7 +147,29 @@ export class RefreshFieldService {
} }
} }
private async sendNewTurnMessages(client: Client, room: Room) { async sendMsgStart(client: Client, room: Room) {
const playerType = room.getIngameDuelPos(client);
await client.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgStart().fromPartial({
playerType,
duelRule: room.hostinfo.duel_rule,
startLp0: room.hostinfo.start_lp,
startLp1: room.hostinfo.start_lp,
player0: {
deckCount: 0,
extraCount: 0,
},
player1: {
deckCount: 0,
extraCount: 0,
},
}),
}),
);
}
async sendNewTurnMessages(client: Client, room: Room) {
const turnCount = Math.max(1, room.turnCount || 0); const turnCount = Math.max(1, room.turnCount || 0);
if (room.isTag) { if (room.isTag) {
const newTurnCount = turnCount % 4 || 4; const newTurnCount = turnCount % 4 || 4;
......
export * from './windbot-provider'; export * from './windbot-provider';
export * from './rewind-service';
export * from './windbot-spawner'; export * from './windbot-spawner';
export * from './join-windbot-ai'; export * from './join-windbot-ai';
export * from './join-windbot-token'; export * from './join-windbot-token';
......
import { h } from 'koishi';
import { Context } from '../../app';
import { Client } from '../../client';
import { KoishiContextService } from '../../koishi';
import { DuelStage, Room, RoomManager } from '../../room';
import { KoishiFragment } from '../../utility';
import { WindBotProvider } from './windbot-provider';
import { sliceOcgcore } from '../../utility/slice-ocgcore';
import { RefreshFieldService } from '../reconnect';
import {
YGOProCtosChat,
YGOProCtosResponse,
YGOProMsgNewTurn,
YGOProMsgResponseBase,
YGOProMsgSelectChain,
YGOProMsgSelectPlace,
YGOProMsgSelectPosition,
YGOProMsgWaiting,
YGOProMsgWin,
YGOProStocChangeSide,
YGOProStocDuelStart,
YGOProStocGameMsg,
} from 'ygopro-msg-encode';
declare module '../../client' {
interface Client {
rewindBanChat?: boolean;
}
}
declare module '../../room' {
interface Room {
rebuildingOcgcore?: boolean;
}
}
export class RewindService {
private koishiContextService = this.ctx.get(() => KoishiContextService);
private windBotProvider = this.ctx.get(() => WindBotProvider);
private rewindResponseWaiters = new Map<
string,
{
resolve: () => void;
timeout: ReturnType<typeof setTimeout>;
}
>();
constructor(private ctx: Context) {}
async init() {
if (!this.windBotProvider.isEnabled) {
return;
}
this.registerKoishiCommand();
this.ctx
.middleware(YGOProCtosResponse, async (_message, client, next) => {
const room = this.ctx
.get(() => RoomManager)
.findByName(client.roomName);
if (room?.rebuildingOcgcore) {
return undefined;
}
const waitKey = this.getRewindResponseWaitKey(client);
if (!waitKey) {
return next();
}
const waiter = this.rewindResponseWaiters.get(waitKey);
if (!waiter) {
return next();
}
this.rewindResponseWaiters.delete(waitKey);
clearTimeout(waiter.timeout);
waiter.resolve();
return undefined;
})
.middleware(YGOProCtosChat, async (message, client, next) => {
if (client.rewindBanChat) {
return undefined;
}
return next();
});
}
private asRedError(message: string) {
return h('Chat', { color: 'Red' }, message);
}
private registerKoishiCommand() {
if (!this.windBotProvider.isEnabled) {
return;
}
const koishi = this.koishiContextService.instance;
this.koishiContextService.attachI18n('rewind', {
description: 'koishi_cmd_rewind_desc',
});
koishi.command('rewind', '').action(async ({ session }) => {
const commandContext =
this.koishiContextService.resolveCommandContext(session);
if (!commandContext) {
return;
}
const { room, client } = commandContext;
if (!room.windbot) {
return this.asRedError('#{koishi_rewind_not_ai_room}');
}
if (room.duelStage !== DuelStage.Dueling) {
return this.asRedError('#{koishi_rewind_duel_not_started}');
}
// if (room.responsePlayer !== client) {
// return this.asRedError('#{koishi_rewind_duel_not_self_responsing}');
// }
return this.rewind(room, client);
});
}
private logger = this.ctx.createLogger(this.constructor.name);
private getRewindResponseWaitKey(client: Pick<Client, 'roomName' | 'pos'>) {
if (!client.roomName || client.pos == null) {
return undefined;
}
return `${client.roomName}:${client.pos}`;
}
private waitForRewindResponse(client: Client) {
const waitKey = this.getRewindResponseWaitKey(client);
if (!waitKey) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
const previous = this.rewindResponseWaiters.get(waitKey);
if (previous) {
this.rewindResponseWaiters.delete(waitKey);
clearTimeout(previous.timeout);
previous.resolve();
}
let settled = false;
const waiter = {} as {
resolve: () => void;
timeout: ReturnType<typeof setTimeout>;
};
const settle = () => {
if (settled) {
return;
}
settled = true;
if (this.rewindResponseWaiters.get(waitKey) === waiter) {
this.rewindResponseWaiters.delete(waitKey);
}
clearTimeout(waiter.timeout);
resolve();
};
waiter.resolve = settle;
waiter.timeout = setTimeout(settle, 10_000);
this.rewindResponseWaiters.set(waitKey, waiter);
});
}
private async rewindSendToObserver(room: Room, client: Client) {
for (const message of room.lastDuelRecord?.toPlayback((msg) =>
msg.observerView(),
) || []) {
await client.send(message);
}
}
private async sendClosePopupMessage(client: Client) {
await client.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgWin().fromPartial({
player: 0x2, // DRAW_GAME
type: 0x10, // just a reasonable reason
}),
}),
);
await client.send(new YGOProStocChangeSide());
await client.send(new YGOProStocDuelStart());
}
private async rewindSendToPlayer(room: Room, client: Client) {
if (client === room.responsePlayer) {
await this.sendClosePopupMessage(client);
}
const refreshField = this.ctx.get(() => RefreshFieldService);
return refreshField.sendReconnectDuelingMessages(client, room);
}
private async rewindSendToWindbot(room: Room, client: Client) {
client.rewindBanChat = true;
try {
await this.sendClosePopupMessage(client);
const ingameDuelPos = room.getIngameDuelPos(client);
let turnCount = 0;
const messages = [
...(room.lastDuelRecord?.toPlayback(
(msg) => {
if (msg instanceof YGOProMsgNewTurn && !(msg.player & 0x2)) {
++turnCount;
}
if (!msg.getSendTargets().includes(ingameDuelPos)) {
return; // skip messages that are not sent to this player
}
if (
client !== room.getIngameOperatingPlayer(ingameDuelPos, turnCount)
) {
if (msg instanceof YGOProMsgResponseBase) {
return; // skip every response for non-operating player
}
return msg.playerView(ingameDuelPos).teammateView();
} else {
return msg.playerView(ingameDuelPos);
}
},
{
includeResponse: true,
includeNonObserver: true,
msgStartPos: ingameDuelPos,
},
) || []),
];
for (let i = 0; i < messages.length; ++i) {
const message = messages[i];
await client.send(message);
if (
message.msg instanceof YGOProMsgResponseBase &&
i < messages.length - 1
) {
await this.waitForRewindResponse(client);
}
}
if (client !== room.responsePlayer) {
await client.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgWaiting(),
}),
);
}
} finally {
client.rewindBanChat = false;
}
}
async rewind(
room: Room,
client: Client,
): Promise<KoishiFragment | undefined> {
let found = false;
let turnCount = room.turnCount;
const ingameDuelPos = room.getIngameDuelPos(client);
const rewindMessageIndex =
room.lastDuelRecord?.messages.findLastIndex((item, i) => {
if (item instanceof YGOProMsgNewTurn && !(item.player & 0x2)) {
--turnCount;
}
if (
!(item instanceof YGOProMsgResponseBase) ||
item.responsePlayer() !== ingameDuelPos ||
room.getIngameOperatingPlayer(ingameDuelPos, turnCount) !== client
) {
return false;
}
if (
(item instanceof YGOProMsgSelectChain && !item.chains?.length) || // skip empty select chain messages
item instanceof YGOProMsgSelectPosition || // skip select summon position / place
item instanceof YGOProMsgSelectPlace ||
(!found && room.responsePlayer === client) // skip messages before the first response message
) {
found = true;
return false;
}
return true;
}) || -1;
if (rewindMessageIndex === -1) {
return this.asRedError('#{koishi_rewind_no_response_found}');
}
const rewindResponseIndex =
room
.lastDuelRecord!.messages.slice(0, rewindMessageIndex + 1)
.filter((msg) => msg instanceof YGOProMsgResponseBase).length - 1;
room.rebuildingOcgcore = true;
try {
await sliceOcgcore(room, rewindResponseIndex);
} catch (e) {
this.logger.warn(
{
error: e instanceof Error ? e.stack : e,
pos: client.pos,
rewindMessageIndex,
rewindResponseIndex,
},
'Failed to rewind',
);
await room.finalize();
return this.asRedError('#{koishi_rewind_failed}');
} finally {
room.rebuildingOcgcore = false;
}
await Promise.all([
...[...room.watchers].map((watcher) =>
this.rewindSendToObserver(room, watcher),
),
...room.playingPlayers.map((player) =>
player.windbot
? this.rewindSendToWindbot(room, player)
: this.rewindSendToPlayer(room, player),
),
]);
return '#{koishi_rewind_success}';
}
}
import { createAppContext } from 'nfkit'; import { createAppContext } from 'nfkit';
import { ContextState } from '../../app'; import { ContextState } from '../../app';
import { RewindService } from './rewind-service';
import { WindBotProvider } from './windbot-provider'; import { WindBotProvider } from './windbot-provider';
import { WindbotSpawner } from './windbot-spawner'; import { WindbotSpawner } from './windbot-spawner';
export const WindbotModule = createAppContext<ContextState>() export const WindbotModule = createAppContext<ContextState>()
.provide(WindBotProvider) .provide(WindBotProvider)
.provide(RewindService)
.provide(WindbotSpawner) .provide(WindbotSpawner)
.define(); .define();
...@@ -28,11 +28,7 @@ export class DuelRecord { ...@@ -28,11 +28,7 @@ export class DuelRecord {
endTime?: Date; endTime?: Date;
winPosition?: number; winPosition?: number;
winReason?: number; winReason?: number;
responsesWithPos: { pos: number; response: Buffer }[] = []; responses: Buffer[] = [];
// responses: Buffer[] = [];
get responses() {
return this.responsesWithPos.map((item) => item.response);
}
messages: YGOProMsgBase[] = []; messages: YGOProMsgBase[] = [];
toSwappedPlayers() { toSwappedPlayers() {
......
...@@ -24,7 +24,7 @@ export class RoomManager { ...@@ -24,7 +24,7 @@ export class RoomManager {
} }
} }
findByName(name: string) { findByName(name: string | undefined) {
if (!name) return undefined; if (!name) return undefined;
return this.rooms.get(name); return this.rooms.get(name);
} }
......
...@@ -511,12 +511,14 @@ export class Room { ...@@ -511,12 +511,14 @@ export class Room {
return this.duelRecords[this.duelRecords.length - 1]; return this.duelRecords[this.duelRecords.length - 1];
} }
private disposeOcgcore() { private disposeOcgcore(ocgcore = this.ocgcore) {
try { try {
void this.ocgcore?.dispose().catch((e) => { void ocgcore?.dispose().catch((e) => {
this.logger.warn({ error: e }, 'Error disposing ocgcore'); this.logger.warn({ error: e }, 'Error disposing ocgcore');
}); });
this.ocgcore = undefined; if (ocgcore === this.ocgcore) {
this.ocgcore = undefined;
}
} catch {} } catch {}
} }
...@@ -1305,9 +1307,7 @@ export class Room { ...@@ -1305,9 +1307,7 @@ export class Room {
} }
async createOcgcore(duelRecord: DuelRecord) { async createOcgcore(duelRecord: DuelRecord) {
if (this.ocgcore) { const oldOcgcore = this.ocgcore;
this.disposeOcgcore();
}
const extraScriptPaths = [ const extraScriptPaths = [
'./script/patches/entry.lua', './script/patches/entry.lua',
'./script/special.lua', './script/special.lua',
...@@ -1343,7 +1343,7 @@ export class Room { ...@@ -1343,7 +1343,7 @@ export class Room {
const ocgcoreWasmBinary = await this.resourceLoader.getOcgcoreWasmBinary(); const ocgcoreWasmBinary = await this.resourceLoader.getOcgcoreWasmBinary();
try { try {
this.ocgcore = await initWorker(OcgcoreWorker, { const ocgcore = await initWorker(OcgcoreWorker, {
seed: duelRecord.seed, seed: duelRecord.seed,
hostinfo: this.hostinfo, hostinfo: this.hostinfo,
ygoproPaths: this.resourceLoader.ygoproPaths, ygoproPaths: this.resourceLoader.ygoproPaths,
...@@ -1353,11 +1353,33 @@ export class Room { ...@@ -1353,11 +1353,33 @@ export class Room {
registry, registry,
decks: duelRecord.toSwappedPlayers().map((p) => p.deck), decks: duelRecord.toSwappedPlayers().map((p) => p.deck),
}); });
ocgcore.message$.subscribe((msg) => {
this.logger.info(
{ message: msg.message, type: msg.type },
'Received message from OCGCoreWorker',
);
if (
msg.type === OcgcoreMessageType.DebugMessage &&
!this.ctx.config.getBoolean('OCGCORE_DEBUG_LOG')
) {
return;
}
this.sendChat(`Debug: ${msg.message}`, ChatColor.RED);
});
ocgcore.registry$.subscribe((registry) => {
this.logger.debug(
{ registry },
'Received registry update from OCGCoreWorker',
);
Object.assign(this.registry, registry);
});
this.ocgcore = ocgcore;
if (oldOcgcore) {
this.disposeOcgcore(oldOcgcore);
}
return true; return true;
} catch (e) { } catch (e) {
this.logger.error({ error: e }, 'Failed to initialize OCGCoreWorker'); this.logger.error({ error: e }, 'Failed to initialize OCGCoreWorker');
await this.sendChat('Failed to initialize game engine.', ChatColor.RED);
await this.finalize(true);
return false; return false;
} }
} }
...@@ -1401,6 +1423,7 @@ export class Room { ...@@ -1401,6 +1423,7 @@ export class Room {
} }
if (!(await this.createOcgcore(duelRecord))) { if (!(await this.createOcgcore(duelRecord))) {
this.finalize(true);
return; return;
} }
...@@ -1460,27 +1483,6 @@ export class Room { ...@@ -1460,27 +1483,6 @@ export class Room {
...[...this.watchers].map((p) => p.send(watcherMsg)), ...[...this.watchers].map((p) => p.send(watcherMsg)),
]); ]);
this.ocgcore.message$.subscribe((msg) => {
this.logger.info(
{ message: msg.message, type: msg.type },
'Received message from OCGCoreWorker',
);
if (
msg.type === OcgcoreMessageType.DebugMessage &&
!this.ctx.config.getBoolean('OCGCORE_DEBUG_LOG')
) {
return;
}
this.sendChat(`Debug: ${msg.message}`, ChatColor.RED);
});
this.ocgcore.registry$.subscribe((registry) => {
this.logger.debug(
{ registry },
'Received registry update from OCGCoreWorker',
);
Object.assign(this.registry, registry);
});
await this.dispatchGameMsg(watcherMsg.msg); await this.dispatchGameMsg(watcherMsg.msg);
await this.ctx.dispatch( await this.ctx.dispatch(
new OnRoomDuelStart(this), new OnRoomDuelStart(this),
...@@ -1540,7 +1542,10 @@ export class Room { ...@@ -1540,7 +1542,10 @@ export class Room {
this.setNewPhase(phase); this.setNewPhase(phase);
} }
getIngameOperatingPlayer(ingameDuelPos: number): Client | undefined { getIngameOperatingPlayer(
ingameDuelPos: number,
turnCount = this.turnCount,
): Client | undefined {
const players = this.getIngameDuelPosPlayers(ingameDuelPos); const players = this.getIngameDuelPosPlayers(ingameDuelPos);
if (!this.isTag) { if (!this.isTag) {
return players[0]; return players[0];
...@@ -1552,7 +1557,7 @@ export class Room { ...@@ -1552,7 +1557,7 @@ export class Room {
// tag_duel.cpp cur_player equivalent, computed from turnCount: // tag_duel.cpp cur_player equivalent, computed from turnCount:
// duelPos 0: start from players[0], toggle every two turns from turn 3 // duelPos 0: start from players[0], toggle every two turns from turn 3
// duelPos 1: start from players[1], toggle every two turns from turn 2 // duelPos 1: start from players[1], toggle every two turns from turn 2
const tc = Math.max(0, this.turnCount); const tc = Math.max(0, turnCount);
if (ingameDuelPos === 0) { if (ingameDuelPos === 0) {
const idx = Math.floor(Math.max(0, tc - 1) / 2) % 2; const idx = Math.floor(Math.max(0, tc - 1) / 2) % 2;
return players[idx]; return players[idx];
...@@ -1714,12 +1719,12 @@ export class Room { ...@@ -1714,12 +1719,12 @@ export class Room {
if (message instanceof YGOProMsgResponseBase) { if (message instanceof YGOProMsgResponseBase) {
this.setLastResponseRequestMsg(message); this.setLastResponseRequestMsg(message);
await this.sendWaitingToNonOperator(message.responsePlayer()); await this.sendWaitingToNonOperator(message.responsePlayer());
await this.setResponseTimer(this.responsePos); await this.setResponseTimer(this.responsePos!);
return; return;
} }
if (message instanceof YGOProMsgRetry && this.responsePos != null) { if (message instanceof YGOProMsgRetry && this.responsePos != null) {
if (this.lastDuelRecord.responsesWithPos.length > 0) { if (this.lastDuelRecord.responses.length > 0) {
this.lastDuelRecord.responsesWithPos.pop(); this.lastDuelRecord.responses.pop();
} }
this.isRetrying = true; this.isRetrying = true;
await this.sendWaitingToNonOperator( await this.sendWaitingToNonOperator(
...@@ -1935,7 +1940,7 @@ export class Room { ...@@ -1935,7 +1940,7 @@ export class Room {
const responsePos = this.responsePos; const responsePos = this.responsePos;
const responseRequestMsg = this.lastResponseRequestMsg; const responseRequestMsg = this.lastResponseRequestMsg;
const response = Buffer.from(msg.response); const response = Buffer.from(msg.response);
this.lastDuelRecord.responsesWithPos.push({ pos: responsePos, response }); this.lastDuelRecord.responses.push(response);
if (this.hasTimeLimit) { if (this.hasTimeLimit) {
this.clearResponseTimer(true); this.clearResponseTimer(true);
const msgType = this.isRetrying const msgType = this.isRetrying
......
import { import {
YGOProMsgBase,
YGOProMsgHint,
YGOProMsgNewPhase, YGOProMsgNewPhase,
YGOProMsgNewTurn, YGOProMsgNewTurn,
YGOProMsgResponseBase, YGOProMsgResponseBase,
YGOProMsgRetry, YGOProMsgRetry,
} from 'ygopro-msg-encode'; } from 'ygopro-msg-encode';
import { Room } from '../room'; import { Room } from '../room';
import { isUpdateMessage } from './is-update-message';
const isVerifySkippingMessage = (message: YGOProMsgBase) =>
message instanceof YGOProMsgHint || isUpdateMessage(message);
export const sliceOcgcore = async (room: Room, i: number) => { export const sliceOcgcore = async (room: Room, i: number) => {
if ( if (
...@@ -16,8 +22,8 @@ export const sliceOcgcore = async (room: Room, i: number) => { ...@@ -16,8 +22,8 @@ export const sliceOcgcore = async (room: Room, i: number) => {
} }
room.resetDuelState(); room.resetDuelState();
const useResponses = room.lastDuelRecord.responses.slice(0, i); const useResponses = room.lastDuelRecord.responses.slice(0, i);
let messagePointer = 0; // 1st message is MSG_START and we skip it let messagePointer = 1; // 1st message is MSG_START and we skip it
while (useResponses.length) { while (true) {
for await (const { message, status, raw } of room.ocgcore!.advance()) { for await (const { message, status, raw } of room.ocgcore!.advance()) {
if (!message) { if (!message) {
if (status) { if (status) {
...@@ -33,15 +39,22 @@ export const sliceOcgcore = async (room: Room, i: number) => { ...@@ -33,15 +39,22 @@ export const sliceOcgcore = async (room: Room, i: number) => {
throw new Error('Unexpected retry message'); throw new Error('Unexpected retry message');
} }
const expectedMessage = room.lastDuelRecord.messages[++messagePointer]; if (isVerifySkippingMessage(message)) {
continue; // skip update messages
}
let expectedMessage = room.lastDuelRecord.messages[messagePointer++];
while (expectedMessage && isVerifySkippingMessage(expectedMessage)) {
expectedMessage = room.lastDuelRecord.messages[messagePointer++];
}
if (!expectedMessage) { if (!expectedMessage) {
throw new Error( throw new Error(
`No more expected messages but got ${message.constructor.name} with payload ${Buffer.from(raw).toString('hex')}`, `No more expected messages but got ${message.constructor.name} with payload ${Buffer.from(raw).toString('hex')} body ${JSON.stringify(message)}`,
); );
} }
if (!Buffer.from(raw).equals(Buffer.from(expectedMessage.toPayload()))) { if (!Buffer.from(raw).equals(Buffer.from(expectedMessage.toPayload()))) {
throw new Error( throw new Error(
`Message mismatch at position ${messagePointer - 1}: expected ${expectedMessage.constructor.name} with payload ${Buffer.from(expectedMessage.toPayload()).toString('hex')}, got ${message.constructor.name} with payload ${Buffer.from(raw).toString('hex')}`, `Message mismatch at position ${messagePointer - 1}: expected ${expectedMessage.constructor.name} with payload ${Buffer.from(expectedMessage.toPayload()).toString('hex')} body ${JSON.stringify(expectedMessage)}, got ${message.constructor.name} with payload ${Buffer.from(raw).toString('hex')} body ${JSON.stringify(message)}`,
); );
} }
if (message instanceof YGOProMsgNewTurn) { if (message instanceof YGOProMsgNewTurn) {
...@@ -60,6 +73,6 @@ export const sliceOcgcore = async (room: Room, i: number) => { ...@@ -60,6 +73,6 @@ export const sliceOcgcore = async (room: Room, i: number) => {
} }
await room.ocgcore!.setResponse(response); await room.ocgcore!.setResponse(response);
} }
room.lastDuelRecord.responsesWithPos.splice(i); room.lastDuelRecord.responses.splice(i);
room.lastDuelRecord.messages.splice(messagePointer + 1); room.lastDuelRecord.messages.splice(messagePointer);
}; };
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