@haelp/teto
Version:
A typescript-based controllable TETR.IO client.
1,803 lines (1,552 loc) • 48.2 kB
text/typescript
import type { Game } from "../types/game";
import { EventEmitter } from "../utils/events";
import { Board, type BoardInitializeParams, ConnectedBoard } from "./board";
import { constants } from "./constants";
import {
GarbageQueue,
type GarbageQueueInitializeParams,
type IncomingGarbage,
LegacyGarbageQueue,
type OutgoingGarbage
} from "./garbage";
import { IGEHandler, type MultiplayerOptions } from "./multiplayer";
import { Queue, type QueueInitializeParams } from "./queue";
import { Mino } from "./queue/types";
import type {
EngineSnapshot,
Events,
IncreasableValue,
LockRes,
SpinType
} from "./types";
import { IncreaseTracker, deepCopy } from "./utils";
import { garbageCalcV2, garbageData } from "./utils/damageCalc";
import { type KickTable, legal, performKick } from "./utils/kicks";
import {
type KickTableName,
cornerTable,
kicks,
spinbonusRules
} from "./utils/kicks/data";
import { Tetromino, tetrominoes } from "./utils/tetromino";
import type { Rotation } from "./utils/tetromino/types";
import chalk from "chalk";
export interface GameOptions {
spinBonuses: Game.SpinBonuses;
comboTable: keyof (typeof garbageData)["comboTable"] | "multiplier";
garbageTargetBonus: "none" | "normal" | string;
clutch: boolean;
garbageBlocking: "combo blocking" | "limited blocking" | "none";
}
export type PCOptions =
| false
| {
garbage: number;
b2b: number;
};
export interface B2BOptions {
chaining: boolean;
charging:
| false
| {
at: number;
base: number;
};
}
export interface MiscellaneousOptions {
movement: {
infinite: boolean;
lockResets: number;
lockTime: number;
may20G: boolean;
};
allowed: {
spin180: boolean;
hardDrop: boolean;
hold: boolean;
};
infiniteHold: boolean;
username?: string;
date?: Date;
}
export interface EngineInitializeParams {
queue: QueueInitializeParams;
board: BoardInitializeParams;
kickTable: KickTable;
options: GameOptions;
gravity: IncreasableValue;
garbage: GarbageQueueInitializeParams;
handling: Game.Handling;
pc: PCOptions;
b2b: B2BOptions;
multiplayer?: MultiplayerOptions;
misc: MiscellaneousOptions;
}
export class Engine {
queue!: Queue;
held!: Mino | null;
holdLocked!: boolean;
falling!: Tetromino;
private _kickTable!: KickTableName;
board!: Board;
connectedBoard!: ConnectedBoard;
lastSpin!: {
piece: Mino;
type: SpinType;
} | null;
lastWasClear!: boolean;
stats!: {
garbage: {
sent: number;
attack: number;
receive: number;
cleared: number;
};
combo: number;
b2b: number;
pieces: number;
lines: number;
};
gameOptions!: GameOptions;
garbageQueue!: GarbageQueue | LegacyGarbageQueue;
frame!: number;
subframe!: number;
initializer: EngineInitializeParams;
handling!: Game.Handling;
input!: {
lShift: { held: boolean; arr: number; das: number; dir: -1 };
rShift: { held: boolean; arr: number; das: number; dir: 1 };
lastShift: number;
keys: {
[k in
| "softDrop"
| "rotateCCW"
| "rotateCW"
| "rotate180"
| "hold"]: boolean;
};
firstInputTime: number;
time: {
start: number;
zero: boolean;
locked: boolean;
prev: number;
frameoffset: number;
};
lastPieceTime: number;
};
pc!: PCOptions;
b2b!: B2BOptions;
dynamic!: {
gravity: IncreaseTracker;
garbageMultiplier: IncreaseTracker;
garbageCap: IncreaseTracker;
};
glock!: number;
multiplayer?: {
options: MultiplayerOptions;
targets: number[];
passthrough: {
network: boolean;
replay: boolean;
travel: boolean;
};
};
igeHandler!: IGEHandler;
misc!: MiscellaneousOptions;
state!: number;
currentSpike!: number;
events = new EventEmitter<Events>();
private resCache!: {
pieces: number;
garbage: {
sent: number[];
received: OutgoingGarbage[];
};
keys: Game.Key[];
lastLock: number;
};
constructor(options: EngineInitializeParams) {
this.initializer = deepCopy(options, [
{ type: Date, copy: (d) => new Date(d) }
]);
this.init();
}
init() {
const options = this.initializer;
this.queue = new Queue(options.queue);
this.queue.onRepopulate(this.#onQueueRepopulate.bind(this));
this._kickTable = options.kickTable;
this.board = new Board(options.board);
this.connectedBoard = new ConnectedBoard(options.board);
this.garbageQueue = new (
(options.misc.date ?? new Date()) > new Date("2025-05-06T15:00:00-04:00")
? GarbageQueue
: LegacyGarbageQueue
)(options.garbage);
this.igeHandler = new IGEHandler(options.multiplayer?.opponents || []);
if (options.multiplayer)
this.multiplayer = {
options: options.multiplayer,
targets: [],
passthrough: {
network: ["consistent", "zero"].includes(
options.multiplayer.passthrough
),
replay: options.multiplayer.passthrough !== "full",
travel: ["zero", "limited"].includes(options.multiplayer.passthrough)
}
};
this.held = null;
this.holdLocked = false;
this.lastSpin = null;
this.lastWasClear = false;
this.stats = {
combo: -1,
b2b: -1,
pieces: 0,
lines: 0,
garbage: {
sent: 0,
attack: 0,
receive: 0,
cleared: 0
}
};
this.pc = options.pc;
this.b2b = {
chaining: options.b2b.chaining,
charging: options.b2b.charging
};
this.dynamic = {
gravity: new IncreaseTracker(
options.gravity.value,
options.gravity.increase,
options.gravity.marginTime
),
garbageMultiplier: new IncreaseTracker(
options.garbage.multiplier.value,
options.garbage.multiplier.increase,
options.garbage.multiplier.marginTime
),
garbageCap: new IncreaseTracker(
options.garbage.cap.value,
options.garbage.cap.increase,
options.garbage.cap.marginTime
)
};
this.glock = 0;
this.misc = options.misc;
this.gameOptions = options.options;
this.handling = options.handling;
this.input = {
lShift: { held: false, arr: 0, das: 0, dir: -1 },
rShift: { held: false, arr: 0, das: 0, dir: 1 },
lastShift: -1,
firstInputTime: -1,
time: { start: 0, zero: true, locked: false, prev: 0, frameoffset: 0 },
lastPieceTime: 0,
keys: {
softDrop: false,
hold: false,
rotateCW: false,
rotateCCW: false,
rotate180: false
}
};
this.frame = 0;
this.subframe = 0;
this.state = 0;
this.currentSpike = 0;
this.flushRes();
this.nextPiece();
this.bindAll();
}
private flushRes() {
const res = this.resCache ? deepCopy(this.resCache) : null;
this.resCache = {
pieces: 0,
garbage: {
sent: [],
received: []
},
keys: [],
lastLock: res?.lastLock ?? 0
};
return res!;
}
reset() {
this.init();
}
bindAll() {
this.moveRight = this.moveRight.bind(this);
this.moveLeft = this.moveLeft.bind(this);
this.dasRight = this.dasRight.bind(this);
this.dasLeft = this.dasLeft.bind(this);
this.softDrop = this.softDrop.bind(this);
this.hardDrop = this.hardDrop.bind(this);
this.hold = this.hold.bind(this);
this.rotateCW = this.rotateCW.bind(this);
this.rotateCCW = this.rotateCCW.bind(this);
this.rotate180 = this.rotate180.bind(this);
this.snapshot = this.snapshot.bind(this);
this.fromSnapshot = this.fromSnapshot.bind(this);
this.nextPiece = this.nextPiece.bind(this);
}
#onQueueRepopulate(pieces: Mino[]) {
this.events.emit("queue.add", pieces);
}
snapshot(): EngineSnapshot {
return {
board: deepCopy(this.board.state),
connectedBoard: deepCopy(this.connectedBoard.state),
falling: this.falling.snapshot(),
frame: this.frame,
garbage: this.garbageQueue.snapshot(),
hold: this.held,
holdLocked: this.holdLocked,
lastSpin: deepCopy(this.lastSpin),
lastWasClear: this.lastWasClear,
queue: this.queue.index,
input: deepCopy(this.input),
subframe: this.subframe,
targets: this.multiplayer?.targets,
stats: deepCopy(this.stats),
glock: this.glock,
ige: this.igeHandler.snapshot(),
state: this.state,
currentSpike: this.currentSpike,
resCache: deepCopy(this.resCache)
};
}
fromSnapshot(snapshot: EngineSnapshot) {
this.board.state = deepCopy(snapshot.board);
this.connectedBoard.state = deepCopy(snapshot.connectedBoard);
this.falling = new Tetromino({
boardHeight: this.board.height,
boardWidth: this.board.width,
initialRotation:
this.kickTable.spawn_rotation[
snapshot.falling.symbol.toLowerCase() as keyof typeof this.kickTable.spawn_rotation
] ?? 0,
symbol: snapshot.falling.symbol,
from: snapshot.falling
});
for (const key of Object.keys(snapshot.falling)) {
// @ts-expect-error
this.falling[key] = deepCopy(snapshot.falling[key]);
}
this.frame = snapshot.frame;
this.subframe = snapshot.subframe;
this.garbageQueue.fromSnapshot(snapshot.garbage);
this.held = snapshot.hold;
this.holdLocked = snapshot.holdLocked;
this.lastSpin = deepCopy(snapshot.lastSpin);
this.lastWasClear = snapshot.lastWasClear;
this.queue = new Queue(this.initializer.queue);
for (let i = 0; i < snapshot.queue; i++) this.queue.shift();
this.queue.onRepopulate(this.#onQueueRepopulate.bind(this));
this.dynamic = {
gravity: new IncreaseTracker(
this.initializer.gravity.value,
this.initializer.gravity.increase,
this.initializer.gravity.marginTime
),
garbageMultiplier: new IncreaseTracker(
this.initializer.garbage.multiplier.value,
this.initializer.garbage.multiplier.increase,
this.initializer.garbage.multiplier.marginTime
),
garbageCap: new IncreaseTracker(
this.initializer.garbage.cap.value,
this.initializer.garbage.cap.increase,
this.initializer.garbage.cap.marginTime
)
};
for (let i = 0; i < this.frame; i++)
for (const key of Object.keys(this.dynamic))
this.dynamic[key as keyof typeof this.dynamic].tick();
this.input = deepCopy(snapshot.input);
if (this.multiplayer && snapshot.targets)
this.multiplayer.targets = [...snapshot.targets];
this.stats = deepCopy(snapshot.stats);
this.glock = snapshot.glock;
this.state = snapshot.state;
this.currentSpike = snapshot.currentSpike;
this.igeHandler.fromSnapshot(snapshot.ige);
this.resCache = deepCopy(snapshot.resCache);
}
get kickTable(): (typeof kicks)[KickTableName] {
return kicks[this._kickTable];
}
get kickTableName(): KickTableName {
return this._kickTable;
}
set kickTable(value: KickTableName) {
this._kickTable = value;
}
get dynamicStats() {
return {
apm: this.stats.garbage.attack / (this.frame / 60 / 60) || 0,
pps: this.stats.pieces / (this.frame / 60) || 0,
vs:
((this.stats.garbage.attack + this.stats.garbage.cleared) /
(this.frame / 60)) *
100 || 0,
surgePower: this.b2b.charging
? Math.floor(
this.stats.b2b - this.b2b.charging.at + this.b2b.charging.base + 1
)
: 0
};
}
#getEffectiveGravity() {
return this.glock <= 0
? this.dynamic.gravity.get()
: this.glock <= 180
? (1 - this.glock / 180) ** 2 * this.dynamic.gravity.get()
: 0;
}
#hasHitWall() {
return !!(this.state & constants.flags.STATE_WALL);
}
// @ts-expect-error unused
#hasRotated() {
return !!(
this.state &
(constants.flags.ROTATION_LEFT | constants.flags.ROTATION_RIGHT)
);
}
// @ts-expect-error unused
#hasRotated180() {
return !!(this.state & constants.flags.ROTATION_180);
}
// @ts-expect-error unused
#isSpin() {
return !!(this.state & constants.flags.ROTATION_SPIN);
}
// @ts-expect-error unused
#isSpinMini() {
return !(
~this.state &
(constants.flags.ROTATION_SPIN | constants.flags.ROTATION_MINI)
);
}
// @ts-expect-error unused
#isSpinAll() {
return !!(this.state & constants.flags.ROTATION_SPIN_ALL);
}
#isSleep() {
return !!(this.state & constants.flags.STATE_SLEEP);
}
// @ts-expect-error unused
#isFloored() {
return !!(this.state & constants.flags.STATE_FLOOR);
}
// @ts-expect-error unused
#isVisible() {
return !(this.state & constants.flags.STATE_NODRAW);
}
// @ts-expect-error unused
#isSoftDropped() {
return !!(this.state & constants.flags.ACTION_SOFTDROP);
}
#isForcedToLock() {
return !!(this.state & constants.flags.ACTION_FORCELOCK);
}
#is20G() {
const is20G = this.dynamic.gravity.get() > this.board.height;
const mode20G = this.misc.movement.may20G;
if (this.input.keys.softDrop) {
const preferSoftDrop = this.handling.may20g || (is20G && mode20G);
return (
(this.handling.sdf === 41 ||
this.dynamic.gravity.get() * this.handling.sdf > this.board.height) &&
preferSoftDrop
);
}
return is20G && mode20G;
}
#shouldLock() {
return (
!this.misc.movement.infinite &&
this.falling.lockResets >= this.misc.movement.lockResets
);
}
#shouldFallFaster() {
return this.misc.movement.infinite
? false
: this.falling.rotResets > this.misc.movement.lockResets + 15;
}
#clearFlags(e: number) {
this.state &= ~e;
}
#__internal_lock(subframe = 1 - this.subframe) {
this.falling.locking += subframe;
return (
this.falling.locking > this.misc.movement.lockTime ||
!!this.#isForcedToLock() ||
this.#shouldLock()
);
}
#__internal_fall(value: number) {
let y1 = Math.round(1e6 * (this.falling.location[1] - value)) / 1e6,
y2 = this.falling.location[1] - 1;
if (y1 % 1 === 0) y1 -= 1e-6;
if (y2 % 1 === 0) y2 += 2e-6;
if (
!legal(this.falling.absoluteAt({ y: y1 }), this.board.state) ||
!legal(this.falling.absoluteAt({ y: y2 }), this.board.state)
)
return false;
const { highestY } = this.falling;
if (highestY > y1) this.falling.highestY = Math.floor(y1);
this.falling.location[1] = y1;
if (this.gameOptions.spinBonuses !== "stupid") this.lastSpin = null;
this.state &= ~constants.flags.STATE_FLOOR;
if (y1 < highestY || this.misc.movement.infinite) {
this.falling.lockResets = 0;
this.falling.rotResets = 0;
}
return true;
}
// @ts-expect-error unused
#__internal_lockout() {
// TODO: implement
// if (this.options.nolockout) return;
// if (!e || false === this.options.clutch)
// return this.self.stm.LoseStockOrGameOver(
// this.options.topoutisclear ? "topout_clear" : "topout",
// );
}
#__internal_dcd() {
if (!this.#hasHitWall() || !this.handling.dcd) return;
this.input.lShift.das = Math.min(
this.input.lShift.das,
this.handling.das - this.handling.dcd
);
this.input.lShift.arr = this.handling.arr;
this.input.rShift.das = Math.min(
this.input.rShift.das,
this.handling.das - this.handling.dcd
);
this.input.rShift.arr = this.handling.arr;
}
#fall(subframe = 1 - this.subframe) {
if (this.falling.safeLock > 0) this.falling.safeLock--;
// TODO: if pause, return: if ((this.piece.safelock > 0 && this.piece.safelock--, this.S.pause)) return;
if (this.#isSleep()) return;
let fall = this.#getEffectiveGravity() * subframe;
if (this.glock > 0) this.glock -= subframe;
if (this.glock < 0) this.glock = 0;
if (this.input.keys.softDrop) {
if (this.handling.sdf === 41) fall = 400 * subframe;
else {
fall *= this.handling.sdf;
fall = Math.max(fall, 0.05 * this.handling.sdf);
}
}
if (
this.#shouldLock() &&
!legal(
this.falling.absoluteAt({ y: this.falling.location[1] - 1 }),
this.board.state
)
) {
fall = 20;
this.state |= constants.flags.ACTION_FORCELOCK;
}
if (this.#shouldFallFaster()) {
fall +=
0.5 *
subframe *
(this.falling.rotResets - (this.misc.movement.lockResets + 15));
}
for (
let dropFactor = fall;
dropFactor > 0;
dropFactor -= Math.min(1, dropFactor)
) {
const y = this.falling.location[1];
if (!this.#__internal_fall(Math.min(1, dropFactor))) {
if (this.#__internal_lock(subframe)) {
if (this.handling.safelock) this.falling.safeLock = 7;
this.#lock(false);
}
return;
}
if (Math.floor(y) !== Math.floor(this.falling.location[1])) {
this.state &= ~constants.flags.ROTATION_ALL;
}
}
}
#clampRotation(amount: number): Rotation {
return ((((this.falling.rotation + amount) % 4) + 4) % 4) as Rotation;
}
#__internal_rotate(
newX: number,
newY: number,
newRotation: number,
rotationDirection: number,
kick: ReturnType<typeof performKick>,
_isIRS = false
) {
// Handle rotation flags based on direction
if (rotationDirection >= 2) {
// 180 degree rotation
rotationDirection = newRotation > this.falling.rotation ? 1 : -1;
this.state |= constants.flags.ROTATION_180;
}
// Update this.falling properties
this.falling.x = newX;
this.falling.y = newY;
this.falling.rotation = newRotation;
// Set rotation direction flags
this.state |=
rotationDirection === 1
? constants.flags.ROTATION_RIGHT
: constants.flags.ROTATION_LEFT;
this.state &= ~(
constants.flags.ROTATION_SPIN |
constants.flags.ROTATION_MINI |
constants.flags.ROTATION_SPIN_ALL
);
// Update lock and rotation resets with caps
if (this.falling.lockResets < 31) this.falling.lockResets++;
if (this.falling.rotResets < 63) this.falling.rotResets++;
// Check for T-Spin
const spin = this.#detectSpin(this.#isTSpinKick(kick));
this.lastSpin = {
piece: this.falling.symbol,
type: spin
};
if (spin) {
this.state |= constants.flags.ROTATION_SPIN;
if (spin === "mini") {
this.state |= constants.flags.ROTATION_MINI;
}
}
this.falling.totalRotations++;
// Handle post-rotation actions
this.#__internal_dcd();
// Reset locking if not going to lock
if (!this.#shouldLock()) {
this.falling.locking = 0;
}
return true as const;
}
#kick(to: Rotation): false | Exclude<ReturnType<typeof performKick>, true> {
const kick = performKick(
this.kickTableName,
this.falling.symbol,
this.falling.location,
[this.falling.aox, this.falling.aoy],
!this.misc.movement.infinite &&
this.falling.totalRotations > this.misc.movement.lockResets + 15,
this.falling.states[to],
this.falling.rotation,
to,
this.board.state
);
if (typeof kick === "object") return kick;
else
return kick === false
? false
: {
kick: [0, 0],
newLocation: [this.falling.location[0], this.falling.location[1]],
id: "00",
index: 0
};
}
#rotate(amount: number, isIRS = false) {
if (this.#isSleep()) {
if (this.handling.irs === "tap")
this.falling.irs = (((this.falling.irs + amount) % 4) + 4) % 4;
return false;
}
const to = this.#clampRotation(amount);
this.state |= constants.flags.ACTION_MOVE;
this.state |= constants.flags.ACTION_ROTATE;
const kick = this.#kick(to);
return kick
? this.#__internal_rotate(
kick.newLocation[0],
kick.newLocation[1],
to,
amount,
kick,
isIRS
)
: false;
}
nextPiece(ignoreBlockout = false, isHold = false) {
const newTetromino = this.queue.shift()!;
this.initiatePiece(newTetromino, ignoreBlockout, isHold);
}
initiatePiece(piece: Mino, ignoreBlockout = false, isHold = false) {
if (this.handling.irs === "hold" && this.falling) {
let rotationState = 0;
// Calculate rotation based on input
if (this.input.keys.rotateCCW) rotationState -= 1;
if (this.input.keys.rotateCW) rotationState += 1;
if (this.input.keys.rotate180) rotationState += 2;
// Ensure rotation state is in 0-3 range
this.falling.irs = ((rotationState % 4) + 4) % 4;
}
if (this.handling.ihs === "hold" && this.input.keys.hold && !isHold) {
this.state |= constants.flags.ACTION_IHS;
}
this.#__internal_dcd();
this.state &= ~(
constants.flags.ROTATION_ALL |
constants.flags.STATE_ALL |
constants.flags.ACTION_FORCELOCK |
constants.flags.ACTION_SOFTDROP |
constants.flags.ACTION_MOVE |
constants.flags.ACTION_ROTATE
);
this.input.firstInputTime = -1;
if (!isHold) this.holdLocked = false;
this.falling = new Tetromino({
boardHeight: this.board.height,
boardWidth: this.board.width,
initialRotation:
this.kickTable.spawn_rotation[
piece.toLowerCase() as keyof typeof this.kickTable.spawn_rotation
] ?? 0,
symbol: piece,
from: this.falling
});
if (!ignoreBlockout && this.#considerBlockout(isHold)) {
// Lose Stock or Game Over
this.state ^= constants.flags.ACTION_IHS;
this.falling.irs = 0;
} else {
if (this.state & constants.flags.ACTION_IHS) {
this.state ^= constants.flags.ACTION_IHS;
this.hold(true, ignoreBlockout);
} else {
if (this.falling.irs !== 0) {
this.#rotate(this.falling.irs, true);
this.falling.irs = 0;
}
if (this.#considerBlockout(!ignoreBlockout || isHold)) {
// lose stock or game over
} else {
if (this.#is20G()) {
this.#slamToFloor();
}
}
}
}
}
#considerBlockout(isSilent: boolean = false) {
if (legal(this.falling.absoluteBlocks, this.board.state)) {
// TODO: clutch count
// if (!isSilent) {
// this.S.clutchCount = 0;
// }
return false;
}
let clutched = false;
if (this.lastWasClear && this.gameOptions.clutch !== false) {
const originalY = this.falling.location[1];
const originalHy = this.falling.highestY;
while (this.falling.y < this.board.fullHeight) {
this.falling.location[1]++;
this.falling.highestY++;
if (legal(this.falling.absoluteBlocks, this.board.state)) {
clutched = true;
break;
}
}
if (!clutched) {
this.falling.location[1] = originalY;
this.falling.highestY = originalHy;
}
}
if (!clutched) return true;
if (!isSilent) {
// TODO: update clutch count +1
}
return false;
}
hold(_ihs = false, ignoreBlockout = false) {
if (this.#isSleep()) {
if (this.handling.ihs === "tap") this.state |= constants.flags.ACTION_IHS;
return false;
}
if (this.holdLocked || !this.misc.allowed.hold) return false;
this.holdLocked = !this.misc.infiniteHold;
if (this.held) {
const save = this.held;
this.held = this.falling.symbol;
this.initiatePiece(save, ignoreBlockout, true);
} else {
this.held = this.falling.symbol;
this.nextPiece(ignoreBlockout, true);
}
this.holdLocked = !this.misc.infiniteHold;
return true;
}
#slamToFloor() {
while (this.#__internal_fall(1)) {}
}
get toppedOut() {
try {
for (const block of this.falling.blocks) {
if (
this.board.state[-block[1] + this.falling.y][
block[0] + this.falling.location[0]
] !== null
)
return true;
}
return false;
} catch {
return true;
}
}
#isTSpinKick(kick: ReturnType<typeof Tetromino.prototype.rotate>) {
if (typeof kick === "object") {
return (
// fin cw and tst ccw
((kick.id === "23" || kick.id === "03") &&
kick.kick[0] === 1 &&
kick.kick[1] === -2) ||
// fin ccw and tst cw
((kick.id === "21" || kick.id === "01") &&
kick.kick[0] === -1 &&
kick.kick[1] === -2)
);
}
return false;
}
rotateCW() {
return this.#processRotate(1);
}
rotateCCW() {
return this.#processRotate(-1);
}
rotate180() {
return this.#processRotate(2);
}
moveRight() {
const res = this.falling.moveRight(this.board.state);
if (res && this.gameOptions.spinBonuses !== "stupid") this.lastSpin = null;
return res;
}
moveLeft() {
const res = this.falling.moveLeft(this.board.state);
if (res && this.gameOptions.spinBonuses !== "stupid") this.lastSpin = null;
return res;
}
dasRight() {
const res = this.falling.dasRight(this.board.state);
if (res && this.gameOptions.spinBonuses !== "stupid") this.lastSpin = null;
return res;
}
dasLeft() {
const res = this.falling.dasLeft(this.board.state);
if (res && this.gameOptions.spinBonuses !== "stupid") this.lastSpin = null;
return res;
}
softDrop() {
const res = this.falling.softDrop(this.board.state);
if (res && this.gameOptions.spinBonuses !== "stupid") this.lastSpin = null;
return res;
}
#maxSpin(...spins: SpinType[]) {
const score = (spin: SpinType) => ["none", "mini", "normal"].indexOf(spin);
return spins.reduce((a, b) => {
return score(a) > score(b) ? a : b;
});
}
#detectSpin(finOrTst: boolean): SpinType {
if (this.gameOptions.spinBonuses === "none") return "none";
const tSpin =
(
[
"all",
"all-mini",
"all-mini+",
"all+",
"T-spins",
"T-spins+"
] as Game.SpinBonuses[]
).includes(this.gameOptions.spinBonuses) && this.falling.symbol === "t"
? this.#detectSpinFromCorners(finOrTst)
: false;
const allSpin = this.falling.isAllSpinPosition(this.board.state);
switch (this.gameOptions.spinBonuses) {
case "stupid":
return this.falling.isStupidSpinPosition(this.board.state)
? "normal"
: "none";
case "T-spins":
return tSpin || "none";
case "T-spins+":
return this.#maxSpin(
tSpin || "none",
allSpin ? (this.falling.symbol === "t" ? "mini" : "none") : "none"
);
case "all":
return tSpin || (allSpin ? "normal" : "none");
case "all-mini":
return tSpin || (allSpin ? "mini" : "none");
case "all+":
return this.#maxSpin(
tSpin || "none",
allSpin ? (this.falling.symbol === "t" ? "mini" : "normal") : "none"
);
case "all-mini+":
return this.#maxSpin(tSpin || "none", allSpin ? "mini" : "none");
case "mini-only":
return tSpin === "normal"
? "mini"
: this.#maxSpin(tSpin || "none", allSpin ? "mini" : "none");
case "handheld":
return this.#detectSpinFromCorners(finOrTst);
}
}
#spinbonuses(piece: Mino) {
const p = tetrominoes[piece];
const rules =
spinbonusRules[
this.gameOptions.spinBonuses as keyof typeof spinbonusRules
];
// @ts-expect-error
return p?.spinbonus_override
? // @ts-expect-error
p.spinbonus_override?.mini
: // @ts-expect-error
!!rules?.types_mini?.includes(piece);
}
#detectSpinFromCorners(finOrTst: boolean): SpinType {
if (
legal(
this.falling.blocks.map((block) => [
block[0] + this.falling.location[0],
-block[1] + this.falling.y - 1
]),
this.board.state
)
)
return "none";
let corners = 0;
let frontCorners = 0;
for (let i = 0; i < 4; i++) {
const table =
cornerTable[this.falling.symbol as keyof typeof cornerTable]?.[
this.falling.rotation
];
if (!table) break;
if (
this.board.occupied(
this.falling.x + table[i][0] + 1,
this.falling.y - table[i][1] - 1
)
) {
corners++;
if (
this.falling.rotation === table[i][2] ||
this.falling.rotation === table[i][3]
) {
frontCorners++;
}
}
}
if (corners < 3) return "none";
let spin: SpinType = "normal";
if (this.#spinbonuses(this.falling.symbol) && frontCorners !== 2)
spin = "mini";
if (finOrTst) spin = "normal";
return spin;
}
/** */
hardDrop() {
while (this.#__internal_fall(1));
return this.#lock(true);
}
#lock(hard: boolean): LockRes {
this.holdLocked = false;
// TODO: ARE (line clear, garbage)
const placed = this.falling.blocks.map(
(block) =>
[
this.falling.symbol,
this.falling.location[0] + block[0],
this.falling.y - block[1]
] as [Mino, number, number]
);
this.board.add(...placed);
this.connectedBoard.add(
...this.connect(placed.map(([_, x, y]) => [x, -y])).map(
([x, y, s]) =>
[{ mino: this.falling.symbol, connection: s }, x, -y] as [
{ mino: Mino; connection: number },
number,
number
]
)
);
this.connectedBoard.clearBombsAndLines(placed.map((b) => [b[1], b[2]]));
const { lines, garbageCleared } = this.board.clearBombsAndLines(
placed.map((b) => [b[1], b[2]])
);
const pc = this.board.perfectClear;
this.stats.garbage.cleared += garbageCleared;
let brokeB2B: false | number = this.stats.b2b;
if (lines > 0) {
this.stats.combo++;
if (
((this.lastSpin && this.lastSpin.type !== "none") || lines >= 4) &&
!(pc && this.pc && this.pc.b2b)
) {
this.stats.b2b++;
brokeB2B = false;
}
if (pc && this.pc && this.pc.b2b) {
this.stats.b2b += this.pc.b2b;
brokeB2B = false;
}
if (brokeB2B !== false) {
this.stats.b2b = -1;
}
} else {
this.stats.combo = -1;
brokeB2B = false;
}
const gSpecialBonus =
this.initializer.garbage.specialBonus &&
garbageCleared > 0 &&
((this.lastSpin && this.lastSpin.type !== "none") || lines >= 4)
? 1
: 0;
const garbage = garbageCalcV2(
{
b2b: Math.max(this.stats.b2b, 0),
combo: Math.max(this.stats.combo, 0),
enemies: 0,
lines,
piece: this.falling.symbol,
spin: this.lastSpin ? this.lastSpin.type : "none"
},
{
...this.gameOptions,
b2b: { chaining: this.b2b.chaining, charging: !!this.b2b.charging }
}
);
const gEvents =
garbage.garbage > 0 || gSpecialBonus > 0
? [
this.garbageQueue.round(
garbage.garbage * this.dynamic.garbageMultiplier.get() +
gSpecialBonus
)
]
: [];
let surged = 0;
if (brokeB2B !== false) {
let btb = brokeB2B;
if (this.b2b.charging !== false && btb + 1 > this.b2b.charging.at) {
surged = Math.floor(
(btb - this.b2b.charging.at + this.b2b.charging.base + 1) *
this.dynamic.garbageMultiplier.get()
);
const garbages = [
Math.round(surged / 3),
Math.round(surged / 3),
surged - 2 * Math.round(surged / 3)
];
gEvents.splice(0, 0, ...garbages);
brokeB2B = false;
}
}
if (pc && this.pc) {
gEvents.push(
this.garbageQueue.round(
this.pc.garbage * this.dynamic.garbageMultiplier.get()
)
);
}
const res: LockRes = {
mino: this.falling.symbol,
garbageCleared,
lines,
spin: this.lastSpin ? this.lastSpin.type : "none",
garbage: gEvents.filter((g) => g > 0),
rawGarbage: gEvents.filter((g) => g > 0),
surge: surged,
stats: this.stats,
garbageAdded: false,
topout: false,
keysPresses: this.resCache.keys.splice(0),
pieceTime:
Math.round((this.frame + this.subframe - this.resCache.lastLock) * 10) /
10
};
for (const gb of res.garbage) this.stats.garbage.attack += gb;
if (lines > 0) {
const cancelEvents: Events["garbage.cancel"][] = [];
this.lastWasClear = true;
while (res.garbage.length > 0) {
if (res.garbage[0] === 0) {
res.garbage.shift();
continue;
}
const [r, cancelled] = this.garbageQueue.cancel(
res.garbage[0],
this.stats.pieces,
{
openerPhase: (this.misc.date ?? new Date()) < new Date(2025, 1, 16)
}
);
cancelEvents.push(
...cancelled.map((c) => ({
iid: c.cid,
amount: c.amount,
size: c.size
}))
);
if (r === 0) res.garbage.shift();
else {
res.garbage[0] = r;
break;
}
}
cancelEvents.forEach((event) => {
this.events.emit("garbage.cancel", event);
});
} else {
this.lastWasClear = false;
const garbages = this.garbageQueue.tank(
this.frame,
this.dynamic.garbageCap.get(),
hard
);
res.garbageAdded = garbages;
if (res.garbageAdded) {
const tankEvent: Events["garbage.tank"][] = [];
garbages.forEach((garbage, idx) => {
this.board.insertGarbage({
...garbage,
bombs: this.garbageQueue.options.bombs
});
this.connectedBoard.insertGarbage({
...garbage,
bombs: this.garbageQueue.options.bombs,
isBeginning: idx === 0 || garbages[idx - 1].id !== garbage.id,
isEnd:
idx === garbages.length - 1 || garbages[idx + 1].id !== garbage.id
});
if (
tankEvent.length === 0 ||
tankEvent[tankEvent.length - 1].iid !== garbage.id
) {
tankEvent.push({
iid: garbage.id,
column: garbage.column,
amount: garbage.amount,
size: garbage.size
});
} else {
tankEvent[tankEvent.length - 1].amount += garbage.amount;
}
});
tankEvent.forEach((event) => {
this.events.emit("garbage.tank", event);
});
}
}
this.nextPiece();
this.lastSpin = null;
try {
if (!legal(this.falling.absoluteBlocks, this.board.state))
res.topout = true;
} catch {
res.topout = true;
}
if (res.garbage.length > 0)
if (this.multiplayer)
this.multiplayer.targets.forEach((target) =>
res.garbage.forEach((g) =>
this.igeHandler.send({ amount: g, playerID: target })
)
);
const sent = res.garbage.reduce((a, b) => a + b, 0);
this.stats.garbage.sent += sent;
if (this.stats.combo >= 0) this.currentSpike += sent;
else this.currentSpike = 0;
this.resCache.pieces++;
this.resCache.garbage.sent.push(...res.garbage);
this.resCache.garbage.received.push(...(res.garbageAdded || []));
this.resCache.lastLock = this.frame + this.subframe;
this.stats.pieces++;
this.stats.lines += lines;
this.events.emit("falling.lock", deepCopy(res));
return res;
}
press<T extends Game.Key | "dasLeft" | "dasRight">(key: T) {
if (key in this) return this[key]();
else throw new Error("invalid key: " + key);
}
#keydown({ data: event }: Game.Replay.Frames.Keypress) {
this.#processSubframe(event.subframe);
this.resCache.keys.push(event.key);
// TODO: inversion?
// if (this.misc.inverted)
// switch (e.key) {
// case "moveLeft":
// e.key = "moveRight";
// break;
// case "moveRight":
// e.key = "moveLeft";
// }
switch (event.key) {
case "moveLeft":
this.falling.keys++;
if (this.input.firstInputTime == -1)
this.input.firstInputTime = this.frame + this.subframe;
this.#activateShift("lShift", !!event.hoisted);
this.#__internal_shift();
return;
case "moveRight":
this.falling.keys++;
if (this.input.firstInputTime == -1)
this.input.firstInputTime = this.frame + this.subframe;
this.#activateShift("rShift", !!event.hoisted);
this.#__internal_shift();
return;
case "softDrop":
this.input.keys.softDrop = true;
if (this.input.firstInputTime == -1)
this.input.firstInputTime = this.frame + this.subframe;
return;
case "rotateCCW":
this.input.keys.rotateCCW = true;
break;
case "rotateCW":
this.input.keys.rotateCW = true;
break;
case "rotate180":
this.input.keys.rotate180 = true;
break;
case "hold":
this.input.keys.hold = true;
break;
}
// todo: all in if (!this.pause)
switch (event.key) {
case "rotateCCW":
if (this.input.firstInputTime == -1)
this.input.firstInputTime = this.frame + this.subframe;
this.#processRotate(-1);
break;
case "rotateCW":
if (this.input.firstInputTime == -1)
this.input.firstInputTime = this.frame + this.subframe;
this.#processRotate(1);
break;
case "rotate180":
if (!this.misc.allowed.spin180) return;
if (this.input.firstInputTime == -1)
this.input.firstInputTime = this.frame + this.subframe;
this.#processRotate(2);
break;
case "hardDrop":
if (this.misc.allowed.hardDrop === false || this.falling.safeLock !== 0)
return;
this.hardDrop();
return;
case "hold":
this.hold();
return;
}
}
#keyup({ data: event }: Game.Replay.Frames.Keypress) {
this.#processSubframe(event.subframe);
// TODO: inversion?
// if (this.misc.inverted)
// switch (e.key) {
// case "moveLeft":
// e.key = "moveRight";
// break;
// case "moveRight":
// e.key = "moveLeft";
// }
switch (event.key) {
case "moveLeft":
this.input.lShift.held = false;
this.input.lShift.das = 0;
this.input.lastShift = this.input.rShift.held
? this.input.rShift.dir
: this.input.lastShift;
if (this.handling.cancel) {
this.input.rShift.arr = this.handling.arr;
this.input.rShift.das = 0;
}
break;
case "moveRight":
this.input.rShift.held = false;
this.input.rShift.das = 0;
this.input.lastShift = this.input.lShift.held
? this.input.lShift.dir
: this.input.lastShift;
if (this.handling.cancel) {
this.input.lShift.arr = this.handling.arr;
this.input.lShift.das = 0;
}
break;
case "softDrop":
this.state |= constants.flags.ACTION_SOFTDROP;
this.input.keys.softDrop = false;
break;
case "rotateCCW":
this.input.keys.rotateCCW = false;
break;
case "rotateCW":
this.input.keys.rotateCW = false;
break;
case "rotate180":
this.input.keys.rotate180 = false;
break;
case "hold":
this.input.keys.hold = false;
break;
}
}
#activateShift(shift: "lShift" | "rShift", hoisted: boolean) {
this.input[shift].held = true;
this.input[shift].das = hoisted ? this.handling.das - this.handling.dcd : 0;
this.input[shift].arr = this.handling.arr;
this.input.lastShift = this.input[shift].dir;
}
#processRotate(rotation: number) {
this.falling.keys += rotation >= 2 ? 2 : 1;
return this.#rotate(rotation as Rotation);
}
#processSubframe(subframe: number) {
if (subframe <= this.subframe) return;
const delta = subframe - this.subframe;
this.#processAllShift(delta);
this.#fall(delta);
this.subframe = subframe;
}
#__internal_shift() {
if (!this.#isSleep()) {
// TODO: missing: && !this.pause
this.state |= constants.flags.ACTION_MOVE;
if (
legal(
this.falling.absoluteAt({
x: this.falling.location[0] + this.input.lastShift
}),
this.board.state
)
) {
this.falling.location[0] += this.input.lastShift;
if (this.falling.lockResets < 31) this.falling.lockResets++;
this.#clearFlags(
constants.flags.ROTATION_ALL | constants.flags.STATE_WALL
);
// fallingdirty = true;
if (this.#is20G()) this.#slamToFloor();
if (!this.#shouldLock()) this.falling.locking = 0;
return true;
} else {
this.state |= constants.flags.STATE_WALL;
return false;
}
}
}
#processShift(shift: "lShift" | "rShift", delta: number) {
if (
!this.input[shift].held ||
this.input.lastShift !== this.input[shift].dir
)
return;
const arrDelta = Math.max(
0,
delta - Math.max(0, this.handling.das - this.input[shift].das)
);
this.input[shift].das = Math.min(
this.input[shift].das + delta,
this.handling.das
);
if (this.input[shift].das < this.handling.das) return;
if (this.#isSleep()) return; // TODO: missing: && !this.pause
this.input[shift].arr += arrDelta;
if (this.input[shift].arr < this.handling.arr) return;
const arrMultiplier =
this.handling.arr === 0
? this.board.width
: Math.floor(this.input[shift].arr / this.handling.arr);
this.input[shift].arr -= this.handling.arr * arrMultiplier;
for (let i = 0; i < arrMultiplier; i++) this.#__internal_shift();
}
#processAllShift(subFrameDiff = 1 - this.subframe) {
for (const shift of ["lShift", "rShift"] as const)
this.#processShift(shift, subFrameDiff);
}
#run(...frames: Game.Replay.Frame[]) {
frames.forEach((frame) => {
switch (frame.type) {
case "keydown":
this.#keydown(frame);
break;
case "keyup":
this.#keyup(frame);
break;
case "ige":
if (frame.data.type === "interaction") {
if (frame.data.data.type === "garbage") {
const original = frame.data.data.amt;
const amount = this.multiplayer?.passthrough?.network
? this.igeHandler.receive({
playerID: frame.data.data.gameid,
ackiid: frame.data.data.ackiid,
amount: frame.data.data.amt,
iid: frame.data.data.iid
})
: frame.data.data.amt;
this.receiveGarbage({
frame:
Number.MAX_SAFE_INTEGER -
this.garbageQueue.options.garbage.speed,
amount,
size: frame.data.data.size,
cid: frame.data.data.iid,
gameid: frame.data.data.gameid,
confirmed: false
});
this.stats.garbage.receive += amount;
this.events.emit("garbage.receive", {
iid: frame.data.data.iid,
amount,
originalAmount: original
});
}
} else if (frame.data.type === "interaction_confirm") {
if (frame.data.data.type === "garbage") {
this.garbageQueue.confirm(
frame.data.data.iid,
frame.data.data.gameid,
frame.frame
);
this.events.emit("garbage.confirm", {
iid: frame.data.data.iid,
gameid: frame.data.data.gameid,
frame: frame.frame
});
}
} else if (frame.data.type === "target" && this.multiplayer) {
this.multiplayer.targets = frame.data.data.targets;
}
break;
}
});
}
tick(frames: Game.Replay.Frame[]) {
this.subframe = 0;
this.#run(...frames);
this.frame++;
this.#processAllShift();
this.#fall();
// TODO: execute waiting frames
// TODO: process garbage are
Object.keys(this.dynamic).forEach((key) =>
this.dynamic[key as keyof typeof this.dynamic].tick()
);
return { ...this.flushRes() };
}
receiveGarbage(...garbage: IncomingGarbage[]) {
this.garbageQueue.receive(...garbage);
}
getPreview(piece: Mino) {
return tetrominoes[piece.toLowerCase()].preview;
}
connect(blocks: [x: number, y: number][]) {
const exists = (x: number, y: number) =>
!!blocks.find(([a, b]) => a === x && y === b);
return blocks.map(([x, y]) => {
let state = 0;
if (!exists(x, y - 1)) state |= 0b1000;
if (!exists(x + 1, y)) state |= 0b0100;
if (!exists(x, y + 1)) state |= 0b0010;
if (!exists(x - 1, y)) state |= 0b0001;
if (
(state === 0b1001 && !exists(x + 1, y + 1)) ||
(state === 0b1100 && !exists(x - 1, y + 1)) ||
(state === 0b0011 && !exists(x + 1, y - 1)) ||
(state === 0b0110 && !exists(x - 1, y - 1)) ||
(state === 0b0010 && !exists(x + 1, y - 1) && !exists(x - 1, y - 1)) ||
(state === 0b0001 && !exists(x + 1, y - 1) && !exists(x + 1, y + 1)) ||
(state === 0b1000 && !exists(x - 1, y + 1) && !exists(x + 1, y + 1)) ||
(state === 0b0100 && !exists(x - 1, y - 1) && !exists(x - 1, y + 1))
) {
state |= 0b1_0000;
}
return [x, y, state] as const;
});
}
getConnectedPreview(piece: Mino) {
const data = tetrominoes[piece.toLowerCase()].preview;
return {
...data,
data: this.connect(data.data as any[])
};
}
/** @deprecated Engine.onQueuePieces is deprecated and no longer functional. Switch to Engine.events.on("queue.add", (pieces) => {}) instead. */
onQueuePieces(_listener: (pieces: Mino[]) => void) {
console.log(
`${chalk.redBright("[Triangle.js]")} Engine.onQueuePieces is deprecated and no longer functional. Switch to Engine.events.on("queue.add", (pieces) => {}) instead.`
);
}
private static colorMap = {
i: chalk.bgCyan,
j: chalk.bgBlue,
l: chalk.bgYellow,
o: chalk.bgWhite,
s: chalk.bgGreenBright,
t: chalk.bgMagentaBright,
z: chalk.bgRedBright,
gb: chalk.bgBlackBright,
bomb: chalk.bgHex("#FFA500")
};
get text() {
const boardTop = this.board.state.findIndex((row: (string | null)[]) =>
row.every((block) => block === null)
);
const height = Math.max(this.garbageQueue.size, boardTop, 0);
const output: string[] = [];
for (let i = 0; i < height; i++) {
let str = i % 2 === 0 ? "|" : " ";
if (i < this.garbageQueue.size) str += " " + chalk.bgRed(" ") + " ";
else str += " ";
if (i > boardTop) {
output.push(
str + " ".repeat(this.board.width) + " " + (i % 2 === 0 ? "|" : " ")
);
continue;
}
for (let j = 0; j < this.board.width; j++) {
const block = this.board.state[i][j];
str += block ? Engine.colorMap[block](" ") : " ";
}
output.push(str + " " + (i % 2 === 0 ? "|" : " "));
}
return output.reverse().join("\n");
}
/** Return an engine with the same config. Does not preserve state. */
clone() {
return new Engine(this.initializer);
}
}
export * from "./queue";
export * from "./garbage";
export * from "./utils";
export * from "./board";
export * from "./types";
export * from "./multiplayer";