Commit 9a20f3ef authored by nanahira's avatar nanahira

replay & update deck

parent 3fc9bbbf
......@@ -22,6 +22,11 @@ export const defaultConfig = {
USE_PROXY: '',
YGOPRO_PATH: './ygopro',
EXTRA_SCRIPT_PATH: '',
DECK_MAIN_MIN: '40',
DECK_MAIN_MAX: '60',
DECK_EXTRA_MAX: '15',
DECK_SIDE_MAX: '15',
DECK_MAX_COPIES: '3',
...(Object.fromEntries(
Object.entries(DefaultHostinfo).map(([key, value]) => [
`HOSTINFO_${key.toUpperCase()}`,
......
......@@ -8,6 +8,8 @@ export const TRANSLATIONS = {
version_polyfilled:
'Temporary compatibility mode has been enabled for your version. We recommend updating your game to avoid potential compatibility issues in the future.',
blank_room_name: 'Blank room name is unallowed, please fill in something.',
replay_hint_part1: 'Sending the replay of the duel number ',
replay_hint_part2: '.',
},
'zh-CN': {
update_required: '请更新你的客户端版本',
......@@ -17,5 +19,7 @@ export const TRANSLATIONS = {
version_polyfilled:
'已为当前版本启用临时兼容模式。建议尽快更新游戏,以避免后续兼容性问题。',
blank_room_name: '房间名不能为空,请在主机密码处填写房间名',
replay_hint_part1: '正在发送第',
replay_hint_part2: '局决斗的录像。',
},
};
import YGOProDeck from 'ygopro-deck-encode';
import { YGOProYrp, ReplayHeader } from 'ygopro-yrp-encode';
import { Room } from './room';
// Constants from ygopro
const REPLAY_COMPRESSED = 0x1;
const REPLAY_TAG = 0x2;
const REPLAY_UNIFORM = 0x10;
const REPLAY_ID_YRP2 = 0x32707279;
const PRO_VERSION = 0x1362;
export class DuelRecord {
seed: number[];
players: { name: string; deck: YGOProDeck }[];
winPosition?: number;
responses: Buffer[];
toYrp(room: Room) {
const isTag = room.isTag;
// Create replay header
const header = new ReplayHeader();
header.id = REPLAY_ID_YRP2;
header.version = PRO_VERSION;
header.flag = REPLAY_COMPRESSED | REPLAY_UNIFORM;
if (isTag) {
header.flag |= REPLAY_TAG;
}
header.seedSequence = this.seed;
// Build YGOProYrp object
// Note: players array is already swapped
//
// YGOProYrp field order matches ygopro replay write order:
// Single mode:
// - hostName, clientName = players[0], players[1]
// - hostDeck, clientDeck = players[0].deck, players[1].deck
//
// Tag mode (ygopro writes: players[0-3] names, then pdeck[0,1,3,2]):
// - hostName, tagHostName, tagClientName, clientName = players[0], players[1], players[2], players[3]
// - hostDeck, tagHostDeck, tagClientDeck, clientDeck = players[0], players[1], players[3], players[2]
// (note the deck order: 0,1,3,2 - this matches ygopro's load order)
const yrp = new YGOProYrp({
header,
hostName: this.players[0]?.name || '',
clientName: isTag
? this.players[3]?.name || ''
: this.players[1]?.name || '',
startLp: room.hostinfo.start_lp,
startHand: room.hostinfo.start_hand,
drawCount: room.hostinfo.draw_count,
opt: room.opt,
hostDeck: this.players[0]?.deck || null,
clientDeck: isTag
? this.players[2]?.deck || null
: this.players[1]?.deck || null,
tagHostName: isTag ? this.players[1]?.name || '' : null,
tagClientName: isTag ? this.players[2]?.name || '' : null,
tagHostDeck: isTag ? this.players[1]?.deck || null : null,
tagClientDeck: isTag ? this.players[3]?.deck || null : null,
singleScript: null,
responses: this.responses.map((buf) => new Uint8Array(buf)),
});
return yrp;
}
}
......@@ -19,6 +19,14 @@ import {
ChatColor,
YGOProCtosChat,
YGOProMsgWin,
YGOProCtosUpdateDeck,
OcgcoreCommonConstants,
YGOProStocErrorMsg,
YGOProStocGameMsg,
YGOProStocReplay,
YGOProStocDuelEnd,
YGOProStocChangeSide,
YGOProStocWaitingSide,
} from 'ygopro-msg-encode';
import { DefaultHostInfoProvider } from './default-hostinfo-provder';
import { CardReaderFinalized } from 'koishipro-core.js';
......@@ -39,6 +47,9 @@ import { OnRoomMatchStart } from './room-event/on-room-match-start';
import { OnRoomGameStart } from './room-event/on-room-game-start';
// import { OnRoomDuelStart } from './room-event/on-room-duel-start'; // 备用事件,暂未使用
import YGOProDeck from 'ygopro-deck-encode';
import { checkDeck, checkChangeSide } from '../utility/check-deck';
import { DuelRecord } from './duel-record';
import { last } from 'rxjs';
export type RoomFinalizor = (self: Room) => Awaitable<any>;
......@@ -53,10 +64,29 @@ export class Room {
.get(() => DefaultHostInfoProvider)
.parseHostinfo(this.name, this.partialHostinfo);
get winMatchCount() {
const firstbit = this.hostinfo.mode & 0x1;
const remainingBits = (this.hostinfo.mode & 0xfc) >>> 1;
return (firstbit | remainingBits) + 1;
}
get isTag() {
return this.hostinfo.mode === 2;
}
get opt() {
const DUEL_PSEUDO_SHUFFLE = 16;
const DUEL_TAG_MODE = 32;
let opt = 0;
if (this.hostinfo.no_shuffle_deck) {
opt |= DUEL_PSEUDO_SHUFFLE;
}
if (this.isTag) {
opt |= DUEL_TAG_MODE;
}
return opt;
}
players = new Array<Client | undefined>(this.hostinfo.mode === 2 ? 4 : 2);
watchers = new Set<Client>();
get playingPlayers() {
......@@ -99,17 +129,21 @@ export class Room {
return this;
}
private finalizors: RoomFinalizor[] = [
() => {
this.allPlayers.forEach((p) => {
p.disconnect();
private async cleanPlayers(sendDuelEnd = false) {
await Promise.all([
...this.allPlayers.map(async (p) => {
await this.kick(p, sendDuelEnd);
if (p.pos < NetPlayerType.OBSERVER) {
this.players[p.pos] = undefined;
}
});
this.watchers.clear();
},
];
}),
Promise.all(
[...this.watchers].map(async (p) => await this.kick(p, sendDuelEnd)),
).then(() => this.watchers.clear()),
]);
}
private finalizors: RoomFinalizor[] = [() => this.cleanPlayers()];
addFinalizor(finalizor: RoomFinalizor, atEnd = false) {
if (atEnd) {
......@@ -259,29 +293,102 @@ export class Room {
}
duelStage = DuelStage.Begin;
score = [0, 0];
duelRecords: DuelRecord[] = [];
get score() {
return [0, 1].map(
(p) => this.duelRecords.filter((d) => d.winPosition === p).length,
);
}
async win(winMsg: Partial<YGOProMsgWin>, winMatch = false) {
async sendReplays(client: Client) {
for (let i = 0; i < this.duelRecords.length; i++) {
await client.sendChat(
`#{replay_hint_part1}${i + 1}#{replay_hint_part2}`,
ChatColor.BABYBLUE,
);
const duelRecord = this.duelRecords[i];
await client.send(
new YGOProStocReplay().fromPartial({
replay: duelRecord.toYrp(this),
}),
);
}
}
private async changeSide() {
if (this.duelStage === DuelStage.Siding) {
return;
}
this.duelStage = DuelStage.Siding;
for (const p of this.playingPlayers) {
p.deck = undefined;
p.send(new YGOProStocChangeSide());
}
for (const p of this.watchers) {
p.send(new YGOProStocWaitingSide());
}
}
async win(winMsg: Partial<YGOProMsgWin>, forceWinMatch = false) {
if (this.duelStage === DuelStage.Siding) {
this.playingPlayers
.filter((p) => p.deck)
.filter((p) => !p.deck)
.forEach((p) => p.send(new YGOProStocDuelStart()));
}
const duelPos = this.getSwappedDuelPosByDuelPos(winMsg.player!);
await Promise.all(
this.allPlayers.map((p) =>
p.send(
new YGOProStocGameMsg().fromPartial({
msg: new YGOProMsgWin().fromPartial(winMsg),
}),
),
),
);
const exactWinMsg = new YGOProMsgWin().fromPartial({
...winMsg,
player: duelPos,
});
++this.score[this.getSwappedPos(exactWinMsg.player)];
// TODO: next game or finalize
const lastDuelRecord = this.duelRecords[this.duelRecords.length - 1];
if (lastDuelRecord) {
lastDuelRecord.winPosition = duelPos;
}
const winMatch = forceWinMatch || this.score[duelPos] >= this.winMatchCount;
if (!winMatch) {
await this.changeSide();
}
await this.ctx.dispatch(
new OnRoomWin(this, exactWinMsg, winMatch),
this.getDuelPosPlayers(duelPos)[0],
);
if (winMatch) {
await this.cleanPlayers(true);
return this.finalize();
}
}
async kick(client: Client, sendDuelEnd = false) {
await this.sendReplays(client);
if (
sendDuelEnd &&
this.duelStage !== DuelStage.Begin &&
// don't send duel end when client didn't finish siding
!(
this.duelStage === DuelStage.Siding &&
!client.deck &&
client.pos < NetPlayerType.OBSERVER
)
) {
await client.send(new YGOProStocDuelEnd());
}
return client.disconnect();
}
@RoomMethod()
private async onDisconnect(client: Client, _msg: YGOProCtosDisconnect) {
if (this.duelStage === DuelStage.End || this.finalizing) {
return;
}
const wasObserver = client.pos === NetPlayerType.OBSERVER;
const oldPos = client.pos;
......@@ -451,7 +558,109 @@ export class Room {
}
// 踢出玩家
targetPlayer.disconnect();
return this.kick(targetPlayer);
}
@RoomMethod()
private async onUpdateDeck(client: Client, msg: YGOProCtosUpdateDeck) {
// 只有玩家可以更新卡组
if (client.pos === NetPlayerType.OBSERVER) {
return;
}
// 在 Siding 阶段已经准备好的玩家不能再更新
if (this.duelStage === DuelStage.Siding && client.deck) {
return;
}
// 不在 Begin 或 Siding 阶段不能更新卡组
if (
this.duelStage !== DuelStage.Begin &&
this.duelStage !== DuelStage.Siding
) {
return;
}
const deck = new YGOProDeck({
main: [],
extra: [],
side: msg.deck.side,
});
// we have to distinguish main and extra deck cards
for (const card of msg.deck.main) {
const cardEntry = this.cardReader.apply(card);
if (
cardEntry?.type &&
cardEntry.type & OcgcoreCommonConstants.TYPES_EXTRA_DECK
) {
deck.extra.push(card);
} else {
deck.main.push(card);
}
}
// Check deck if needed
if (!this.hostinfo.no_check_deck) {
let deckError;
if (this.duelStage === DuelStage.Begin) {
// First duel: check deck validity
deckError = checkDeck(deck, this.cardReader, {
ot: this.hostinfo.rule,
lflist: this.lflist,
minMain: parseInt(this.ctx.getConfig('DECK_MAIN_MIN', '40')),
maxMain: parseInt(this.ctx.getConfig('DECK_MAIN_MAX', '60')),
maxExtra: parseInt(this.ctx.getConfig('DECK_EXTRA_MAX', '15')),
maxSide: parseInt(this.ctx.getConfig('DECK_SIDE_MAX', '15')),
maxCopies: parseInt(this.ctx.getConfig('DECK_MAX_COPIES', '3')),
});
if (deckError) {
client.send(
new YGOProStocErrorMsg().fromPartial({
msg: 1, // ERRMSG_DECKERROR
code: deckError.toPayload(),
}),
);
return;
}
} else if (this.duelStage === DuelStage.Siding) {
// Side deck change: check if cards match original deck
if (!client.startDeck) {
return;
}
if (!checkChangeSide(client.startDeck, deck)) {
client.send(
new YGOProStocErrorMsg().fromPartial({
msg: 1, // ERRMSG_DECKERROR
code: 0,
}),
);
return;
}
}
}
// Save deck
client.deck = deck;
// In Begin stage, also save as startDeck for side deck checking
if (this.duelStage === DuelStage.Begin) {
client.startDeck = deck;
// TODO: In Begin stage, may need to send PlayerChange or auto-ready
} else if (this.duelStage === DuelStage.Siding) {
// In Siding stage, send DUEL_START to the player who submitted deck
client.send(new YGOProStocDuelStart());
// Check if all players have submitted their decks
const allReady = this.playingPlayers.every((p) => p.deck);
if (allReady) {
return this.startGame(
this.duelRecords[this.duelRecords.length - 1]?.winPosition,
);
}
}
}
@RoomMethod()
......@@ -484,7 +693,6 @@ export class Room {
return Promise.all(this.allPlayers.map((p) => p.sendChat(msg, type)));
}
duelCount = 0;
firstgoPlayer?: Client;
private async toFirstGo(firstgoPos: number) {
......@@ -505,8 +713,7 @@ export class Room {
if (![DuelStage.Finger, DuelStage.Siding].includes(this.duelStage)) {
return false;
}
++this.duelCount;
if (this.duelCount === 1) {
if (this.winnerPositions.length === 0) {
this.allPlayers.forEach((p) => p.send(new YGOProStocDuelStart()));
const displayCountDecks = [0, 1].map(
(p) => this.getDuelPosPlayers(p)[0].deck!,
......@@ -540,14 +747,14 @@ export class Room {
});
}
if (firstgoPos != null) {
if (firstgoPos != null && firstgoPos >= 0 && firstgoPos <= 1) {
await this.toFirstGo(firstgoPos);
} else {
await this.toFinger();
}
// 触发事件
if (this.duelCount === 1) {
if (this.winnerPositions.length === 0) {
// 触发比赛开始事件(第一局)
await this.ctx.dispatch(
new OnRoomMatchStart(this),
......
import { CardReader } from 'koishipro-core.js';
import YGOProDeck from 'ygopro-deck-encode';
import {
YGOProLFListError,
YGOProLFListErrorReason,
YGOProLFListItem,
} from 'ygopro-lflist-encode';
import { OcgcoreCommonConstants } from 'ygopro-msg-encode';
// Constants from ygopro
const { TYPES_EXTRA_DECK, TYPE_TOKEN } = OcgcoreCommonConstants;
const AVAIL_OCG = 0x1;
const AVAIL_TCG = 0x2;
const AVAIL_CUSTOM = 0x4;
const AVAIL_SC = 0x8;
const AVAIL_OCGTCG = AVAIL_OCG | AVAIL_TCG;
export const checkDeck = (
deck: YGOProDeck,
reader: CardReader,
options: {
ot?: number; // default 5 (AVAIL_OCGTCG)
lflist?: YGOProLFListItem;
minMain?: number; // default 40
maxMain?: number; // default 60
maxExtra?: number; // default 15
maxSide?: number; // default 15
maxCopies?: number; // default 3
} = {},
): YGOProLFListError | null => {
const {
ot = 5,
lflist,
minMain = 40,
maxMain = 60,
maxExtra = 15,
maxSide = 15,
maxCopies = 3,
} = options;
// Check deck size constraints
if (deck.main.length < minMain || deck.main.length > maxMain) {
return new YGOProLFListError(
YGOProLFListErrorReason.MAINCOUNT,
deck.main.length,
);
}
if (deck.extra.length > maxExtra) {
return new YGOProLFListError(
YGOProLFListErrorReason.EXTRACOUNT,
deck.extra.length,
);
}
if (deck.side.length > maxSide) {
return new YGOProLFListError(
YGOProLFListErrorReason.SIDECOUNT,
deck.side.length,
);
}
// Map rule to availability flags
const rule_map = [
AVAIL_OCG,
AVAIL_TCG,
AVAIL_SC,
AVAIL_CUSTOM,
AVAIL_OCGTCG,
0,
];
const avail = ot >= 0 && ot < rule_map.length ? rule_map[ot] : 0;
// Helper function to check card availability
const checkAvail = (
cardOt: number,
availFlag: number,
): YGOProLFListErrorReason | null => {
if (!!(cardOt & 0x4)) {
return null; // AVAIL_CUSTOM
}
if ((cardOt & availFlag) === availFlag) {
return null;
}
if (cardOt & AVAIL_OCG && availFlag !== AVAIL_OCG) {
return YGOProLFListErrorReason.OCGONLY;
}
if (cardOt & AVAIL_TCG && availFlag !== AVAIL_TCG) {
return YGOProLFListErrorReason.TCGONLY;
}
return YGOProLFListErrorReason.NOTAVAIL;
};
// Count cards by code (using alias if available)
const cardCount = new Map<number, number>();
// Collect all card codes (with alias) for lflist check
const allCardCodes: number[] = [];
// Helper to process a single card
const processCard = (
code: number,
location: 'main' | 'extra' | 'side',
): YGOProLFListError | null => {
const cardData =
typeof reader === 'function' ? reader(code) : reader.apply(code);
if (!cardData) {
return new YGOProLFListError(YGOProLFListErrorReason.UNKNOWNCARD, code);
}
// Check availability
const availError = checkAvail(cardData.ot ?? 0, avail);
if (availError !== null) {
return new YGOProLFListError(availError, code);
}
// Check card type constraints
const cardType = cardData.type ?? 0;
if (location === 'main') {
if (cardType & (TYPES_EXTRA_DECK | TYPE_TOKEN)) {
return new YGOProLFListError(YGOProLFListErrorReason.MAINCOUNT, code);
}
} else if (location === 'extra') {
if (!(cardType & TYPES_EXTRA_DECK) || cardType & TYPE_TOKEN) {
return new YGOProLFListError(YGOProLFListErrorReason.EXTRACOUNT, code);
}
} else if (location === 'side') {
if (cardType & TYPE_TOKEN) {
return new YGOProLFListError(YGOProLFListErrorReason.SIDECOUNT, code);
}
}
// Count cards (use alias if available)
const countCode = cardData.alias || code;
const count = (cardCount.get(countCode) || 0) + 1;
cardCount.set(countCode, count);
// Collect card code for lflist check
allCardCodes.push(countCode);
// Check max copies
if (count > maxCopies) {
return new YGOProLFListError(YGOProLFListErrorReason.CARDCOUNT, code);
}
return null;
};
// Check all cards in main deck
for (const code of deck.main) {
const error = processCard(code, 'main');
if (error) {
return error;
}
}
// Check all cards in extra deck
for (const code of deck.extra) {
const error = processCard(code, 'extra');
if (error) {
return error;
}
}
// Check all cards in side deck
for (const code of deck.side) {
const error = processCard(code, 'side');
if (error) {
return error;
}
}
// Check forbidden/limited list if provided
if (lflist) {
const lflistError = lflist.checkDeck(allCardCodes);
if (lflistError) {
return lflistError;
}
}
return null;
};
export const checkChangeSide = (
oldDeck: YGOProDeck,
newDeck: YGOProDeck,
): boolean => {
// Helper function to count all cards in a deck
const countCards = (deck: YGOProDeck): Map<number, number> => {
const count = new Map<number, number>();
for (const code of [...deck.main, ...deck.extra, ...deck.side]) {
count.set(code, (count.get(code) || 0) + 1);
}
return count;
};
// Check deck sizes remain the same
if (
(['main', 'extra', 'side'] as const).some(
(part) => newDeck[part].length !== oldDeck[part].length,
)
) {
return false;
}
// Count cards in both decks
const oldCount = countCards(oldDeck);
const newCount = countCards(newDeck);
// Collect all unique card codes
const allCodes = new Set<number>([...oldCount.keys(), ...newCount.keys()]);
// Check that each card count is the same
for (const code of allCodes) {
if ((oldCount.get(code) || 0) !== (newCount.get(code) || 0)) {
return false;
}
}
return true;
};
export const generateSeed = () => {
const res: number[] = [];
for (let i = 0; i < 8; i++) {
res.push(Math.floor(Math.random() * 0x100000000));
}
return res;
};
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