Commit 3ac2c111 authored by nanahira's avatar nanahira

add cloud replay

parent b55ce242
......@@ -115,6 +115,18 @@ 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.',
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': {
update_required: '请更新你的客户端版本',
......@@ -224,5 +236,16 @@ export const TRANSLATIONS = {
koishi_ai_disabled: '人机功能未开启。',
koishi_ai_disabled_random_room: '随机对战房间不允许使用 /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: '返回',
},
};
......@@ -53,7 +53,7 @@ export class DuelRecordPlayer extends BaseTimeEntity {
clientKey!: string; // getClientKey(client)
@Column('bool')
isFirst!: boolean; // 如果 room.getIngameDuelPos(client) === 0 就是 true
isFirst!: boolean; // wasSwapped ? duelPos==1 : duelPos==0
@Index()
@Column('smallint')
......@@ -71,6 +71,12 @@ export class DuelRecordPlayer extends BaseTimeEntity {
@Column('smallint')
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')
winner!: boolean;
......
......@@ -64,7 +64,7 @@ export class DuelRecordEntity extends BaseTimeEntity {
@Column({
type: 'text',
})
responses!: string; // duelRecord.responses 直接拼接 base64
responses!: string; // duelRecord.responses 按 [uint8 len][payload]... 拼接再 base64
// 32 bytes binary seed => 44 chars base64.
@Column({
......
export * from './duel-record.entity';
export * from './duel-record-player.entity';
export * from './cloud-replay-service';
export * from './utility';
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 { Room } from '../../../room';
const RESPONSE_LENGTH_BYTES = 1;
export function resolvePlayerScore(room: Room, client: Client) {
const duelPos = room.getIngameDuelPos(client);
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[]) {
if (!messages.length) {
return '';
......@@ -28,7 +66,13 @@ export function encodeResponsesBase64(responses: Buffer[]) {
if (!responses.length) {
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[]) {
......@@ -50,19 +94,115 @@ export function resolveStartDeckMainc(client: Client) {
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) {
return client.deck;
}
const ingamePos = room.getIngamePos(client);
const duelRecordPlayer = room.lastDuelRecord?.players[ingamePos];
return duelRecordPlayer?.deck;
return resolveRecordDeck(room, client, wasSwapped);
}
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) {
return resolveCurrentDeck(room, client)?.main?.length || 0;
export function encodeIngameDeckBase64(
room: Room,
client: Client,
wasSwapped: boolean,
) {
return encodeDeckBase64(resolveRecordDeck(room, client, wasSwapped));
}
export function encodeCurrentDeckBase64(room: Room, client: Client) {
return encodeDeckBase64(resolveCurrentDeck(room, client));
export function resolveIngameDeckMainc(
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';
import { JoinWindbotAi, JoinWindbotToken } from '../feats/windbot';
import { JoinRoom } from './join-room';
import { JoinRoomIp } from './join-room-ip';
import { CloudReplayJoinHandler } from './cloud-replay-join-handler';
import { JoinFallback } from './fallback';
import { JoinPrechecks } from './join-prechecks';
import { RandomDuelJoinHandler } from './random-duel-join-handler';
......@@ -20,6 +21,7 @@ export const JoinHandlerModule = createAppContext<ContextState>()
.provide(RandomDuelJoinHandler)
.provide(JoinWindbotAi)
.provide(JoinRoomIp)
.provide(CloudReplayJoinHandler)
.provide(JoinRoom)
.provide(JoinBlankPassMenu)
.provide(JoinBlankPassRandomDuel)
......
......@@ -33,7 +33,7 @@ export class DuelRecord {
});
}
toYrp(room: Room) {
toYrp(room: Pick<Room, 'hostinfo' | 'isTag'>) {
const isTag = room.isTag;
// Create replay header
......
export * from './room';
export * from './duel-record';
export * from './room-manager';
export * from './duel-stage';
export * from './room-event/on-room-create';
......
......@@ -7,6 +7,7 @@ export class OnRoomWin extends RoomEvent {
room: Room,
public winMsg: YGOProMsgWin,
public winMatch = false,
public wasSwapped = false,
) {
super(room);
}
......
......@@ -485,6 +485,7 @@ export class Room {
this.resetResponseState();
this.disposeOcgcore();
this.ocgcore = undefined;
const wasSwapped = this.isPosSwapped;
if (this.duelStage === DuelStage.Siding) {
await Promise.all(
this.playingPlayers
......@@ -525,7 +526,7 @@ export class Room {
await this.changeSide();
}
await this.ctx.dispatch(
new OnRoomWin(this, exactWinMsg, winMatch),
new OnRoomWin(this, exactWinMsg, winMatch, wasSwapped),
this.getDuelPosPlayers(duelPos)[0],
);
if (winMatch) {
......@@ -869,7 +870,14 @@ export class Room {
const changeMsg = client.prepareChangePacket();
this.allPlayers.forEach((p) => p.send(changeMsg));
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) {
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