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