Commit 3ac2c111 authored by nanahira's avatar nanahira

add cloud replay

parent b55ce242
...@@ -115,6 +115,18 @@ export const TRANSLATIONS = { ...@@ -115,6 +115,18 @@ 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.',
cloud_replay_no: 'Replay not found.',
cloud_replay_error: 'Replay opening failed.',
cloud_replay_playing: 'Accessing cloud replay',
cloud_replay_hint:
'These are your recent cloud replays. Select one from the menu to continue.',
cloud_replay_detail_time: 'Time: ',
cloud_replay_detail_players: 'Duel: ',
cloud_replay_detail_score: 'Score: ',
cloud_replay_detail_winner: 'Winner: ',
cloud_replay_menu_play: 'Play Cloud Replay',
cloud_replay_menu_download_yrp: 'Download YRP Replay',
cloud_replay_menu_back: 'Back',
}, },
'zh-CN': { 'zh-CN': {
update_required: '请更新你的客户端版本', update_required: '请更新你的客户端版本',
...@@ -224,5 +236,16 @@ export const TRANSLATIONS = { ...@@ -224,5 +236,16 @@ 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。',
cloud_replay_no: '没有找到录像',
cloud_replay_error: '播放录像出错',
cloud_replay_playing: '正在观看云录像',
cloud_replay_hint: '以下是您近期的云录像,请在菜单中选择一条继续。',
cloud_replay_detail_time: '时间:',
cloud_replay_detail_players: '对局:',
cloud_replay_detail_score: '比分:',
cloud_replay_detail_winner: '胜者:',
cloud_replay_menu_play: '播放云录像',
cloud_replay_menu_download_yrp: '下载 YRP 录像',
cloud_replay_menu_back: '返回',
}, },
}; };
import cryptoRandomString from 'crypto-random-string'; import cryptoRandomString from 'crypto-random-string';
import YGOProDeck from 'ygopro-deck-encode';
import {
ChatColor,
HostInfo,
NetPlayerType,
YGOProMsgResponseBase,
YGOProMsgWin,
YGOProStocDuelEnd,
YGOProStocDuelStart,
YGOProStocGameMsg,
YGOProStocHsPlayerEnter,
YGOProStocJoinGame,
YGOProStocReplay,
} from 'ygopro-msg-encode';
import { Context } from '../../app'; import { Context } from '../../app';
import { Client } from '../../client';
import { DuelRecord, OnRoomCreate, OnRoomWin, Room } from '../../room';
import { ClientKeyProvider } from '../client-key-provider'; import { ClientKeyProvider } from '../client-key-provider';
import { OnRoomCreate, OnRoomWin, Room } from '../../room'; import { MenuEntry, MenuManager } from '../menu-manager';
import { DuelRecordEntity } from './duel-record.entity'; import { DuelRecordEntity } from './duel-record.entity';
import { DuelRecordPlayer } from './duel-record-player.entity'; import { DuelRecordPlayer } from './duel-record-player.entity';
import { Client } from '../../client';
import { import {
decodeDeckBase64,
decodeMessagesBase64,
decodeResponsesBase64,
decodeSeedBase64,
encodeCurrentDeckBase64, encodeCurrentDeckBase64,
encodeDeckBase64, encodeDeckBase64,
encodeIngameDeckBase64,
encodeMessagesBase64, encodeMessagesBase64,
encodeResponsesBase64, encodeResponsesBase64,
encodeSeedBase64, encodeSeedBase64,
resolveCurrentDeckMainc, resolveCurrentDeckMainc,
resolveIngameDeckMainc,
resolveIngamePosBySeat,
resolveIsFirstPlayer,
resolvePlayerScore, resolvePlayerScore,
resolveStartDeckMainc, resolveStartDeckMainc,
} from './utility'; } from './utility';
type ReplayPage = {
entries: DuelRecordEntity[];
hasNext: boolean;
nextCursor?: number;
};
declare module '../../room' { declare module '../../room' {
interface Room { interface Room {
identifier?: string; identifier?: string;
} }
} }
declare module '../../client' {
interface Client {
cloudReplayPageCursors?: Array<number | null>;
cloudReplayPageIndex?: number;
cloudReplaySelectedReplayId?: number;
}
}
export class CloudReplayService { export class CloudReplayService {
private logger = this.ctx.createLogger(this.constructor.name); private logger = this.ctx.createLogger(this.constructor.name);
private clientKeyProvider = this.ctx.get(() => ClientKeyProvider); private clientKeyProvider = this.ctx.get(() => ClientKeyProvider);
private menuManager = this.ctx.get(() => MenuManager);
constructor(private ctx: Context) { constructor(private ctx: Context) {
this.ctx.middleware(OnRoomCreate, async (event, _client, next) => { this.ctx.middleware(OnRoomCreate, async (event, _client, next) => {
...@@ -38,6 +76,26 @@ export class CloudReplayService { ...@@ -38,6 +76,26 @@ export class CloudReplayService {
}); });
} }
async tryHandleJoinPass(pass: string, client: Client) {
const normalized = (pass || '').trim().toUpperCase();
if (!normalized || !['R', 'W'].includes(normalized)) {
return false;
}
if (!this.ctx.database) {
await client.die('#{cloud_replay_no}', ChatColor.RED);
return true;
}
if (normalized === 'W') {
await this.playRandomReplay(client);
return true;
}
await this.openReplayListMenu(client);
return true;
}
private createRoomIdentifier() { private createRoomIdentifier() {
return cryptoRandomString({ return cryptoRandomString({
length: 64, length: 64,
...@@ -73,7 +131,12 @@ export class CloudReplayService { ...@@ -73,7 +131,12 @@ export class CloudReplayService {
responses: encodeResponsesBase64(duelRecord.responses), responses: encodeResponsesBase64(duelRecord.responses),
seed: encodeSeedBase64(duelRecord.seed), seed: encodeSeedBase64(duelRecord.seed),
players: room.playingPlayers.map((client) => players: room.playingPlayers.map((client) =>
this.buildPlayerRecord(room, client, event.winMsg.player), this.buildPlayerRecord(
room,
client,
event.winMsg.player,
event.wasSwapped,
),
), ),
}); });
...@@ -89,20 +152,27 @@ export class CloudReplayService { ...@@ -89,20 +152,27 @@ export class CloudReplayService {
} }
} }
private buildPlayerRecord(room: Room, client: Client, winPlayer: number) { private buildPlayerRecord(
room: Room,
client: Client,
winPlayer: number,
wasSwapped: boolean,
) {
const player = new DuelRecordPlayer(); const player = new DuelRecordPlayer();
player.name = client.name; player.name = client.name;
player.pos = client.pos; player.pos = client.pos;
player.realName = client.name_vpass || client.name; player.realName = client.name_vpass || client.name;
player.ip = client.ip || ''; player.ip = client.ip || '';
player.clientKey = this.clientKeyProvider.getClientKey(client); player.clientKey = this.clientKeyProvider.getClientKey(client);
player.isFirst = room.getIngameDuelPos(client) === 0; player.isFirst = resolveIsFirstPlayer(room, client, wasSwapped);
player.score = resolvePlayerScore(room, client); player.score = resolvePlayerScore(room, client);
player.startDeckBuffer = encodeDeckBase64(client.startDeck); player.startDeckBuffer = encodeDeckBase64(client.startDeck);
player.startDeckMainc = resolveStartDeckMainc(client); player.startDeckMainc = resolveStartDeckMainc(client);
player.currentDeckBuffer = encodeCurrentDeckBase64(room, client); player.currentDeckBuffer = encodeCurrentDeckBase64(room, client, wasSwapped);
player.currentDeckMainc = resolveCurrentDeckMainc(room, client); player.currentDeckMainc = resolveCurrentDeckMainc(room, client, wasSwapped);
player.winner = room.getIngameDuelPos(client) === winPlayer; player.ingameDeckBuffer = encodeIngameDeckBase64(room, client, wasSwapped);
player.ingameDeckMainc = resolveIngameDeckMainc(room, client, wasSwapped);
player.winner = room.getDuelPos(client) === winPlayer;
return player; return player;
} }
...@@ -112,4 +182,528 @@ export class CloudReplayService { ...@@ -112,4 +182,528 @@ export class CloudReplayService {
} }
return room.identifier; return room.identifier;
} }
private async playRandomReplay(client: Client) {
const replay = await this.getRandomReplay();
if (!replay) {
await client.die('#{cloud_replay_no}', ChatColor.RED);
return;
}
await this.playReplayStream(client, replay, true);
}
private async openReplayListMenu(client: Client) {
await client.sendChat('#{cloud_replay_hint}', ChatColor.BABYBLUE);
client.cloudReplayPageCursors = [null];
client.cloudReplayPageIndex = 0;
client.cloudReplaySelectedReplayId = undefined;
await this.renderReplayListMenu(client);
}
private async renderReplayListMenu(client: Client) {
const page = await this.getReplayPage(client);
if (!page.entries.length) {
await client.die('#{cloud_replay_no}', ChatColor.RED);
return;
}
const menu: MenuEntry[] = [];
if (!this.isFirstReplayPage(client)) {
menu.push({
title: '#{menu_prev_page}',
callback: async (currentClient) => {
this.goToPrevReplayPage(currentClient);
await this.renderReplayListMenu(currentClient);
},
});
}
for (const replay of page.entries) {
menu.push({
title: `R#${replay.id}`,
callback: async (currentClient) => {
currentClient.cloudReplaySelectedReplayId = replay.id;
await this.renderReplayDetailMenu(currentClient, replay.id);
},
});
}
if (page.hasNext && page.nextCursor != null) {
menu.push({
title: '#{menu_next_page}',
callback: async (currentClient) => {
this.goToNextReplayPage(currentClient, page.nextCursor!);
await this.renderReplayListMenu(currentClient);
},
});
}
while (menu.length <= 2) {
menu.push({
title: '',
callback: async (currentClient) => {
await this.renderReplayListMenu(currentClient);
},
});
}
await this.menuManager.launchMenu(client, menu);
}
private async renderReplayDetailMenu(client: Client, replayId: number) {
const replay = await this.findOwnedReplayById(client, replayId);
if (!replay) {
await client.sendChat('#{cloud_replay_no}', ChatColor.RED);
await this.renderReplayListMenu(client);
return;
}
await this.sendReplayDetail(client, replay);
const menu: MenuEntry[] = [
{
title: '#{cloud_replay_menu_play}',
callback: async (currentClient) => {
const selectedReplay = await this.findOwnedReplayById(
currentClient,
replayId,
);
if (!selectedReplay) {
await currentClient.die('#{cloud_replay_no}', ChatColor.RED);
return;
}
await this.playReplayStream(currentClient, selectedReplay, false);
},
},
{
title: '#{cloud_replay_menu_download_yrp}',
callback: async (currentClient) => {
const selectedReplay = await this.findOwnedReplayById(
currentClient,
replayId,
);
if (!selectedReplay) {
await currentClient.die('#{cloud_replay_no}', ChatColor.RED);
return;
}
await this.downloadReplayYrp(currentClient, selectedReplay);
},
},
{
title: '#{cloud_replay_menu_back}',
callback: async (currentClient) => {
await this.renderReplayListMenu(currentClient);
},
},
];
await this.menuManager.launchMenu(client, menu);
}
private async sendReplayDetail(client: Client, replay: DuelRecordEntity) {
const dateText = this.formatDate(replay.endTime);
const versus = this.formatReplayVersus(replay);
const score = this.formatReplayScore(replay);
const winners = this.formatReplayWinners(replay);
await client.sendChat(`#{cloud_replay_detail_time}${dateText}`, ChatColor.BABYBLUE);
await client.sendChat(`#{cloud_replay_detail_players}${versus}`, ChatColor.BABYBLUE);
await client.sendChat(`#{cloud_replay_detail_score}${score}`, ChatColor.BABYBLUE);
await client.sendChat(`#{cloud_replay_detail_winner}${winners}`, ChatColor.BABYBLUE);
}
private async playReplayStream(
client: Client,
replay: DuelRecordEntity,
withYrp: boolean,
) {
try {
await client.sendChat(
`#{cloud_replay_playing} R#${replay.id}`,
ChatColor.BABYBLUE,
);
await client.send(this.createJoinGamePacket(replay));
await this.sendReplayPlayers(client, replay);
await client.send(new YGOProStocDuelStart());
const gameMessages = this.resolveReplayVisibleMessages(replay.messages);
for (const msg of gameMessages) {
await client.send(msg);
}
await this.sendReplayWinMsg(client, replay);
if (withYrp) {
await client.send(this.createReplayPacket(replay));
}
await client.send(new YGOProStocDuelEnd());
client.disconnect();
} catch (error) {
this.logger.warn(
{
replayId: replay.id,
clientName: client.name,
error: (error as Error).toString(),
},
'Failed to play cloud replay',
);
await client.die('#{cloud_replay_error}', ChatColor.RED);
}
}
private resolveReplayVisibleMessages(messagesBase64: string) {
return decodeMessagesBase64(messagesBase64).filter((packet) => {
const msg = packet.msg;
if (!msg) {
return false;
}
if (msg instanceof YGOProMsgResponseBase) {
return false;
}
if (msg instanceof YGOProMsgWin) {
return false;
}
return msg.getSendTargets().includes(NetPlayerType.OBSERVER);
});
}
private async sendReplayWinMsg(client: Client, replay: DuelRecordEntity) {
const player = this.resolveReplayWinPlayer(replay);
if (player == null) {
return;
}
await client.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgWin().fromPartial({
player,
type: replay.winReason,
}),
}),
);
}
private resolveReplayWinPlayer(replay: DuelRecordEntity) {
const winnerPlayer = replay.players.find((player) => player.winner);
if (!winnerPlayer) {
return undefined;
}
const winnerDuelPos = this.resolveDuelPosBySeat(
winnerPlayer.pos,
replay.hostInfo,
);
const swapped = this.resolveReplaySwappedByIsFirst(replay);
return swapped ? 1 - winnerDuelPos : winnerDuelPos;
}
private resolveDuelPosBySeat(pos: number, hostInfo: HostInfo) {
const teamOffsetBit = this.isTagMode(hostInfo) ? 1 : 0;
return (pos & (0x1 << teamOffsetBit)) >>> teamOffsetBit;
}
private resolveReplaySwappedByIsFirst(replay: DuelRecordEntity) {
const pos0Player = replay.players.find((player) => player.pos === 0);
return !pos0Player?.isFirst;
}
private async downloadReplayYrp(client: Client, replay: DuelRecordEntity) {
try {
await client.send(new YGOProStocDuelStart());
await client.send(this.createReplayPacket(replay));
await client.send(new YGOProStocDuelEnd());
client.disconnect();
} catch (error) {
this.logger.warn(
{
replayId: replay.id,
clientName: client.name,
error: (error as Error).toString(),
},
'Failed to download cloud replay yrp',
);
await client.die('#{cloud_replay_error}', ChatColor.RED);
}
}
private async sendReplayPlayers(client: Client, replay: DuelRecordEntity) {
const seatCount = this.resolveSeatCount(replay.hostInfo);
const sortedPlayers = [...replay.players].sort((a, b) => a.pos - b.pos);
for (let pos = 0; pos < seatCount; pos += 1) {
const player = sortedPlayers.find((entry) => entry.pos === pos);
await client.send(
new YGOProStocHsPlayerEnter().fromPartial({
pos,
name: player?.name || '',
}),
);
}
}
private createJoinGamePacket(replay: DuelRecordEntity) {
return new YGOProStocJoinGame().fromPartial({
info: {
...replay.hostInfo,
},
});
}
private createReplayPacket(replay: DuelRecordEntity) {
const duelRecord = this.restoreDuelRecord(replay);
return new YGOProStocReplay().fromPartial({
replay: duelRecord.toYrp({
hostinfo: replay.hostInfo as any,
isTag: this.isTagMode(replay.hostInfo),
}),
});
}
private restoreDuelRecord(replay: DuelRecordEntity) {
const isTag = this.isTagMode(replay.hostInfo);
const wasSwapped = this.resolveReplaySwappedByIsFirst(replay);
const seatCount = this.resolveSeatCount(replay.hostInfo);
const players = Array.from({ length: seatCount }, () => ({
name: '',
deck: new YGOProDeck(),
}));
const sortedPlayers = [...replay.players].sort((a, b) => a.pos - b.pos);
for (const player of sortedPlayers) {
const deckBuffer = player.ingameDeckBuffer || player.currentDeckBuffer;
const mainc = player.ingameDeckMainc ?? player.currentDeckMainc ?? 0;
const ingamePos = resolveIngamePosBySeat(
player.pos,
isTag,
wasSwapped,
);
players[ingamePos] = {
name: player.name,
deck: decodeDeckBase64(deckBuffer, mainc),
};
}
const duelRecord = new DuelRecord(decodeSeedBase64(replay.seed), players);
duelRecord.responses = decodeResponsesBase64(replay.responses);
return duelRecord;
}
private async getReplayPage(client: Client): Promise<ReplayPage> {
const cursor = this.getReplayCursor(client);
const firstPage = this.isFirstReplayPage(client);
const take = firstPage ? 5 : 4;
const entries = await this.getOwnedReplays(client, cursor, take);
if (firstPage) {
if (entries.length <= 4) {
return {
entries,
hasNext: false,
};
}
return {
entries: entries.slice(0, 3),
hasNext: true,
nextCursor: entries[2].id,
};
}
if (entries.length <= 3) {
return {
entries,
hasNext: false,
};
}
return {
entries: entries.slice(0, 2),
hasNext: true,
nextCursor: entries[1].id,
};
}
private async getOwnedReplays(
client: Client,
cursor: number | null,
take: number,
) {
const database = this.ctx.database;
if (!database) {
return [];
}
const clientKey = this.clientKeyProvider.getClientKey(client);
const repo = database.getRepository(DuelRecordEntity);
const qb = repo
.createQueryBuilder('replay')
.leftJoinAndSelect('replay.players', 'player');
const subQuery = qb
.subQuery()
.select('1')
.from(DuelRecordPlayer, 'owned_player')
.where('owned_player.duelRecordId = replay.id')
.andWhere('owned_player.clientKey = :clientKey')
.getQuery();
qb.where(`EXISTS ${subQuery}`, { clientKey });
if (cursor != null) {
qb.andWhere('replay.id < :cursor', { cursor });
}
return qb.orderBy('replay.id', 'DESC').take(take).getMany();
}
private async findOwnedReplayById(client: Client, replayId: number) {
const replay = await this.findReplayById(replayId);
if (!replay) {
return undefined;
}
const clientKey = this.clientKeyProvider.getClientKey(client);
const hasOwnedPlayer = replay.players.some(
(player) => player.clientKey === clientKey,
);
return hasOwnedPlayer ? replay : undefined;
}
private async findReplayById(replayId: number) {
const database = this.ctx.database;
if (!database) {
return undefined;
}
return database.getRepository(DuelRecordEntity).findOne({
where: {
id: replayId,
},
relations: ['players'],
});
}
private async getRandomReplay() {
const database = this.ctx.database;
if (!database) {
return undefined;
}
const repo = database.getRepository(DuelRecordEntity);
const minMax = await repo
.createQueryBuilder('replay')
.select('MIN(replay.id)', 'minId')
.addSelect('MAX(replay.id)', 'maxId')
.getRawOne<{ minId?: string; maxId?: string }>();
const minId = Number(minMax?.minId);
const maxId = Number(minMax?.maxId);
if (!Number.isFinite(minId) || !Number.isFinite(maxId) || minId > maxId) {
return undefined;
}
const targetId = Math.floor(Math.random() * (maxId - minId + 1)) + minId;
let replay = await repo
.createQueryBuilder('replay')
.leftJoinAndSelect('replay.players', 'player')
.where('replay.id >= :targetId', { targetId })
.orderBy('replay.id', 'ASC')
.getOne();
if (!replay) {
replay = await repo
.createQueryBuilder('replay')
.leftJoinAndSelect('replay.players', 'player')
.where('replay.id <= :targetId', { targetId })
.orderBy('replay.id', 'DESC')
.getOne();
}
return replay || undefined;
}
private getReplayCursor(client: Client) {
const cursors = client.cloudReplayPageCursors || [null];
const pageIndex = client.cloudReplayPageIndex || 0;
return cursors[pageIndex] ?? null;
}
private isFirstReplayPage(client: Client) {
return (client.cloudReplayPageIndex || 0) === 0;
}
private goToNextReplayPage(client: Client, cursor: number) {
const pageIndex = client.cloudReplayPageIndex || 0;
const cursors = (client.cloudReplayPageCursors || [null]).slice(
0,
pageIndex + 1,
);
cursors.push(cursor);
client.cloudReplayPageCursors = cursors;
client.cloudReplayPageIndex = pageIndex + 1;
}
private goToPrevReplayPage(client: Client) {
const pageIndex = client.cloudReplayPageIndex || 0;
if (pageIndex <= 0) {
return;
}
client.cloudReplayPageIndex = pageIndex - 1;
}
private formatDate(date: Date) {
const normalized = new Date(date);
const year = normalized.getFullYear();
const month = `${normalized.getMonth() + 1}`.padStart(2, '0');
const day = `${normalized.getDate()}`.padStart(2, '0');
const hour = `${normalized.getHours()}`.padStart(2, '0');
const minute = `${normalized.getMinutes()}`.padStart(2, '0');
const second = `${normalized.getSeconds()}`.padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
private formatReplayVersus(replay: DuelRecordEntity) {
const [team0, team1] = this.resolveReplayTeams(replay);
return `${team0.join('+')} VS ${team1.join('+')}`;
}
private formatReplayScore(replay: DuelRecordEntity) {
const [team0, team1] = this.resolveReplayTeamPlayers(replay);
const score0 = team0[0]?.score || 0;
const score1 = team1[0]?.score || 0;
return `${score0}-${score1}`;
}
private formatReplayWinners(replay: DuelRecordEntity) {
const [team0, team1] = this.resolveReplayTeamPlayers(replay);
const team0Won = team0.some((player) => player.winner);
const team1Won = team1.some((player) => player.winner);
if (team0Won === team1Won) {
return '-';
}
const winners = (team0Won ? team0 : team1).map((player) => player.name);
return winners.join('+');
}
private resolveReplayTeams(replay: DuelRecordEntity) {
const [team0, team1] = this.resolveReplayTeamPlayers(replay);
const left = team0.map((player) => player.name);
const right = team1.map((player) => player.name);
return [left, right] as const;
}
private resolveReplayTeamPlayers(replay: DuelRecordEntity) {
const sortedPlayers = [...replay.players].sort((a, b) => a.pos - b.pos);
const isTag = this.isTagMode(replay.hostInfo);
const teamOffsetBit = isTag ? 1 : 0;
const team0 = sortedPlayers.filter(
(player) => ((player.pos & (0x1 << teamOffsetBit)) >> teamOffsetBit) === 0,
);
const team1 = sortedPlayers.filter(
(player) => ((player.pos & (0x1 << teamOffsetBit)) >> teamOffsetBit) === 1,
);
return [team0, team1] as const;
}
private isTagMode(hostInfo: HostInfo) {
return (hostInfo.mode & 0x2) !== 0;
}
private resolveSeatCount(hostInfo: HostInfo) {
return this.isTagMode(hostInfo) ? 4 : 2;
}
} }
...@@ -53,7 +53,7 @@ export class DuelRecordPlayer extends BaseTimeEntity { ...@@ -53,7 +53,7 @@ export class DuelRecordPlayer extends BaseTimeEntity {
clientKey!: string; // getClientKey(client) clientKey!: string; // getClientKey(client)
@Column('bool') @Column('bool')
isFirst!: boolean; // 如果 room.getIngameDuelPos(client) === 0 就是 true isFirst!: boolean; // wasSwapped ? duelPos==1 : duelPos==0
@Index() @Index()
@Column('smallint') @Column('smallint')
...@@ -71,6 +71,12 @@ export class DuelRecordPlayer extends BaseTimeEntity { ...@@ -71,6 +71,12 @@ export class DuelRecordPlayer extends BaseTimeEntity {
@Column('smallint') @Column('smallint')
currentDeckMainc!: number; // client.currentDeck.main.length currentDeckMainc!: number; // client.currentDeck.main.length
@Column('text', {})
ingameDeckBuffer!: string; // duelRecord.players[x].deck.toPayload() base64
@Column('smallint')
ingameDeckMainc!: number; // duelRecord.players[x].deck.main.length
@Column('bool') @Column('bool')
winner!: boolean; winner!: boolean;
......
...@@ -64,7 +64,7 @@ export class DuelRecordEntity extends BaseTimeEntity { ...@@ -64,7 +64,7 @@ export class DuelRecordEntity extends BaseTimeEntity {
@Column({ @Column({
type: 'text', type: 'text',
}) })
responses!: string; // duelRecord.responses 直接拼接 base64 responses!: string; // duelRecord.responses 按 [uint8 len][payload]... 拼接再 base64
// 32 bytes binary seed => 44 chars base64. // 32 bytes binary seed => 44 chars base64.
@Column({ @Column({
......
export * from './duel-record.entity'; export * from './duel-record.entity';
export * from './duel-record-player.entity'; export * from './duel-record-player.entity';
export * from './cloud-replay-service'; export * from './cloud-replay-service';
export * from './utility';
import YGOProDeck from 'ygopro-deck-encode'; import YGOProDeck from 'ygopro-deck-encode';
import { YGOProMsgBase, YGOProStocGameMsg } from 'ygopro-msg-encode'; import {
YGOProMsgBase,
YGOProStoc,
YGOProStocGameMsg,
} from 'ygopro-msg-encode';
import { Client } from '../../../client'; import { Client } from '../../../client';
import { Room } from '../../../room'; import { Room } from '../../../room';
const RESPONSE_LENGTH_BYTES = 1;
export function resolvePlayerScore(room: Room, client: Client) { export function resolvePlayerScore(room: Room, client: Client) {
const duelPos = room.getIngameDuelPos(client); const duelPos = room.getIngameDuelPos(client);
return room.score[duelPos] || 0; return room.score[duelPos] || 0;
} }
function resolveTeamOffsetBit(isTag: boolean) {
return isTag ? 1 : 0;
}
export function resolveIngamePosBySeat(
pos: number,
isTag: boolean,
wasSwapped: boolean,
) {
if (!wasSwapped) {
return pos;
}
return pos ^ (0x1 << resolveTeamOffsetBit(isTag));
}
export function resolveRecordIngamePos(
room: Room,
client: Client,
wasSwapped: boolean,
) {
return resolveIngamePosBySeat(client.pos, room.isTag, wasSwapped);
}
export function resolveIsFirstPlayer(
room: Room,
client: Client,
wasSwapped: boolean,
) {
const firstgoDuelPos = wasSwapped ? 1 : 0;
return room.getDuelPos(client) === firstgoDuelPos;
}
export function encodeMessagesBase64(messages: YGOProMsgBase[]) { export function encodeMessagesBase64(messages: YGOProMsgBase[]) {
if (!messages.length) { if (!messages.length) {
return ''; return '';
...@@ -28,7 +66,13 @@ export function encodeResponsesBase64(responses: Buffer[]) { ...@@ -28,7 +66,13 @@ export function encodeResponsesBase64(responses: Buffer[]) {
if (!responses.length) { if (!responses.length) {
return ''; return '';
} }
return Buffer.concat(responses).toString('base64'); const payloads = responses.flatMap((response) => {
const length = response.length & 0xff;
const lengthBuffer = Buffer.alloc(RESPONSE_LENGTH_BYTES, 0);
lengthBuffer.writeUInt8(length, 0);
return [lengthBuffer, response];
});
return Buffer.concat(payloads).toString('base64');
} }
export function encodeSeedBase64(seed: number[]) { export function encodeSeedBase64(seed: number[]) {
...@@ -50,19 +94,115 @@ export function resolveStartDeckMainc(client: Client) { ...@@ -50,19 +94,115 @@ export function resolveStartDeckMainc(client: Client) {
return client.startDeck?.main?.length || 0; return client.startDeck?.main?.length || 0;
} }
function resolveCurrentDeck(room: Room, client: Client) { function resolveRecordDeck(room: Room, client: Client, wasSwapped = false) {
const ingamePos = resolveRecordIngamePos(room, client, wasSwapped);
const duelRecordPlayer = room.lastDuelRecord?.players[ingamePos];
return duelRecordPlayer?.deck;
}
function resolveCurrentDeck(room: Room, client: Client, wasSwapped = false) {
if (client.deck) { if (client.deck) {
return client.deck; return client.deck;
} }
const ingamePos = room.getIngamePos(client); return resolveRecordDeck(room, client, wasSwapped);
const duelRecordPlayer = room.lastDuelRecord?.players[ingamePos]; }
return duelRecordPlayer?.deck;
export function resolveCurrentDeckMainc(
room: Room,
client: Client,
wasSwapped = false,
) {
return resolveCurrentDeck(room, client, wasSwapped)?.main?.length || 0;
}
export function encodeCurrentDeckBase64(
room: Room,
client: Client,
wasSwapped = false,
) {
return encodeDeckBase64(resolveCurrentDeck(room, client, wasSwapped));
} }
export function resolveCurrentDeckMainc(room: Room, client: Client) { export function encodeIngameDeckBase64(
return resolveCurrentDeck(room, client)?.main?.length || 0; room: Room,
client: Client,
wasSwapped: boolean,
) {
return encodeDeckBase64(resolveRecordDeck(room, client, wasSwapped));
} }
export function encodeCurrentDeckBase64(room: Room, client: Client) { export function resolveIngameDeckMainc(
return encodeDeckBase64(resolveCurrentDeck(room, client)); room: Room,
client: Client,
wasSwapped: boolean,
) {
return resolveRecordDeck(room, client, wasSwapped)?.main?.length || 0;
}
export function decodeMessagesBase64(messagesBase64: string) {
if (!messagesBase64) {
return [];
}
const payload = Buffer.from(messagesBase64, 'base64');
if (!payload.length) {
return [];
}
const stocPackets = YGOProStoc.getInstancesFromPayload(payload);
return stocPackets.filter(
(packet): packet is YGOProStocGameMsg =>
packet instanceof YGOProStocGameMsg && !!packet.msg,
);
}
export function decodeResponsesBase64(responsesBase64: string) {
if (!responsesBase64) {
return [];
}
const payload = Buffer.from(responsesBase64, 'base64');
if (!payload.length) {
return [];
}
return decodeLengthPrefixedResponses(payload) || [];
}
function decodeLengthPrefixedResponses(payload: Buffer) {
const responses: Buffer[] = [];
let offset = 0;
while (offset < payload.length) {
if (offset + RESPONSE_LENGTH_BYTES > payload.length) {
return undefined;
}
const length = payload.readUInt8(offset);
offset += RESPONSE_LENGTH_BYTES;
if (offset + length > payload.length) {
return undefined;
}
responses.push(payload.subarray(offset, offset + length));
offset += length;
}
return responses;
}
export function decodeSeedBase64(seedBase64: string) {
const decoded = seedBase64 ? Buffer.from(seedBase64, 'base64') : Buffer.alloc(0);
const raw = Buffer.alloc(32, 0);
decoded.copy(raw, 0, 0, Math.min(decoded.length, raw.length));
const seed: number[] = [];
for (let i = 0; i < 8; i += 1) {
seed.push(raw.readUInt32LE(i * 4) >>> 0);
}
return seed;
}
export function decodeDeckBase64(deckBase64: string, mainc: number) {
if (!deckBase64) {
return new YGOProDeck();
}
const payload = Buffer.from(deckBase64, 'base64');
if (!payload.length) {
return new YGOProDeck();
}
return YGOProDeck.fromUpdateDeckPayload(payload, (_code, index) => {
return index >= mainc;
});
} }
import { YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { CloudReplayService } from '../feats';
export class CloudReplayJoinHandler {
private cloudReplayService = this.ctx.get(() => CloudReplayService);
constructor(private ctx: Context) {
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
const pass = (msg.pass || '').trim();
if (!pass) {
return next();
}
if (await this.cloudReplayService.tryHandleJoinPass(pass, client)) {
return;
}
return next();
});
}
}
...@@ -4,6 +4,7 @@ import { ClientVersionCheck } from '../feats'; ...@@ -4,6 +4,7 @@ import { ClientVersionCheck } from '../feats';
import { JoinWindbotAi, JoinWindbotToken } from '../feats/windbot'; import { JoinWindbotAi, JoinWindbotToken } from '../feats/windbot';
import { JoinRoom } from './join-room'; import { JoinRoom } from './join-room';
import { JoinRoomIp } from './join-room-ip'; import { JoinRoomIp } from './join-room-ip';
import { CloudReplayJoinHandler } from './cloud-replay-join-handler';
import { JoinFallback } from './fallback'; import { JoinFallback } from './fallback';
import { JoinPrechecks } from './join-prechecks'; import { JoinPrechecks } from './join-prechecks';
import { RandomDuelJoinHandler } from './random-duel-join-handler'; import { RandomDuelJoinHandler } from './random-duel-join-handler';
...@@ -20,6 +21,7 @@ export const JoinHandlerModule = createAppContext<ContextState>() ...@@ -20,6 +21,7 @@ export const JoinHandlerModule = createAppContext<ContextState>()
.provide(RandomDuelJoinHandler) .provide(RandomDuelJoinHandler)
.provide(JoinWindbotAi) .provide(JoinWindbotAi)
.provide(JoinRoomIp) .provide(JoinRoomIp)
.provide(CloudReplayJoinHandler)
.provide(JoinRoom) .provide(JoinRoom)
.provide(JoinBlankPassMenu) .provide(JoinBlankPassMenu)
.provide(JoinBlankPassRandomDuel) .provide(JoinBlankPassRandomDuel)
......
...@@ -33,7 +33,7 @@ export class DuelRecord { ...@@ -33,7 +33,7 @@ export class DuelRecord {
}); });
} }
toYrp(room: Room) { toYrp(room: Pick<Room, 'hostinfo' | 'isTag'>) {
const isTag = room.isTag; const isTag = room.isTag;
// Create replay header // Create replay header
......
export * from './room'; export * from './room';
export * from './duel-record';
export * from './room-manager'; export * from './room-manager';
export * from './duel-stage'; export * from './duel-stage';
export * from './room-event/on-room-create'; export * from './room-event/on-room-create';
......
...@@ -7,6 +7,7 @@ export class OnRoomWin extends RoomEvent { ...@@ -7,6 +7,7 @@ export class OnRoomWin extends RoomEvent {
room: Room, room: Room,
public winMsg: YGOProMsgWin, public winMsg: YGOProMsgWin,
public winMatch = false, public winMatch = false,
public wasSwapped = false,
) { ) {
super(room); super(room);
} }
......
...@@ -485,6 +485,7 @@ export class Room { ...@@ -485,6 +485,7 @@ export class Room {
this.resetResponseState(); this.resetResponseState();
this.disposeOcgcore(); this.disposeOcgcore();
this.ocgcore = undefined; this.ocgcore = undefined;
const wasSwapped = this.isPosSwapped;
if (this.duelStage === DuelStage.Siding) { if (this.duelStage === DuelStage.Siding) {
await Promise.all( await Promise.all(
this.playingPlayers this.playingPlayers
...@@ -525,7 +526,7 @@ export class Room { ...@@ -525,7 +526,7 @@ export class Room {
await this.changeSide(); await this.changeSide();
} }
await this.ctx.dispatch( await this.ctx.dispatch(
new OnRoomWin(this, exactWinMsg, winMatch), new OnRoomWin(this, exactWinMsg, winMatch, wasSwapped),
this.getDuelPosPlayers(duelPos)[0], this.getDuelPosPlayers(duelPos)[0],
); );
if (winMatch) { if (winMatch) {
...@@ -869,7 +870,14 @@ export class Room { ...@@ -869,7 +870,14 @@ export class Room {
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) { if (this.noHost) {
const allReadyAndFull = this.players.every((player) => !!player?.deck); let allReadyAndFull = true;
for (let i = 0; i < this.players.length; i++) {
const p = this.players[i];
if (!p || !p.deck) {
allReadyAndFull = false;
break;
}
}
if (allReadyAndFull) { if (allReadyAndFull) {
await this.startGame(); await this.startGame();
} }
......
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