Commit 9a20f3ef authored by nanahira's avatar nanahira

replay & update deck

parent 3fc9bbbf
...@@ -22,6 +22,11 @@ export const defaultConfig = { ...@@ -22,6 +22,11 @@ export const defaultConfig = {
USE_PROXY: '', USE_PROXY: '',
YGOPRO_PATH: './ygopro', YGOPRO_PATH: './ygopro',
EXTRA_SCRIPT_PATH: '', 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.fromEntries(
Object.entries(DefaultHostinfo).map(([key, value]) => [ Object.entries(DefaultHostinfo).map(([key, value]) => [
`HOSTINFO_${key.toUpperCase()}`, `HOSTINFO_${key.toUpperCase()}`,
......
...@@ -8,6 +8,8 @@ export const TRANSLATIONS = { ...@@ -8,6 +8,8 @@ export const TRANSLATIONS = {
version_polyfilled: version_polyfilled:
'Temporary compatibility mode has been enabled for your version. We recommend updating your game to avoid potential compatibility issues in the future.', '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.', 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': { 'zh-CN': {
update_required: '请更新你的客户端版本', update_required: '请更新你的客户端版本',
...@@ -17,5 +19,7 @@ export const TRANSLATIONS = { ...@@ -17,5 +19,7 @@ export const TRANSLATIONS = {
version_polyfilled: version_polyfilled:
'已为当前版本启用临时兼容模式。建议尽快更新游戏,以避免后续兼容性问题。', '已为当前版本启用临时兼容模式。建议尽快更新游戏,以避免后续兼容性问题。',
blank_room_name: '房间名不能为空,请在主机密码处填写房间名', 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 { ...@@ -19,6 +19,14 @@ import {
ChatColor, ChatColor,
YGOProCtosChat, YGOProCtosChat,
YGOProMsgWin, YGOProMsgWin,
YGOProCtosUpdateDeck,
OcgcoreCommonConstants,
YGOProStocErrorMsg,
YGOProStocGameMsg,
YGOProStocReplay,
YGOProStocDuelEnd,
YGOProStocChangeSide,
YGOProStocWaitingSide,
} from 'ygopro-msg-encode'; } from 'ygopro-msg-encode';
import { DefaultHostInfoProvider } from './default-hostinfo-provder'; import { DefaultHostInfoProvider } from './default-hostinfo-provder';
import { CardReaderFinalized } from 'koishipro-core.js'; import { CardReaderFinalized } from 'koishipro-core.js';
...@@ -39,6 +47,9 @@ import { OnRoomMatchStart } from './room-event/on-room-match-start'; ...@@ -39,6 +47,9 @@ import { OnRoomMatchStart } from './room-event/on-room-match-start';
import { OnRoomGameStart } from './room-event/on-room-game-start'; import { OnRoomGameStart } from './room-event/on-room-game-start';
// import { OnRoomDuelStart } from './room-event/on-room-duel-start'; // 备用事件,暂未使用 // import { OnRoomDuelStart } from './room-event/on-room-duel-start'; // 备用事件,暂未使用
import YGOProDeck from 'ygopro-deck-encode'; 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>; export type RoomFinalizor = (self: Room) => Awaitable<any>;
...@@ -53,10 +64,29 @@ export class Room { ...@@ -53,10 +64,29 @@ export class Room {
.get(() => DefaultHostInfoProvider) .get(() => DefaultHostInfoProvider)
.parseHostinfo(this.name, this.partialHostinfo); .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() { get isTag() {
return this.hostinfo.mode === 2; 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); players = new Array<Client | undefined>(this.hostinfo.mode === 2 ? 4 : 2);
watchers = new Set<Client>(); watchers = new Set<Client>();
get playingPlayers() { get playingPlayers() {
...@@ -99,17 +129,21 @@ export class Room { ...@@ -99,17 +129,21 @@ export class Room {
return this; return this;
} }
private finalizors: RoomFinalizor[] = [ private async cleanPlayers(sendDuelEnd = false) {
() => { await Promise.all([
this.allPlayers.forEach((p) => { ...this.allPlayers.map(async (p) => {
p.disconnect(); await this.kick(p, sendDuelEnd);
if (p.pos < NetPlayerType.OBSERVER) { if (p.pos < NetPlayerType.OBSERVER) {
this.players[p.pos] = undefined; 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) { addFinalizor(finalizor: RoomFinalizor, atEnd = false) {
if (atEnd) { if (atEnd) {
...@@ -259,29 +293,102 @@ export class Room { ...@@ -259,29 +293,102 @@ export class Room {
} }
duelStage = DuelStage.Begin; duelStage = DuelStage.Begin;
score = [0, 0]; duelRecords: DuelRecord[] = [];
get score() {
return [0, 1].map(
(p) => this.duelRecords.filter((d) => d.winPosition === p).length,
);
}
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),
}),
);
}
}
async win(winMsg: Partial<YGOProMsgWin>, winMatch = false) { 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) { if (this.duelStage === DuelStage.Siding) {
this.playingPlayers this.playingPlayers
.filter((p) => p.deck) .filter((p) => !p.deck)
.forEach((p) => p.send(new YGOProStocDuelStart())); .forEach((p) => p.send(new YGOProStocDuelStart()));
} }
const duelPos = this.getSwappedDuelPosByDuelPos(winMsg.player!); 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({ const exactWinMsg = new YGOProMsgWin().fromPartial({
...winMsg, ...winMsg,
player: duelPos, player: duelPos,
}); });
++this.score[this.getSwappedPos(exactWinMsg.player)]; const lastDuelRecord = this.duelRecords[this.duelRecords.length - 1];
// TODO: next game or finalize if (lastDuelRecord) {
lastDuelRecord.winPosition = duelPos;
}
const winMatch = forceWinMatch || this.score[duelPos] >= this.winMatchCount;
if (!winMatch) {
await this.changeSide();
}
await this.ctx.dispatch( await this.ctx.dispatch(
new OnRoomWin(this, exactWinMsg, winMatch), new OnRoomWin(this, exactWinMsg, winMatch),
this.getDuelPosPlayers(duelPos)[0], 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() @RoomMethod()
private async onDisconnect(client: Client, _msg: YGOProCtosDisconnect) { private async onDisconnect(client: Client, _msg: YGOProCtosDisconnect) {
if (this.duelStage === DuelStage.End || this.finalizing) {
return;
}
const wasObserver = client.pos === NetPlayerType.OBSERVER; const wasObserver = client.pos === NetPlayerType.OBSERVER;
const oldPos = client.pos; const oldPos = client.pos;
...@@ -451,7 +558,109 @@ export class Room { ...@@ -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() @RoomMethod()
...@@ -484,7 +693,6 @@ export class Room { ...@@ -484,7 +693,6 @@ export class Room {
return Promise.all(this.allPlayers.map((p) => p.sendChat(msg, type))); return Promise.all(this.allPlayers.map((p) => p.sendChat(msg, type)));
} }
duelCount = 0;
firstgoPlayer?: Client; firstgoPlayer?: Client;
private async toFirstGo(firstgoPos: number) { private async toFirstGo(firstgoPos: number) {
...@@ -505,8 +713,7 @@ export class Room { ...@@ -505,8 +713,7 @@ export class Room {
if (![DuelStage.Finger, DuelStage.Siding].includes(this.duelStage)) { if (![DuelStage.Finger, DuelStage.Siding].includes(this.duelStage)) {
return false; return false;
} }
++this.duelCount; if (this.winnerPositions.length === 0) {
if (this.duelCount === 1) {
this.allPlayers.forEach((p) => p.send(new YGOProStocDuelStart())); this.allPlayers.forEach((p) => p.send(new YGOProStocDuelStart()));
const displayCountDecks = [0, 1].map( const displayCountDecks = [0, 1].map(
(p) => this.getDuelPosPlayers(p)[0].deck!, (p) => this.getDuelPosPlayers(p)[0].deck!,
...@@ -540,14 +747,14 @@ export class Room { ...@@ -540,14 +747,14 @@ export class Room {
}); });
} }
if (firstgoPos != null) { if (firstgoPos != null && firstgoPos >= 0 && firstgoPos <= 1) {
await this.toFirstGo(firstgoPos); await this.toFirstGo(firstgoPos);
} else { } else {
await this.toFinger(); await this.toFinger();
} }
// 触发事件 // 触发事件
if (this.duelCount === 1) { if (this.winnerPositions.length === 0) {
// 触发比赛开始事件(第一局) // 触发比赛开始事件(第一局)
await this.ctx.dispatch( await this.ctx.dispatch(
new OnRoomMatchStart(this), 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