Commit 29cd7800 authored by nanahira's avatar nanahira

add rewind

parent 50dfa86d
......@@ -119,6 +119,7 @@ export const TRANSLATIONS = {
menu_next_page: 'Next Page',
koishi_cmd_tip_desc: 'Send a random tip.',
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_roomname_desc: 'Show current room name.',
koishi_cmd_refresh_desc: 'Refresh duel field state.',
......@@ -127,6 +128,13 @@ export const TRANSLATIONS = {
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.',
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_error: 'Replay opening failed.',
cloud_replay_playing: 'Accessing cloud replay',
......@@ -292,6 +300,7 @@ export const TRANSLATIONS = {
menu_next_page: '下一页',
koishi_cmd_tip_desc: '发送一条随机提示。',
koishi_cmd_ai_desc: '为当前房间添加AI。',
koishi_cmd_rewind_desc: '悔棋,仅 AI 房间可用',
koishi_cmd_surrender_desc: '投降当前对局。',
koishi_cmd_roomname_desc: '显示当前房间名。',
koishi_cmd_refresh_desc: '刷新当前场面信息。',
......@@ -300,6 +309,12 @@ export const TRANSLATIONS = {
koishi_ai_disabled: '人机功能未开启。',
koishi_ai_disabled_random_room: '随机对战房间不允许使用 /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_error: '播放录像出错',
cloud_replay_playing: '正在观看云录像',
......
......@@ -119,7 +119,7 @@ export class DuelRecordEntity extends BaseTimeEntity {
duelRecord.winPosition = this.resolveWinPosition();
duelRecord.winReason = this.winReason;
duelRecord.messages = decodeMessagesBase64(this.messages).map(
(packet) => packet.msg,
(packet) => packet.msg!,
);
duelRecord.responses = decodeResponsesBase64(this.responses);
return duelRecord;
......
......@@ -489,28 +489,6 @@ export class Reconnect {
// Dueling 阶段:决斗中
// 这是原来的完整重连逻辑
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(
newClient,
......
......@@ -7,6 +7,7 @@ import {
YGOProMsgNewPhase,
YGOProMsgNewTurn,
YGOProMsgReverseDeck,
YGOProMsgStart,
YGOProMsgWaiting,
YGOProStocGameMsg,
} from 'ygopro-msg-encode';
......@@ -43,6 +44,7 @@ export class RefreshFieldService {
async sendReconnectDuelingMessages(client: Client, room: Room) {
this.assertRefreshAllowed(client, room);
await this.sendMsgStart(client, room);
await this.sendNewTurnMessages(client, room);
await this.sendRefreshFieldMessages(client, room);
}
......@@ -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);
if (room.isTag) {
const newTurnCount = turnCount % 4 || 4;
......
export * from './windbot-provider';
export * from './rewind-service';
export * from './windbot-spawner';
export * from './join-windbot-ai';
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 { ContextState } from '../../app';
import { RewindService } from './rewind-service';
import { WindBotProvider } from './windbot-provider';
import { WindbotSpawner } from './windbot-spawner';
export const WindbotModule = createAppContext<ContextState>()
.provide(WindBotProvider)
.provide(RewindService)
.provide(WindbotSpawner)
.define();
......@@ -28,11 +28,7 @@ export class DuelRecord {
endTime?: Date;
winPosition?: number;
winReason?: number;
responsesWithPos: { pos: number; response: Buffer }[] = [];
// responses: Buffer[] = [];
get responses() {
return this.responsesWithPos.map((item) => item.response);
}
responses: Buffer[] = [];
messages: YGOProMsgBase[] = [];
toSwappedPlayers() {
......
......@@ -24,7 +24,7 @@ export class RoomManager {
}
}
findByName(name: string) {
findByName(name: string | undefined) {
if (!name) return undefined;
return this.rooms.get(name);
}
......
......@@ -511,12 +511,14 @@ export class Room {
return this.duelRecords[this.duelRecords.length - 1];
}
private disposeOcgcore() {
private disposeOcgcore(ocgcore = this.ocgcore) {
try {
void this.ocgcore?.dispose().catch((e) => {
void ocgcore?.dispose().catch((e) => {
this.logger.warn({ error: e }, 'Error disposing ocgcore');
});
this.ocgcore = undefined;
if (ocgcore === this.ocgcore) {
this.ocgcore = undefined;
}
} catch {}
}
......@@ -1305,9 +1307,7 @@ export class Room {
}
async createOcgcore(duelRecord: DuelRecord) {
if (this.ocgcore) {
this.disposeOcgcore();
}
const oldOcgcore = this.ocgcore;
const extraScriptPaths = [
'./script/patches/entry.lua',
'./script/special.lua',
......@@ -1343,7 +1343,7 @@ export class Room {
const ocgcoreWasmBinary = await this.resourceLoader.getOcgcoreWasmBinary();
try {
this.ocgcore = await initWorker(OcgcoreWorker, {
const ocgcore = await initWorker(OcgcoreWorker, {
seed: duelRecord.seed,
hostinfo: this.hostinfo,
ygoproPaths: this.resourceLoader.ygoproPaths,
......@@ -1353,11 +1353,33 @@ export class Room {
registry,
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;
} catch (e) {
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;
}
}
......@@ -1401,6 +1423,7 @@ export class Room {
}
if (!(await this.createOcgcore(duelRecord))) {
this.finalize(true);
return;
}
......@@ -1460,27 +1483,6 @@ export class Room {
...[...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.ctx.dispatch(
new OnRoomDuelStart(this),
......@@ -1540,7 +1542,10 @@ export class Room {
this.setNewPhase(phase);
}
getIngameOperatingPlayer(ingameDuelPos: number): Client | undefined {
getIngameOperatingPlayer(
ingameDuelPos: number,
turnCount = this.turnCount,
): Client | undefined {
const players = this.getIngameDuelPosPlayers(ingameDuelPos);
if (!this.isTag) {
return players[0];
......@@ -1552,7 +1557,7 @@ export class Room {
// tag_duel.cpp cur_player equivalent, computed from turnCount:
// 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
const tc = Math.max(0, this.turnCount);
const tc = Math.max(0, turnCount);
if (ingameDuelPos === 0) {
const idx = Math.floor(Math.max(0, tc - 1) / 2) % 2;
return players[idx];
......@@ -1714,12 +1719,12 @@ export class Room {
if (message instanceof YGOProMsgResponseBase) {
this.setLastResponseRequestMsg(message);
await this.sendWaitingToNonOperator(message.responsePlayer());
await this.setResponseTimer(this.responsePos);
await this.setResponseTimer(this.responsePos!);
return;
}
if (message instanceof YGOProMsgRetry && this.responsePos != null) {
if (this.lastDuelRecord.responsesWithPos.length > 0) {
this.lastDuelRecord.responsesWithPos.pop();
if (this.lastDuelRecord.responses.length > 0) {
this.lastDuelRecord.responses.pop();
}
this.isRetrying = true;
await this.sendWaitingToNonOperator(
......@@ -1935,7 +1940,7 @@ export class Room {
const responsePos = this.responsePos;
const responseRequestMsg = this.lastResponseRequestMsg;
const response = Buffer.from(msg.response);
this.lastDuelRecord.responsesWithPos.push({ pos: responsePos, response });
this.lastDuelRecord.responses.push(response);
if (this.hasTimeLimit) {
this.clearResponseTimer(true);
const msgType = this.isRetrying
......
import {
YGOProMsgBase,
YGOProMsgHint,
YGOProMsgNewPhase,
YGOProMsgNewTurn,
YGOProMsgResponseBase,
YGOProMsgRetry,
} from 'ygopro-msg-encode';
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) => {
if (
......@@ -16,8 +22,8 @@ export const sliceOcgcore = async (room: Room, i: number) => {
}
room.resetDuelState();
const useResponses = room.lastDuelRecord.responses.slice(0, i);
let messagePointer = 0; // 1st message is MSG_START and we skip it
while (useResponses.length) {
let messagePointer = 1; // 1st message is MSG_START and we skip it
while (true) {
for await (const { message, status, raw } of room.ocgcore!.advance()) {
if (!message) {
if (status) {
......@@ -33,15 +39,22 @@ export const sliceOcgcore = async (room: Room, i: number) => {
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) {
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()))) {
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) {
......@@ -60,6 +73,6 @@ export const sliceOcgcore = async (room: Room, i: number) => {
}
await room.ocgcore!.setResponse(response);
}
room.lastDuelRecord.responsesWithPos.splice(i);
room.lastDuelRecord.messages.splice(messagePointer + 1);
room.lastDuelRecord.responses.splice(i);
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