@haelp/teto
Version:
A typescript-based controllable TETR.IO client.
451 lines (450 loc) • 18.1 kB
JavaScript
import { Engine } from "../../engine/index.mjs";
import { getFullFrame } from "./utils.mjs";
import chalk from "chalk";
const moveElementToFirst = (arr, n)=>[
arr[n],
...arr.slice(0, n),
...arr.slice(n + 1)
];
export class Game {
client;
listeners = [];
frameQueue = [];
incomingGarbage = [];
timeout = null;
messageQueue = [];
startTime = null;
#target = {
strategy: "even"
};
tick;
// @ts-expect-error
isPractice = false;
#pauseIGEs = false;
#forcePauseIGEs = false;
#igeQueue = [];
#slowTickWarning = false;
/** The client's engine */ engine;
/** Data on all players in game, including the client */ players = [];
get opponents() {
return this.players.filter((p)=>p.gameid !== this.gameid);
}
/** The client's `gameid` set by the server */ gameid;
/** The raw game config sent by TETR.IO */ options;
/** The raw game config for all players, including the client's own game config */ readyData;
/** The targets set by the server */ serverTargets = [];
/** The gameids of the users targeting the client */ enemies = [];
/** The keys the client has queued to press (allows for pressing keys in the future) */ keyQueue = [];
/** Whether or not targeting is allowed (changed by server). Setting target while this is `false` will throw an error. */ canTarget = true;
/** Whether or not the client's game is over (topped out), and no longer ticking. */ over = false;
/** The BotWrapper in use. When a BotWrapper is set, `tick` will not be called. */ botWrapper;
/** The last time IGEs were flushed */ lastIGEFlush = 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 */ static fpm = 12;
/** @hideconstructor */ constructor(client, 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, { level = "info" } = {
level: "info"
}) {
const func = level === "info" ? chalk.blue : level === "warning" ? chalk.yellow : chalk.red;
console.log(`${func("[🎀\u2009Triangle.JS]")}: ${msg}`);
}
listen(event, cb, once = false) {
this.listeners.push([
event,
cb
]);
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() {
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) || null;
this.engine.events.removeAllListeners();
this.over = true;
this.client.ribbon.fasterPing = false;
}
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 = [];
while(game.queue.length > 0 && game.queue[0].frame <= game.engine.frame){
frames.push(game.queue.shift());
}
game.engine.tick(frames);
}
});
}
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, gameid) {
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,
options: {
comboTable: options.combotable,
garbageBlocking: options.garbageblocking,
clutch: options.clutch,
garbageTargetBonus: options.garbagetargetbonus,
spinBonuses: options.spinbonuses
},
queue: {
minLength: 31,
seed: options.seed,
type: options.bagtype
},
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
});
}
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;
}
async tickGame() {
if (this.over) return;
let 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)=>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"
].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 = [];
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",
...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) {
this.frameQueue.push(...frames);
}
#handleIGE(data) {
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) {
const 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) {
if (!this.canTarget) throw new Error("Targeting not allowed.");
const strategyMap = {
even: 0,
elims: 1,
random: 2,
payback: 3,
manual: 4
};
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
*/ get frame() {
return this.engine ? this.engine.frame : 0;
}
set frame(newFrame) {
this.engine.frame = newFrame;
}
/** Pauses accepting IGEs (garbage events) when the `keyQueue` has items, when `pauseIGEs` is set to true. */ get pauseIGEs() {
return this.#pauseIGEs;
}
set pauseIGEs(value) {
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. */ get forcePauseIGEs() {
return this.#forcePauseIGEs;
}
set forcePauseIGEs(value) {
this.#forcePauseIGEs = value;
this.#flushIGEs();
}
/** Whether or not the `Game` is currently allowed to process IGEs. */ get igesPaused() {
return this.#forcePauseIGEs || this.#pauseIGEs && this.keyQueue.length > 0;
}
}
//# sourceMappingURL=index.js.map