@haelp/teto
Version:
A typescript-based controllable TETR.IO client.
626 lines (555 loc) • 18.1 kB
text/typescript
import { Engine } from "../../engine";
import type { Events, Game as GameTypes } from "../../types";
import type { BotWrapper } from "../../utils/bot-wrapper";
import { Client } from "../client";
import { getFullFrame } from "./utils";
import chalk from "chalk";
const moveElementToFirst = <T>(arr: T[], n: number) => [
arr[n],
...arr.slice(0, n),
...arr.slice(n + 1)
];
export class Game {
private client: Client;
private listeners: Parameters<typeof this.client.on>[] = [];
private frameQueue: GameTypes.Replay.Frame[] = [];
private incomingGarbage: (GameTypes.Replay.Frames.IGE & { frame: number })[] =
[];
private timeout: NodeJS.Timeout | null = null;
private messageQueue: GameTypes.Client.Events[] = [];
private startTime: number | null = null;
#target: GameTypes.Target = { strategy: "even" };
private tick?: GameTypes.Tick.Func;
// @ts-expect-error
private isPractice = false;
#pauseIGEs = false;
#forcePauseIGEs = false;
#igeQueue: GameTypes.IGE[] = [];
#slowTickWarning = false;
/** The client's engine */
public engine!: Engine;
/** Data on all players in game, including the client */
public players: {
name: string;
gameid: number;
userid: string;
engine: Engine;
queue: GameTypes.Replay.Frame[];
}[] = [];
public get opponents() {
return this.players.filter((p) => p.gameid !== this.gameid);
}
/** The client's `gameid` set by the server */
public gameid: number;
/** The raw game config sent by TETR.IO */
public options: GameTypes.ReadyOptions;
/** The raw game config for all players, including the client's own game config */
public readyData: GameTypes.Ready;
/** The targets set by the server */
public serverTargets: number[] = [];
/** The gameids of the users targeting the client */
public enemies: number[] = [];
/** The keys the client has queued to press (allows for pressing keys in the future) */
public keyQueue: NonNullable<GameTypes.Tick.Out["keys"]> = [];
/** Whether or not targeting is allowed (changed by server). Setting target while this is `false` will throw an error. */
public canTarget = true;
/** Whether or not the client's game is over (topped out), and no longer ticking. */
public over = false;
/** The BotWrapper in use. When a BotWrapper is set, `tick` will not be called. */
public botWrapper?: BotWrapper;
/** The last time IGEs were flushed */
public lastIGEFlush: number = performance.now();
/** The Frames Per Second of the TETR.IO engine */
static fps = 60;
/** The maximum amount of time before all IGEs are force-flushed */
static maxIGETimeout = 30000;
/** Frames per message */
private static fpm = 12;
/** @hideconstructor */
constructor(client: Client, ready: GameTypes.Ready) {
this.client = client;
const self = ready.players.find((p) => p.userid === client.user.id);
if (!self) throw new Error("User not in game");
this.gameid = self.gameid;
this.options = self.options;
this.readyData = ready;
try {
this.engine = this.createEngine(this.options, this.gameid);
} catch (e) {
console.error(e);
throw e;
}
// including ourself means we get our own replay data
ready.players.forEach((player) =>
this.client.emit("game.scope.start", player.gameid)
);
this.init();
}
#log(
msg: string,
{ level = "info" }: { level: "info" | "warning" | "error" } = {
level: "info"
}
) {
const func =
level === "info"
? chalk.blue
: level === "warning"
? chalk.yellow
: chalk.red;
console.log(`${func("[🎀\u2009Triangle.JS]")}: ${msg}`);
}
private listen<T extends keyof Events.in.all>(
event: T,
cb: (data: Events.in.all[T]) => void,
once = false
) {
this.listeners.push([event, cb] as any);
if (once) {
this.client.once(event, cb);
} else {
this.client.on(event, cb);
}
}
/** Kill the game. This is called automatically by the Room class when a game ends/is aborted, you don't need to use this. */
destroy(): undefined {
if (!this.over) this.stop();
this.listeners.forEach((l) => this.client.off(l[0], l[1]));
this.players.forEach((engine) => engine.engine.events.removeAllListeners());
delete this.client.game;
}
/**
* Stops the client's gameplay, when it dies. Does not destroy the game.
*/
stop() {
this.listeners.forEach(
(l) => l[0] !== "game.replay" && this.client.off(l[0], l[1])
);
this.listeners = this.listeners.filter((l) => l[0] === "game.replay");
if (this.timeout)
this.timeout = (clearTimeout(this.timeout) as any) || null;
this.engine.events.removeAllListeners();
this.over = true;
this.client.ribbon.fasterPing = false;
}
private init() {
this.client.ribbon.fasterPing = true;
this.listen("game.match", (_data) => {});
this.listen(
"game.start",
() => {
this.timeout = setTimeout(
() => {
this.start();
},
this.options.countdown_count * this.options.countdown_interval +
this.options.precountdown +
this.options.prestart
);
this.listen("game.abort", () => clearTimeout(this.timeout!), true);
},
true
);
const p = this.readyData.players;
// TODO: Add support for choosing who to spectate
// const players = p.filter((p) => p.gameid !== this.gameid);
const players = p;
this.players = players.map((o) => ({
name: o.options.username,
userid: o.userid,
gameid: o.gameid,
engine: this.createEngine(o.options, o.gameid),
queue: []
}));
// this.listen("game.replay.ige", (data) => this.addIGE(data));
this.listen("game.replay.ige", (data) => this.#handleIGE(data));
this.listen("game.replay", ({ gameid, frames }) => {
const game = this.players.find((player) => player.gameid === gameid);
if (!game || game.engine.toppedOut) return false;
game.queue.push(...frames);
while (game.queue.some((f) => f.frame > game.engine.frame)) {
const frames: GameTypes.Replay.Frame[] = [];
while (
game.queue.length > 0 &&
game.queue[0].frame <= game.engine.frame
) {
frames.push(game.queue.shift()!);
}
game.engine.tick(frames);
}
});
}
private start() {
this.pipe(
{
type: "start",
frame: 0,
data: {}
},
getFullFrame(this.options)
);
this.startTime = performance.now();
try {
this.target = this.target;
} catch {}
this.client.emit("client.game.round.start", [
(f) => {
this.tick = f;
},
this.engine,
[
...this.players.map((player) => ({
name: player.name,
gameid: player.gameid,
engine: player.engine
}))
]
]);
this.timeout = setTimeout(this.tickGame.bind(this), 0);
}
createEngine(options: GameTypes.ReadyOptions, gameid: number) {
return new Engine({
multiplayer: {
opponents: this.readyData.players
.map((o) => o.gameid)
.filter((id) => id !== gameid),
passthrough: options.passthrough
},
board: {
width: options.boardwidth,
height: options.boardheight,
buffer: 20 // there is always a buffer of 20 over the visible board
},
kickTable: options.kickset as any,
options: {
comboTable: options.combotable as any,
garbageBlocking: options.garbageblocking as any,
clutch: options.clutch,
garbageTargetBonus: options.garbagetargetbonus,
spinBonuses: options.spinbonuses
},
queue: {
minLength: 31,
seed: options.seed,
type: options.bagtype as any
},
garbage: {
cap: {
absolute: options.garbageabsolutecap,
increase: options.garbagecapincrease,
max: options.garbagecapmax,
value: options.garbagecap,
marginTime: options.garbagecapmargin
},
multiplier: {
value: options.garbagemultiplier,
increase: options.garbageincrease,
marginTime: options.garbagemargin
},
boardWidth: options.boardwidth,
garbage: {
speed: options.garbagespeed,
holeSize: options.garbageholesize
},
messiness: {
change: options.messiness_change,
nosame: options.messiness_nosame,
timeout: options.messiness_timeout,
within: options.messiness_inner,
center: options.messiness_center ?? false
},
bombs: options.usebombs,
seed: options.seed,
rounding: options.roundmode,
openerPhase: options.openerphase,
specialBonus: options.garbagespecialbonus
},
pc: options.allclears
? {
garbage: options.allclear_garbage,
b2b: options.allclear_b2b
}
: false,
b2b: {
chaining: options.b2bchaining,
charging: options.b2bcharging
? { at: options.b2bcharge_at, base: options.b2bcharge_base }
: false
},
gravity: {
value: options.g,
increase: options.gincrease,
marginTime: options.gmargin
},
misc: {
movement: {
infinite: options.infinite_movement,
lockResets: options.lockresets,
lockTime: options.locktime,
may20G: options.gravitymay20g
},
allowed: {
spin180: options.allow180,
hardDrop: options.allow_harddrop,
hold: options.display_hold
},
infiniteHold: options.infinite_hold,
username: options.username,
date: new Date()
},
handling: options.handling
});
}
private flushFrames() {
let returnFrames = this.frameQueue.filter((f) => f.frame <= this.frame);
this.frameQueue = this.frameQueue.filter((f) => f.frame > this.frame);
if (!this.canTarget)
returnFrames = returnFrames.filter(
(f) => f.type !== "strategy" && f.type !== "manual_target"
);
if (!this.options.manual_allowed)
returnFrames = returnFrames.filter((f) => f.type !== "manual_target");
// move the full frame to the front as a precaution
const fullFrameIndex = returnFrames.findIndex(
(frame) => frame.type === "full"
);
if (fullFrameIndex >= 0) {
returnFrames = moveElementToFirst(returnFrames, fullFrameIndex);
}
// move start frame to front (start -> full at the end)
const startFrameIndex = returnFrames.findIndex(
(frame) => frame.type === "start"
);
if (startFrameIndex >= 0) {
returnFrames = moveElementToFirst(returnFrames, startFrameIndex);
}
return returnFrames;
}
private async tickGame(): Promise<void> {
if (this.over) return;
let runAfter: GameTypes.Tick.Out["runAfter"] = [];
if (this.tick) {
try {
const res = await this.tick({
gameid: this.gameid,
frame: this.frame,
events: this.messageQueue.splice(0, this.messageQueue.length),
engine: this.engine!
});
const isValidObject = (obj: any) =>
typeof obj === "object" && obj !== null;
if (res.keys)
this.keyQueue.push(
...res.keys.filter((k, idx) => {
if (
!isValidObject(k) ||
!(k.type === "keydown" || k.type === "keyup") ||
typeof k.frame !== "number" ||
isNaN(k.frame) ||
k.frame < this.frame ||
!isValidObject(k.data) ||
!(
[
"moveLeft",
"moveRight",
"hardDrop",
"hold",
"softDrop",
"rotateCW",
"rotate180",
"rotateCCW"
] satisfies GameTypes.Tick.Keypress["data"]["key"][]
).includes(k.data.key) ||
typeof k.data.subframe !== "number" ||
isNaN(k.data.subframe) ||
k.data.subframe < 0
) {
this.#log(
`Invalid key event at index ${idx} passed on frame ${this.frame}:\n${JSON.stringify(k, null, 2)}`,
{
level: "error"
}
);
return false;
}
return true;
})
);
if (res.runAfter)
runAfter.push(
...res.runAfter.filter((ra, idx) => {
if (typeof ra !== "function") {
this.#log(
`Invalid runAfter callback at index ${idx} passed on frame ${this.frame}.`,
{
level: "warning"
}
);
return false;
}
return true;
})
);
} catch (e) {
if (this.over) return;
throw e;
}
}
if (this.over) return;
// ideally, iges get flushed here
this.#flushIGEs();
const keys: typeof this.keyQueue = [];
for (let i = this.keyQueue.length - 1; i >= 0; i--) {
const key = this.keyQueue[i];
if (Math.floor(key.frame) === this.frame) {
keys.push(key);
this.keyQueue.splice(i, 1);
}
}
keys.splice(0, keys.length, ...keys.reverse());
try {
const { garbage } = this.engine.tick([
...this.incomingGarbage.splice(0, this.incomingGarbage.length),
...keys
]);
this.messageQueue.push(
...garbage.received.map((g) => ({
type: "garbage" as const,
...g
}))
);
this.pipe(...keys);
if (this.frame !== 0 && this.frame % Game.fpm === 0) {
const frames = this.flushFrames();
this.client.emit("game.replay", {
gameid: this.gameid,
provisioned: this.frame,
frames
});
this.messageQueue.push({
type: "frameset",
provisioned: this.frame,
frames
});
}
runAfter.forEach((f) => f());
// flush at the time `keyQueue` is most likely to be empty (so that continuous playing doesn't cause an ige backup). Garbage might be a frame late.
this.#flushIGEs();
const target =
((this.frame + 1) / Game.fps) * 1000 -
(performance.now() - this.startTime!);
if (target <= -2000 && !this.#slowTickWarning) {
console.log(
`${chalk.red("[Triangle.js]")} Triangle.js is lagging behind by more than 2 seconds! Your \`tick\` function is likely taking too long to execute.`
);
this.#slowTickWarning = true;
}
// Every half second, use timeout even if we lag to ensure the websocket can send frames
if (target <= 0 && (this.engine.frame % Game.fps) / 2 !== 0) {
return this.tickGame();
}
this.timeout = setTimeout(this.tickGame.bind(this), Math.max(0, target));
} catch (e) {
console.error(e);
throw e;
}
}
/**
* Send raw frames to TETR.IO -
* Not recommended for normal use.
*/
pipe(...frames: GameTypes.Replay.Frame[]) {
this.frameQueue.push(...frames);
}
#handleIGE(data: Events.in.Game["game.replay.ige"]) {
this.#igeQueue.push(...data.iges);
this.#flushIGEs();
}
#flushIGEs() {
if (this.igesPaused && this.lastIGEFlush + Game.maxIGETimeout > performance.now()) return;
this.#igeQueue
.splice(0, this.#igeQueue.length)
.forEach((ige) => this.#__internal_handleIGE(ige));
this.lastIGEFlush = performance.now();
}
#__internal_handleIGE(ige: GameTypes.IGE) {
const frame: GameTypes.Replay.Frame = {
frame: this.frame,
type: "ige",
data: ige
};
this.pipe(frame);
this.incomingGarbage.push({ ...frame });
if (ige.type === "interaction_confirm") {
if (ige.data.type === "targeted") {
// TODO: implement
}
} else if (ige.type === "target") {
this.serverTargets = ige.data.targets;
} else if (ige.type === "allow_targeting") {
this.canTarget = ige.data.value;
}
}
/**
* The current targeting strategy. Setting a targeting strategy throws error if targeting is not allowed.
*/
get target() {
return this.#target;
}
set target(t: GameTypes.Target) {
if (!this.canTarget) throw new Error("Targeting not allowed.");
const strategyMap = {
even: 0,
elims: 1,
random: 2,
payback: 3,
manual: 4
} as const;
const frame = this.frame;
if (t.strategy === "manual") {
this.pipe({
frame,
type: "manual_target",
data: t.target
});
} else {
this.pipe({
frame,
type: "strategy",
data: strategyMap[t.strategy]
});
}
this.#target = t;
}
/**
* The game's current frame
*/
public get frame() {
return this.engine ? this.engine.frame : 0;
}
private set frame(newFrame: number) {
this.engine.frame = newFrame;
}
/** Pauses accepting IGEs (garbage events) when the `keyQueue` has items, when `pauseIGEs` is set to true. */
public get pauseIGEs() {
return this.#pauseIGEs;
}
public set pauseIGEs(value: boolean) {
this.#pauseIGEs = value;
this.#flushIGEs();
}
/** Force stops the processing of all IGEs (garbage, etc). If left `true` for too long (~30s), IGEs will be automatically flushed to avoid a forced disconnect. Use sparingly. */
public get forcePauseIGEs() {
return this.#forcePauseIGEs;
}
public set forcePauseIGEs(value: boolean) {
this.#forcePauseIGEs = value;
this.#flushIGEs();
}
/** Whether or not the `Game` is currently allowed to process IGEs. */
public get igesPaused() {
return (
this.#forcePauseIGEs || (this.#pauseIGEs && this.keyQueue.length > 0)
);
}
}