Commit ca28558c authored by Sophia's avatar Sophia

initial commit 😂

parents
import net from "net";
import Client from "@network/Client";
import logger from "@utils/logger";
export default class App {
private server: net.Server;
public constructor() {
this.server = net.createServer(this.onConnect);
}
public start(port: number) {
this.server.listen(port, () => {
logger.info(`The server is now listening on port: ${port}`);
});
}
private onConnect = (clientSocket: net.Socket) => {
logger.info(`Client connected from: ${clientSocket.remoteAddress}`);
// eslint-disable-next-line no-new
new Client(clientSocket);
};
}
/* eslint-disable @typescript-eslint/no-unused-vars,class-methods-use-this */
import { Arg, Int, Mutation, Query, Resolver } from "type-graphql";
import Client from "@network/Client";
import ClientObject from "@api/Client";
@Resolver(of => ClientObject)
export default class ClientResolver {
@Query(returns => [ClientObject])
public clients() {
return Client.dumpAllClients();
}
@Query(returns => Int)
public clientCount() {
return Client.dumpAllClients().length;
}
@Mutation(returns => Int)
public broadcastMessage(@Arg("message") message: string) {
return Client.broadcastMessage(message);
}
}
import { Field, ObjectType } from "type-graphql";
@ObjectType("Client")
export default class ClientObject {
@Field()
public id: string;
@Field()
public name: string;
@Field()
public remoteAddress: string;
@Field()
public position: number;
@Field()
public isHost: boolean;
}
/* eslint-disable @typescript-eslint/no-unused-vars,class-methods-use-this */
import { Arg, Int, Mutation, Query, Resolver } from "type-graphql";
import RoomObject from "@root/api/Room";
import Room from "@game/Room";
@Resolver(of => RoomObject)
export default class RoomResolver {
@Query(returns => Int)
public async roomCount() {
return Room.getOpenedRoomCount();
}
@Query(returns => [RoomObject])
public async rooms() {
return Room.dumpAllRooms();
}
@Mutation(returns => Boolean)
public async destroyRoom(@Arg("id") id: string) {
const room = Room.findRoomById(id);
if (!room) return false;
room.destroy();
return true;
}
}
/* eslint-disable @typescript-eslint/no-unused-vars,class-methods-use-this */
import { Field, ObjectType } from "type-graphql";
import { RoomType } from "@game/Room";
import ClientObject from "@api/Client";
import { RoomState } from "@root/constants";
@ObjectType("Room")
export default class RoomObject {
@Field()
public id: string;
@Field(type => RoomType)
public type: RoomType;
@Field(type => RoomState)
public status: RoomState;
@Field(type => [ClientObject])
public connectedClients: ClientObject[];
}
import { ApolloServer, ServerInfo } from "apollo-server";
import { buildSchema, registerEnumType } from "type-graphql";
import { RoomType } from "@game/Room";
import RoomResolver from "@api/Room.resolver";
import ClientResolver from "@api/Client.resolver";
import logger from "@utils/logger";
import { RoomState } from "@root/constants";
registerEnumType(RoomType, {
name: "RoomType",
});
registerEnumType(RoomState, {
name: "RoomState",
});
export default async function initializeAPIServer() {
const schema = await buildSchema({
resolvers: [RoomResolver, ClientResolver],
});
// The ApolloServer constructor requires two parameters: your schema
// definition and your set of resolvers.
const server = new ApolloServer({
schema,
introspection: __DEV__,
debug: __DEV__,
playground: __DEV__,
});
await new Promise(resolve => {
// The `listen` method launches a web server.
server.listen().then(({ port }: ServerInfo) => {
logger.info(`Apollo graphql api server is now opened on port: ${port}`);
resolve();
});
});
}
/* eslint-disable import/prefer-default-export */
export enum ChatType {
LIGHTBLUE = 8,
RED = 11,
GREEN = 12,
BLUE = 13,
BABYBLUE = 14,
PINK = 15,
YELLOW = 16,
WHITE = 17,
GRAY = 18,
DARKGRAY = 19,
}
export enum GameMessageType {
RETRY = 1,
HINT = 2,
WAITING = 3,
START = 4,
WIN = 5,
UPDATE_DATA = 6,
UPDATE_CARD = 7,
REQUEST_DECK = 8,
SELECT_BATTLECMD = 10,
SELECT_IDLECMD = 11,
SELECT_EFFECTYN = 12,
SELECT_YESNO = 13,
SELECT_OPTION = 14,
SELECT_CARD = 15,
SELECT_CHAIN = 16,
SELECT_PLACE = 18,
SELECT_POSITION = 19,
SELECT_TRIBUTE = 20,
SORT_CHAIN = 21,
SELECT_COUNTER = 22,
SELECT_SUM = 23,
SELECT_DISFIELD = 24,
SORT_CARD = 25,
SELECT_UNSELECT_CARD = 26,
CONFIRM_DECKTOP = 30,
CONFIRM_CARDS = 31,
SHUFFLE_DECK = 32,
SHUFFLE_HAND = 33,
REFRESH_DECK = 34,
SWAP_GRAVE_DECK = 35,
SHUFFLE_SET_CARD = 36,
REVERSE_DECK = 37,
DECK_TOP = 38,
MSG_SHUFFLE_EXTRA = 39,
NEW_TURN = 40,
NEW_PHASE = 41,
CONFIRM_EXTRATOP = 42,
MOVE = 50,
POS_CHANGE = 53,
SET = 54,
SWAP = 55,
FIELD_DISABLED = 56,
SUMMONING = 60,
SUMMONED = 61,
SPSUMMONING = 62,
SPSUMMONED = 63,
FLIPSUMMONING = 64,
FLIPSUMMONED = 65,
CHAINING = 70,
CHAINED = 71,
CHAIN_SOLVING = 72,
CHAIN_SOLVED = 73,
CHAIN_END = 74,
CHAIN_NEGATED = 75,
CHAIN_DISABLED = 76,
CARD_SELECTED = 80,
RANDOM_SELECTED = 81,
BECOME_TARGET = 83,
DRAW = 90,
DAMAGE = 91,
RECOVER = 92,
EQUIP = 93,
LPUPDATE = 94,
UNEQUIP = 95,
CARD_TARGET = 96,
CANCEL_TARGET = 97,
PAY_LPCOST = 100,
ADD_COUNTER = 101,
REMOVE_COUNTER = 102,
ATTACK = 110,
BATTLE = 111,
ATTACK_DISABLED = 112,
DAMAGE_STEP_START = 113,
DAMAGE_STEP_END = 114,
MISSED_EFFECT = 120,
BE_CHAIN_TARGET = 121,
CREATE_RELATION = 122,
RELEASE_RELATION = 123,
TOSS_COIN = 130,
TOSS_DICE = 131,
ROCK_PAPER_SCISSORS = 132,
HAND_RES = 133,
ANNOUNCE_RACE = 140,
ANNOUNCE_ATTRIB = 141,
ANNOUNCE_CARD = 142,
ANNOUNCE_NUMBER = 143,
CARD_HINT = 160,
TAG_SWAP = 161,
RELOAD_FIELD = 162,
AI_NAME = 163,
SHOW_HINT = 164,
MATCH_KILL = 170,
CUSTOM_MSG = 180,
}
export enum RoomState {
Ready,
ChoosingFirst,
FirstGo,
Dueling,
Siding,
Ended,
}
import net from "net";
import { EventEmitter } from "tsee";
import Room from "@game/Room";
import Client from "@network/Client";
import PacketBuilder from "@network/PacketBuilder";
import { PacketType } from "@network/message";
import { ClientToServerMessageType } from "@network/message/ctos";
import Packet from "@network/Packet";
import { ServerToClientMessageType } from "@network/message/stoc";
import moment from "moment";
type RecorderEvents = {
receive: (packet: Packet<PacketType.ServerToClient>) => void;
end: () => void;
};
export default class Recorder extends EventEmitter<RecorderEvents> {
private recorderSocket: net.Socket = new net.Socket();
private spectatorSocket: net.Socket = new net.Socket();
private recordedData: Buffer[] = [];
private spectatorData: Buffer[] = [];
private pipeClients: Set<Client> = new Set<Client>();
private _joinGameDataBuffer: Buffer;
private _startTime: moment.Moment;
public get joinGameDataBuffer(): Buffer {
return this._joinGameDataBuffer.slice(0);
}
public get recordedBuffer(): Buffer {
return Buffer.concat(this.recordedData);
}
public get startTime(): moment.Moment {
return this._startTime;
}
public connectTo = (room: Room) => {
this.spectatorSocket.connect(room.port, this.onSpectatorConnect);
this.spectatorSocket.on("data", this.onSpectatorData);
this.spectatorSocket.on("error", this.onError);
this.recorderSocket.connect(room.port, this.onConnect);
this.recorderSocket.on("data", this.onData);
this.recorderSocket.on("error", this.onError);
};
public isClientPiped = (client: Client) => {
return this.pipeClients.has(client);
};
public pipe = (client: Client) => {
this.spectatorData.forEach(data => {
client.send(data);
});
this.pipeClients.add(client);
};
public unpipe = (client: Client) => {
this.pipeClients.delete(client);
};
private sendMockedPackets = (socket: net.Socket, name: string) => {
const data = [
PacketBuilder.build(PacketType.ClientToServer)
.type(ClientToServerMessageType.PLAYER_INFO)
.add("string", name)
.build(),
PacketBuilder.build(PacketType.ClientToServer)
.type(ClientToServerMessageType.JOIN_GAME)
.add("unsigned short", 4945)
.add("unsigned short", 0)
.add("unsigned int", 0)
.add("string", name)
.build(),
PacketBuilder.build(PacketType.ClientToServer)
.type(ClientToServerMessageType.HS_TOOBSERVER)
.build(),
];
data.forEach(packet => socket.write(packet.buffer));
};
private onConnect = () => {
this.sendMockedPackets(this.recorderSocket, "Marshtomp");
};
private onData = (buffer: Buffer) => {
const packets = Packet.parse(buffer, PacketType.ServerToClient);
if (!this._joinGameDataBuffer) {
packets.some(packet => {
if (packet.type === ServerToClientMessageType.JOIN_GAME) {
this._joinGameDataBuffer = packet.dataBuffer;
return true;
}
return false;
});
}
packets.forEach(packet => {
this.emit("receive", packet);
});
this.recordedData.push(buffer);
if (packets.some(packet => packet.type === 22)) {
this.emit("end");
} else if (packets.some(packet => packet.type === ServerToClientMessageType.DUEL_START)) {
this._startTime = moment();
}
};
private onError = () => {};
private onSpectatorConnect = () => {
this.sendMockedPackets(this.spectatorSocket, "the Big Brother");
};
private onSpectatorData = (buffer: Buffer) => {
this.pipeClients.forEach(client => {
client.send(buffer);
});
this.spectatorData.push(buffer);
};
public record = (packet: Packet<PacketType.ServerToClient>) => {
this.recordedData.push(packet.buffer);
this.spectatorData.push(packet.buffer);
};
public release() {
this.recorderSocket.destroy();
this.spectatorSocket.destroy();
this.recordedData = [];
this.spectatorData = [];
this.pipeClients.clear();
}
}
This diff is collapsed.
/* eslint-disable import/prefer-default-export,no-cond-assign */
import * as _ from "lodash";
import moment from "moment";
import fs from "fs";
import { GameOption, GameType } from "@root/types";
import path from "path";
export interface WindbotInformation {
name: string;
deck: string;
dialog: string;
}
interface LfListItem {
date: moment.Moment;
tcg: boolean;
}
const LFLISTS = (() => {
const result: LfListItem[] = [];
if (!fs.existsSync("ygopro/lflist.conf")) {
return result;
}
const matches = fs.readFileSync("ygopro/lflist.conf", "utf8").match(/!.*/g);
if (!matches) {
throw new Error("Failed to parse lflist.conf!");
}
matches.forEach(list => {
const date = list.match(/!([\d.]+)/);
if (!date) {
return;
}
const dateString = list.match(/!([\d.]+)/);
if (!dateString) {
throw new Error(`Failed to parse lflist.conf with date string: ${list}`);
}
result.push({
date: moment(dateString[1], "YYYY.MM.DD").utcOffset("-08:00"),
tcg: list.indexOf("TCG") !== -1,
});
});
return result;
})();
export function parseRoomName(name: string) {
const gameOptions: Partial<GameOption> = {};
if (name === "AI") {
gameOptions.rule = 2;
gameOptions.lfList = -1;
gameOptions.timeLimit = 999;
gameOptions.isAIRoom = true;
return gameOptions;
}
if (LFLISTS.length) {
if (gameOptions.rule === 1 && gameOptions.lfList === 0) {
gameOptions.lfList = _.findIndex(LFLISTS, list => list.tcg);
}
} else {
gameOptions.lfList = -1;
}
let param: RegExpMatchArray | null;
if (name.slice(0, 2) === "M#") {
gameOptions.type = GameType.Match;
} else if (name.slice(0, 2) === "T#") {
gameOptions.type = GameType.Tag;
gameOptions.lifePoints = 16000;
} else if (name.slice(0, 3) === "AI#") {
gameOptions.rule = 2;
gameOptions.lfList = -1;
gameOptions.timeLimit = 999;
gameOptions.isAIRoom = true;
} else if ((param = name.match(/^(\d)(\d)([TF])([TF])([TF])(\d+),(\d+),(\d+)/i))) {
gameOptions.rule = parseInt(param[1], 10);
gameOptions.type = parseInt(param[2], 10).toString() as GameType;
gameOptions.duelRule = param[3] === "T" ? 3 : 4;
gameOptions.noDeckCheck = param[4] === "T";
gameOptions.noDeckShuffle = param[5] === "T";
gameOptions.lifePoints = parseInt(param[6], 10);
gameOptions.startHandCount = parseInt(param[7], 10);
gameOptions.drawCount = parseInt(param[8], 10);
} else if ((param = name.match(/(.+)#/)) !== null) {
const rule = param[1].toUpperCase();
if (rule.match(/(^|,|,)(M|MATCH)(,|,|$)/)) {
gameOptions.type = GameType.Match;
}
if (rule.match(/(^|,|,)(T|TAG)(,|,|$)/)) {
gameOptions.type = GameType.Tag;
gameOptions.lifePoints = 16000;
}
if (rule.match(/(^|,|,)(TCGONLY|TO)(,|,|$)/)) {
gameOptions.rule = 1;
gameOptions.lfList = _.findIndex(LFLISTS, list => list.tcg);
}
if (rule.match(/(^|,|,)(OCGONLY|OO)(,|,|$)/)) {
gameOptions.rule = 0;
gameOptions.lfList = 0;
}
if (rule.match(/(^|,|,)(OT|TCG)(,|,|$)/)) {
gameOptions.rule = 2;
}
if ((param = rule.match(/(^|,|,)LP(\d+)(,|,|$)/))) {
gameOptions.lifePoints = _.clamp(parseInt(param[2], 10), 1, 99999);
}
if ((param = rule.match(/(^|,|,)(TIME|TM|TI)(\d+)(,|,|$)/))) {
let timeLimit = parseInt(param[3], 10);
if (timeLimit >= 1 && timeLimit <= 60) {
timeLimit *= 60;
}
if (timeLimit < 0) {
timeLimit = 180;
} else if (timeLimit >= 999) {
timeLimit = 999;
}
gameOptions.timeLimit = _.clamp(timeLimit, 180, 999);
}
if ((param = rule.match(/(^|,|,)(START|ST)(\d+)(,|,|$)/))) {
gameOptions.startHandCount = _.clamp(parseInt(param[3], 10), 1, 40);
}
if ((param = rule.match(/(^|,|,)(DRAW|DR)(\d+)(,|,|$)/))) {
gameOptions.drawCount = _.clamp(parseInt(param[3], 10), 1, 35);
}
if ((param = rule.match(/(^|,|,)(LFLIST|LF)(\d+)(,|,|$)/))) {
gameOptions.lfList = parseInt(param[3], 10) - 1;
}
if (rule.match(/(^|,|,)(NOLFLIST|NF)(,|,|$)/)) {
gameOptions.lfList = -1;
}
if (rule.match(/(^|,|,)(NOUNIQUE|NU)(,|,|$)/)) {
gameOptions.rule = 3;
}
if (rule.match(/(^|,|,)(NOCHECK|NC)(,|,|$)/)) {
gameOptions.noDeckCheck = true;
}
if (rule.match(/(^|,|,)(NOSHUFFLE|NS)(,|,|$)/)) {
gameOptions.noDeckShuffle = true;
}
if (rule.match(/(^|,|,)(IGPRIORITY|PR)(,|,|$)/)) {
gameOptions.duelRule = 4;
}
if ((param = rule.match(/(^|,|,)(DUELRULE|MR)(\d+)(,|,|$)/))) {
const duelRule = parseInt(param[3], 10);
if (duelRule && duelRule > 0 && duelRule <= 5) {
gameOptions.duelRule = duelRule;
}
}
if ((param = rule.match(/(^|,|,)(DEATH|DH)(\d*)(,|,|$)/))) {
const deathTime = parseInt(param[3], 10);
if (deathTime && deathTime > 0) {
gameOptions.autoDeath = deathTime;
} else {
gameOptions.autoDeath = 40;
}
}
}
return gameOptions;
}
export const WINDBOT_PATH = (() => {
if (!process.env.WINDBOT_PATH) {
return path.join(process.cwd(), "windbot");
}
if (path.isAbsolute(process.env.WINDBOT_PATH)) {
return process.env.WINDBOT_PATH;
}
return path.join(process.cwd(), process.env.WINDBOT_PATH);
})();
export const YGOPRO_PATH = (() => {
if (!process.env.YGOPRO_PATH) {
return path.join(process.cwd(), "ygopro");
}
if (path.isAbsolute(process.env.YGOPRO_PATH)) {
return process.env.YGOPRO_PATH;
}
return path.join(process.cwd(), process.env.YGOPRO_PATH);
})();
export const WINDBOT_DEFINITIONS: WindbotInformation[] = JSON.parse(
fs.readFileSync(path.join(WINDBOT_PATH, "./bots.json")).toString(),
).windbots;
import "reflect-metadata";
import * as Sentry from "@sentry/node";
import fs from "fs-extra";
import path from "path";
import initializeAPIServer from "@root/api";
import App from "@root/App";
declare global {
namespace NodeJS {
interface Global {
__SENTRY_DSN__: string;
__DEV__: boolean;
__VERSION__: string;
__SENTRY_PROVIDED__: boolean;
}
}
}
global.__SENTRY_PROVIDED__ = typeof __SENTRY_PROVIDED__ === "undefined" ? false : __SENTRY_PROVIDED__;
global.__SENTRY_DSN__ = typeof __SENTRY_DSN__ === "undefined" ? process.env.__SENTRY_DSN__! : __SENTRY_DSN__;
global.__DEV__ = typeof __DEV__ === "undefined" ? true : __DEV__;
global.__VERSION__ = typeof __VERSION__ === "undefined" ? "dev" : __VERSION__;
if (process.env.__PRERELEASE__ !== "1") {
fs.removeSync(path.join("./ygopro", "expansions"));
}
if (global.__SENTRY_PROVIDED__) {
Sentry.init({
dsn: global.__SENTRY_DSN__,
});
}
initializeAPIServer().then(() => {
new App().start(3000);
});
/* eslint-disable no-dupe-class-members */
//
// This class only relays all of the packets from YGOPro server to client (of this server) or vice versa.
//
import { Socket } from "net";
import Packet from "@network/Packet";
import Room from "@game/Room";
import { PacketType } from "@network/message";
import { ServerToClientMessageType } from "@network/message/stoc";
import logger from "@utils/logger";
type PacketListener = (packet: Packet<PacketType.ServerToClient>) => void;
//
// This class is just for `Tunneling` between ygopro server instance and client.
//
export default class Bridge {
private buffer: Buffer[] = [];
private socket: Socket | null = null;
private packetListener: PacketListener | null = null;
private pipelines: PacketListener[] = [];
private _hadNewConnection: boolean = false;
private _closed: boolean = false;
public get opened() {
return Boolean(this.socket);
}
public get hadNewConnection() {
return this._hadNewConnection;
}
public get closed(): boolean {
return this._closed;
}
/**
* Connect to a ygopro server instance.
*
* @param room
*/
public open = (room: Room) => {
return new Promise(resolve => {
this.socket = new Socket();
this.socket.on("error", console.error);
this.socket.on("data", this.onData);
this.socket.on("close", this.onClose);
this.socket.connect(room.port, room.host, () => {
this.flushBuffer();
resolve();
});
});
};
public close = (force: boolean = false, newConnection: boolean = false) => {
if (!this.opened || !this.socket) return;
if (!force) {
this.socket.end();
} else {
this.socket.destroy();
}
this._hadNewConnection = newConnection;
};
/**
* Send series of data from client to ygopro server instance.
*
* @param data
*/
public write = (data: Buffer) => {
if (!this.opened) {
// if there's no connection with ygopro server instance, store on the buffer first.
// the buffer will be flushed when it connected with server.
this.buffer.push(data);
return;
}
if (this.socket) {
// refuse if socket is already closed.
if (!this.socket.writable) {
return;
}
// otherwise just send it to the server directly.
this.socket.write(data);
}
};
public send(buffer: Buffer): void;
public send(packet: Packet<PacketType.ClientToServer>): void;
public send(data: Buffer | Packet<PacketType.ClientToServer>) {
if (data instanceof Packet) {
this.write(data.buffer);
return;
}
this.write(data);
}
/**
* Register event listener only for server to client packets.
*
* @param packetListener
*/
public receive = (packetListener: PacketListener) => {
this.packetListener = packetListener;
};
public addPipeline = (pipeline: PacketListener) => {
this.pipelines.push(pipeline);
};
public removePipeline = (pipeline: PacketListener) => {
this.pipelines = this.pipelines.filter(p => p !== pipeline);
};
public removeAllPipelines = () => {
this.pipelines = [];
};
private flushBuffer = () => {
this.buffer = this.buffer.filter(data => {
this.write(data);
return false;
});
};
private onData = (data: Buffer) => {
if (!this.packetListener) {
return;
}
const packets = Packet.parse(data, PacketType.ServerToClient);
if (__DEV__) {
const debugInfo = packets
.map(packet => {
return `[type: ${packet.type} (${ServerToClientMessageType[packet.type]})]`;
})
.join(", ");
logger.debug(`Received ${packets.length} server to client packets: ${debugInfo}`);
}
packets.forEach(this.packetListener);
this.pipelines.forEach(pipeline => {
packets.forEach(packet => {
pipeline(packet);
});
});
};
private onClose = () => {
this._closed = true;
};
}
This diff is collapsed.
import { BaseMessageData, PacketType } from "@network/message";
import getMessageStructure, { MessageStructureContainer } from "@network/message/structure";
import { ClientToServerMessages } from "@network/message/ctos";
import { ServerToClientMessages } from "@network/message/stoc";
export default class Packet<PacketType extends PacketType.ServerToClient | PacketType.ClientToServer> {
public static parse<T extends PacketType.ServerToClient | PacketType.ClientToServer>(
data: Buffer,
packetType: T,
): Packet<T>[] {
const result: Packet<T>[] = [];
// eslint-disable-next-line no-constant-condition
while (true) {
if (data.length < 2 || data.length < 3) {
break;
}
const messageLength = data.readUInt16LE(0);
const messageType = data.readUInt8(2) as keyof MessageStructureContainer[T];
if (data.length < 2 + messageLength) {
break;
}
const packet = new Packet<T>(messageType, messageLength, data.slice(0, 2 + messageLength), packetType);
result.push(packet);
// eslint-disable-next-line no-param-reassign
data = data.slice(2 + messageLength);
}
return result;
}
public readonly type: keyof MessageStructureContainer[PacketType];
public readonly length: number;
public readonly packetType: PacketType;
public readonly buffer: Buffer;
public readonly dataBuffer: Buffer;
private cursorOffset: number;
private constructor(
messageType: keyof MessageStructureContainer[PacketType],
messageLength: number,
buffer: Buffer,
packetType: PacketType,
) {
this.type = messageType;
this.length = messageLength;
this.buffer = buffer;
this.dataBuffer = buffer.slice(3, messageLength - 1 + 3);
this.packetType = packetType;
this.cursorOffset = 0;
}
private retrieveStringField = (length: number, offset?: number) => {
const targetOffset = offset || this.cursorOffset;
if (!offset) {
this.cursorOffset += length;
}
const result = this.dataBuffer.toString("UTF-16LE", targetOffset, targetOffset + length);
const stringLength = result.indexOf("\0");
if (stringLength === -1) {
return result;
}
return result.slice(0, stringLength);
};
private retrieveNumericField = (byteCount: number, length: number = 1, offset?: number) => {
const targetOffset = offset || this.cursorOffset;
if (!offset) {
this.cursorOffset += byteCount * length;
}
if (length === 1) {
return this.dataBuffer.readUIntLE(targetOffset, byteCount);
}
const result = [];
for (let i = 0; i < length; ++i) {
result.push(this.dataBuffer.readUIntLE(targetOffset + i * byteCount, byteCount));
}
return result;
};
public process = <PT extends PacketType.ServerToClient | PacketType.ClientToServer = PacketType>() => {
const messageStructure = getMessageStructure(this.packetType, this.type);
if (!messageStructure) {
const base: BaseMessageData<string> = {
type: this.type as any,
bypass: true,
};
return base;
}
const result: BaseMessageData<any> = {
type: this.type as any,
bypass: false,
};
messageStructure.forEach(item => {
let value: any;
switch (item.type) {
case "unsigned int":
value = this.retrieveNumericField(4, item.length);
break;
case "unsigned short":
value = this.retrieveNumericField(2, item.length);
break;
case "unsigned char":
value = this.retrieveNumericField(1, item.length);
break;
case "string":
if (item.length) {
value = this.retrieveStringField(item.length * 2, item.offset);
}
break;
default:
break;
}
(result as any)[item.name] = value;
});
return (result as any) as PT extends PacketType.ClientToServer
? ClientToServerMessages
: ServerToClientMessages;
};
}
/* eslint-disable no-dupe-class-members */
import Packet from "@network/Packet";
import { PacketType } from "@network/message";
import { PacketDataType } from "@network/message/structure";
import { ClientToServerMessageType } from "@network/message/ctos";
import { ServerToClientMessageType } from "@network/message/stoc";
export default class PacketBuilder<Type extends PacketType.ServerToClient | PacketType.ClientToServer> {
public static build<Type extends PacketType.ServerToClient | PacketType.ClientToServer>(
type: Type,
): PacketBuilder<Type> {
return new PacketBuilder(type);
}
private readonly data: Buffer[];
private messageType: ClientToServerMessageType | ServerToClientMessageType;
private packetType: Type;
private constructor(type: Type) {
this.data = [];
this.packetType = type;
}
public type(type: ClientToServerMessageType | ServerToClientMessageType): PacketBuilder<Type> {
this.messageType = type;
return this;
}
public add(type: "unsigned int", value: number, length?: 1): PacketBuilder<Type>;
public add(type: "unsigned int", value: number[], length: number): PacketBuilder<Type>;
public add(type: "unsigned short", value: number, length?: 1): PacketBuilder<Type>;
public add(type: "unsigned short", value: number[], length: number): PacketBuilder<Type>;
public add(type: "unsigned char", value: number, length?: 1): PacketBuilder<Type>;
public add(type: "unsigned char", value: number[], length: number): PacketBuilder<Type>;
public add(type: "string", value: string, length?: number): PacketBuilder<Type>;
public add(type: PacketDataType, value: number | number[] | string, length?: 1 | number): PacketBuilder<Type> {
if (type !== "string" && typeof value !== "string") {
let byteCount = 0;
switch (type) {
case "unsigned char":
byteCount = 1;
break;
case "unsigned short":
byteCount = 2;
break;
case "unsigned int":
byteCount = 4;
break;
default:
throw new Error(`Unknown packet data type: ${type}`);
}
let bufferSize = byteCount;
if (length) {
bufferSize *= length;
}
const buffer = Buffer.alloc(bufferSize);
if (Array.isArray(value)) {
value.forEach((v, i) => {
buffer.writeIntLE(v, i * byteCount, byteCount);
});
} else {
buffer.writeIntLE(value, 0, byteCount);
}
this.data.push(buffer);
} else if (type === "string" && typeof value === "string") {
let targetValue = `${value}\0`;
if (length && targetValue.length > length) {
targetValue = targetValue.substring(0, length);
}
const buffer = Buffer.alloc((length || targetValue.length) * 2, 0);
buffer.write(targetValue, "utf16le");
this.data.push(buffer);
} else {
throw new Error("Invalid arguments");
}
return this;
}
public buffer(data: Buffer) {
this.data.push(data);
return this;
}
public build(): Packet<Type> {
const data = Buffer.concat(this.data);
const packetHeader = Buffer.alloc(3, 0);
packetHeader.writeUInt16LE(data.length + 1, 0);
packetHeader.writeUInt8(this.messageType as number, 2);
return Packet.parse<Type>(Buffer.concat([packetHeader, data]), this.packetType)[0];
}
}
// eslint-disable-next-line import/prefer-default-export
import { BaseMessageData } from "@network/message";
export enum ClientToServerMessageType {
UNKNOWN = -1,
RESPONSE = 1,
UPDATE_DECK = 2,
HAND_RESULT = 3,
TP_RESULT = 4,
PLAYER_INFO = 16,
CREATE_GAME = 17,
JOIN_GAME = 18,
LEAVE_GAME = 19,
SURRENDER = 20,
TIME_CONFIRM = 21,
CHAT = 22,
HS_TODUELIST = 32,
HS_TOOBSERVER = 33,
HS_READY = 34,
HS_NOTREADY = 35,
HS_KICK = 36,
HS_START = 37,
REQUEST_FIELD = 48,
}
export interface ChatMessageData extends BaseMessageData<ClientToServerMessageType.CHAT> {
message: string;
}
export interface TPResultMessageData extends BaseMessageData<ClientToServerMessageType.TP_RESULT> {}
export interface HandResultMessageData extends BaseMessageData<ClientToServerMessageType.HAND_RESULT> {}
export interface UpdateDeckMessageData extends BaseMessageData<ClientToServerMessageType.UPDATE_DECK> {}
export interface TimeConfirmMessageData extends BaseMessageData<ClientToServerMessageType.TIME_CONFIRM> {}
export interface PlayerInfoMessageData extends BaseMessageData<ClientToServerMessageType.PLAYER_INFO> {
name: string;
}
export interface JoinGameMessageData extends BaseMessageData<ClientToServerMessageType.JOIN_GAME> {
version: number;
align: number;
gameid: number;
password: string;
}
export interface KickMessageData extends BaseMessageData<ClientToServerMessageType.HS_KICK> {
position: number;
}
export type ClientToServerMessages =
| ChatMessageData
| TPResultMessageData
| HandResultMessageData
| UpdateDeckMessageData
| TimeConfirmMessageData
| PlayerInfoMessageData
| JoinGameMessageData
| KickMessageData;
export interface BaseMessageData<Type> {
type: Type;
bypass?: boolean;
}
export enum PacketType {
ClientToServer,
ServerToClient,
}
// eslint-disable-next-line import/prefer-default-export
import { BaseMessageData } from "@network/message";
export enum ServerToClientMessageType {
UNKNOWN = -1,
GAME_MSG = 1,
ERROR_MSG = 2,
SELECT_HAND = 3,
SELECT_TP = 4,
HAND_RESULT = 5,
TP_RESULT = 6,
CHANGE_SIDE = 7,
WAITING_SIDE = 8,
CREATE_GAME = 17,
JOIN_GAME = 18,
TYPE_CHANGE = 19,
LEAVE_GAME = 20,
DUEL_START = 21,
DUEL_END = 22,
REPLAY = 23,
TIME_LIMIT = 24,
CHAT = 25,
HS_PLAYER_ENTER = 32,
HS_PLAYER_CHANGE = 33,
HS_WATCH_CHANGE = 34,
FIELD_FINISH = 48,
}
export interface PlayerChangeMessageData extends BaseMessageData<ServerToClientMessageType.HS_PLAYER_CHANGE> {
status: number;
}
export interface ChangeSideMessageData extends BaseMessageData<ServerToClientMessageType.CHANGE_SIDE> {}
export interface SelectTPMessageData extends BaseMessageData<ServerToClientMessageType.SELECT_TP> {}
export interface SelectHandMessageData extends BaseMessageData<ServerToClientMessageType.SELECT_HAND> {}
export interface DuelStartMessageData extends BaseMessageData<ServerToClientMessageType.DUEL_START> {}
export interface JoinGameMessageData extends BaseMessageData<ServerToClientMessageType.JOIN_GAME> {}
export interface TimeLimitMessageData extends BaseMessageData<ServerToClientMessageType.TIME_LIMIT> {
player: number;
timeLeft: number;
}
export interface GameMessageData extends BaseMessageData<ServerToClientMessageType.GAME_MSG> {}
export interface TypeChangeMessageData extends BaseMessageData<ServerToClientMessageType.TYPE_CHANGE> {
value: number;
}
export type ServerToClientMessages =
| PlayerChangeMessageData
| ChangeSideMessageData
| SelectTPMessageData
| SelectHandMessageData
| DuelStartMessageData
| JoinGameMessageData
| TimeLimitMessageData
| GameMessageData
| TypeChangeMessageData;
import { BaseMessageData, PacketType } from "@network/message";
import { ClientToServerMessages, ClientToServerMessageType } from "@network/message/ctos";
import { ServerToClientMessages, ServerToClientMessageType } from "@network/message/stoc";
import { UnionToIntersection } from "@root/types";
export type PacketDataType = "unsigned int" | "unsigned short" | "unsigned char" | "string";
export interface MessageStructItem<Names> {
name: Names;
type: PacketDataType;
length?: number;
offset?: number;
}
export type MessageStructureContainer = {
[PacketType.ClientToServer]: {
[Key in ClientToServerMessages["type"]]: MessageStructItem<
Exclude<keyof UnionToIntersection<ClientToServerMessages>, keyof BaseMessageData<any>>
>[];
};
[PacketType.ServerToClient]: {
[Key in ServerToClientMessages["type"]]: MessageStructItem<
Exclude<keyof UnionToIntersection<ServerToClientMessages>, keyof BaseMessageData<any>>
>[];
};
};
const MESSAGE_STRUCTURE: MessageStructureContainer = {
[PacketType.ClientToServer]: {
[ClientToServerMessageType.CHAT]: [{ name: "message", type: "string", length: 255 }],
[ClientToServerMessageType.UPDATE_DECK]: [],
[ClientToServerMessageType.HAND_RESULT]: [],
[ClientToServerMessageType.TP_RESULT]: [],
[ClientToServerMessageType.TIME_CONFIRM]: [],
[ClientToServerMessageType.HS_KICK]: [{ name: "position", type: "unsigned char" }],
[ClientToServerMessageType.PLAYER_INFO]: [{ name: "name", type: "string", length: 20 }],
[ClientToServerMessageType.JOIN_GAME]: [
{ name: "version", type: "unsigned short" },
{ name: "align", type: "unsigned short" },
{ name: "gameid", type: "unsigned int" },
{ name: "password", type: "string", length: 20 },
],
},
[PacketType.ServerToClient]: {
[ServerToClientMessageType.HS_PLAYER_CHANGE]: [{ name: "status", type: "unsigned char" }],
[ServerToClientMessageType.SELECT_TP]: [],
[ServerToClientMessageType.CHANGE_SIDE]: [],
[ServerToClientMessageType.SELECT_HAND]: [],
[ServerToClientMessageType.DUEL_START]: [],
[ServerToClientMessageType.JOIN_GAME]: [],
[ServerToClientMessageType.TIME_LIMIT]: [
{ name: "player", type: "unsigned char" },
{ name: "timeLeft", type: "unsigned short" },
],
[ServerToClientMessageType.GAME_MSG]: [],
[ServerToClientMessageType.TYPE_CHANGE]: [{ name: "value", type: "unsigned char" }],
},
};
export default function getMessageStructure<
PT extends keyof MessageStructureContainer,
MT extends keyof MessageStructureContainer[PT]
>(packetType: PT, messageType: MT): MessageStructItem<any>[] {
return (MESSAGE_STRUCTURE as any)[packetType][messageType];
}
/* eslint-disable no-bitwise */
import Client, { CloseReason } from "@network/Client";
import Packet from "@network/Packet";
import PacketBuilder from "@network/PacketBuilder";
import { PacketType } from "@network/message";
import { ServerToClientMessageType, TimeLimitMessageData } from "@network/message/stoc";
import { ClientToServerMessageType, TimeConfirmMessageData } from "@network/message/ctos";
import Room from "@game/Room";
import { GameType, PacketData } from "@root/types";
import { GameMessageType, RoomState } from "@root/constants";
const LONG_RESOLVE_CARDS = [11110587, 32362575, 43040603, 58577036, 79106360];
export default class Heartbeat {
private static readonly instances: Map<Client, Heartbeat> = new Map<Client, Heartbeat>();
private static remove(client: Client) {
Heartbeat.instances.delete(client);
}
public static get(client: Client) {
let heartbeat = Heartbeat.instances.get(client);
if (!heartbeat) {
heartbeat = new Heartbeat(client);
Heartbeat.instances.set(client, heartbeat);
}
return heartbeat;
}
private readonly client: Client;
private heartbeatActivating: boolean;
private heartbeatTimeout: NodeJS.Timeout | null;
private heartbeatAnswered: boolean;
private isFirst: boolean;
public constructor(client: Client) {
this.client = client;
this.heartbeatActivating = false;
this.heartbeatAnswered = false;
this.heartbeatTimeout = null;
this.client.on("close", this.onClose.bind(this));
this.client.on("send", this.onSend.bind(this));
this.client.on("receive", this.onReceive.bind(this));
}
private onClose() {
this.release();
}
private onReceive(packet: Packet<PacketType.ServerToClient>, packetData: PacketData<PacketType.ServerToClient>) {
switch (packetData.type) {
case ServerToClientMessageType.GAME_MSG:
this.onGameMessage(packet.buffer.readInt8(0), packet.buffer);
break;
case ServerToClientMessageType.TIME_LIMIT:
this.onTimeLimit(packetData);
break;
default:
break;
}
}
private onSend(_: Packet<PacketType.ClientToServer>, packetData: PacketData<PacketType.ClientToServer>) {
switch (packetData.type) {
case ClientToServerMessageType.TIME_CONFIRM:
this.onTimeConfirm(packetData);
break;
default:
break;
}
}
private onGameMessage = (type: GameMessageType, buffer: Buffer) => {
const { currentRoom } = this.client;
if (!currentRoom) {
return;
}
switch (type) {
case GameMessageType.START:
this.isFirst = !(buffer.readUInt8(1) & 0xf);
break;
case GameMessageType.WIN:
currentRoom.connectedClients.forEach(client => {
// eslint-disable-next-line no-param-reassign
Heartbeat.get(client).heartbeatActivating = false;
});
currentRoom.longResolveChain = null;
currentRoom.longResolveCard = null;
break;
case GameMessageType.CONFIRM_CARDS:
{
const count = buffer.readInt8(2);
let check = false;
let foundDeckCount = 0;
let foundLimboCount = 0;
// eslint-disable-next-line no-multi-assign
for (let i = 3, n = 3, ref5 = 3 + (count - 1) * 7; n <= ref5; i = n += 7) {
const location = buffer.readInt8(i + 5);
if ((location & 0x41) > 0) {
foundDeckCount++;
} else if (location === 0) {
foundLimboCount++;
}
if ((foundDeckCount > 0 && count > 1) || foundLimboCount > 0) {
check = true;
break;
}
}
if (check) {
this.heartbeatActivating = true;
}
}
break;
default:
break;
}
if (!this.client.closed && this.client.position === 0) {
if (type === GameMessageType.CHAINING) {
const card = buffer.readUInt32LE(1);
const found = LONG_RESOLVE_CARDS.some(id => id === card);
if (found) {
currentRoom.longResolveCard = card;
} else {
currentRoom.longResolveCard = null;
}
} else if (type === GameMessageType.CHAINED) {
const chain = buffer.readInt8(1);
if (!currentRoom.longResolveChain) {
currentRoom.longResolveChain = [];
}
currentRoom.longResolveChain[chain] = true;
delete currentRoom.longResolveCard;
} else if (type === GameMessageType.CHAIN_SOLVING && currentRoom.longResolveChain) {
const chain = buffer.readInt8(1);
if (currentRoom.longResolveChain[chain]) {
currentRoom.connectedClients.forEach(client => {
Heartbeat.get(client).heartbeatActivating = true;
});
}
} else if (
(type === GameMessageType.CHAIN_NEGATED || type === GameMessageType.CHAIN_DISABLED) &&
currentRoom.longResolveChain
) {
const chain = buffer.readInt8(1);
delete currentRoom.longResolveChain[chain];
} else if (type === GameMessageType.CHAIN_END) {
currentRoom.longResolveCard = null;
currentRoom.longResolveChain = null;
}
}
};
private onTimeLimit = ({ player }: TimeLimitMessageData) => {
const room = Room.findConnectedRoom(this.client);
if (!room) {
return;
}
if (!(room.status === RoomState.Dueling && !room.hasBot)) {
return;
}
let check: boolean;
if (room.gameOptions.type !== GameType.Tag) {
check = (this.isFirst && player === 0) || (!this.isFirst && player === 1);
} else {
const cur_players = [];
switch (room.turn % 4) {
case 1:
cur_players[0] = 0;
cur_players[1] = 3;
break;
case 2:
cur_players[0] = 0;
cur_players[1] = 2;
break;
case 3:
cur_players[0] = 1;
cur_players[1] = 2;
break;
case 0:
cur_players[0] = 1;
cur_players[1] = 3;
break;
default:
break;
}
const firstClient = room.connectedClients[0];
if (!Heartbeat.get(firstClient).isFirst) {
cur_players[0] += 2;
cur_players[1] -= 2;
}
check = this.client.position === cur_players[player];
}
if (check) {
this.register(false);
}
};
private onTimeConfirm = (_data: TimeConfirmMessageData) => {
this.heartbeatActivating = false;
this.heartbeatAnswered = true;
this.unregister();
};
private unregister = () => {
if (!this.heartbeatTimeout) {
return false;
}
clearTimeout(this.heartbeatTimeout);
this.heartbeatTimeout = null;
return true;
};
public register = (send: boolean) => {
const room = Room.findConnectedRoom(this.client);
if (room) {
return false;
}
if (this.client.closed || this.client.position > 3 || this.heartbeatActivating) {
return false;
}
if (this.heartbeatTimeout) {
this.unregister();
}
this.heartbeatAnswered = false;
if (send) {
this.client.send(
PacketBuilder.build(PacketType.ServerToClient)
.type(ServerToClientMessageType.TIME_LIMIT)
.add("unsigned char", 0)
.add("unsigned short", 0)
.build(),
);
this.client.send(
PacketBuilder.build(PacketType.ServerToClient)
.type(ServerToClientMessageType.TIME_LIMIT)
.add("unsigned char", 1)
.add("unsigned short", 0)
.build(),
);
}
this.heartbeatTimeout = setTimeout(() => {
this.unregister();
if (!(this.client.closed || this.heartbeatAnswered)) {
this.client.close(CloseReason.Heartbeat);
}
}, 10000);
return true;
};
private release() {
this.unregister();
this.client.removeListener("close", this.onClose);
this.client.removeListener("send", this.onSend);
this.client.removeListener("receive", this.onReceive);
Heartbeat.remove(this.client);
}
}
/* eslint-disable no-dupe-class-members */
import Client, { CloseReason } from "@network/Client";
import Bridge from "@network/Bridge";
import Room from "@game/Room";
export interface Connection {
client: Client;
server: Bridge;
room: Room;
deck: Buffer;
timeout?: NodeJS.Timeout;
}
export default class ConnectionManager {
private static readonly connections: Map<string, Connection> = new Map<string, Connection>();
public static get(client: Client) {
return this.connections.get(client.id);
}
public static has(client: Client) {
return this.connections.has(client.id);
}
public static set(client: Client, connection: Connection) {
this.connections.set(client.id, connection);
}
public static remove(client: Client) {
this.connections.delete(client.id);
}
public static release(connection: Client, reconnected?: boolean): void;
public static release(connection: Connection, reconnected?: boolean): void;
public static release(target: Client | Connection, reconnected?: boolean) {
if (target instanceof Client) {
const connection = ConnectionManager.get(target);
if (!connection) {
return;
}
// eslint-disable-next-line no-param-reassign
target = connection;
}
if (target.client && target.client.closed && !reconnected) {
target.client.close(CloseReason.Unknown);
}
if (target.server && target.server.closed && !reconnected) {
target.server.close(true);
}
if (target.timeout) {
clearTimeout(target.timeout);
}
}
public static clearConnectionsByRoom(room: Room) {
ConnectionManager.connections.forEach(connection => {
if (connection.room !== room) {
return;
}
ConnectionManager.release(connection);
ConnectionManager.remove(connection.client);
});
}
}
This diff is collapsed.
/* eslint-disable no-await-in-loop */
import FormData from "form-data";
import fetch from "node-fetch";
import moment from "moment";
import * as Sentry from "@sentry/node";
import Room from "@game/Room";
import Recorder from "@game/Recorder";
import Client from "@network/Client";
import logger, { __DEBUG_LOG__ } from "@utils/logger";
export default class Uploader {
public static async upload(room: Room, clients: Client[], recorder: Recorder) {
try {
if (!process.env.__UPLOAD_ENDPOINT__) {
__DEBUG_LOG__("bypassed");
return;
}
const formData = new FormData();
formData.append(
"info",
JSON.stringify({
rules: { ...room.gameOptions },
clients: clients.map(client => client.toData()),
startTime: recorder.startTime.unix(),
endTime: moment().unix(),
}),
);
clients.forEach(client => {
formData.append("clientDecks", client.deckBuffer, {
contentType: "application/x-binary",
filename: `${client.position}.dat`,
});
});
formData.append("data", recorder.recordedBuffer, {
contentType: "application/x-binary",
filename: "duel.dat",
});
const response = await fetch(process.env.__UPLOAD_ENDPOINT__, {
method: "POST",
headers: formData.getHeaders(),
body: formData,
});
const data = await response.json();
if (data.status !== 0) {
throw new Error(`Failed to upload replay data to server, response code: ${data.status}`);
}
} catch (e) {
if (__DEV__) {
console.error(e);
}
Sentry.captureException(e);
logger.warn("Failed to upload replay data to server.");
}
}
}
import { BaseMessageData, PacketType } from "@network/message";
import { ServerToClientMessages } from "@network/message/stoc";
import { ClientToServerMessages } from "@network/message/ctos";
export enum GameType {
Single = "0",
Match = "1",
Tag = "2",
}
export interface GameOption {
type: GameType;
lifePoints: number;
timeLimit: number;
startHandCount: number;
drawCount: number;
lfList: number;
rule: number;
noDeckCheck: boolean;
noDeckShuffle: boolean;
duelRule: number | string;
autoDeath: number;
isAIRoom: boolean;
}
export type ObjectType<Values> = {
[key: string]: Values;
};
export type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
export type PacketData<PT extends PacketType.ServerToClient | PacketType.ClientToServer> =
| BaseMessageData<string>
| (PT extends PacketType.ServerToClient ? ServerToClientMessages : ClientToServerMessages);
export type OmitFirstArg<F> = F extends (x: any, ...args: infer P) => infer R ? P : never;
import winston from "winston";
import chalk from "chalk";
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp({
format: "HH:mm:ss.SSS",
}),
winston.format.printf(({ timestamp, message, level }) => {
let chalkFunction = chalk.blue;
if (level === "warn") {
chalkFunction = chalk.yellow;
} else if (level === "error") {
chalkFunction = chalk.red;
} else if (level === "debug") {
chalkFunction = chalk.cyan;
}
let levelText = level;
if (level === "error") {
levelText = "errr";
} else if (level === "debug") {
levelText = "debg";
}
levelText = levelText
.padEnd(4, " ")
.slice(0, 4)
.toUpperCase();
return `${chalk.green(`[${timestamp}]`)}${chalkFunction(`[${levelText}]`)} ${message}`;
}),
),
transports: [new winston.transports.Console({ level: "debug" })],
});
// eslint-disable-next-line no-underscore-dangle
export function __DEBUG_LOG__(content: string) {
if (__DEV__) {
logger.debug(content);
}
}
export default logger;
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