├── .gitattributes ├── .github └── workflows │ └── npm-publish.yml ├── .gitignore ├── .lfsconfig ├── .prettierignore ├── .prettierrc ├── .swcrc ├── LICENSE.md ├── README.md ├── bun.lock ├── documents ├── Adapters.md ├── Channel.md ├── Engine.md ├── Gameplay.md ├── Protocol.md ├── Quickstart.md └── Troubleshooting.md ├── example ├── index.ts └── solver │ ├── Cargo.lock │ ├── Cargo.toml │ ├── README.md │ └── src │ ├── data.rs │ ├── main.rs │ └── protocol.rs ├── package-lock.json ├── package.json ├── scripts ├── renameModules.ts └── version.ts ├── src ├── channel │ └── index.ts ├── classes │ ├── client │ │ ├── index.ts │ │ └── types.ts │ ├── game │ │ ├── index.ts │ │ └── utils.ts │ ├── index.ts │ ├── ribbon │ │ ├── codecs │ │ │ ├── codec-candor.ts │ │ │ ├── teto-pack.ts │ │ │ └── utils │ │ │ │ ├── bits.ts │ │ │ │ ├── msgpackr-943ed70.ts │ │ │ │ └── shared.ts │ │ ├── index.ts │ │ └── types.ts │ ├── room │ │ ├── index.ts │ │ ├── presets.ts │ │ └── replayManager.ts │ ├── social │ │ ├── index.ts │ │ └── relationship.ts │ └── utils │ │ └── index.ts ├── engine │ ├── board │ │ ├── connected.ts │ │ └── index.ts │ ├── constants │ │ └── index.ts │ ├── garbage │ │ ├── index.ts │ │ └── legacy.ts │ ├── index.ts │ ├── multiplayer │ │ ├── ige.ts │ │ └── index.ts │ ├── queue │ │ ├── index.ts │ │ ├── rng │ │ │ ├── bag14.ts │ │ │ ├── bag7-1.ts │ │ │ ├── bag7-2.ts │ │ │ ├── bag7-x.ts │ │ │ ├── bag7.ts │ │ │ ├── classic.ts │ │ │ ├── index.ts │ │ │ ├── pairs.ts │ │ │ └── random.ts │ │ └── types.ts │ ├── types.ts │ └── utils │ │ ├── damageCalc │ │ └── index.ts │ │ ├── increase │ │ └── index.ts │ │ ├── index.ts │ │ ├── kicks │ │ ├── data.ts │ │ └── index.ts │ │ ├── polyfills │ │ └── index.ts │ │ ├── rng │ │ └── index.ts │ │ ├── seed.ts │ │ └── tetromino │ │ ├── data.ts │ │ ├── index.ts │ │ └── types.ts ├── index.ts ├── types │ ├── events │ │ ├── in │ │ │ ├── client.ts │ │ │ ├── game.ts │ │ │ ├── index.ts │ │ │ ├── ribbon.ts │ │ │ ├── room.ts │ │ │ ├── social.ts │ │ │ ├── staff.ts │ │ │ └── wrapper.ts │ │ ├── index.ts │ │ ├── out │ │ │ ├── game.ts │ │ │ ├── index.ts │ │ │ ├── ribbon.ts │ │ │ ├── room.ts │ │ │ ├── social.ts │ │ │ └── wrapper.ts │ │ └── wrapper.ts │ ├── game.ts │ ├── index.ts │ ├── replay │ │ ├── index.ts │ │ └── versus.ts │ ├── room.ts │ ├── social.ts │ ├── user.ts │ └── utils.ts └── utils │ ├── adapters │ ├── adapter-io.ts │ ├── core │ │ └── index.ts │ ├── index.ts │ └── types │ │ ├── index.ts │ │ └── messages.ts │ ├── api │ ├── basic.ts │ ├── channel.ts │ ├── index.ts │ ├── relationship.ts │ ├── rooms.ts │ ├── server.ts │ ├── users.ts │ └── wrapper.ts │ ├── bot-wrapper │ └── index.ts │ ├── constants.ts │ ├── docs.ts │ ├── events.ts │ ├── index.ts │ ├── jwt.ts │ ├── theorypack │ ├── index.ts │ ├── msgpackr.ts │ └── ts-wrap.ts │ └── version.ts ├── test ├── client.test.ts ├── data │ └── replays.tar.gz ├── engine.test.ts └── tsconfig.json ├── tsconfig.json └── typedoc.json /.gitattributes: -------------------------------------------------------------------------------- 1 | test/data/replays.tar.gz filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created. 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Publish Triangle.JS 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | publish-npm: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 20 18 | registry-url: https://registry.npmjs.org/ 19 | - uses: oven-sh/setup-bun@v2 20 | - run: npm ci 21 | - run: bun run build 22 | - name: Publish to npm 23 | run: | 24 | if [ "${{ github.event.release.prerelease }}" = "true" ]; then 25 | npm publish --tag next 26 | else 27 | npm publish 28 | fi 29 | env: 30 | NODE_AUTH_TOKEN: ${{ secrets.npm_token }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | docs 3 | dist 4 | 5 | .env.test 6 | 7 | test/data/replays 8 | 9 | example/solver/target -------------------------------------------------------------------------------- /.lfsconfig: -------------------------------------------------------------------------------- 1 | [lfs] 2 | fetchexclude = "test/data/**" 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | docs 4 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": false, 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "trailingComma": "none", 7 | "bracketSpacing": true, 8 | "arrowParens": "always", 9 | "endOfLine": "lf", 10 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 11 | "importOrder": ["\\.(.*)$", "^node:(.*)$", "(.*)$"], 12 | "importOrderSeparation": true 13 | } 14 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": false 6 | }, 7 | "target": "esnext" 8 | }, 9 | "module": { 10 | "type": "commonjs" 11 | }, 12 | "sourceMaps": true 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 halp 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /documents/Adapters.md: -------------------------------------------------------------------------------- 1 | # Triangle.js Adapters Documentation 2 | 3 | This document describes the adapter system in Triangle.js, which provides a standardized interface for integrating external engines and bots with the Triangle.js game engine. Adapters abstract away the communication layer, allowing developers to focus on game logic rather than protocol implementation. 4 | 5 | Transparency note: this document was AI-generated based on the Triangle.js adapter source code. 6 | 7 | ## Overview 8 | 9 | The adapter system consists of: 10 | 11 | - **Abstract Base Class**: `Adapter` provides the core interface and helper methods 12 | - **Built-in Implementations**: Ready-to-use adapters like `AdapterIO` 13 | - **Custom Message Data**: Type-safe extension mechanism for adapter-specific data 14 | - **Protocol Standardization**: Consistent JSON-based communication (see [Protocol.md](./Protocol.md)) 15 | 16 | ## Base Adapter Class 17 | 18 | The `Adapter` abstract class defines the core interface that all adapters must implement: 19 | 20 | ```typescript 21 | import { adapters } from "@haelp/teto/utils"; 22 | 23 | abstract class Adapter { 24 | // Must be implemented by subclasses 25 | abstract initialize(): Promise>; 26 | abstract config(engine: Engine, data?: T["config"]): void; 27 | abstract update(engine: Engine, data?: T["state"]): void; 28 | abstract play( 29 | engine: Engine, 30 | data?: T["play"] 31 | ): Promise>; 32 | abstract stop(): void; 33 | 34 | // Helper methods provided by base class 35 | protected configFromEngine( 36 | engine: Engine 37 | ): Omit, "type" | "data">; 38 | protected stateFromEngine( 39 | engine: Engine 40 | ): Omit, "type" | "data">; 41 | protected playFromEngine( 42 | engine: Engine 43 | ): Omit, "type" | "data">; 44 | } 45 | ``` 46 | 47 | ### Helper Methods 48 | 49 | The base class provides three helper methods that extract relevant data from the Triangle.js `Engine`: 50 | 51 | **`configFromEngine(engine)`** - Extracts game configuration: 52 | 53 | - Board dimensions (`boardWidth`, `boardHeight`) 54 | - Game rules (`kicks`, `spins`, `comboTable`) 55 | - Back-to-back settings (`b2bCharing`, `b2bChargeAt`, etc.) 56 | - Garbage mechanics (`garbageMultiplier`, `garbageCap`, etc.) 57 | - Perfect clear bonuses (`pcB2b`, `pcGarbage`) 58 | - Initial piece queue 59 | 60 | **`stateFromEngine(engine)`** - Extracts current game state: 61 | 62 | - Board state as 2D array 63 | - Current piece, held piece, and upcoming queue 64 | - Incoming garbage queue 65 | - Combo and back-to-back counters 66 | 67 | **`playFromEngine(engine)`** - Extracts play-specific data: 68 | 69 | - Current garbage multiplier and cap 70 | 71 | ## Built-in Adapters 72 | 73 | ### AdapterIO 74 | 75 | The `AdapterIO` class communicates with external processes via standard input/output using JSON messages. It's available through the `adapters.IO` export. 76 | 77 | ```typescript 78 | import { adapters } from "@haelp/teto/utils"; 79 | 80 | const adapter = new adapters.IO({ 81 | path: "./my-bot", // Required: path to executable 82 | name: "MyBot", // Optional: display name (default: "AdapterIO") 83 | verbose: true, // Optional: enable debug logging (default: false) 84 | env: { RUST_BACKTRACE: "1" }, // Optional: environment variables 85 | args: ["--mode", "fast"] // Optional: command line arguments 86 | }); 87 | 88 | // Initialize and use 89 | const info = await adapter.initialize(); 90 | console.log(`Connected to ${info.name} v${info.version} by ${info.author}`); 91 | 92 | // Configure the external process 93 | adapter.config(engine); 94 | 95 | // Send state updates 96 | adapter.update(engine); 97 | 98 | // Request moves (used during `tick`) 99 | const move = await adapter.play(engine); 100 | console.log("Received keys:", move.keys); 101 | 102 | // Clean shutdown 103 | adapter.stop(); 104 | ``` 105 | 106 | #### Configuration Options 107 | 108 | ```typescript 109 | interface AdapterIOConfig { 110 | name: string; // Adapter name for logging 111 | verbose: boolean; // Enable detailed logging 112 | path: string; // Path to executable (required) 113 | env: NodeJS.ProcessEnv; // Environment variables 114 | args: string[]; // Command line arguments 115 | } 116 | ``` 117 | 118 | #### Logging 119 | 120 | When `verbose: true`, AdapterIO logs: 121 | 122 | - All outgoing JSON messages 123 | - All incoming JSON messages 124 | - Non-JSON output from the external process 125 | - Error messages from stderr 126 | 127 | ## Custom Message Data 128 | 129 | Extend the `CustomMessageData` interface to add adapter-specific data to messages: 130 | 131 | ```typescript 132 | interface MyBotData extends adapters.Types.CustomMessageData { 133 | info: { 134 | capabilities: string[]; 135 | supportedModes: string[]; 136 | }; 137 | move: { 138 | confidence: number; 139 | evaluationTime: number; 140 | nodesSearched: number; 141 | }; 142 | config: { 143 | difficulty: "easy" | "medium" | "hard"; 144 | searchDepth: number; 145 | }; 146 | state: { 147 | boardEvaluation: number; 148 | threatLevel: number; 149 | }; 150 | play: { 151 | timeLimit: number; 152 | allowHold: boolean; 153 | }; 154 | } 155 | 156 | // Use with adapter 157 | const adapter = new adapters.IO({ 158 | path: "./my-advanced-bot" 159 | }); 160 | 161 | // Send config with custom data 162 | adapter.config(engine, { 163 | difficulty: "hard", 164 | searchDepth: 7 165 | }); 166 | 167 | // Send state with custom data 168 | adapter.update(engine, { 169 | boardEvaluation: 0.75, 170 | threatLevel: 2 171 | }); 172 | 173 | // Request move with custom data 174 | const move = await adapter.play(engine, { 175 | timeLimit: 1000, 176 | allowHold: true 177 | }); 178 | 179 | // Access custom move data 180 | console.log(`Move confidence: ${move.data.confidence}%`); 181 | console.log(`Nodes searched: ${move.data.nodesSearched}`); 182 | ``` 183 | -------------------------------------------------------------------------------- /documents/Channel.md: -------------------------------------------------------------------------------- 1 | # Triangle.js Tetra Channel API 2 | 3 | This is the documentation for the subsection of Triangle.js that interacts with the public `https://ch.tetr.io/api` api. For the full documentation, see the [main README](https://triangle.haelp.dev). 4 | 5 | ## Usage 6 | 7 | ### Import 8 | 9 | ```ts 10 | import { ch } from "@haelp/teto"; 11 | ``` 12 | 13 | Or 14 | 15 | ```ts 16 | // only load the required channel api module 17 | import { ch } from "@haelp/teto/ch"; 18 | ``` 19 | 20 | ### Handling the SessionID 21 | 22 | ```ts 23 | ch.setSessionID("your-session-id"); 24 | const id = ch.getSessionID(); 25 | ``` 26 | 27 | ## Methods 28 | 29 | There are 4 different types of requests that the api can make. Here is an explanation of each one: 30 | 31 | ### Empty requests 32 | 33 | These send no data to the server. 34 | Example: 35 | 36 | ```ts 37 | const res = await ch.general.stats(); 38 | ``` 39 | 40 | ### Argument only requests 41 | 42 | These send data in the form of arugments in the request uri. 43 | Example: 44 | 45 | ```ts 46 | // This sends a request to https://ch.tetr.io/api/users/halp 47 | const res = await ch.users.get("halp"); 48 | ``` 49 | 50 | Or 51 | 52 | ```ts 53 | const res = await ch.users.get({ user: halp }); 54 | ``` 55 | 56 | ### Query param only requests 57 | 58 | These send data in the form of query parameters. 59 | Example: 60 | 61 | ```ts 62 | // This sends a request to https://ch.tetr.io/api/news/?limit=100 63 | const res = await ch.news.all({ limit: 100 }); 64 | ``` 65 | 66 | ### Query param and argument requests 67 | 68 | These send data in the form of query parameters and arguments in the request uri. 69 | Example: 70 | 71 | ```ts 72 | // This sends a request to https://ch.tetr.io/api/users/by/xp?limit=100 73 | const res = await ch.users.leaderboard("xp", { limit: 100 }); 74 | ``` 75 | 76 | ## Proxying 77 | 78 | Because the Tetra Channel API does not have cors enabled, you need to proxy requests coming from a browser. This is an example express route that proxies requests to the ch.tetr.io server: 79 | 80 | ```ts 81 | app.get("/api/ch-proxy/*", async (req, res) => { 82 | const url = req.url.replace("/api/ch-proxy", ""); 83 | const response = await fetch(`https://ch.tetr.io/api${url}`, { 84 | headers: { 85 | "X-Session-ID": req.headers["x-session-id"]! 86 | } 87 | }); 88 | const json = await response.json(); 89 | res.json(json); 90 | }); 91 | ``` 92 | 93 | You can then set the 'host' setting on the client to your proxy: 94 | 95 | ```ts 96 | ch.setConfig({ host: "/api/ch-proxy/" }); 97 | ``` 98 | 99 | [View the official docs](https://tetr.io/about/api) for more information on the api. 100 | -------------------------------------------------------------------------------- /documents/Engine.md: -------------------------------------------------------------------------------- 1 | # Triangle.js Engine Documentation 2 | 3 | This is the documentation for the subsection of Triangle.js that simulates the TETR.IO core game engine. For the full documentation, see the [main README](https://triangle.haelp.dev). 4 | 5 | ## Engine 6 | 7 | ### State and config 8 | 9 | An `Engine` is the wrapper class that handles an individual player's game. It takes in an [`EngineInitializeParams`](https://triangle.haelp.dev/interfaces/src.Engine.EngineInitializeParams.html), which are saved to the `Engine`'s `initializer` property. 10 | 11 | An `Engine`'s state is made up of several different components, such as the falling piece state, garbage queue, and more. To save an `Engine`'s state, use the `engine.snapshot()` method. Then, you can restore the state using `engine.fromSnapshot()` method. 12 | 13 | The following is an example of how you would create a clone of an engine: 14 | 15 | ```ts 16 | // assume `engine` has already been delared as an `Engine` 17 | 18 | const newEngine = new Engine(engine.initializer); 19 | newEngine.fromSnapshot(engine.snapshot()); 20 | ``` 21 | 22 | ### Events 23 | 24 | An `Engine` has the `events` property, which functions very similarly to the Node `EventEmitter` class. You can view the list of events [here](https://triangle.haelp.dev/interfaces/src.Engine.Events.html). 25 | 26 | ### State manipulation 27 | 28 | There are two main ways to manipulate state: using using the `engine.press()` function or by passing frames into the `engine.tick()` function. 29 | 30 | **`engine.press()`** performs the key press passed into the function. For example, running `engine.press("moveRight")` moves the falling piece 1 square right. There are also two non-standard keys (not valid for passing into `engine.tick`): `dasLeft` and `dasRight`. 31 | The function returns a [`LockRes`](https://triangle.haelp.dev/interfaces/src.Engine.LockRes.html) if the move is `hardDrop`. Otherwise, it returns `true` if the move altered the falling piece position or rotation, and `false` if it did not. 32 | 33 | **engine.tick()** ticks 1 game frame and runs the frames passed in. The frames must be valid TETR.IO gameplay events. The full list of valid frames can be found [here](https://triangle.haelp.dev/modules/src.Types.Game.Replay.Frames.html), although the only relevant frames are of `type` `ige`, `keydown`, or `keyup`. 34 | 35 | When using `engine.press` on the engine controlled by the [`Game`](https://triangle.haelp.dev/classes/src.Classes.Game.html) class, you must either save a snapshot before manipulating the state (recommended) or clone the engine before manipulating the state. Otherwise, the `Game`'s state will desync from the server's state. 36 | 37 | ## Queue 38 | 39 | The `Engine`'s queue is available through the `engine.queue` property. It is an instance of the [`Queue`](https://triangle.haelp.dev/classes/src.Engine.Queue.html) class, which is a wrapper around the TETR.IO queue system. You may freely set `engine.queue.minLength`, which is the minimum number of pieces that must be in the queue. The queue will automatically refill to this length when it is below it. 40 | 41 | ## GarbageQueue 42 | 43 | The `Engine`'s garbage queue is available through the `engine.garbageQueue` property. It is an instance of the [`GarbageQueue`](https://triangle.haelp.dev/classes/src.Engine.GarbageQueue.html) class, which is a wrapper around the TETR.IO garbage queue system. 44 | The full size of the garbage queue can be accessed through `engine.garbageQueue.size`. You can also access the current garbage queue through `engine.garbageQueue.queue`, which is an array of [`IncomingGarbage`](https://triangle.haelp.dev/interfaces/src.Engine.IncomingGarbage.html) objects. 45 | An `IncomingGarbage` will become tankable when the following is true: 46 | 47 | ```ts 48 | item.frame + this.options.garbage.speed <= frame; 49 | ``` 50 | 51 | Where `item` is the `IncomingGarbage` object, `this` is the `GarbageQueue` instance, and `frame` is the current game frame. 52 | 53 | ## Board 54 | 55 | The `Engine`'s board is available through the `engine.board` property. It is an instance of the [`Board`](https://triangle.haelp.dev/classes/src.Engine.Board.html) class. The board has a 4 key properties: 56 | 57 | - `board.width`: The width of the board. 58 | - `board.height`: The height of the board. 59 | - `board.fullHeight`: The full height of the board, which is the height of the board plus the board buffer. 60 | - `board.state`: The 2D array (array of rows) of the board state, where each element is a [`BoardSquare`](https://triangle.haelp.dev/types/src.Engine.BoardSquare.html). The 0th row is at the bottom of the board, and the last row is at the top of the board. 61 | 62 | ## Falling Piece 63 | 64 | The `Engine`'s falling piece is available through the `engine.falling` property. It is an instance of the [`Tetromino`](https://triangle.haelp.dev/classes/src.Engine.Tetromino.html) class. A `Tetromino` instance has a few important properties: 65 | 66 | - `tetromino.symbol`: One of `Mino.I`, `Mino.J`, `Mino.L`, `Mino.O`, `Mino.S`, `Mino.T`, or `Mino.Z`. 67 | - `tetromino.x`: The x position of the tetromino on the board. This is the same as `tetromino.location[0]`. 68 | - `tetromino.y`: The y position of the tetromino on the board, rounded down to the nearest integer. This is NOT the same as `tetromino.location[1]`, which is a float. 69 | - `tetromino.rotation`: The `Rotation` of the tetromino, which one of `0`, `1`, `2`, or `3`. Setting `Rotation` will normalize the rotation to the range of `0` to `3`, but will not perform kicks or boundary checks. 70 | - `tetromino.location`: The location of the tetromino on the board, which is an array of `[x, y]` where `x` is the x position and `y` is the y position. `[0, 0]` is the bottom left corner of the board. 71 | - 72 | -------------------------------------------------------------------------------- /documents/Gameplay.md: -------------------------------------------------------------------------------- 1 | # Triangle.js Gameplay API 2 | 3 | This is the documentation for the subsection of Triangle.js that is directly connected to gameplay. For the full documentation, see the [main README](https://triangle.haelp.dev). 4 | 5 | This page assumes you have created a Client named `client` that is in a room. 6 | 7 | ## Game start and end events 8 | 9 | ### Game start 10 | 11 | `client.game.start` 12 | Runs when a game is started. Contains information about first to, win by, and the players that are playing. 13 | 14 | ### Game end 15 | 16 | `client.game.end` 17 | 18 | Runs when a game ends gracefully. Contains information about the winner, the players, and the scoreboard. 19 | 20 | ### Game abort 21 | 22 | `client.game.abort` 23 | Runs when a game is aborted through the `/abort` command. 24 | 25 | ### Game over 26 | 27 | `client.game.over` 28 | 29 | Runs in any game over scenario. This includes when a player disconnects, when a game is aborted, and when a game ends gracefully. If the game ends gracefully, contains information about how the game ended. 30 | 31 | ### Game round start 32 | 33 | `client.game.round.start` 34 | 35 | Runs when a round starts. Contains a ticker callback and a reference to the game's internal engine. 36 | Opponent information is available through `client.game.opponents`. 37 | 38 | ### Game round end 39 | 40 | `client.game.round.end` 41 | 42 | Runs when a round ends. Contains the gameid of the winning player. 43 | 44 | ## Playing a game 45 | 46 | ### Starting a game 47 | 48 | ```ts 49 | client.room.start(); 50 | ``` 51 | 52 | ### Waiting for a game start 53 | 54 | You should listen for the `client.game.start` event. In the handler, this is where you should initialize your gameplay engine, keyfinder, etc. 55 | 56 | ```ts 57 | client.on("client.game.start", (data) => { 58 | // initialize your engine here 59 | console.log( 60 | "playing a game against", 61 | data.players.map((p) => p.name).join(", ") 62 | ); 63 | }); 64 | ``` 65 | 66 | ### Waiting for a round to start 67 | 68 | You should listen for the `client.game.round.start` event. In the handler, this is where you should pass in your tick callback. You can also process engine data here. 69 | 70 | ```ts 71 | client.on("client.game.round.start", ([tick, engine]) => { 72 | console.log( 73 | "the game board has a width of", 74 | engine.board.width, 75 | "and a height of", 76 | engine.board.height 77 | ); 78 | 79 | tick(tickerCallback); 80 | }); 81 | ``` 82 | 83 | ### Processing a tick (frame) 84 | 85 | > Note: if the `Game` has an active `BotWrapper` in use, the tick callback will not be called. 86 | 87 | The tick callback is called every frame. It can be asynchronous, but you should optimize it to be as fast as possible. The tick callback takes in the engine and incoming events, and should return data about keys pressed, etc. The keys returned by the callback may have a `frame` property in the future: they will be queued to be played at the assigned frame. 88 | 89 | ```ts 90 | const tickerCallback = async ({ 91 | gameid, 92 | frame, 93 | events, 94 | engine 95 | }: Types.Game.Tick.In): Promise => { 96 | // First, process incoming events 97 | 98 | let garbageAdded = false; 99 | for (const event of events) { 100 | if (event.type === "garbage") garbageAdded = true; 101 | } 102 | 103 | if (garbageAdded) 104 | // If your solve maintains an internal state, you should update it here 105 | // Tell your solver (what you use to calculate moves) that garbage has been tanked and needs to be updated 106 | solver.update(engine); 107 | 108 | // For example, playing 1 piece every second. 109 | 110 | let keys: Types.Game.Tick.Keypress[] = []; 111 | if (frame % 60 === 0) { 112 | // If your solver does not maintain an internal state, you can pass in the engine to the solver 113 | const move = solver.getMove(); 114 | // Any move must have a set of keys 115 | const solvedKeys = solver.findKeys(move); 116 | let runningSubframe = 0; 117 | solvedKeys.forEach((key) => { 118 | keys.push({ 119 | frame, 120 | type: "keydown", 121 | data: { 122 | key, 123 | subframe: runningSubframe 124 | } 125 | }); 126 | if (key === "softDrop") { 127 | runningSubframe += 0.1; 128 | } 129 | keys.push({ 130 | frame: frame, 131 | type: "keyup", 132 | data: { 133 | key, 134 | subframe: runningSubframe 135 | } 136 | }); 137 | }); 138 | } 139 | 140 | return { keys }; 141 | }; 142 | ``` 143 | 144 | ### Running a callback after the frame is processed 145 | 146 | If you need to run something right after a frame is processed internally, you can return a runAfter property from the tick callback. This will run after the frame is processed, but before the tick is called. This is useful for things like logging. 147 | 148 | ```ts 149 | const tickerCallback = async ({ 150 | gameid, 151 | frame, 152 | events, 153 | engine 154 | }: Types.Game.Tick.In): Promise => { 155 | // your code here 156 | 157 | return { keys, runAfter: [() => console.log("Frame processed")] }; 158 | }; 159 | ``` 160 | -------------------------------------------------------------------------------- /documents/Quickstart.md: -------------------------------------------------------------------------------- 1 | # Triangle.JS Quickstart Guide 2 | 3 | This document outlines the fastest way to connect your bot to TETR.IO via Triangle.js. It provides a step-by-step explanation of the example code. If you want to avoid using Typescript as much as possible, this document is for you. If you are more experienced with Typescript and want fine-grained control over your client, you may want to refer to the [full documentation](https://triangle.haelp.dev). 4 | 5 | ## Step 0: Create an engine and compile it 6 | 7 | Before beginning, you need an engine that can play TETR.IO and interact with the [Triangle.JS Adapter Protocol](https://triangle.haelp.dev/documents/Protocol.html). You can find an extremely minimal example [here](https://github.com/halp1/triangle/tree/main/example/solver). 8 | 9 | ## Step 1: Setup 10 | 11 | ### Using Bun 12 | 13 | The fastest way to get started with Triangle.js is with the Bun runtime. 14 | 15 | Windows: 16 | 17 | ```powershell 18 | powershell -c "irm bun.sh/install.ps1|iex" 19 | ``` 20 | 21 | Linux/Macos: 22 | 23 | ```bash 24 | curl -fsSL https://bun.com/install | bash 25 | ``` 26 | 27 | > Note: you might need to install `unzip` on Linux. 28 | 29 | ### Creating a project 30 | 31 | In an empty directory for the project, run: 32 | 33 | ```bash 34 | bun init -y 35 | bun install @haelp/teto 36 | ``` 37 | 38 | Finally, add your bot credentials to a new `.env` file: 39 | 40 | ``` 41 | USERNAME=your-bot-username 42 | PASSWORD=your-bot-password 43 | ``` 44 | 45 | ## Step 2: Creating the client 46 | 47 | In the `index.ts` file, start by importing the required modules: 48 | 49 | ```ts 50 | import { Client } from "@haelp/teto"; 51 | import { BotWrapper, adapters } from "@haelp/teto/utils"; 52 | ``` 53 | 54 | Then, create a `Client`: 55 | 56 | ```ts 57 | const client = await Client.connect({ 58 | username: process.env.USERNAME!, 59 | password: process.env.PASSWORD! 60 | }); 61 | ``` 62 | 63 | ## Step 3: Joining a room 64 | 65 | Next, allow the bot to join the first room it is invited to: 66 | 67 | ```ts 68 | const { roomid } = await client.wait("social.invite"); 69 | 70 | await client.rooms.join(roomid); 71 | await client.room?.switch("player"); 72 | ``` 73 | 74 | ## Step 4: Playing the game 75 | 76 | First, you need to listen for when a round begins: 77 | 78 | ```ts 79 | client.on("client.game.round.start", async ([tick, engine]) => {}); 80 | ``` 81 | 82 | Inside the callback, create a new Adapter. This creates a bridge between your typescript logic and a binary program. 83 | 84 | ```ts 85 | const adapter = new adapters.IO({ 86 | path: path.join(__dirname, "./your-binary") 87 | }); 88 | ``` 89 | 90 | Then, create a `BotWrapper`. This wraps around the adapter and controls the gameplay logic: 91 | 92 | ```ts 93 | const wrapper = new BotWrapper(adapter, { pps: 1 }); 94 | const initPromise = wrapper.init(engine); 95 | ``` 96 | 97 | The BotWrapper then needs to be used in the game loop: 98 | 99 | ```ts 100 | tick(async ({ engine, events }) => { 101 | await initPromise; // ensure the BotWrapper is ready before using it 102 | return { 103 | keys: await wrapper.tick(engine, events) 104 | }; 105 | }); 106 | ``` 107 | 108 | Finally, when the round ends, we need to clean up the engine process: 109 | 110 | ```ts 111 | await client.wait("client.game.over"); 112 | wrapper.stop(); 113 | ``` 114 | 115 | Your bot is complete! Here's what the final code should look like: 116 | 117 | ```ts 118 | import { Client } from "@haelp/teto"; 119 | import { BotWrapper, adapters } from "@haelp/teto/utils"; 120 | import path from "path"; 121 | 122 | const client = await Client.connect({ 123 | username: process.env.USERNAME!, 124 | password: process.env.PASSWORD! 125 | }); 126 | 127 | // Or: 128 | // const client = await Client.connect({ 129 | // token: process.env.TOKEN! 130 | // }); 131 | 132 | const { roomid } = await client.wait("social.invite"); 133 | await client.rooms.join(roomid); 134 | await client.room?.switch("player").catch(() => {}); 135 | 136 | client.on("client.game.round.start", async ([tick, engine]) => { 137 | const adapter = new adapters.IO({ 138 | path: path.join(__dirname, "./solver/target/release/triangle-rust-demo") 139 | }); 140 | const wrapper = new BotWrapper(adapter, { 141 | pps: 1 142 | }); 143 | const initPromise = wrapper.init(engine); 144 | 145 | tick(async ({ engine, events }) => { 146 | await initPromise; 147 | return { 148 | keys: await wrapper.tick(engine, events) 149 | }; 150 | }); 151 | 152 | await client.wait("client.game.over"); 153 | wrapper.stop(); 154 | }); 155 | ``` 156 | 157 | ## Next steps 158 | 159 | Now that your bot is complete, you can explore additional features and improvements: 160 | 161 | - Read the other core documents to understand how Triangle.JS works 162 | - Adding chat commands to control the bot, using the `room.chat` event 163 | - Allow the bot to join multiple rooms by spawning a new `Client` on each invite 164 | -------------------------------------------------------------------------------- /documents/Troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting Triangle.js 2 | 3 | ## Common Issues 4 | 5 | ### Native WebSocket not found 6 | 7 | Starting with `v22.4.0`, Node.js has a native WebSocket implementation. If you are using an older version of Node.js, upgrade your Node.js version. 8 | 9 | If you are unable to upgrade your Node.js version, do the following: 10 | 11 | 1. Install the `ws` package: 12 | ```bash 13 | npm install ws 14 | ``` 15 | 2. At the top of your code, (or anywhere before calling your first `Client.connect`), add the following code: 16 | **ES6/Import:** 17 | 18 | ```ts 19 | import { WebSocket } from "ws"; 20 | 21 | globalThis.WebSocket = WebSocket; 22 | ``` 23 | 24 | **CommonJS:** 25 | 26 | ```ts 27 | const { WebSocket } = require("ws"); 28 | globalThis.WebSocket = WebSocket; 29 | ``` 30 | -------------------------------------------------------------------------------- /example/index.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@haelp/teto"; 2 | import { BotWrapper, adapters } from "@haelp/teto/utils"; 3 | import path from "path"; 4 | 5 | const client = await Client.connect({ 6 | username: process.env.USERNAME!, 7 | password: process.env.PASSWORD! 8 | }); 9 | 10 | // Or: 11 | // const client = await Client.connect({ 12 | // token: process.env.TOKEN! 13 | // }); 14 | 15 | const { roomid } = await client.wait("social.invite"); 16 | await client.rooms.join(roomid); 17 | await client.room?.switch("player").catch(() => {}); 18 | 19 | client.on("client.game.round.start", async ([tick, engine]) => { 20 | const adapter = new adapters.IO({ 21 | path: path.join(__dirname, "./solver/target/release/triangle-rust-demo") 22 | }); 23 | const wrapper = new BotWrapper(adapter, { 24 | pps: 1 25 | }); 26 | const initPromise = wrapper.init(engine); 27 | 28 | tick(async ({ engine, events }) => { 29 | await initPromise; 30 | return { 31 | keys: await wrapper.tick(engine, events) 32 | }; 33 | }); 34 | 35 | await client.wait("client.game.over"); 36 | wrapper.stop(); 37 | }); 38 | -------------------------------------------------------------------------------- /example/solver/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "futures" 7 | version = "0.3.31" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 10 | dependencies = [ 11 | "futures-channel", 12 | "futures-core", 13 | "futures-executor", 14 | "futures-io", 15 | "futures-sink", 16 | "futures-task", 17 | "futures-util", 18 | ] 19 | 20 | [[package]] 21 | name = "futures-channel" 22 | version = "0.3.31" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 25 | dependencies = [ 26 | "futures-core", 27 | "futures-sink", 28 | ] 29 | 30 | [[package]] 31 | name = "futures-core" 32 | version = "0.3.31" 33 | source = "registry+https://github.com/rust-lang/crates.io-index" 34 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 35 | 36 | [[package]] 37 | name = "futures-executor" 38 | version = "0.3.31" 39 | source = "registry+https://github.com/rust-lang/crates.io-index" 40 | checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 41 | dependencies = [ 42 | "futures-core", 43 | "futures-task", 44 | "futures-util", 45 | ] 46 | 47 | [[package]] 48 | name = "futures-io" 49 | version = "0.3.31" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 52 | 53 | [[package]] 54 | name = "futures-macro" 55 | version = "0.3.31" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 58 | dependencies = [ 59 | "proc-macro2", 60 | "quote", 61 | "syn", 62 | ] 63 | 64 | [[package]] 65 | name = "futures-sink" 66 | version = "0.3.31" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 69 | 70 | [[package]] 71 | name = "futures-task" 72 | version = "0.3.31" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 75 | 76 | [[package]] 77 | name = "futures-util" 78 | version = "0.3.31" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 81 | dependencies = [ 82 | "futures-channel", 83 | "futures-core", 84 | "futures-io", 85 | "futures-macro", 86 | "futures-sink", 87 | "futures-task", 88 | "memchr", 89 | "pin-project-lite", 90 | "pin-utils", 91 | "slab", 92 | ] 93 | 94 | [[package]] 95 | name = "itoa" 96 | version = "1.0.15" 97 | source = "registry+https://github.com/rust-lang/crates.io-index" 98 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 99 | 100 | [[package]] 101 | name = "memchr" 102 | version = "2.7.5" 103 | source = "registry+https://github.com/rust-lang/crates.io-index" 104 | checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" 105 | 106 | [[package]] 107 | name = "pin-project-lite" 108 | version = "0.2.16" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 111 | 112 | [[package]] 113 | name = "pin-utils" 114 | version = "0.1.0" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 117 | 118 | [[package]] 119 | name = "proc-macro2" 120 | version = "1.0.95" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 123 | dependencies = [ 124 | "unicode-ident", 125 | ] 126 | 127 | [[package]] 128 | name = "quote" 129 | version = "1.0.40" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 132 | dependencies = [ 133 | "proc-macro2", 134 | ] 135 | 136 | [[package]] 137 | name = "ryu" 138 | version = "1.0.20" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 141 | 142 | [[package]] 143 | name = "serde" 144 | version = "1.0.219" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 147 | dependencies = [ 148 | "serde_derive", 149 | ] 150 | 151 | [[package]] 152 | name = "serde_derive" 153 | version = "1.0.219" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 156 | dependencies = [ 157 | "proc-macro2", 158 | "quote", 159 | "syn", 160 | ] 161 | 162 | [[package]] 163 | name = "serde_json" 164 | version = "1.0.142" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" 167 | dependencies = [ 168 | "itoa", 169 | "memchr", 170 | "ryu", 171 | "serde", 172 | ] 173 | 174 | [[package]] 175 | name = "slab" 176 | version = "0.4.10" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" 179 | 180 | [[package]] 181 | name = "syn" 182 | version = "2.0.104" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" 185 | dependencies = [ 186 | "proc-macro2", 187 | "quote", 188 | "unicode-ident", 189 | ] 190 | 191 | [[package]] 192 | name = "triangle-rust-demo" 193 | version = "0.1.0" 194 | dependencies = [ 195 | "futures", 196 | "serde", 197 | "serde_json", 198 | ] 199 | 200 | [[package]] 201 | name = "unicode-ident" 202 | version = "1.0.18" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 205 | -------------------------------------------------------------------------------- /example/solver/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "triangle-rust-demo" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | futures = "0.3.31" 8 | serde = { version = "1.0.219", features = ["derive"] } 9 | serde_json = "1.0.142" 10 | -------------------------------------------------------------------------------- /example/solver/README.md: -------------------------------------------------------------------------------- 1 | # Demo Rust <-> Triangle.js protocol implementation 2 | 3 | ## Building 4 | 5 | ```bash 6 | cargo build --release 7 | ``` 8 | -------------------------------------------------------------------------------- /example/solver/src/data.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Clone, Copy, Debug, PartialEq, Deserialize)] 4 | #[serde(rename_all = "camelCase")] 5 | pub enum Mino { 6 | I, 7 | J, 8 | L, 9 | O, 10 | S, 11 | T, 12 | Z, 13 | } 14 | 15 | #[derive(Clone, Copy, Debug, PartialEq, Deserialize)] 16 | #[serde(rename_all = "camelCase")] 17 | pub enum BoardSquare { 18 | Empty, 19 | Piece, 20 | Garbage, 21 | } 22 | 23 | #[derive(Deserialize, Clone)] 24 | #[serde(rename_all = "camelCase")] 25 | pub enum KickTable { 26 | #[serde(rename = "SRS")] 27 | SRS, 28 | #[serde(rename = "SRS+")] 29 | SRSPlus, 30 | #[serde(rename = "SRS-X")] 31 | SRSX, 32 | } 33 | 34 | #[derive(Deserialize, Clone, Copy, Debug, PartialEq)] 35 | pub enum ComboTable { 36 | #[serde(rename = "none")] 37 | None, 38 | #[serde(rename = "classic-guideline")] 39 | Classic, 40 | #[serde(rename = "modern-guideline")] 41 | Modern, 42 | #[serde(rename = "multiplier")] 43 | Multiplier, 44 | } 45 | 46 | #[derive(Deserialize, Clone, Copy, Debug, PartialEq)] 47 | pub enum Spins { 48 | None, 49 | #[serde(rename = "T-spins")] 50 | T, 51 | #[serde(rename = "T-spins+")] 52 | TPlus, 53 | #[serde(rename = "all-mini")] 54 | Mini, 55 | #[serde(rename = "all-mini+")] 56 | MiniPlus, 57 | #[serde(rename = "all")] 58 | All, 59 | #[serde(rename = "all+")] 60 | AllPlus, 61 | #[serde(rename = "mini-only")] 62 | MiniOnly, 63 | #[serde(rename = "handheld")] 64 | Handheld, 65 | #[serde(rename = "stupid")] 66 | Stupid, 67 | } 68 | 69 | #[derive(Deserialize, Clone)] 70 | #[serde(rename_all = "camelCase")] 71 | pub struct GameConfig { 72 | pub board_width: u16, 73 | pub board_height: u16, 74 | 75 | pub kicks: KickTable, 76 | pub spins: Spins, 77 | pub b2b_charging: bool, 78 | pub b2b_charge_at: i16, 79 | pub b2b_charge_base: i16, 80 | pub b2b_chaining: bool, 81 | pub combo_table: ComboTable, 82 | pub garbage_multiplier: f32, 83 | pub pc_b2b: u16, 84 | pub pc_send: u16, 85 | pub garbage_special_bonus: bool, 86 | } 87 | 88 | #[derive(Clone, Copy, Debug, PartialEq, Serialize)] 89 | pub enum Move { 90 | #[serde(rename = "none")] 91 | None, 92 | #[serde(rename = "moveLeft")] 93 | Left, 94 | #[serde(rename = "moveRight")] 95 | Right, 96 | #[serde(rename = "softDrop")] 97 | SoftDrop, 98 | #[serde(rename = "rotateCCW")] 99 | CCW, 100 | #[serde(rename = "rotateCW")] 101 | CW, 102 | #[serde(rename = "rotate180")] 103 | Flip, 104 | #[serde(rename = "dasLeft")] 105 | DasLeft, 106 | #[serde(rename = "dasRight")] 107 | DasRight, 108 | #[serde(rename = "hold")] 109 | Hold, 110 | #[serde(rename = "hardDrop")] 111 | HardDrop, 112 | } 113 | -------------------------------------------------------------------------------- /example/solver/src/main.rs: -------------------------------------------------------------------------------- 1 | pub mod data; 2 | pub mod protocol; 3 | 4 | fn main() { 5 | futures::executor::block_on(protocol::start_server()); 6 | } 7 | -------------------------------------------------------------------------------- /example/solver/src/protocol.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use futures::{SinkExt, StreamExt}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::data::{BoardSquare, ComboTable, KickTable, Mino, Move, Spins}; 7 | 8 | #[derive(Deserialize)] 9 | #[serde(rename_all = "camelCase")] 10 | #[serde(tag = "type")] 11 | pub enum Incoming { 12 | Config(Config), 13 | State(State), 14 | Pieces(Pieces), 15 | Play(Play), 16 | } 17 | 18 | #[derive(Deserialize)] 19 | #[serde(rename_all = "camelCase")] 20 | #[serde(tag = "type")] 21 | pub struct Config { 22 | board_width: i32, 23 | board_height: i32, 24 | 25 | kicks: KickTable, 26 | spins: Spins, 27 | combo_table: ComboTable, 28 | 29 | b2b_charing: bool, 30 | b2b_charge_at: i32, 31 | b2b_charge_base: i32, 32 | b2b_chaining: bool, 33 | 34 | garbage_multiplier: i32, 35 | garbage_cap: i32, 36 | garbage_special_bonus: bool, 37 | 38 | pc_b2b: i32, 39 | pc_garbage: i32, 40 | 41 | queue: Vec, 42 | } 43 | 44 | #[derive(Deserialize)] 45 | #[serde(rename_all = "camelCase")] 46 | #[serde(tag = "type")] 47 | pub struct State { 48 | board: Vec>, 49 | 50 | current: Mino, 51 | hold: Option, 52 | queue: Vec, 53 | 54 | garbage: Vec, 55 | 56 | combo: i32, 57 | b2b: i32, 58 | } 59 | 60 | #[derive(Deserialize)] 61 | #[serde(rename_all = "camelCase")] 62 | #[serde(tag = "type")] 63 | pub struct Pieces { 64 | pieces: Vec, 65 | } 66 | 67 | #[derive(Deserialize)] 68 | #[serde(rename_all = "camelCase")] 69 | #[serde(tag = "type")] 70 | pub struct Play { 71 | garbage_multiplier: f64, 72 | garbage_cap: i32, 73 | } 74 | 75 | #[derive(Serialize)] 76 | #[serde(rename_all = "camelCase")] 77 | #[serde(tag = "type")] 78 | pub enum Outgoing { 79 | Info { 80 | name: &'static str, 81 | version: &'static str, 82 | author: &'static str, 83 | }, 84 | Move { 85 | keys: Vec, 86 | }, 87 | } 88 | 89 | pub async fn start_server() { 90 | let incoming = futures::stream::repeat_with(|| { 91 | let mut line = String::new(); 92 | std::io::stdin().read_line(&mut line).unwrap(); 93 | serde_json::from_str::(&line).unwrap() 94 | }); 95 | 96 | let outgoing = futures::sink::unfold((), |_, msg: Outgoing| { 97 | serde_json::to_writer(std::io::stdout(), &msg).unwrap(); 98 | println!(); 99 | async { Ok::<(), ()>(()) } 100 | }); 101 | 102 | futures::pin_mut!(incoming); 103 | futures::pin_mut!(outgoing); 104 | 105 | outgoing 106 | .send(Outgoing::Info { 107 | name: "Triangle.js Rust Demo", 108 | version: "1.0.0", 109 | author: "halp", 110 | }) 111 | .await 112 | .unwrap(); 113 | 114 | while let Some(msg) = incoming.next().await { 115 | match msg { 116 | Incoming::Config(_config) => {} 117 | 118 | Incoming::State(_state) => {} 119 | 120 | Incoming::Pieces(_pieces) => {} 121 | Incoming::Play(_play) => { 122 | outgoing 123 | .send(Outgoing::Move { 124 | keys: vec![Move::HardDrop], 125 | }) 126 | .await 127 | .unwrap(); 128 | } 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@haelp/teto", 3 | "version": "3.3.5", 4 | "description": "A typescript-based controllable TETR.IO client.", 5 | "maintainers": [ 6 | "haelp" 7 | ], 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/halp1/triangle" 11 | }, 12 | "displayName": "Triangle.js", 13 | "private": false, 14 | "homepage": "https://triangle.haelp.dev", 15 | "main": "dist/index.js", 16 | "scripts": { 17 | "check": "tsc --noEmit", 18 | "test": "bun check && bun test", 19 | "format": "prettier --write \"./**/*.ts\"", 20 | "docs": "typedoc", 21 | "clean": "rm -rf dist", 22 | "build:types": "tsc", 23 | "build:swc": "swc src --strip-leading-paths -d dist -C module.type=es6 --copy-files && bun scripts/renameModules.ts && swc src --strip-leading-paths -d dist --copy-files", 24 | "build": "bun scripts/version.ts && bun clean && bun build:types && bun build:swc", 25 | "pub": "npm publish --access public", 26 | "bundle-test-data": "tar -C test/data/replays -cf - . | pv -s $(du -sb test/data/replays | awk '{print $1}') | pigz -9 > test/data/replays.tar.gz", 27 | "download-test-data": "git lfs fetch --include=\"test/data/**\" --exclude=\"\" && git lfs checkout test/data && bun extract-test-data", 28 | "extract-test-data": "mkdir -p test/data/replays && pv test/data/replays.tar.gz | pigz -d | tar -xf - -C test/data/replays" 29 | }, 30 | "files": [ 31 | "dist", 32 | "engine.js", 33 | "engine.d.ts", 34 | "ch.js", 35 | "ch.d.ts", 36 | "src" 37 | ], 38 | "exports": { 39 | ".": { 40 | "types": "./dist/index.d.ts", 41 | "import": "./dist/index.mjs", 42 | "default": "./dist/index.js" 43 | }, 44 | "./engine": { 45 | "types": "./dist/engine/index.d.ts", 46 | "import": "./dist/engine/index.mjs", 47 | "default": "./dist/engine/index.js" 48 | }, 49 | "./ch": { 50 | "types": "./dist/channel/index.d.ts", 51 | "import": "./dist/channel/index.mjs", 52 | "default": "./dist/channel/index.js" 53 | }, 54 | "./utils": { 55 | "types": "./dist/utils/index.d.ts", 56 | "import": "./dist/utils/index.mjs", 57 | "default": "./dist/utils/index.js" 58 | } 59 | }, 60 | "types": "dist/index.d.ts", 61 | "keywords": [ 62 | "tetrio", 63 | "tetris", 64 | "ribbon", 65 | "websocket", 66 | "bot" 67 | ], 68 | "author": "halp", 69 | "license": "MIT", 70 | "dependencies": { 71 | "buffer": "^6.0.3", 72 | "chalk": "^5.6.0", 73 | "fast-equals": "^5.2.2", 74 | "lodash": "^4.17.21", 75 | "msgpackr": "^1.11.5", 76 | "node-fetch": "^2.7.0" 77 | }, 78 | "devDependencies": { 79 | "@swc/cli": "^0.7.8", 80 | "@swc/core": "^1.13.5", 81 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 82 | "@types/bun": "1.2.17", 83 | "@types/lodash": "^4.17.20", 84 | "@types/node": "^24.3.1", 85 | "@types/ws": "^8.18.1", 86 | "prettier": "^3.6.2", 87 | "typedoc": "^0.26.11", 88 | "typescript": "~5.6.3" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /scripts/renameModules.ts: -------------------------------------------------------------------------------- 1 | import fsSync from "node:fs"; 2 | import fs from "node:fs/promises"; 3 | import path from "node:path"; 4 | 5 | async function renameModuleFiles(dir: string) { 6 | const entries = await fs.readdir(dir, { withFileTypes: true }); 7 | for (const entry of entries) { 8 | const fullPath = path.join(dir, entry.name); 9 | if (entry.isDirectory()) { 10 | await renameModuleFiles(fullPath); 11 | } else if (entry.name.endsWith(".js")) { 12 | await fs.rename(fullPath, fullPath.replace(/\.js$/, ".mjs")); 13 | } else if (entry.name.endsWith(".js.map")) { 14 | await fs.rename(fullPath, fullPath.replace(/\.js\.map$/, ".mjs.map")); 15 | } 16 | } 17 | } 18 | 19 | await renameModuleFiles("dist"); 20 | 21 | async function rewriteImports(dir: string) { 22 | const entries = await fs.readdir(dir, { withFileTypes: true }); 23 | 24 | for (const entry of entries) { 25 | const fullPath = path.join(dir, entry.name); 26 | 27 | if (entry.isDirectory()) { 28 | await rewriteImports(fullPath); 29 | } else if (entry.name.endsWith(".mjs")) { 30 | let content = await fs.readFile(fullPath, "utf-8"); 31 | 32 | content = content.replace( 33 | /from\s+["'](\.{1,2}(?:\/[^'"]+)*)["']/g, 34 | (match, importPath) => { 35 | const baseDir = path.dirname(fullPath); 36 | 37 | // Try file.mjs 38 | const filePath = path.join(baseDir, importPath + ".mjs"); 39 | 40 | try { 41 | fsSync.accessSync(filePath); 42 | return `from "${importPath}.mjs"`; 43 | } catch {} 44 | 45 | // Try folder/index.mjs 46 | const indexPath = path.join(baseDir, importPath, "index.mjs"); 47 | 48 | try { 49 | fsSync.accessSync(indexPath); 50 | return `from "${importPath}/index.mjs"`; 51 | } catch {} 52 | 53 | return match; // Leave as-is if neither exist 54 | } 55 | ); 56 | 57 | await fs.writeFile(fullPath, content); 58 | } 59 | } 60 | } 61 | 62 | await rewriteImports("dist"); 63 | -------------------------------------------------------------------------------- /scripts/version.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | const packageJson = await fs.promises.readFile( 5 | path.join(__dirname, "../package.json"), 6 | "utf-8" 7 | ); 8 | const { version } = JSON.parse(packageJson); 9 | 10 | await fs.promises.writeFile( 11 | path.join(__dirname, "../src/utils/version.ts"), 12 | `export const version = "${version}";\n` 13 | ); 14 | 15 | console.log("Version:", version); 16 | -------------------------------------------------------------------------------- /src/classes/client/types.ts: -------------------------------------------------------------------------------- 1 | import type { Game, User, Social } from "../../types"; 2 | import type { RibbonOptions } from "../ribbon"; 3 | 4 | export type ClientOptions = ( 5 | | { 6 | /** The account's JWT authentication token (you can get this from the browser cookies when logging in on https://tetr.io) */ 7 | token: string; 8 | } 9 | | { 10 | /** The account's username */ 11 | username: string; 12 | /** The accont's password */ 13 | password: string; 14 | } 15 | ) & { 16 | /** The client's handling settings. 17 | * @default { arr: 0, cancel: false, das: 5, dcd: 0, safelock: false, may20g: true, sdf: 41, irs: "tap", ihs: "tap" } 18 | */ 19 | handling?: Game.Handling; 20 | /** The client's user agent. 21 | * @default "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0" 22 | */ 23 | userAgent?: string; 24 | /** a cf_clearance Cloudflare turnstile token. */ 25 | turnstile?: string; 26 | /** The Ribbon (websocket handler) config */ 27 | ribbon?: Partial; 28 | /** The `Social` config */ 29 | social?: Partial; 30 | }; 31 | 32 | export interface ClientUser { 33 | id: string; 34 | username: string; 35 | role: User.Role; 36 | sessionID: string; 37 | userAgent: string; 38 | } 39 | -------------------------------------------------------------------------------- /src/classes/game/utils.ts: -------------------------------------------------------------------------------- 1 | import { Queue } from "../../engine"; 2 | import type { Game } from "../../types"; 3 | 4 | export const getFullFrame = ( 5 | options: Game.ReadyOptions 6 | ): Game.Replay.Frames.Full & { frame: number } => ({ 7 | type: "full", 8 | frame: 0, 9 | data: { 10 | game: { 11 | board: Array.from({ length: options.boardheight + 20 }, () => 12 | Array.from({ length: options.boardwidth }, () => null) 13 | ), 14 | bag: new Queue({ 15 | type: options.bagtype, 16 | minLength: 7, 17 | seed: options.seed 18 | }).value as Game.Mino[], 19 | hold: { 20 | piece: null, 21 | locked: false 22 | }, 23 | g: options.g, 24 | controlling: { 25 | lShift: { 26 | held: false, 27 | arr: 0, 28 | das: 0, 29 | dir: -1 30 | }, 31 | rShift: { 32 | held: false, 33 | arr: 0, 34 | das: 0, 35 | dir: 1 36 | }, 37 | lastshift: -1, 38 | inputSoftdrop: false 39 | }, 40 | falling: { 41 | type: "i", 42 | x: 0, 43 | y: 0, 44 | r: 0, 45 | hy: 0, 46 | irs: 0, 47 | kick: 0, 48 | keys: 0, 49 | flags: 0, 50 | safelock: 0, 51 | locking: 0, 52 | lockresets: 0, 53 | rotresets: 0, 54 | skip: [] 55 | }, 56 | handling: options.handling, 57 | playing: true 58 | }, 59 | stats: { 60 | lines: 0, 61 | level_lines: 0, 62 | level_lines_needed: 1, 63 | inputs: 0, 64 | holds: 0, 65 | score: 0, 66 | zenlevel: 1, 67 | zenprogress: 0, 68 | level: 1, 69 | combo: 0, 70 | topcombo: 0, 71 | combopower: 0, 72 | btb: 0, 73 | topbtb: 0, 74 | btbpower: 0, 75 | tspins: 0, 76 | piecesplaced: 0, 77 | clears: { 78 | singles: 0, 79 | doubles: 0, 80 | triples: 0, 81 | quads: 0, 82 | pentas: 0, 83 | realtspins: 0, 84 | minitspins: 0, 85 | minitspinsingles: 0, 86 | tspinsingles: 0, 87 | minitspindoubles: 0, 88 | tspindoubles: 0, 89 | minitspintriples: 0, 90 | tspintriples: 0, 91 | minitspinquads: 0, 92 | tspinquads: 0, 93 | tspinpentas: 0, 94 | allclear: 0 95 | }, 96 | garbage: { 97 | sent: 0, 98 | sent_nomult: 0, 99 | maxspike: 0, 100 | maxspike_nomult: 0, 101 | received: 0, 102 | attack: 0, 103 | cleared: 0 104 | }, 105 | kills: 0, 106 | finesse: { 107 | combo: 0, 108 | faults: 0, 109 | perfectpieces: 0 110 | }, 111 | zenith: { 112 | altitude: 0, 113 | rank: 1, 114 | peakrank: 1, 115 | avgrankpts: 0, 116 | floor: 0, 117 | targetingfactor: 3, 118 | targetinggrace: 0, 119 | totalbonus: 0, 120 | revives: 0, 121 | revivesTotal: 0, 122 | revivesMaxOfBoth: 0, 123 | speedrun: false, 124 | speedrun_seen: false, 125 | splits: [0, 0, 0, 0, 0, 0, 0, 0, 0] 126 | } 127 | }, 128 | diyusi: 0 129 | } 130 | }); 131 | 132 | /** round frame */ 133 | export const rf = (frame: number) => Math.round(frame * 10) / 10; 134 | /** split frame */ 135 | export const sf = (frame: number) => [ 136 | Math.floor(frame), 137 | Math.round((frame % 1) * 10) / 10 138 | ]; 139 | -------------------------------------------------------------------------------- /src/classes/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./client"; 2 | export * from "./game"; 3 | export * from "./ribbon"; 4 | export * from "./room"; 5 | export * from "./social"; 6 | export * from "./utils"; 7 | -------------------------------------------------------------------------------- /src/classes/ribbon/codecs/utils/bits.ts: -------------------------------------------------------------------------------- 1 | const MAX_BITS = Number.MAX_SAFE_INTEGER.toString(2).length; 2 | 3 | export class Bits { 4 | public buffer: Buffer; 5 | private _length: number; 6 | private _offset: number; 7 | 8 | constructor(t: number | Buffer) { 9 | if (typeof t === "number") { 10 | this.buffer = Buffer.alloc(Math.ceil(t / 8)); 11 | } else if (t instanceof Buffer) { 12 | this.buffer = t; 13 | } else { 14 | throw new TypeError( 15 | "Initialize by specifying a bit-length or referencing a Buffer" 16 | ); 17 | } 18 | this._length = this.buffer.length * 8; 19 | this._offset = 0; 20 | } 21 | 22 | static alloc(t: number, r?: number, n?: number): Bits { 23 | return new Bits(Buffer.alloc(t, r ?? 0, n as BufferEncoding | undefined)); 24 | } 25 | 26 | static from(t: any, r?: number, n?: number): Bits { 27 | return new Bits(Buffer.from(t, r, n)); 28 | } 29 | 30 | get eof(): boolean { 31 | return this._offset === this._length; 32 | } 33 | 34 | get length(): number { 35 | return this._length; 36 | } 37 | 38 | get offset(): number { 39 | return this._offset; 40 | } 41 | 42 | set offset(t: number) { 43 | if (t < 0) { 44 | throw new RangeError("Cannot set offset below 0"); 45 | } 46 | if (t > this._length) { 47 | throw new RangeError( 48 | `Cannot set offset to ${t}, buffer length is ${this._length}` 49 | ); 50 | } 51 | this._offset = Math.floor(t); 52 | } 53 | 54 | get remaining(): number { 55 | return this._length - this._offset; 56 | } 57 | 58 | clear(t: number = 0): this { 59 | this.buffer.fill(t); 60 | this._offset = 0; 61 | return this; 62 | } 63 | 64 | clearBit(t: number): this { 65 | this.insert(0, 1, t); 66 | return this; 67 | } 68 | 69 | flipBit(t: number): number { 70 | const result = this.peek(1, t) ^ 1; 71 | this.modifyBit(result, t); 72 | return result; 73 | } 74 | 75 | getBit(t: number): number { 76 | return this.peek(1, t); 77 | } 78 | 79 | insert(val: number, bits: number = 1, pos?: number): number { 80 | let r = typeof pos === "number" ? pos | 0 : this._offset; 81 | if (r + bits > this._length) { 82 | throw new RangeError( 83 | `Cannot write ${bits} bits, only ${this.remaining} bit(s) left` 84 | ); 85 | } 86 | if (bits > MAX_BITS) { 87 | throw new RangeError(`Cannot write ${bits} bits, max is ${MAX_BITS}`); 88 | } 89 | let i = bits; 90 | while (i > 0) { 91 | const byteIndex = r >> 3; 92 | const bitIndex = r & 7; 93 | const o = Math.min(8 - bitIndex, i); 94 | const mask = (1 << o) - 1; 95 | const shift = 8 - o - bitIndex; 96 | const u = ((val >>> (i - o)) & mask) << shift; 97 | this.buffer[byteIndex] = (this.buffer[byteIndex] & ~(mask << shift)) | u; 98 | r += o; 99 | i -= o; 100 | } 101 | return r; 102 | } 103 | 104 | modifyBit(val: number, pos: number): number { 105 | this.insert(val, 1, pos); 106 | return val; 107 | } 108 | 109 | peek(bits: number = 1, pos?: number): number { 110 | let e = typeof pos === "number" ? pos | 0 : this._offset; 111 | if (e + bits > this._length) { 112 | throw new RangeError( 113 | `Cannot read ${bits} bits, only ${this.remaining} bit(s) left` 114 | ); 115 | } 116 | if (bits > MAX_BITS) { 117 | throw new RangeError( 118 | `Reading ${bits} bits would overflow result, max is ${MAX_BITS}` 119 | ); 120 | } 121 | const bitOffset = e & 7; 122 | const i = Math.min(8 - bitOffset, bits); 123 | const mask = (1 << i) - 1; 124 | let s = (this.buffer[e >> 3] >> (8 - i - bitOffset)) & mask; 125 | e += i; 126 | let remainingBits = bits - i; 127 | while (remainingBits >= 8) { 128 | s <<= 8; 129 | s |= this.buffer[e >> 3]; 130 | e += 8; 131 | remainingBits -= 8; 132 | } 133 | if (remainingBits > 0) { 134 | const shift = 8 - remainingBits; 135 | s <<= remainingBits; 136 | s |= (this.buffer[e >> 3] >> shift) & (255 >> shift); 137 | } 138 | return s; 139 | } 140 | 141 | read(bits: number = 1): number { 142 | const val = this.peek(bits, this._offset); 143 | this._offset += bits; 144 | return val; 145 | } 146 | 147 | seek(t: number, mode: number = 1): this { 148 | switch (mode) { 149 | case 2: 150 | this.offset = this._offset + t; 151 | break; 152 | case 3: 153 | this.offset = this.length - t; 154 | break; 155 | default: 156 | this.offset = t; 157 | } 158 | return this; 159 | } 160 | 161 | setBit(t: number): this { 162 | this.insert(1, 1, t); 163 | return this; 164 | } 165 | 166 | skip(t: number): this { 167 | return this.seek(t, 2); 168 | } 169 | 170 | testBit(t: number): boolean { 171 | return !!this.peek(1, t); 172 | } 173 | 174 | toString(encoding: BufferEncoding = "utf8"): string { 175 | return this.buffer.toString(encoding); 176 | } 177 | 178 | write(val: number, bits: number = 1): this { 179 | this._offset = this.insert(val, bits, this._offset); 180 | return this; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/classes/ribbon/types.ts: -------------------------------------------------------------------------------- 1 | import { type Codec, type LoggingLevel } from "."; 2 | import type { Game } from "../../types"; 3 | 4 | export namespace RibbonEvents { 5 | export type Raw = { 6 | [P in keyof T]: T[P] extends void 7 | ? { command: P } 8 | : { command: P; data: T[P] }; 9 | }[keyof T]; 10 | 11 | export interface Send {} 12 | } 13 | 14 | export interface RibbonParams { 15 | token: string; 16 | userAgent: string; 17 | handling: Game.Handling; 18 | } 19 | 20 | export interface RibbonOptions { 21 | /** 22 | * The type of websocket encoder to use. `json`, `candor`, or `teto` is recommended. 23 | * `json` only works if the JSON protocol is enabled on your account. You must request it to be enabled before use or your account will be banned when Triangle tries to connect. 24 | * @default "teto" 25 | */ 26 | codec: Codec; 27 | /** 28 | * The target level of Ribbon terminal log output. 29 | * `none` = no logs 30 | * `error` = only errors 31 | * `all` = all logs 32 | */ 33 | logging: LoggingLevel; 34 | /** 35 | * Whether or not connect to a spool (when off, the client will connect directly to tetr.io). 36 | * It is highly recommended to leave this on. 37 | * @default true 38 | */ 39 | spooling: boolean; 40 | /** 41 | * @deprecated - use `logging` 42 | * Enables logging 43 | * @default false 44 | */ 45 | verbose: boolean; 46 | } 47 | -------------------------------------------------------------------------------- /src/classes/social/relationship.ts: -------------------------------------------------------------------------------- 1 | import { Social } from "."; 2 | import type { Social as SocialTypes } from "../../types"; 3 | import { Client } from "../client"; 4 | 5 | export class Relationship { 6 | private social: Social; 7 | private client: Client; 8 | 9 | /** ID of the account on the other side of the relationsihp */ 10 | id: string; 11 | /** ID of the relationship */ 12 | relationshipID: string; 13 | /** Username of the account on the other side of the relationship */ 14 | username: string; 15 | /** Avatar ID of the account on the other side of the relationship */ 16 | avatar: number; 17 | /** The DMs that have been sent and received. You may need to call `loadDms` to populate this information */ 18 | dms: SocialTypes.DM[]; 19 | 20 | /** Promise that resolves when the dms have been loaded. This will not resovle if `Relationship.lazyLoadDms` is set to true. */ 21 | ready: Promise; 22 | /** Whether or not the dms have been loaded */ 23 | dmsLoaded = false; 24 | 25 | static lazyLoadDms = true; 26 | 27 | constructor( 28 | options: { 29 | id: string; 30 | relationshipID: string; 31 | username: string; 32 | avatar: number; 33 | }, 34 | social: Social, 35 | client: Client 36 | ) { 37 | this.id = options.id; 38 | this.relationshipID = options.relationshipID; 39 | this.username = options.username; 40 | this.avatar = options.avatar; 41 | 42 | this.social = social; 43 | this.client = client; 44 | 45 | this.dms = []; 46 | 47 | this.ready = Relationship.lazyLoadDms 48 | ? new Promise(() => {}) 49 | : new Promise(async (resolve) => { 50 | await this.loadDms(); 51 | resolve(); 52 | }); 53 | } 54 | 55 | /** 56 | * Send a dm to the user. 57 | * @example 58 | * relationship.dm("Hello!"); 59 | */ 60 | async dm(content: string) { 61 | await this.social.dm(this.id, content); 62 | } 63 | 64 | /** 65 | * Mark the dms as read 66 | * @example 67 | * relationship.markAsRead(); 68 | */ 69 | markAsRead() { 70 | this.client.emit("social.relation.ack", this.id); 71 | } 72 | 73 | /** 74 | * Load the DMs for this relationship 75 | * @example 76 | * await relationship.loadDms(); 77 | */ 78 | async loadDms() { 79 | const dms = (await this.client.api.social.dms(this.id)).reverse(); 80 | this.dms = dms; 81 | this.dmsLoaded = true; 82 | return dms; 83 | } 84 | 85 | /** 86 | * Invite the user to a game 87 | * @example 88 | * relationship.invite(); 89 | */ 90 | invite() { 91 | this.social.invite(this.id); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/classes/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "../client"; 2 | 3 | /** 4 | * Client utils. This may be deprecated in the future 5 | */ 6 | export class ClientUtils { 7 | private client: Client; 8 | /** @hideconstructor */ 9 | constructor(client: Client) { 10 | this.client = client; 11 | } 12 | 13 | private get api() { 14 | return this.client.api; 15 | } 16 | 17 | /** 18 | * Get the userid based on username 19 | * @deprecated in favor of `client.social.resolve` 20 | */ 21 | async getID(username: string) { 22 | return this.api.users.resolve(username); 23 | } 24 | 25 | /** 26 | * Get a user. 27 | * @deprecated in favor of `client.social.who` 28 | */ 29 | async getUser(opts: Parameters[0]) { 30 | return this.api.users.get(opts); 31 | } 32 | 33 | /** 34 | * Promise that resolves after `time` ms 35 | */ 36 | async sleep(time: number) { 37 | return await new Promise((resolve) => setTimeout(resolve, time)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/engine/board/connected.ts: -------------------------------------------------------------------------------- 1 | import { type BoardInitializeParams } from "."; 2 | import { Mino } from "../queue"; 3 | 4 | export type ConnectedBoardSquare = { 5 | mino: Mino; 6 | connection: number; 7 | } | null; 8 | 9 | export class ConnectedBoard { 10 | state: ConnectedBoardSquare[][]; 11 | 12 | private _height: number; 13 | private _width: number; 14 | private _buffer: number; 15 | 16 | constructor(options: BoardInitializeParams) { 17 | this._width = options.width; 18 | this._height = options.height; 19 | this._buffer = options.buffer; 20 | this.state = Array(this.fullHeight) 21 | .fill(null) 22 | .map(() => Array(this.width).fill(null)); 23 | } 24 | 25 | get height(): number { 26 | return this._height; 27 | } 28 | 29 | set height(value: number) { 30 | this._height = value; 31 | } 32 | 33 | get width(): number { 34 | return this._width; 35 | } 36 | 37 | set width(value: number) { 38 | this._width = value; 39 | } 40 | 41 | get buffer(): number { 42 | return this._buffer; 43 | } 44 | 45 | set buffer(value: number) { 46 | this._buffer = value; 47 | } 48 | 49 | get fullHeight(): number { 50 | return this.height + this.buffer; 51 | } 52 | 53 | add(...blocks: [ConnectedBoardSquare, number, number][]) { 54 | blocks.forEach(([item, x, y]) => { 55 | if (y < 0 || y >= this.fullHeight || x < 0 || x >= this.width) return; 56 | this.state[y][x] = item; 57 | }); 58 | } 59 | 60 | clearLines() { 61 | let garbageCleared = 0; 62 | const lines: number[] = []; 63 | this.state.forEach((row, idx) => { 64 | if (row.every((block) => block !== null && block.mino !== Mino.BOMB)) { 65 | lines.push(idx); 66 | if (idx > 0) { 67 | this.state[idx - 1].forEach((block) => { 68 | if (block) { 69 | block.connection |= 0b1000; 70 | if (block.connection & 0b0010) block.connection &= 0b0_1111; 71 | } 72 | }); 73 | } 74 | if (idx < this.fullHeight - 1) { 75 | this.state[idx + 1].forEach((block) => { 76 | if (block) { 77 | block.connection |= 0b0010; 78 | if (block.connection & 0b1000) block.connection &= 0b0_1111; 79 | } 80 | }); 81 | } 82 | if (row.some((block) => block?.mino === Mino.GARBAGE)) garbageCleared++; 83 | } 84 | }); 85 | 86 | [...lines].reverse().forEach((line) => { 87 | this.state.splice(line, 1); 88 | this.state.push(new Array(this.width).fill(null)); 89 | }); 90 | return { lines: lines.length, garbageCleared }; 91 | } 92 | 93 | clearBombs(placedBlocks: [number, number][]) { 94 | let lowestY = placedBlocks.reduce( 95 | (acc, [_, y]) => Math.min(acc, y), 96 | this.fullHeight 97 | ); 98 | if (lowestY === 0) return { lines: 0, garbageCleared: 0 }; 99 | 100 | const lowestBlocks = placedBlocks.filter(([_, y]) => y === lowestY); 101 | 102 | const bombColumns = lowestBlocks 103 | .filter(([x, y]) => this.state[y - 1][x]?.mino === Mino.BOMB) 104 | .map(([x, _]) => x); 105 | if (bombColumns.length === 0) return { lines: 0, garbageCleared: 0 }; 106 | 107 | const lines: number[] = []; 108 | 109 | while ( 110 | lowestY > 0 && 111 | bombColumns.some( 112 | (col) => this.state[lowestY - 1][col]?.mino === Mino.BOMB 113 | ) 114 | ) { 115 | lines.push(--lowestY); 116 | } 117 | 118 | if (lines.length === 0) return { lines: 0, garbageCleared: 0 }; 119 | 120 | lines.forEach((line) => { 121 | this.state.splice(line, 1); 122 | this.state.push(new Array(this.width).fill(null)); 123 | }); 124 | 125 | return { lines: lines.length, garbageCleared: lines.length }; 126 | } 127 | 128 | clearBombsAndLines(placedBlocks: [number, number][]) { 129 | const bombs = this.clearBombs(placedBlocks); 130 | const lines = this.clearLines(); 131 | return { 132 | lines: lines.lines + bombs.lines, 133 | garbageCleared: bombs.garbageCleared + lines.garbageCleared 134 | }; 135 | } 136 | 137 | get perfectClear() { 138 | return this.state.every((row) => row.every((block) => block === null)); 139 | } 140 | 141 | insertGarbage({ 142 | amount, 143 | size, 144 | column, 145 | bombs, 146 | isBeginning, 147 | isEnd 148 | }: { 149 | amount: number; 150 | size: number; 151 | column: number; 152 | bombs: boolean; 153 | isBeginning: boolean; 154 | isEnd: boolean; 155 | }) { 156 | this.state.splice( 157 | 0, 158 | 0, 159 | ...Array.from({ length: amount }, (_, y) => 160 | Array.from({ length: this.width }, (_, x) => 161 | x >= column && x < column + size 162 | ? bombs 163 | ? { mino: Mino.BOMB, connection: 0 } 164 | : null 165 | : { 166 | mino: Mino.GARBAGE, 167 | connection: (() => { 168 | let connection = 0; 169 | 170 | if (isEnd && y === 0) connection |= 0b0010; 171 | if (isBeginning && y === amount - 1) connection |= 0b1000; 172 | if (x === 0) connection |= 0b0001; 173 | if (x === this.width - 1) connection |= 0b0100; 174 | if (x === column - 1) connection |= 0b0100; 175 | if (x === column + size) connection |= 0b0001; 176 | 177 | return connection; 178 | })() 179 | } 180 | ) 181 | ) 182 | ); 183 | 184 | this.state.splice(this.fullHeight - amount - 1, amount); 185 | } 186 | } 187 | 188 | export * from "./connected"; 189 | -------------------------------------------------------------------------------- /src/engine/board/index.ts: -------------------------------------------------------------------------------- 1 | import { Mino } from "../queue/types"; 2 | 3 | export interface BoardInitializeParams { 4 | width: number; 5 | height: number; 6 | buffer: number; 7 | } 8 | 9 | export type BoardSquare = Mino | null; 10 | 11 | export class Board { 12 | state: BoardSquare[][]; 13 | 14 | private _height: number; 15 | private _width: number; 16 | private _buffer: number; 17 | 18 | constructor(options: BoardInitializeParams) { 19 | this._width = options.width; 20 | this._height = options.height; 21 | this._buffer = options.buffer; 22 | this.state = Array(this.fullHeight) 23 | .fill(null) 24 | .map(() => Array(this.width).fill(null)); 25 | } 26 | 27 | get height(): number { 28 | return this._height; 29 | } 30 | 31 | set height(value: number) { 32 | this._height = value; 33 | } 34 | 35 | get width(): number { 36 | return this._width; 37 | } 38 | 39 | set width(value: number) { 40 | this._width = value; 41 | } 42 | 43 | get buffer(): number { 44 | return this._buffer; 45 | } 46 | 47 | set buffer(value: number) { 48 | this._buffer = value; 49 | } 50 | 51 | get fullHeight(): number { 52 | return this.height + this.buffer; 53 | } 54 | 55 | occupied(x: number, y: number): boolean { 56 | return ( 57 | x < 0 || 58 | y < 0 || 59 | x >= this.width || 60 | y >= this.fullHeight || 61 | this.state[y][x] !== null 62 | ); 63 | } 64 | 65 | add(...blocks: [BoardSquare, number, number][]) { 66 | blocks.forEach(([char, x, y]) => { 67 | if (y < 0 || y >= this.fullHeight || x < 0 || x >= this.width) return; 68 | this.state[y][x] = char; 69 | }); 70 | } 71 | 72 | clearLines() { 73 | let garbageCleared = 0; 74 | const lines: number[] = []; 75 | this.state.forEach((row, idx) => { 76 | if (row.every((block) => block !== null && block !== Mino.BOMB)) { 77 | lines.push(idx); 78 | if (row.some((block) => block === Mino.GARBAGE)) garbageCleared++; 79 | } 80 | }); 81 | 82 | [...lines].reverse().forEach((line) => { 83 | this.state.splice(line, 1); 84 | this.state.push(new Array(this.width).fill(null)); 85 | }); 86 | return { lines: lines.length, garbageCleared }; 87 | } 88 | 89 | clearBombs(placedBlocks: [number, number][]) { 90 | let lowestY = placedBlocks.reduce( 91 | (acc, [_, y]) => Math.min(acc, y), 92 | this.fullHeight 93 | ); 94 | if (lowestY === 0) return { lines: 0, garbageCleared: 0 }; 95 | 96 | const lowestBlocks = placedBlocks.filter(([_, y]) => y === lowestY); 97 | 98 | const bombColumns = lowestBlocks 99 | .filter(([x, y]) => this.state[y - 1][x] === Mino.BOMB) 100 | .map(([x, _]) => x); 101 | if (bombColumns.length === 0) return { lines: 0, garbageCleared: 0 }; 102 | 103 | const lines: number[] = []; 104 | 105 | while ( 106 | lowestY > 0 && 107 | bombColumns.some((col) => this.state[lowestY - 1][col] === Mino.BOMB) 108 | ) { 109 | lines.push(--lowestY); 110 | } 111 | 112 | if (lines.length === 0) return { lines: 0, garbageCleared: 0 }; 113 | 114 | lines.forEach((line) => { 115 | this.state.splice(line, 1); 116 | this.state.push(new Array(this.width).fill(null)); 117 | }); 118 | 119 | return { lines: lines.length, garbageCleared: lines.length }; 120 | } 121 | 122 | clearBombsAndLines(placedBlocks: [number, number][]) { 123 | const bombs = this.clearBombs(placedBlocks); 124 | const lines = this.clearLines(); 125 | return { 126 | lines: lines.lines + bombs.lines, 127 | garbageCleared: bombs.garbageCleared + lines.garbageCleared 128 | }; 129 | } 130 | 131 | get perfectClear() { 132 | return this.state.every((row) => row.every((block) => block === null)); 133 | } 134 | 135 | insertGarbage({ 136 | amount, 137 | size, 138 | column, 139 | bombs 140 | }: { 141 | amount: number; 142 | size: number; 143 | column: number; 144 | bombs: boolean; 145 | }) { 146 | this.state.splice( 147 | 0, 148 | 0, 149 | ...Array.from({ length: amount }, () => 150 | Array.from({ length: this.width }, (_, idx) => 151 | idx >= column && idx < column + size 152 | ? bombs 153 | ? Mino.BOMB 154 | : null 155 | : Mino.GARBAGE 156 | ) 157 | ) 158 | ); 159 | 160 | this.state.splice(this.fullHeight - amount - 1, amount); 161 | } 162 | } 163 | 164 | export * from "./connected"; 165 | -------------------------------------------------------------------------------- /src/engine/constants/index.ts: -------------------------------------------------------------------------------- 1 | export namespace constants { 2 | export namespace flags { 3 | export const ROTATION_LEFT = 1; 4 | export const ROTATION_RIGHT = 2; 5 | export const ROTATION_180 = 4; 6 | export const ROTATION_SPIN = 8; 7 | export const ROTATION_MINI = 16; 8 | export const ROTATION_SPIN_ALL = 32; 9 | export const ROTATION_ALL = 10 | ROTATION_LEFT | 11 | ROTATION_RIGHT | 12 | ROTATION_180 | 13 | ROTATION_SPIN | 14 | ROTATION_MINI | 15 | ROTATION_SPIN_ALL; 16 | export const STATE_WALL = 64; 17 | export const STATE_SLEEP = 128; 18 | export const STATE_FLOOR = 256; 19 | export const STATE_NODRAW = 512; 20 | export const STATE_ALL = 21 | STATE_WALL | STATE_SLEEP | STATE_FLOOR | STATE_NODRAW; 22 | export const ACTION_IHS = 1024; 23 | export const ACTION_FORCELOCK = 2048; 24 | export const ACTION_SOFTDROP = 4096; 25 | export const ACTION_MOVE = 8192; 26 | export const ACTION_ROTATE = 16384; 27 | export const FLAGS_COUNT = 15; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/engine/garbage/index.ts: -------------------------------------------------------------------------------- 1 | import { deepCopy, RNG } from "../utils"; 2 | 3 | export interface GarbageQueueInitializeParams { 4 | cap: { 5 | value: number; 6 | marginTime: number; 7 | increase: number; 8 | absolute: number; 9 | max: number; 10 | }; 11 | messiness: { 12 | change: number; 13 | within: number; 14 | nosame: boolean; 15 | timeout: number; 16 | center: boolean; 17 | }; 18 | 19 | garbage: { 20 | speed: number; 21 | holeSize: number; 22 | }; 23 | 24 | multiplier: { 25 | value: number; 26 | increase: number; 27 | marginTime: number; 28 | }; 29 | 30 | bombs: boolean; 31 | 32 | seed: number; 33 | boardWidth: number; 34 | rounding: "down" | "rng"; 35 | openerPhase: number; 36 | specialBonus: boolean; 37 | } 38 | 39 | export interface Garbage { 40 | frame: number; 41 | amount: number; 42 | size: number; 43 | } 44 | 45 | export interface IncomingGarbage extends Garbage { 46 | cid: number; 47 | gameid: number; 48 | confirmed: boolean; 49 | } 50 | export interface OutgoingGarbage extends Garbage { 51 | id: number; 52 | column: number; 53 | } 54 | 55 | export interface GarbageQueueSnapshot { 56 | seed: number; 57 | lastTankTime: number; 58 | lastColumn: number | null; 59 | sent: number; 60 | hasChangedColumn: boolean; 61 | lastReceivedCount: number; 62 | queue: IncomingGarbage[]; 63 | } 64 | 65 | export class GarbageQueue { 66 | options: GarbageQueueInitializeParams; 67 | 68 | queue: IncomingGarbage[]; 69 | 70 | lastTankTime: number = 0; 71 | lastColumn: number | null = null; 72 | hasChangedColumn: boolean = false; 73 | lastReceivedCount: number = 0; 74 | rng: RNG; 75 | 76 | // for opener phase calculations 77 | sent = 0; 78 | 79 | constructor(options: GarbageQueueInitializeParams) { 80 | this.options = deepCopy(options); 81 | if (!this.options.cap.absolute) 82 | this.options.cap.absolute = Number.MAX_SAFE_INTEGER; 83 | 84 | this.queue = []; 85 | 86 | this.rng = new RNG(this.options.seed); 87 | } 88 | 89 | snapshot(): GarbageQueueSnapshot { 90 | return { 91 | seed: this.rng.seed, 92 | lastTankTime: this.lastTankTime, 93 | lastColumn: this.lastColumn, 94 | sent: this.sent, 95 | hasChangedColumn: this.hasChangedColumn, 96 | lastReceivedCount: this.lastReceivedCount, 97 | queue: deepCopy(this.queue) 98 | }; 99 | } 100 | 101 | fromSnapshot(snapshot: GarbageQueueSnapshot) { 102 | this.queue = deepCopy(snapshot.queue); 103 | this.lastTankTime = snapshot.lastTankTime; 104 | this.lastColumn = snapshot.lastColumn; 105 | this.rng = new RNG(snapshot.seed); 106 | this.sent = snapshot.sent; 107 | this.hasChangedColumn = snapshot.hasChangedColumn; 108 | this.lastReceivedCount = snapshot.lastReceivedCount; 109 | } 110 | 111 | rngex() { 112 | return this.rng.nextFloat(); 113 | } 114 | 115 | get size() { 116 | return this.queue.reduce((a, b) => a + b.amount, 0); 117 | } 118 | 119 | resetReceivedCount() { 120 | this.lastReceivedCount = 0; 121 | } 122 | 123 | receive(...args: IncomingGarbage[]) { 124 | this.queue.push(...args.filter((arg) => arg.amount > 0)); 125 | 126 | while (this.size > this.options.cap.absolute) { 127 | const total = this.size; 128 | if (this.queue.at(-1)!.amount <= total - this.options.cap.absolute) { 129 | this.queue.pop(); 130 | } else { 131 | this.queue.at(-1)!.amount -= total - this.options.cap.absolute; 132 | } 133 | } 134 | } 135 | 136 | confirm(cid: number, gameid: number, frame: number) { 137 | const obj = this.queue.find((g) => g.cid === cid && g.gameid === gameid); 138 | if (!obj) return false; 139 | obj.frame = frame; 140 | obj.confirmed = true; 141 | return true; 142 | } 143 | 144 | cancel( 145 | amount: number, 146 | pieceCount: number, 147 | legacy: { openerPhase?: boolean } = {} 148 | ) { 149 | let send = amount, 150 | cancel = 0; 151 | 152 | let cancelled: IncomingGarbage[] = []; 153 | if ( 154 | pieceCount + 1 <= 155 | this.options.openerPhase - (legacy.openerPhase ? 1 : 0) && 156 | this.size >= this.sent 157 | ) 158 | cancel += amount; 159 | while ((send > 0 || cancel > 0) && this.size > 0) { 160 | this.queue[0].amount--; 161 | if ( 162 | cancelled.length === 0 || 163 | cancelled[cancelled.length - 1].cid !== this.queue[0].cid 164 | ) { 165 | cancelled.push({ ...this.queue[0], amount: 1 }); 166 | } else { 167 | cancelled[cancelled.length - 1].amount++; 168 | } 169 | if (this.queue[0].amount <= 0) { 170 | this.queue.shift(); 171 | if (this.rngex() < this.options.messiness.change) { 172 | this.#reroll_column(); 173 | this.hasChangedColumn = true; 174 | } 175 | } 176 | if (send > 0) send--; 177 | else cancel--; 178 | } 179 | 180 | this.sent += send; 181 | return [send, cancelled] as const; 182 | } 183 | 184 | get #columnWidth() { 185 | return Math.max( 186 | 0, 187 | this.options.boardWidth - (this.options.garbage.holeSize - 1) 188 | ); 189 | } 190 | 191 | #reroll_column() { 192 | const centerBuffer = this.options.messiness.center 193 | ? Math.round(this.options.boardWidth / 5) 194 | : 0; 195 | 196 | let col: number; 197 | if (this.options.messiness.nosame && this.lastColumn !== null) { 198 | col = 199 | centerBuffer + 200 | Math.floor(this.rngex() * (this.#columnWidth - 1 - 2 * centerBuffer)); 201 | if (col >= this.lastColumn) col++; 202 | } else { 203 | col = 204 | centerBuffer + 205 | Math.floor(this.rngex() * (this.#columnWidth - 2 * centerBuffer)); 206 | } 207 | 208 | this.lastColumn = col; 209 | return col; 210 | } 211 | 212 | tank(frame: number, cap: number, hard: boolean): OutgoingGarbage[] { 213 | if (this.queue.length === 0) return []; 214 | 215 | const res: OutgoingGarbage[] = []; 216 | 217 | this.queue = this.queue.sort((a, b) => a.frame - b.frame); 218 | 219 | if ( 220 | this.options.messiness.timeout && 221 | frame >= this.lastTankTime + this.options.messiness.timeout 222 | ) { 223 | this.#reroll_column(); 224 | this.hasChangedColumn = true; 225 | } 226 | 227 | const tankAll = false; 228 | const lines = tankAll 229 | ? 400 230 | : Math.floor(Math.min(cap, this.options.cap.max)); 231 | 232 | for (let i = 0; i < lines && this.queue.length !== 0; i++) { 233 | const item = this.queue[0]; 234 | 235 | // TODO: Fix this 236 | // The real game uses an "active" system where garbages have a proptery, may be an issue later 237 | if (item.frame + this.options.garbage.speed > (hard ? frame : frame - 1)) 238 | break; 239 | 240 | item.amount--; 241 | this.lastReceivedCount++; 242 | 243 | let col: number = this.lastColumn!; 244 | if ( 245 | (col === null || this.rngex() < this.options.messiness.within) && 246 | !this.hasChangedColumn 247 | ) { 248 | col = this.#reroll_column(); 249 | this.hasChangedColumn = true; 250 | } 251 | 252 | res.push({ 253 | ...item, 254 | amount: 1, 255 | column: col, 256 | id: item.cid 257 | }); 258 | 259 | this.hasChangedColumn = false; 260 | 261 | if (item.amount <= 0) { 262 | this.queue.shift(); 263 | 264 | if (this.rngex() < this.options.messiness.change) { 265 | this.#reroll_column(); 266 | this.hasChangedColumn = true; 267 | } 268 | } 269 | } 270 | 271 | return res; 272 | } 273 | 274 | round(amount: number): number { 275 | switch (this.options.rounding) { 276 | case "down": 277 | return Math.floor(amount); 278 | case "rng": 279 | const floored = Math.floor(amount); 280 | if (floored === amount) return floored; 281 | const decimal = amount - floored; 282 | return floored + (this.rngex() < decimal ? 1 : 0); 283 | default: 284 | throw new Error(`Invalid rounding mode ${this.options.rounding}`); 285 | } 286 | } 287 | } 288 | 289 | export * from "./legacy"; 290 | -------------------------------------------------------------------------------- /src/engine/garbage/legacy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type GarbageQueueInitializeParams, 3 | type IncomingGarbage, 4 | type GarbageQueueSnapshot, 5 | type OutgoingGarbage 6 | } from "."; 7 | import { deepCopy, RNG } from "../utils"; 8 | 9 | const columnWidth = (width: number, garbageHoleSize: number) => { 10 | return Math.max(0, width - (garbageHoleSize - 1)); 11 | }; 12 | 13 | export class LegacyGarbageQueue { 14 | options: GarbageQueueInitializeParams; 15 | 16 | queue: IncomingGarbage[]; 17 | 18 | lastTankTime: number = 0; 19 | lastColumn: number | null = null; 20 | rng: RNG; 21 | 22 | // for opener phase calculations 23 | sent = 0; 24 | 25 | constructor(options: GarbageQueueInitializeParams) { 26 | this.options = deepCopy(options); 27 | if (!this.options.cap.absolute) 28 | this.options.cap.absolute = Number.MAX_SAFE_INTEGER; 29 | 30 | this.queue = []; 31 | 32 | this.rng = new RNG(this.options.seed); 33 | } 34 | 35 | snapshot(): GarbageQueueSnapshot { 36 | return { 37 | seed: this.rng.seed, 38 | lastTankTime: this.lastTankTime, 39 | lastColumn: this.lastColumn, 40 | sent: this.sent, 41 | queue: deepCopy(this.queue), 42 | hasChangedColumn: false, 43 | lastReceivedCount: 0 44 | }; 45 | } 46 | 47 | fromSnapshot(snapshot: GarbageQueueSnapshot) { 48 | this.queue = deepCopy(snapshot.queue); 49 | this.lastTankTime = snapshot.lastTankTime; 50 | this.lastColumn = snapshot.lastColumn; 51 | this.rng = new RNG(snapshot.seed); 52 | this.sent = snapshot.sent; 53 | } 54 | 55 | rngex() { 56 | return this.rng.nextFloat(); 57 | } 58 | 59 | get size() { 60 | return this.queue.reduce((a, b) => a + b.amount, 0); 61 | } 62 | 63 | receive(...args: IncomingGarbage[]) { 64 | this.queue.push(...args.filter((arg) => arg.amount > 0)); 65 | 66 | while (this.size > this.options.cap.absolute) { 67 | const total = this.size; 68 | if (this.queue.at(-1)!.amount <= total - this.options.cap.absolute) { 69 | this.queue.pop(); 70 | } else { 71 | this.queue.at(-1)!.amount -= total - this.options.cap.absolute; 72 | } 73 | } 74 | } 75 | 76 | confirm(cid: number, gameid: number, frame: number) { 77 | const obj = this.queue.find((g) => g.cid === cid && g.gameid === gameid); 78 | if (!obj) return false; 79 | obj.frame = frame; 80 | obj.confirmed = true; 81 | return true; 82 | } 83 | 84 | cancel( 85 | amount: number, 86 | pieceCount: number, 87 | legacy: { openerPhase?: boolean } = {} 88 | ) { 89 | let send = amount, 90 | cancel = 0; 91 | 92 | let cancelled: IncomingGarbage[] = []; 93 | 94 | if ( 95 | pieceCount + 1 <= 96 | this.options.openerPhase - (legacy.openerPhase ? 1 : 0) && 97 | this.size >= this.sent 98 | ) 99 | cancel += amount; 100 | while ((send > 0 || cancel > 0) && this.size > 0) { 101 | this.queue[0].amount--; 102 | 103 | if ( 104 | cancelled.length === 0 || 105 | cancelled[cancelled.length - 1].cid !== this.queue[0].cid 106 | ) { 107 | cancelled.push({ ...this.queue[0], amount: 1 }); 108 | } else { 109 | cancelled[cancelled.length - 1].amount++; 110 | } 111 | 112 | if (this.queue[0].amount <= 0) this.queue.shift(); 113 | if (send > 0) send--; 114 | else cancel--; 115 | } 116 | 117 | this.sent += send; 118 | return [send, cancelled] as const; 119 | } 120 | 121 | /** 122 | * This function does NOT take into account messiness on timeout. 123 | * The first garbage hole will be correct, 124 | * but subsequent holes depend on whether or not garbage is cancelled. 125 | */ 126 | predict() { 127 | const rng = this.rng.clone(); 128 | const rngex = rng.nextFloat.bind(rng); 129 | 130 | let lastColumn = this.lastColumn; 131 | 132 | const reroll = () => { 133 | lastColumn = this.#__internal_rerollColumn(lastColumn, rngex); 134 | return lastColumn; 135 | }; 136 | 137 | const result = this.#__internal_tank( 138 | deepCopy(this.queue), 139 | () => lastColumn, 140 | rngex, 141 | reroll, 142 | -Number.MIN_SAFE_INTEGER, 143 | Number.MAX_SAFE_INTEGER, 144 | false 145 | ); 146 | 147 | return result.res; 148 | } 149 | 150 | get nextColumn() { 151 | const rng = this.rng.clone(); 152 | if (this.lastColumn === null) 153 | return this.#__internal_rerollColumn(null, rng.nextFloat.bind(rng)); 154 | return this.lastColumn; 155 | } 156 | 157 | #__internal_rerollColumn(current: number | null, rngex: RNG["nextFloat"]) { 158 | let col: number; 159 | const cols = columnWidth( 160 | this.options.boardWidth, 161 | this.options.garbage.holeSize 162 | ); 163 | 164 | if (this.options.messiness.nosame && current !== null) { 165 | col = Math.floor(rngex() * (cols - 1)); 166 | if (col >= current) col++; 167 | } else { 168 | col = Math.floor(rngex() * cols); 169 | } 170 | 171 | return col; 172 | } 173 | 174 | #rerollColumn() { 175 | const col = this.#__internal_rerollColumn( 176 | this.lastColumn, 177 | this.rngex.bind(this) 178 | ); 179 | this.lastColumn = col; 180 | return col; 181 | } 182 | 183 | #__internal_tank( 184 | queue: IncomingGarbage[], 185 | lastColumn: () => number | null, 186 | rngex: () => number, 187 | reroll: () => number, 188 | frame: number, 189 | cap: number, 190 | hard: boolean 191 | ) { 192 | if (queue.length === 0) return { res: [], lastColumn, queue }; 193 | 194 | const res: OutgoingGarbage[] = []; 195 | 196 | queue = queue.sort((a, b) => a.frame - b.frame); 197 | 198 | if ( 199 | this.options.messiness.timeout && 200 | frame >= this.lastTankTime + this.options.messiness.timeout 201 | ) { 202 | reroll(); 203 | this.lastTankTime = frame; 204 | } 205 | 206 | let total = 0; 207 | 208 | while (total < cap && queue.length > 0) { 209 | const item = deepCopy(queue[0]); 210 | 211 | // TODO: wtf hacky fix, this is 100% not right idk how to fix this 212 | if (item.frame + this.options.garbage.speed > (hard ? frame : frame - 1)) 213 | break; // do not spawn garbage that is still traveling 214 | total += item.amount; 215 | 216 | let exausted = false; 217 | 218 | if (total > cap) { 219 | const excess = total - cap; 220 | queue[0].amount = excess; 221 | item.amount -= excess; 222 | } else { 223 | queue.shift(); 224 | exausted = true; 225 | } 226 | 227 | for (let i = 0; i < item.amount; i++) { 228 | const r = 229 | lastColumn() === null || rngex() < this.options.messiness.within; 230 | res.push({ 231 | ...item, 232 | id: item.cid, 233 | amount: 1, 234 | column: r ? reroll() : lastColumn()! 235 | }); 236 | } 237 | 238 | if (exausted && rngex() < this.options.messiness.change) { 239 | reroll(); 240 | } 241 | } 242 | 243 | return { 244 | res, 245 | queue 246 | }; 247 | } 248 | 249 | tank(frame: number, cap: number, hard: boolean) { 250 | const { res, queue } = this.#__internal_tank( 251 | this.queue, 252 | () => this.lastColumn, 253 | this.rngex.bind(this), 254 | this.#rerollColumn.bind(this), 255 | frame, 256 | cap, 257 | hard 258 | ); 259 | 260 | this.queue = queue; 261 | 262 | return res.map((v) => ({ ...v, bombs: this.options.bombs })); 263 | } 264 | 265 | round(amount: number): number { 266 | switch (this.options.rounding) { 267 | case "down": 268 | return Math.floor(amount); 269 | case "rng": 270 | const floored = Math.floor(amount); 271 | if (floored === amount) return floored; 272 | const decimal = amount - floored; 273 | return floored + (this.rngex() < decimal ? 1 : 0); 274 | default: 275 | throw new Error(`Invalid rounding mode ${this.options.rounding}`); 276 | } 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/engine/multiplayer/ige.ts: -------------------------------------------------------------------------------- 1 | import { polyfills } from "../utils"; 2 | 3 | interface GarbageRecord { 4 | amount: number; 5 | iid: number; 6 | } 7 | 8 | interface PlayerData { 9 | incoming: number; 10 | outgoing: GarbageRecord[]; 11 | } 12 | 13 | export interface IGEHandlerSnapshot { 14 | iid: number; 15 | players: { [key: number]: string }; 16 | } 17 | /** 18 | * Manages network IGE cancelling 19 | */ 20 | export class IGEHandler { 21 | /** @hidden */ 22 | private players: polyfills.Map< 23 | number, 24 | // { incoming: number; outgoing: GarbageRecord[] } 25 | //! there was an issue where as an object some garbage numbers would magically turn into other numbers, wtf js 26 | string 27 | >; 28 | /** @hidden */ 29 | private iid = 0; 30 | 31 | /** @hidden */ 32 | private extract(data: string): PlayerData { 33 | return JSON.parse(data); 34 | } 35 | 36 | /** @hidden */ 37 | private stringify(data: PlayerData): string { 38 | return JSON.stringify(data); 39 | } 40 | 41 | /** 42 | * Manages network IGE cancelling 43 | * @param players - list of player ids 44 | */ 45 | constructor(players: number[]) { 46 | this.players = new polyfills.Map(); 47 | players.forEach((player) => { 48 | this.players.set(player, this.stringify({ incoming: 0, outgoing: [] })); 49 | }); 50 | } 51 | 52 | /** 53 | * Sends a message to a player. 54 | * @param options - info on sending player 55 | * @param options.playerID - The ID of the player to send the message to. 56 | * @param options.amount - The amount of the message. 57 | * @throws {Error} If the player is not found. 58 | */ 59 | send({ playerID, amount }: { playerID: number; amount: number }) { 60 | if (amount === 0) return; 61 | const player = this.players.get(playerID); 62 | const iid = ++this.iid; 63 | 64 | if (!player) 65 | throw new Error( 66 | `player not found: player with id ${playerID} not in ${[ 67 | ...(this.players.keys() as any) 68 | ].join(", ")}` 69 | ); 70 | 71 | this.players.set( 72 | playerID, 73 | JSON.stringify({ 74 | incoming: JSON.parse(player).incoming, 75 | outgoing: [...this.extract(player).outgoing, { iid, amount }] 76 | }) 77 | ); 78 | 79 | // console.log( 80 | // "send", 81 | // playerID, 82 | // Object.fromEntries( 83 | // [...this.players.entries()].map(([k, v]) => [k, this.extract(v)]) 84 | // ) 85 | // ); 86 | } 87 | 88 | /** 89 | * Receives a garbage from a player and processes it. 90 | * @param garbage - garbage object of data 91 | * @param garbage.playerID - The ID of the player sending the garbage. 92 | * @param garbage.ackiid - The IID of the last acknowledged item. 93 | * @param garbage.iid - The IID of the incoming item. 94 | * @param garbage.amount - The amount of the incoming item. 95 | * @returns The remaining amount after processing the message. 96 | * @throws {Error} If the player is not found. 97 | */ 98 | receive({ 99 | playerID, 100 | ackiid, 101 | iid, 102 | amount 103 | }: { 104 | playerID: number; 105 | ackiid: number; 106 | iid: number; 107 | amount: number; 108 | }) { 109 | const player = this.players.get(playerID); 110 | if (!player) 111 | throw new Error( 112 | `player not found: player with id ${playerID} not in ${[ 113 | ...(this.players.keys() as any) 114 | ].join(", ")}` 115 | ); 116 | 117 | const p = this.extract(player); 118 | 119 | const incomingIID = Math.max(iid, p.incoming ?? 0); 120 | 121 | const newIGEs: GarbageRecord[] = []; 122 | 123 | let runningAmount = amount; 124 | p.outgoing.forEach((item) => { 125 | if (item.iid <= ackiid) return; 126 | const amt = Math.min(item.amount, runningAmount); 127 | item.amount -= amt; 128 | runningAmount -= amt; 129 | if (item.amount > 0) newIGEs.push(item); 130 | }); 131 | 132 | this.players.set( 133 | playerID, 134 | this.stringify({ incoming: incomingIID, outgoing: newIGEs }) 135 | ); 136 | 137 | // console.log( 138 | // "receive", 139 | // playerID, 140 | // Object.fromEntries( 141 | // [...this.players.entries()].map(([k, v]) => [k, this.extract(v)]) 142 | // ) 143 | // ); 144 | 145 | return runningAmount; 146 | } 147 | 148 | snapshot(): IGEHandlerSnapshot { 149 | return { 150 | players: Object.fromEntries(this.players.entries()), 151 | iid: this.iid 152 | }; 153 | } 154 | 155 | fromSnapshot(snapshot: IGEHandlerSnapshot) { 156 | this.players = new polyfills.Map( 157 | Object.entries(snapshot.players).map(([k, v]) => [Number(k), v]) 158 | ); 159 | this.iid = snapshot.iid; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/engine/multiplayer/index.ts: -------------------------------------------------------------------------------- 1 | import type { Game } from "../../types"; 2 | 3 | export * from "./ige"; 4 | 5 | export interface MultiplayerOptions { 6 | opponents: number[]; 7 | passthrough: Game.Passthrough; 8 | } 9 | -------------------------------------------------------------------------------- /src/engine/queue/index.ts: -------------------------------------------------------------------------------- 1 | import { type BagType, type RngInnerFunction, rngMap } from "./rng"; 2 | import { Mino } from "./types"; 3 | 4 | export interface QueueInitializeParams { 5 | seed: number; 6 | type: BagType; 7 | minLength: number; 8 | } 9 | 10 | export class Queue { 11 | seed: number; 12 | type: BagType; 13 | genFunction!: RngInnerFunction; 14 | value: Mino[]; 15 | _minLength!: number; 16 | index: number; 17 | repopulateListener: ((pieces: Mino[]) => void) | null = null; 18 | constructor(options: QueueInitializeParams) { 19 | this.seed = options.seed; 20 | this.type = options.type; 21 | this.reset(); 22 | this.value = []; 23 | this.minLength = options.minLength; 24 | 25 | this.index = 0; 26 | } 27 | 28 | reset(index = 0) { 29 | this.genFunction = rngMap[this.type](this.seed); 30 | this.value = []; 31 | this.index = 0; 32 | this.repopulate(); 33 | for (let i = 0; i < index; i++) { 34 | this.shift(); 35 | this.repopulate(); 36 | } 37 | } 38 | 39 | onRepopulate(listener: NonNullable) { 40 | this.repopulateListener = listener; 41 | } 42 | 43 | get minLength() { 44 | return this._minLength; 45 | } 46 | set minLength(val: number) { 47 | this._minLength = val; 48 | this.repopulate(); 49 | } 50 | 51 | get next() { 52 | return this.value[0]; 53 | } 54 | 55 | at(index: number) { 56 | return this.value.at(index); 57 | } 58 | 59 | shift() { 60 | const val = this.value.shift(); 61 | this.index++; 62 | this.repopulate(); 63 | return val; 64 | } 65 | 66 | private repopulate() { 67 | const added: Mino[] = []; 68 | while (this.value.length < this.minLength) { 69 | const newValues = this.genFunction(); 70 | this.value.push(...newValues); 71 | added.push(...newValues); 72 | } 73 | 74 | if (this.repopulateListener && added.length) { 75 | this.repopulateListener(added); 76 | } 77 | } 78 | } 79 | export * from "./rng"; 80 | export * from "./types"; 81 | -------------------------------------------------------------------------------- /src/engine/queue/rng/bag14.ts: -------------------------------------------------------------------------------- 1 | import { RNG } from "../../utils"; 2 | import { Mino } from "../types"; 3 | 4 | export const bag14 = (seed: number) => { 5 | const gen = new RNG(seed); 6 | return () => 7 | gen.shuffleArray([ 8 | "z", 9 | "l", 10 | "o", 11 | "s", 12 | "i", 13 | "j", 14 | "t", 15 | "z", 16 | "l", 17 | "o", 18 | "s", 19 | "i", 20 | "j", 21 | "t" 22 | ] as Mino[]); 23 | }; 24 | -------------------------------------------------------------------------------- /src/engine/queue/rng/bag7-1.ts: -------------------------------------------------------------------------------- 1 | import { RNG } from "../../utils"; 2 | import { Mino } from "../types"; 3 | 4 | export const bag7_1 = (seed: number): (() => Mino[]) => { 5 | const gen = new RNG(seed); 6 | return () => 7 | gen.shuffleArray([ 8 | Mino.Z, 9 | Mino.L, 10 | Mino.O, 11 | Mino.S, 12 | Mino.I, 13 | Mino.J, 14 | Mino.T, 15 | ([Mino.Z, Mino.L, Mino.O, Mino.S, Mino.I, Mino.J, Mino.T] as const)[ 16 | Math.floor(gen.nextFloat() * 7) 17 | ] 18 | ] as const); 19 | }; 20 | -------------------------------------------------------------------------------- /src/engine/queue/rng/bag7-2.ts: -------------------------------------------------------------------------------- 1 | import { RNG } from "../../utils"; 2 | import { Mino } from "../types"; 3 | 4 | export const bag7_2 = (seed: number): (() => Mino[]) => { 5 | const gen = new RNG(seed); 6 | return () => 7 | gen.shuffleArray([ 8 | Mino.Z, 9 | Mino.L, 10 | Mino.O, 11 | Mino.S, 12 | Mino.I, 13 | Mino.J, 14 | Mino.T, 15 | ([Mino.Z, Mino.L, Mino.O, Mino.S, Mino.I, Mino.J, Mino.T] as const)[ 16 | Math.floor(gen.nextFloat() * 7) 17 | ], 18 | ([Mino.Z, Mino.L, Mino.O, Mino.S, Mino.I, Mino.J, Mino.T] as const)[ 19 | Math.floor(gen.nextFloat() * 7) 20 | ] 21 | ] as const); 22 | }; 23 | -------------------------------------------------------------------------------- /src/engine/queue/rng/bag7-x.ts: -------------------------------------------------------------------------------- 1 | import { RNG } from "../../utils"; 2 | import { Mino } from "../types"; 3 | 4 | export const bag7_X = (seed: number): (() => Mino[]) => { 5 | const gen = new RNG(seed); 6 | const extraPieceCount = [3, 2, 1, 1]; 7 | let bagid = 0; 8 | let extraBag: Mino[] = []; 9 | return () => { 10 | const extra = extraPieceCount[bagid++] ?? 0; 11 | if (extraBag.length < extra) 12 | extraBag = gen.shuffleArray([ 13 | Mino.Z, 14 | Mino.L, 15 | Mino.O, 16 | Mino.S, 17 | Mino.I, 18 | Mino.J, 19 | Mino.T 20 | ] as const); 21 | return gen.shuffleArray([ 22 | Mino.Z, 23 | Mino.L, 24 | Mino.O, 25 | Mino.S, 26 | Mino.I, 27 | Mino.J, 28 | Mino.T, 29 | ...extraBag.splice(0, extra) 30 | ] as const); 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/engine/queue/rng/bag7.ts: -------------------------------------------------------------------------------- 1 | import { RNG } from "../../utils"; 2 | import { Mino } from "../types"; 3 | 4 | export const bag7 = (seed: number) => { 5 | const gen = new RNG(seed); 6 | return () => gen.shuffleArray(["z", "l", "o", "s", "i", "j", "t"] as Mino[]); 7 | }; 8 | -------------------------------------------------------------------------------- /src/engine/queue/rng/classic.ts: -------------------------------------------------------------------------------- 1 | import { RNG } from "../../utils"; 2 | import { Mino } from "../types"; 3 | 4 | export const classic = (seed: number) => { 5 | const TETROMINOS: Mino[] = [ 6 | Mino.Z, 7 | Mino.L, 8 | Mino.O, 9 | Mino.S, 10 | Mino.I, 11 | Mino.J, 12 | Mino.T 13 | ]; 14 | let lastGenerated: number | null = null; 15 | const gen = new RNG(seed); 16 | 17 | return () => { 18 | let index = Math.floor(gen.nextFloat() * (TETROMINOS.length + 1)); 19 | 20 | if (index === lastGenerated || index >= TETROMINOS.length) { 21 | index = Math.floor(gen.nextFloat() * TETROMINOS.length); 22 | } 23 | 24 | lastGenerated = index; 25 | return [TETROMINOS[index]]; 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/engine/queue/rng/index.ts: -------------------------------------------------------------------------------- 1 | import { Mino } from "../types"; 2 | import { bag7 } from "./bag7"; 3 | import { bag7_1 } from "./bag7-1"; 4 | import { bag7_2 } from "./bag7-2"; 5 | import { bag7_X } from "./bag7-x"; 6 | import { bag14 } from "./bag14"; 7 | import { classic } from "./classic"; 8 | import { pairs } from "./pairs"; 9 | import { random } from "./random"; 10 | 11 | export type BagType = 12 | | "7-bag" 13 | | "14-bag" 14 | | "classic" 15 | | "pairs" 16 | | "total mayhem" 17 | | "7+1-bag" 18 | | "7+2-bag" 19 | | "7+x-bag"; 20 | export type RngInnerFunction = () => Mino[]; 21 | export type RngFunction = (seed: number) => RngInnerFunction; 22 | 23 | export const rngMap: { [k in BagType]: RngFunction } = { 24 | "7-bag": bag7, 25 | "14-bag": bag14, 26 | classic: classic, 27 | pairs: pairs, 28 | "total mayhem": random, 29 | "7+1-bag": bag7_1, 30 | "7+2-bag": bag7_2, 31 | "7+x-bag": bag7_X 32 | }; 33 | 34 | export * from "./bag7"; 35 | export * from "./bag14"; 36 | export * from "./classic"; 37 | export * from "./pairs"; 38 | export * from "./random"; 39 | export * from "./bag7-1"; 40 | export * from "./bag7-2"; 41 | export * from "./bag7-x"; 42 | -------------------------------------------------------------------------------- /src/engine/queue/rng/pairs.ts: -------------------------------------------------------------------------------- 1 | import { RNG } from "../../utils"; 2 | import { Mino } from "../types"; 3 | 4 | export const pairs = (seed: number) => { 5 | const gen = new RNG(seed); 6 | return () => { 7 | const s = gen.shuffleArray(["z", "l", "o", "s", "i", "j", "t"] as Mino[]); 8 | const pairs = gen.shuffleArray([s[0], s[0], s[0], s[1], s[1], s[1]]); 9 | 10 | return pairs; 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/engine/queue/rng/random.ts: -------------------------------------------------------------------------------- 1 | import { RNG } from "../../utils"; 2 | import { Mino } from "../types"; 3 | 4 | export const random = (seed: number) => { 5 | const gen = new RNG(seed); 6 | return () => { 7 | const TETROMINOS: Mino[] = [ 8 | Mino.Z, 9 | Mino.L, 10 | Mino.O, 11 | Mino.S, 12 | Mino.I, 13 | Mino.J, 14 | Mino.T 15 | ]; 16 | return [TETROMINOS[Math.floor(gen.nextFloat() * TETROMINOS.length)]]; 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/engine/queue/types.ts: -------------------------------------------------------------------------------- 1 | export enum Mino { 2 | I = "i", 3 | J = "j", 4 | L = "l", 5 | O = "o", 6 | S = "s", 7 | T = "t", 8 | Z = "z", 9 | GARBAGE = "gb", 10 | BOMB = "bomb" 11 | } 12 | -------------------------------------------------------------------------------- /src/engine/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Board, 3 | ConnectedBoard, 4 | Engine, 5 | GarbageQueueSnapshot, 6 | Mino, 7 | OutgoingGarbage, 8 | TetrominoSnapshot 9 | } from "."; 10 | import type { Game } from "../types"; 11 | import { type IGEHandlerSnapshot } from "./multiplayer"; 12 | 13 | export type SpinType = "none" | "mini" | "normal"; 14 | 15 | export interface IncreasableValue { 16 | value: number; 17 | increase: number; 18 | marginTime: number; 19 | } 20 | 21 | export interface EngineSnapshot { 22 | frame: number; 23 | subframe: number; 24 | queue: number; 25 | hold: Mino | null; 26 | holdLocked: boolean; 27 | input: Engine["input"]; 28 | falling: TetrominoSnapshot; 29 | lastSpin: Engine["lastSpin"]; 30 | lastWasClear: boolean; 31 | garbage: GarbageQueueSnapshot; 32 | board: Board["state"]; 33 | connectedBoard: ConnectedBoard["state"]; 34 | targets?: number[]; 35 | stats: Engine["stats"]; 36 | glock: number; 37 | state: number; 38 | currentSpike: number; 39 | ige: IGEHandlerSnapshot; 40 | resCache: Engine["resCache"]; 41 | } 42 | 43 | export interface LockRes { 44 | /** The locked mino */ 45 | mino: Mino; 46 | /** The number of garbage lines cleared */ 47 | garbageCleared: number; 48 | /** The number of lines cleared */ 49 | lines: number; 50 | /** The type of spin performed */ 51 | spin: SpinType; 52 | /** Garbage from attacks before cancelling */ 53 | rawGarbage: number[]; 54 | /** Garbage from attacks after cancelling */ 55 | garbage: number[]; 56 | /** The amount of garbage released by surge before cancelling */ 57 | surge: number; 58 | /** The current engine stats */ 59 | stats: { 60 | garbage: { 61 | sent: number; 62 | attack: number; 63 | receive: number; 64 | cleared: number; 65 | }; 66 | combo: number; 67 | b2b: number; 68 | pieces: number; 69 | lines: number; 70 | }; 71 | /** The amount of garbage added to the board */ 72 | garbageAdded: false | OutgoingGarbage[]; 73 | /** Whether or not the engine is topped out */ 74 | topout: boolean; 75 | /** The number of frames since the last piece was placed */ 76 | pieceTime: number; 77 | /** The keys pressed since the last lock */ 78 | keysPresses: Game.Key[]; 79 | } 80 | 81 | export interface Events { 82 | /** Fired when garbage is recieved, immediately after it is added to the garbage queue */ 83 | "garbage.receive": { 84 | /** The garbage's interaction id */ 85 | iid: number; 86 | /** The amount added to the garbage queue after passthrough canceling */ 87 | amount: number; 88 | /** The original amount recieved before passthrough cancelling */ 89 | originalAmount: number; 90 | }; 91 | /** Fired when garbage is confirmed (interaction_confirm ige). This starts the cancel timer (usually 20 frames) */ 92 | "garbage.confirm": { 93 | /** The garbage's interaction id */ 94 | iid: number; 95 | /** The sender's game id */ 96 | gameid: number; 97 | /** The frame to start timer at */ 98 | frame: number; 99 | }; 100 | /** Fired immediately after garbage is tanked. */ 101 | "garbage.tank": { 102 | /** The garbage's interaction id */ 103 | iid: number; 104 | /** The garbage's spawn column (0-indexed) */ 105 | column: number; 106 | /** The height of the garbage column */ 107 | amount: number; 108 | /** The width of the garbage column */ 109 | size: number; 110 | }; 111 | /** Fired immediately after garbage is cancelled. */ 112 | "garbage.cancel": { 113 | /** The garbage's interaction id */ 114 | iid: number; 115 | /** The amount of garbage that was cancelled */ 116 | amount: number; 117 | /** The width of the would-be garbage */ 118 | size: number; 119 | }; 120 | 121 | /** Fired whenever a piece locks. */ 122 | "falling.lock": LockRes; 123 | 124 | /** Fired whenever a new set of pieces is added to the queue. */ 125 | "queue.add": Mino[]; 126 | } 127 | -------------------------------------------------------------------------------- /src/engine/utils/damageCalc/index.ts: -------------------------------------------------------------------------------- 1 | import { Mino, type SpinType } from "../.."; 2 | 3 | export const garbageData = { 4 | single: 0, 5 | double: 1, 6 | triple: 2, 7 | quad: 4, 8 | penta: 5, 9 | tspinMini: 0, 10 | tspin: 0, 11 | tspinMiniSingle: 0, 12 | tspinSingle: 2, 13 | tspinMiniDouble: 1, 14 | tspinMiniTriple: 2, 15 | tspinDouble: 4, 16 | tspinTriple: 6, 17 | tspinQuad: 10, 18 | tspinPenta: 12, 19 | backtobackBonus: 1, 20 | backtobackBonusLog: 0.8, 21 | comboMinifier: 1, 22 | comboMinifierLog: 1.25, 23 | comboBonus: 0.25, 24 | allClear: 10, 25 | comboTable: { 26 | none: [0], 27 | "classic guideline": [0, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5], 28 | "modern guideline": [0, 1, 1, 2, 2, 2, 3, 3, 3, 3, 3, 3, 4] 29 | } 30 | } as const; 31 | 32 | export const garbageCalcV2 = ( 33 | data: { 34 | lines: number; 35 | spin?: SpinType; 36 | piece: Mino; 37 | b2b: number; 38 | combo: number; 39 | enemies: number; 40 | }, 41 | config: { 42 | spinBonuses: string; 43 | comboTable: keyof (typeof garbageData)["comboTable"] | "multiplier"; 44 | garbageTargetBonus: "none" | "normal" | string; 45 | b2b: { 46 | chaining: boolean; 47 | charging: boolean; 48 | }; 49 | } 50 | ) => { 51 | let garbage = 0; 52 | const { spin: rawSpin, lines, piece, combo, b2b, enemies } = data; 53 | const { 54 | spinBonuses, 55 | comboTable, 56 | garbageTargetBonus, 57 | b2b: b2bOptions 58 | } = config; 59 | 60 | const spin: "mini" | "normal" | null | undefined = 61 | rawSpin === "none" ? null : rawSpin; 62 | 63 | switch (lines) { 64 | case 0: 65 | garbage = 66 | spin === "mini" 67 | ? garbageData.tspinMini 68 | : spin === "normal" 69 | ? garbageData.tspin 70 | : 0; 71 | break; 72 | case 1: 73 | garbage = 74 | spin === "mini" 75 | ? garbageData.tspinMiniSingle 76 | : spin === "normal" 77 | ? garbageData.tspinSingle 78 | : garbageData.single; 79 | break; 80 | case 2: 81 | garbage = 82 | spin === "mini" 83 | ? garbageData.tspinMiniDouble 84 | : spin === "normal" 85 | ? garbageData.tspinDouble 86 | : garbageData.double; 87 | break; 88 | case 3: 89 | garbage = 90 | spin === "mini" 91 | ? garbageData.tspinMiniTriple 92 | : spin === "normal" 93 | ? garbageData.tspinTriple 94 | : garbageData.triple; 95 | break; 96 | case 4: 97 | garbage = spin ? garbageData.tspinQuad : garbageData.quad; 98 | break; 99 | case 5: 100 | garbage = spin ? garbageData.tspinPenta : garbageData.penta; 101 | break; 102 | default: { 103 | const t = lines - 5; 104 | garbage = spin ? garbageData.tspinPenta + 2 * t : garbageData.penta + t; 105 | break; 106 | } 107 | } 108 | 109 | if (spin && spinBonuses === "handheld" && piece.toUpperCase() !== "T") { 110 | garbage /= 2; 111 | } 112 | 113 | if (lines > 0 && b2b > 0) { 114 | if (b2bOptions.chaining) { 115 | const b2bGains = 116 | garbageData.backtobackBonus * 117 | (Math.floor(1 + Math.log1p(b2b * garbageData.backtobackBonusLog)) + 118 | (b2b == 1 119 | ? 0 120 | : (1 + (Math.log1p(b2b * garbageData.backtobackBonusLog) % 1)) / 121 | 3)); 122 | garbage += b2bGains; 123 | } else { 124 | garbage += garbageData.backtobackBonus; 125 | } 126 | } 127 | 128 | if (combo > 0) { 129 | if (comboTable === "multiplier") { 130 | garbage *= 1 + garbageData.comboBonus * combo; 131 | if (combo > 1) { 132 | garbage = Math.max( 133 | Math.log1p( 134 | garbageData.comboMinifier * combo * garbageData.comboMinifierLog 135 | ), 136 | garbage 137 | ); 138 | } 139 | } else { 140 | const comboTableData = garbageData.comboTable[comboTable] || [0]; 141 | garbage += 142 | comboTableData[ 143 | Math.max(0, Math.min(combo - 1, comboTableData.length - 1)) 144 | ]; 145 | } 146 | } 147 | 148 | let garbageBonus = 0; 149 | if (lines > 0 && garbageTargetBonus !== "none") { 150 | let targetBonus = 0; 151 | switch (enemies) { 152 | case 0: 153 | case 1: 154 | break; 155 | case 2: 156 | targetBonus += 1; 157 | break; 158 | case 3: 159 | targetBonus += 3; 160 | break; 161 | case 4: 162 | targetBonus += 5; 163 | break; 164 | case 5: 165 | targetBonus += 7; 166 | break; 167 | default: 168 | targetBonus += 9; 169 | } 170 | 171 | if (garbageTargetBonus === "normal") { 172 | garbage += targetBonus; 173 | } else { 174 | garbageBonus = targetBonus; 175 | } 176 | } 177 | 178 | // TODO: how to bonus 179 | return { 180 | garbage, 181 | bonus: garbageBonus 182 | }; 183 | }; 184 | -------------------------------------------------------------------------------- /src/engine/utils/increase/index.ts: -------------------------------------------------------------------------------- 1 | export class IncreaseTracker { 2 | #value: number; 3 | 4 | base: number; 5 | increase: number; 6 | margin: number; 7 | frame: number; 8 | constructor(base: number, increase: number, margin: number) { 9 | this.#value = this.base = base; 10 | this.increase = increase; 11 | this.margin = margin; 12 | this.frame = 0; 13 | } 14 | 15 | reset() { 16 | this.#value = this.base; 17 | this.frame = 0; 18 | } 19 | 20 | tick() { 21 | this.frame++; 22 | if (this.frame > this.margin) this.#value += this.increase / 60; 23 | return this.get(); 24 | } 25 | 26 | get() { 27 | return this.#value; 28 | } 29 | 30 | set(value: number) { 31 | this.#value = value; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/engine/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./damageCalc"; 2 | export * from "./increase"; 3 | export * from "./kicks"; 4 | export * from "./tetromino"; 5 | export * from "./seed"; 6 | export * from "./polyfills"; 7 | export * from "./rng"; 8 | 9 | export interface Handler { 10 | type: new (...args: any[]) => T; 11 | copy: (value: T) => T; 12 | } 13 | export function deepCopy(obj: T): T; 14 | export function deepCopy[]>(obj: T, handlers: H): T; 15 | export function deepCopy[]>( 16 | obj: T, 17 | handlers: H = [] as unknown as H 18 | ): T { 19 | if (obj === null || obj === undefined || typeof obj !== "object") { 20 | return obj; 21 | } 22 | 23 | for (const h of handlers) { 24 | if (obj instanceof h.type) { 25 | return h.copy(obj as any) as T; 26 | } 27 | } 28 | 29 | if (Array.isArray(obj)) { 30 | return obj.map((item) => deepCopy(item, handlers)) as any; 31 | } 32 | 33 | const out: { [k: string]: any } = {}; 34 | for (const key of Object.keys(obj)) { 35 | out[key] = deepCopy((obj as any)[key], handlers); 36 | } 37 | return out as T; 38 | } 39 | -------------------------------------------------------------------------------- /src/engine/utils/kicks/index.ts: -------------------------------------------------------------------------------- 1 | import { type BoardSquare } from "../../board"; 2 | import { Mino } from "../../queue/types"; 3 | import type { Rotation } from "../tetromino/types"; 4 | import { kicks } from "./data"; 5 | 6 | export { kicks as kickData }; 7 | export type KickTable = keyof typeof kicks; 8 | 9 | export const legal = (blocks: [number, number][], board: BoardSquare[][]) => { 10 | if (board.length === 0) return false; 11 | for (const block of blocks) { 12 | if (block[0] < 0) return false; 13 | if (block[0] >= board[0].length) return false; 14 | if (block[1] < 0) return false; 15 | if (block[1] >= board.length) return false; 16 | if (board[block[1]][block[0]]) return false; 17 | } 18 | 19 | return true; 20 | }; 21 | 22 | export const performKick = ( 23 | kicktable: KickTable, 24 | piece: Mino, 25 | pieceLocation: [number, number], 26 | ao: [number, number], 27 | maxMovement: boolean, 28 | blocks: [number, number][], 29 | startRotation: Rotation, 30 | endRotation: Rotation, 31 | board: BoardSquare[][] 32 | ): 33 | | { 34 | kick: [number, number]; 35 | newLocation: [number, number]; 36 | id: string; 37 | index: number; 38 | } 39 | | boolean => { 40 | try { 41 | if ( 42 | legal( 43 | blocks.map((block) => [ 44 | pieceLocation[0] + block[0] - ao[0], 45 | Math.floor(pieceLocation[1]) - block[1] - ao[1] 46 | ]), 47 | board 48 | ) 49 | ) 50 | return true; 51 | 52 | const kickID = `${startRotation}${endRotation}`; 53 | const table = kicks[kicktable]; 54 | const customKicksetID = 55 | `${piece.toLowerCase()}_kicks` as keyof typeof table; 56 | const kickset: [number, number][] = 57 | customKicksetID in table 58 | ? table[customKicksetID][ 59 | kickID as keyof (typeof table)[typeof customKicksetID] 60 | ] 61 | : (table.kicks[kickID as keyof typeof table.kicks] as [ 62 | number, 63 | number 64 | ][]); 65 | 66 | for (let i = 0; i < kickset.length; i++) { 67 | const [dx, dy] = kickset[i]; 68 | 69 | const newY = maxMovement 70 | ? pieceLocation[1] - dy - ao[1] 71 | : Math.ceil(pieceLocation[1]) - 0.1 - dy - ao[1]; 72 | 73 | if ( 74 | legal( 75 | blocks.map((block) => [ 76 | pieceLocation[0] + block[0] + dx - ao[0], 77 | Math.floor(newY) - block[1] 78 | ]), 79 | board 80 | ) 81 | ) { 82 | return { 83 | newLocation: [pieceLocation[0] + dx - ao[0], newY], 84 | kick: [dx, -dy], 85 | id: kickID, 86 | index: i 87 | }; 88 | } 89 | } 90 | 91 | return false; 92 | } catch { 93 | return false; 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /src/engine/utils/polyfills/index.ts: -------------------------------------------------------------------------------- 1 | export namespace polyfills { 2 | export class Map { 3 | private _entries: Array<[K, V]> = []; 4 | 5 | constructor(iterable?: Iterable<[K, V]>) { 6 | if (iterable) { 7 | for (const [key, value] of iterable) { 8 | this.set(key, value); 9 | } 10 | } 11 | } 12 | 13 | get size(): number { 14 | return this._entries.length; 15 | } 16 | 17 | set = (key: K, value: V): this => { 18 | const index = this._entries.findIndex(([k]) => k === key); 19 | if (index !== -1) { 20 | this._entries[index][1] = value; 21 | } else { 22 | this._entries.push([key, value]); 23 | } 24 | return this; 25 | }; 26 | 27 | get = (key: K): V | undefined => { 28 | const entry = this._entries.find(([k]) => k === key); 29 | return entry ? entry[1] : undefined; 30 | }; 31 | 32 | has = (key: K): boolean => { 33 | return this._entries.some(([k]) => k === key); 34 | }; 35 | 36 | delete = (key: K): boolean => { 37 | const index = this._entries.findIndex(([k]) => k === key); 38 | if (index !== -1) { 39 | this._entries.splice(index, 1); 40 | return true; 41 | } 42 | return false; 43 | }; 44 | 45 | clear = (): void => { 46 | this._entries = []; 47 | }; 48 | 49 | forEach = ( 50 | callback: (value: V, key: K, map: Map) => void, 51 | thisArg?: any 52 | ): void => { 53 | // Use a shallow copy to prevent issues if the map is modified during iteration 54 | const entriesCopy = this._entries.slice(); 55 | for (const [key, value] of entriesCopy) { 56 | callback.call(thisArg, value, key, this); 57 | } 58 | }; 59 | 60 | *entries(): IterableIterator<[K, V]> { 61 | for (const entry of this._entries) { 62 | yield entry; 63 | } 64 | } 65 | 66 | *keys(): IterableIterator { 67 | for (const [key] of this._entries) { 68 | yield key; 69 | } 70 | } 71 | 72 | *values(): IterableIterator { 73 | for (const [, value] of this._entries) { 74 | yield value; 75 | } 76 | } 77 | 78 | [Symbol.iterator] = function* (this: Map) { 79 | yield* this.entries(); 80 | }; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/engine/utils/rng/index.ts: -------------------------------------------------------------------------------- 1 | export class RNG { 2 | private static readonly MODULUS: number = 2147483647; 3 | private static readonly MULTIPLIER: number = 16807; 4 | private static readonly MAX_FLOAT: number = 2147483646; 5 | 6 | private value: number; 7 | 8 | index = 0; 9 | 10 | constructor(seed: number) { 11 | this.value = seed % RNG.MODULUS; 12 | 13 | if (this.value <= 0) { 14 | this.value += RNG.MAX_FLOAT; 15 | } 16 | 17 | this.next = this.next.bind(this); 18 | this.nextFloat = this.nextFloat.bind(this); 19 | this.shuffleArray = this.shuffleArray.bind(this); 20 | this.updateFromIndex = this.updateFromIndex.bind(this); 21 | this.clone = this.clone.bind(this); 22 | } 23 | 24 | next(): number { 25 | this.index++; 26 | return (this.value = (RNG.MULTIPLIER * this.value) % RNG.MODULUS); 27 | } 28 | 29 | nextFloat(): number { 30 | return (this.next() - 1) / RNG.MAX_FLOAT; 31 | } 32 | 33 | shuffleArray(array: T): T { 34 | if (array.length === 0) { 35 | return array; 36 | } 37 | 38 | for (let i = array.length - 1; i !== 0; i--) { 39 | const r = Math.floor(this.nextFloat() * (i + 1)); 40 | [array[i], array[r]] = [array[r], array[i]]; 41 | } 42 | 43 | return array; 44 | } 45 | 46 | get seed() { 47 | return this.value; 48 | } 49 | 50 | set seed(value: number) { 51 | this.value = value % RNG.MODULUS; 52 | 53 | if (this.value <= 0) { 54 | this.value += RNG.MAX_FLOAT; 55 | } 56 | } 57 | 58 | updateFromIndex(index: number) { 59 | while (this.index < index) { 60 | this.next(); 61 | } 62 | } 63 | 64 | clone() { 65 | return new RNG(this.value); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/engine/utils/seed.ts: -------------------------------------------------------------------------------- 1 | export const randomSeed = () => Math.floor(Math.random() * 2147483646); 2 | -------------------------------------------------------------------------------- /src/engine/utils/tetromino/index.ts: -------------------------------------------------------------------------------- 1 | import { deepCopy } from ".."; 2 | import { type BoardSquare } from "../../board"; 3 | import { Mino } from "../../queue/types"; 4 | import { type KickTable, legal, performKick } from "../kicks"; 5 | import { tetrominoes } from "./data"; 6 | import type { Rotation } from "./types"; 7 | 8 | export interface TetrominoInitializeParams { 9 | symbol: Mino; 10 | initialRotation: Rotation; 11 | boardHeight: number; 12 | boardWidth: number; 13 | from?: Tetromino | TetrominoSnapshot; 14 | } 15 | 16 | export interface TetrominoSnapshot { 17 | symbol: Mino; 18 | location: [number, number]; 19 | locking: number; 20 | lockResets: number; 21 | rotResets: number; 22 | safeLock: number; 23 | highestY: number; 24 | rotation: Rotation; 25 | fallingRotations: number; 26 | totalRotations: number; 27 | irs: number; 28 | ihs: boolean; 29 | aox: number; 30 | aoy: number; 31 | keys: number; 32 | } 33 | 34 | export class Tetromino { 35 | #rotation!: Rotation; 36 | symbol: Mino; 37 | states: [number, number][][]; 38 | location: [number, number]; 39 | 40 | locking: number; 41 | lockResets: number; 42 | rotResets: number; 43 | safeLock: number; 44 | highestY: number; 45 | fallingRotations: number; 46 | totalRotations: number; 47 | irs: number; 48 | ihs: boolean; 49 | aox: number; 50 | aoy: number; 51 | keys: number; 52 | 53 | constructor(options: TetrominoInitializeParams) { 54 | this.rotation = options.initialRotation; 55 | this.symbol = options.symbol; 56 | const tetromino = tetrominoes[this.symbol.toLowerCase()]; 57 | 58 | this.states = tetromino.matrix.data as any; 59 | 60 | this.location = [ 61 | Math.floor(options.boardWidth / 2 - tetromino.matrix.w / 2), 62 | options.boardHeight + 2.04 63 | ]; 64 | 65 | // other stuff 66 | this.locking = 0; 67 | this.lockResets = 0; 68 | this.rotResets = 0; 69 | this.safeLock = options.from?.safeLock ?? 0; 70 | this.highestY = options.boardHeight + 2; 71 | this.fallingRotations = 0; 72 | this.totalRotations = 0; 73 | this.irs = options.from?.irs ?? 0; 74 | this.ihs = options.from?.ihs ?? false; 75 | this.aox = 0; 76 | this.aoy = 0; 77 | this.keys = 0; 78 | } 79 | 80 | get blocks() { 81 | return this.states[Math.min(this.rotation, this.states.length)]; 82 | } 83 | 84 | get absoluteBlocks() { 85 | return this.blocks.map((block): [number, number] => [ 86 | block[0] + this.location[0], 87 | -block[1] + this.y 88 | ]); 89 | } 90 | 91 | absoluteAt({ 92 | x = this.location[0], 93 | y = this.location[1], 94 | rotation = this.rotation 95 | }: { 96 | x?: number; 97 | y?: number; 98 | rotation?: number; 99 | }) { 100 | const currentState = [this.location[0], this.location[1], this.rotation]; 101 | 102 | this.location = [x, y]; 103 | this.rotation = rotation; 104 | 105 | const res = this.absoluteBlocks; 106 | 107 | this.location = [currentState[0], currentState[1]]; 108 | this.rotation = currentState[2]; 109 | 110 | return res; 111 | } 112 | 113 | get rotation(): Rotation { 114 | return (this.#rotation % 4) as any; 115 | } 116 | 117 | set rotation(value: number) { 118 | this.#rotation = (value % 4) as any; 119 | } 120 | 121 | get x() { 122 | return this.location[0]; 123 | } 124 | 125 | set x(value: number) { 126 | this.location[0] = value; 127 | } 128 | 129 | get y() { 130 | return Math.floor(this.location[1]); 131 | } 132 | 133 | set y(value: number) { 134 | this.location[1] = value; 135 | } 136 | 137 | isStupidSpinPosition(board: BoardSquare[][]) { 138 | return !legal( 139 | this.blocks.map((block) => [ 140 | block[0] + this.location[0], 141 | -block[1] + this.y - 1 142 | ]), 143 | board 144 | ); 145 | } 146 | 147 | isAllSpinPosition(board: BoardSquare[][]) { 148 | return ( 149 | !legal( 150 | this.blocks.map((block) => [ 151 | block[0] + this.location[0] - 1, 152 | -block[1] + this.y 153 | ]), 154 | board 155 | ) && 156 | !legal( 157 | this.blocks.map((block) => [ 158 | block[0] + this.location[0] + 1, 159 | -block[1] + this.y 160 | ]), 161 | board 162 | ) && 163 | !legal( 164 | this.blocks.map((block) => [ 165 | block[0] + this.location[0], 166 | -block[1] + this.y + 1 167 | ]), 168 | board 169 | ) && 170 | !legal( 171 | this.blocks.map((block) => [ 172 | block[0] + this.location[0], 173 | -block[1] + this.y - 1 174 | ]), 175 | board 176 | ) 177 | ); 178 | } 179 | 180 | rotate( 181 | board: BoardSquare[][], 182 | kickTable: KickTable, 183 | amt: Rotation, 184 | maxMovement: boolean 185 | ) { 186 | const rotatedBlocks = this.states[(this.rotation + amt) % 4]; 187 | const kickRes = performKick( 188 | kickTable, 189 | this.symbol, 190 | this.location, 191 | [this.aox, this.aoy], 192 | maxMovement, 193 | rotatedBlocks, 194 | this.rotation, 195 | ((this.rotation + amt) % 4) as any, 196 | board 197 | ); 198 | 199 | if (typeof kickRes === "object") { 200 | this.location = [...kickRes.newLocation]; 201 | } 202 | if (kickRes) { 203 | this.rotation = this.rotation + amt; 204 | 205 | return kickRes; 206 | } 207 | 208 | return false; 209 | } 210 | 211 | moveRight(board: BoardSquare[][]) { 212 | if ( 213 | legal( 214 | this.blocks.map((block) => [ 215 | block[0] + this.location[0] + 1, 216 | -block[1] + this.y 217 | ]), 218 | board 219 | ) 220 | ) { 221 | this.location[0]++; 222 | return true; 223 | } 224 | return false; 225 | } 226 | moveLeft(board: BoardSquare[][]) { 227 | if ( 228 | legal( 229 | this.blocks.map((block) => [ 230 | block[0] + this.location[0] - 1, 231 | -block[1] + this.y 232 | ]), 233 | board 234 | ) 235 | ) { 236 | this.location[0]--; 237 | return true; 238 | } 239 | 240 | return false; 241 | } 242 | 243 | dasRight(board: BoardSquare[][]) { 244 | if (this.moveRight(board)) { 245 | while (this.moveRight(board)) {} 246 | return true; 247 | } 248 | return false; 249 | } 250 | 251 | dasLeft(board: BoardSquare[][]) { 252 | if (this.moveLeft(board)) { 253 | while (this.moveLeft(board)) {} 254 | return true; 255 | } 256 | return false; 257 | } 258 | 259 | softDrop(board: BoardSquare[][]) { 260 | const start = this.location[1]; 261 | while ( 262 | legal( 263 | this.blocks.map((block) => [ 264 | block[0] + this.location[0], 265 | -block[1] + this.y - 1 266 | ]), 267 | board 268 | ) 269 | ) { 270 | this.location[1]--; 271 | } 272 | 273 | return start !== this.location[1]; 274 | } 275 | 276 | snapshot(): TetrominoSnapshot { 277 | return { 278 | aox: this.aox, 279 | aoy: this.aoy, 280 | fallingRotations: this.fallingRotations, 281 | highestY: this.highestY, 282 | ihs: this.ihs, 283 | irs: this.irs, 284 | keys: this.keys, 285 | rotation: this.rotation, 286 | location: deepCopy(this.location), 287 | locking: this.locking, 288 | lockResets: this.lockResets, 289 | rotResets: this.rotResets, 290 | safeLock: this.safeLock, 291 | symbol: this.symbol, 292 | totalRotations: this.totalRotations 293 | }; 294 | } 295 | } 296 | 297 | export * from "./data"; 298 | export * from "./types"; 299 | -------------------------------------------------------------------------------- /src/engine/utils/tetromino/types.ts: -------------------------------------------------------------------------------- 1 | export type Rotation = 0 | 1 | 2 | 3; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { version } from "./utils"; 2 | 3 | import chalk from "chalk"; 4 | 5 | export { CH, ch, ChannelAPI } from "./channel"; 6 | export * as Utils from "./utils"; 7 | export * as Types from "./types"; 8 | export { Client } from "./classes"; 9 | export * as Classes from "./classes"; 10 | export * as Engine from "./engine"; 11 | export { version } from "./utils"; 12 | 13 | const suppressKey = "TRIANGLE_VERSION_SUPPRESS"; 14 | if (typeof process !== "undefined" && !(suppressKey in process.env)) { 15 | fetch("https://registry.npmjs.org/@haelp/teto") 16 | .then((r) => r.json()) 17 | .then((d: any) => { 18 | if (version < d["dist-tags"].latest) 19 | console.log( 20 | `${chalk.redBright("[Triangle.js]")} Your triangle.js is out of date (v${version} vs v${d["dist-tags"].latest}). We recommend updating with 'npm install @haelp/teto@latest'.\nTo suppress this warning, set the ${suppressKey} environment variable.` 21 | ); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/types/events/in/client.ts: -------------------------------------------------------------------------------- 1 | import type { Game, Room } from "../.."; 2 | import type { Relationship, Room as RoomClass } from "../../../classes"; 3 | import type { Engine } from "../../../engine"; 4 | import type { Social } from "../../social"; 5 | import type { Game as GameEvents } from "./game"; 6 | import type { Ribbon } from "./ribbon"; 7 | 8 | export type Hex = `#${string}`; 9 | 10 | export interface Client { 11 | /** Fires inside Client.create(), will never fire afterwards. */ 12 | "client.ready": { 13 | endpoint: string; 14 | social: Ribbon["server.authorize"]["social"]; 15 | }; 16 | /** Fires when recieving an "err" notification. Data is the "msg" of the notification */ 17 | "client.error": string; 18 | /** Fires when the client dies. */ 19 | "client.dead": string; 20 | /** 21 | * Fires when the websocket closes. 22 | * Note: the websocket might just be migrating, to check for a fully disconnected client, use `client.dead` 23 | */ 24 | "client.close": string; 25 | 26 | /** Any notification popup */ 27 | "client.notify": { 28 | msg: string; 29 | timeout?: number; 30 | subcolor?: Hex; 31 | fcolor?: Hex; 32 | color?: Hex; 33 | bgcolor?: Hex; 34 | icon?: string; 35 | subicon?: string; 36 | header?: string; 37 | classes?: string; 38 | buttons?: { icon?: string; label: string; classes?: string }[]; 39 | id?: string; 40 | }; 41 | 42 | /** Fires whenever the players state changes. */ 43 | "client.room.players": Room.Player[]; 44 | 45 | /** Fires when the client joins a room */ 46 | "client.room.join": RoomClass; 47 | 48 | /** Fires when a game starts */ 49 | "client.game.start": { multi: boolean; ft: number; wb: number } & { 50 | players: { id: string; name: string; points: 0 }[]; 51 | }; 52 | 53 | /** Fires when a round starts (this includes 1-round games) */ 54 | "client.game.round.start": [ 55 | (cb: Game.Tick.Func) => void, 56 | Engine, 57 | { name: string; gameid: number; engine: Engine }[] 58 | ]; 59 | /** Fires when the client's game ends (topout). Finish = game.replay.end, abort = game.abort, end = game.end or game.advance or game.score */ 60 | "client.game.over": 61 | | { 62 | reason: "finish"; 63 | data: GameEvents["game.replay.end"]["data"]; 64 | } 65 | | { 66 | reason: "abort"; 67 | } 68 | | { 69 | reason: "end"; 70 | } 71 | | { 72 | reason: "leave"; 73 | }; 74 | /** Fires when a round is over, sends the user id of the winning player, if there is one. */ 75 | "client.game.round.end": string | null; 76 | /** 77 | * Fires when a game ends. Likely known issue: 78 | * @see https://github.com/tetrjs/tetr.js/issues/62 79 | */ 80 | "client.game.end": 81 | | { 82 | duration: number; 83 | source: "scoreboard"; 84 | players: { 85 | id: string; 86 | name: string; 87 | /** @deprecated */ 88 | points: number; 89 | won: boolean; 90 | lifetime: number; 91 | raw: Game.Scoreboard; 92 | }[]; 93 | } 94 | | { 95 | duration: number; 96 | source: "leaderboard"; 97 | players: { 98 | id: string; 99 | name: string; 100 | points: number; 101 | won: boolean; 102 | raw: Game.Leaderboard; 103 | }[]; 104 | }; 105 | 106 | /** Same as game.abort */ 107 | "client.game.abort": void; 108 | 109 | /** Fires when a message is recived from the server. Contains the raw data of the server message. Useful for logging, do not use for handling events for functionality. Instead, use `client.on()`. */ 110 | "client.ribbon.receive": { command: string; data?: any }; 111 | 112 | /** Fires when a message is sent to the server. Contains the raw data of the server message. Useful for logging. */ 113 | "client.ribbon.send": { command: string; data?: any }; 114 | 115 | /** Fires whenever a Ribbon log is outputted */ 116 | "client.ribbon.log": string; 117 | /** Fires whenever a Ribbon warning is outputted*/ 118 | "client.ribbon.warn": string; 119 | /** Fires whenever Ribbon encounters an error */ 120 | "client.ribbon.error": string; 121 | 122 | // relationship stuff 123 | /** Fires whenever the client is friended */ 124 | "client.friended": { id: string; name: string; avatar: number }; 125 | 126 | /** Fires when a DM (direct message) has been received and AFTER any unknown data has been loaded about the user */ 127 | "client.dm": { 128 | user: Relationship; 129 | content: string; 130 | reply: (message: string) => Promise; 131 | }; 132 | } 133 | -------------------------------------------------------------------------------- /src/types/events/in/game.ts: -------------------------------------------------------------------------------- 1 | import type { Game as GameTypes } from "../.."; 2 | 3 | export interface Game { 4 | "game.ready": GameTypes.Ready; 5 | "game.abort": void; 6 | "game.match": { 7 | gamemode: GameTypes.GameMode; 8 | modename: string; 9 | rb: { 10 | type: string; 11 | options: { 12 | ft: number; 13 | wb: number; 14 | gp: number; 15 | }; 16 | leaderboard: GameTypes.Leaderboard[]; 17 | }; 18 | rrb: { 19 | type: string; 20 | options: {}; 21 | scoreboard: { 22 | id: string; 23 | username: string; 24 | active: boolean; 25 | naturalorder: number; 26 | shadows: any[]; 27 | shadowedBy: (null | string)[]; 28 | alive: boolean; 29 | lifetime: number; 30 | stats: { 31 | apm: number | null; 32 | pps: number | null; 33 | vsscore: number | null; 34 | garbagesent: number; 35 | garbagereceived: number; 36 | kills: number; 37 | altitude: number; 38 | rank: number; 39 | targetingfactor: number; 40 | targetinggrace: number; 41 | btb: number; 42 | revives: number; 43 | escapeartist: number; 44 | blockrationing_app: number; 45 | blockrationing_final: number; 46 | }; 47 | }[]; 48 | }; 49 | }; 50 | "game.start": void; 51 | "game.advance": { 52 | scoreboard: { 53 | id: string; 54 | username: string; 55 | active: boolean; 56 | naturalorder: number; 57 | shadows: []; 58 | shadowedBy: [null, null]; 59 | alive: boolean; 60 | lifetime: number; 61 | stats: { 62 | apm: number; 63 | pps: number; 64 | vsscore: number; 65 | garbagesent: number; 66 | garbagereceived: number; 67 | kills: 1; 68 | altitude: 0; 69 | rank: 1; 70 | targetingfactor: 3; 71 | targetinggrace: 0; 72 | }; 73 | }[]; 74 | }; 75 | "game.score": { 76 | refereedata: { ft: number; wb: number; modename: string }; 77 | leaderboard: GameTypes.Leaderboard[]; 78 | victor: string; 79 | }; 80 | "game.end": { 81 | leaderboard: GameTypes.Leaderboard[]; 82 | scoreboard: GameTypes.Scoreboard[]; 83 | xpPerUser: number; 84 | winners: { 85 | id: string; 86 | username: string; 87 | active: boolean; 88 | naturalorder: number; 89 | shadows: any[]; 90 | shadowedBy: (null | any)[]; 91 | }[]; 92 | }; 93 | "game.replay.state": { 94 | gameid: number; 95 | data: "early" | "wait"; 96 | }; 97 | 98 | "game.replay.ige": { 99 | gameid: number; 100 | iges: GameTypes.IGE[]; 101 | }; 102 | 103 | "game.replay.board": { 104 | boards: { 105 | 0: { 106 | board: { 107 | /** Board state */ 108 | b: GameTypes.BoardSquare[][]; 109 | /** Frame number or turn */ 110 | f: number; 111 | /** Game status or flag */ 112 | g: number; 113 | /** Board width */ 114 | w: number; 115 | /** Board height */ 116 | h: number; 117 | }; 118 | gameid: number; 119 | }; 120 | 1: { 121 | board: { 122 | /** Board state */ 123 | b: GameTypes.BoardSquare[][]; 124 | /** Frame number or turn */ 125 | f: number; 126 | /** Game status or flag */ 127 | g: number; 128 | /** Board width */ 129 | w: number; 130 | /** Board height */ 131 | h: number; 132 | }; 133 | gameid: number; 134 | }; 135 | }; 136 | }; 137 | "game.replay": { 138 | gameid: number; 139 | provisioned: number; 140 | frames: GameTypes.Replay.Frame[]; 141 | }; 142 | "game.replay.end": { 143 | gameid: number; 144 | data: { 145 | gameoverreason: GameTypes.GameOverReason; 146 | killer: { gameid: number; type: "sizzle"; username: null | string }; 147 | }; 148 | }; 149 | 150 | "game.spectate": { 151 | id: number; 152 | data: { 153 | players: { 154 | userid: string; 155 | gameid: number; 156 | alive: boolean; 157 | naturalorder: number; 158 | options: GameTypes.ReadyOptions; 159 | }[]; 160 | match: { 161 | gamemode: string; 162 | modename: string; 163 | rb: { 164 | type: "elimination"; 165 | options: { 166 | ft: number; 167 | wb: number; 168 | gp: number; 169 | }; 170 | leaderboard: { 171 | id: string; 172 | username: string; 173 | active: boolean; 174 | naturalorder: number; 175 | shadows: any[]; 176 | shadowedBy: (null | any)[]; 177 | wins: number; 178 | stats: { 179 | apm: number | null; 180 | pps: number | null; 181 | vsscore: number | null; 182 | garbagesent: number; 183 | garbagereceived: number; 184 | kills: number; 185 | altitude: number; 186 | rank: number; 187 | targetingfactor: number; 188 | targetinggrace: number; 189 | btb: number; 190 | revives: number; 191 | }; 192 | }[]; 193 | }; 194 | rrb: { 195 | type: string; 196 | options: Record; 197 | scoreboard: { 198 | sb: { 199 | stats: { 200 | rank: number; 201 | altitude: number; 202 | btb: number; 203 | revives: number; 204 | }; 205 | allies: any[]; 206 | gameid: number; 207 | specCount: number; 208 | speedrun: boolean; 209 | nearWR: boolean; 210 | lovers: boolean; 211 | }[]; 212 | }; 213 | }; 214 | }; 215 | }; 216 | }; 217 | } 218 | -------------------------------------------------------------------------------- /src/types/events/in/index.ts: -------------------------------------------------------------------------------- 1 | export * as in from "./wrapper"; 2 | -------------------------------------------------------------------------------- /src/types/events/in/ribbon.ts: -------------------------------------------------------------------------------- 1 | import type { Social } from "../../social"; 2 | import type { Client } from "./client"; 3 | 4 | export interface Ribbon { 5 | session: { 6 | ribbonid: string; 7 | tokenid: string; 8 | }; 9 | 10 | ping: { 11 | recvid: number; 12 | }; 13 | 14 | "server.authorize": { 15 | success: boolean; 16 | maintenance: boolean; 17 | worker: { 18 | name: string; 19 | flag: string; 20 | }; 21 | social: { 22 | total_online: number; 23 | notifications: Social.Notification[]; 24 | presences: { 25 | [userId: string]: { 26 | status: string; 27 | detail: string; 28 | invitable: boolean; 29 | }; 30 | }; 31 | relationships: Social.Relationship[]; 32 | }; 33 | }; 34 | 35 | "server.migrate": { 36 | endpoint: string; 37 | name: string; 38 | flag: string; 39 | }; 40 | 41 | "server.migrated": {}; 42 | 43 | "server.announcement": { 44 | type: string; 45 | msg: string; 46 | ts: number; 47 | }; 48 | 49 | "server.maintenance": {}; 50 | 51 | kick: { 52 | reason: string; 53 | }; 54 | 55 | nope: { 56 | reason: string; 57 | }; 58 | 59 | error: any; 60 | err: any; 61 | packets: { 62 | packets: Buffer[]; 63 | }; 64 | 65 | notify: 66 | | { 67 | type: "err" | "warn" | "ok" | "announce"; 68 | msg: string; 69 | } 70 | | { 71 | type: "deny"; 72 | msg: string; 73 | timeout?: number; 74 | } 75 | | Client["client.notify"] 76 | | string; 77 | } 78 | -------------------------------------------------------------------------------- /src/types/events/in/room.ts: -------------------------------------------------------------------------------- 1 | import type { Game as GameTypes, Room as RoomTypes, User } from "../.."; 2 | 3 | export interface Room { 4 | "room.join": { 5 | id: string; 6 | banner: null; // TODO: what is this 7 | silent: boolean; 8 | }; 9 | 10 | "room.leave": { 11 | id: string; 12 | }; 13 | 14 | "room.kick": "hostkick" | "hostban"; 15 | 16 | "room.update": { 17 | id: string; 18 | public: boolean; 19 | name: string; 20 | name_safe: string; 21 | type: RoomTypes.Type; 22 | owner: string; 23 | creator: string; 24 | state: RoomTypes.State; 25 | topic: {}; 26 | info: {}; 27 | auto: RoomTypes.Autostart; 28 | options: GameTypes.Options; 29 | match: RoomTypes.Match; 30 | players: RoomTypes.Player[]; 31 | }; 32 | 33 | /** Fires when the room's autostart state changes */ 34 | "room.update.auto": { 35 | enabled: boolean; 36 | status: RoomTypes.State; 37 | time: number; 38 | maxtime: number; 39 | }; 40 | 41 | "room.player.add": RoomTypes.Player; 42 | "room.player.remove": string; 43 | "room.update.host": string; 44 | /** Fires when a player's bracket is moved */ 45 | "room.update.bracket": { 46 | uid: string; 47 | bracket: RoomTypes.Bracket; 48 | }; 49 | 50 | "room.chat": { 51 | content: string; 52 | content_safe: string; 53 | user: { 54 | username: string; 55 | _id: string; 56 | role: User.Role; 57 | supporter: boolean; 58 | supporter_tier: number; 59 | verified: boolean; 60 | }; 61 | pinned: boolean; 62 | system: boolean; 63 | }; 64 | 65 | /** Fires when a single user's chat messages are deleted */ 66 | "room.chat.delete": { 67 | uid: string; 68 | /** Whether or not to completely delete the messages or just mark them as deleted */ 69 | purge: string; 70 | }; 71 | "room.chat.clear": void; 72 | /** Fires when someone is gifted supporter in the room */ 73 | "room.chat.gift": { 74 | /** UID of who gave supporter */ 75 | sender: number; 76 | /** UID of who received supporter */ 77 | target: number; 78 | months: number; 79 | }; 80 | 81 | // TODO: find out what is this? 82 | "party.members": any[]; 83 | } 84 | -------------------------------------------------------------------------------- /src/types/events/in/social.ts: -------------------------------------------------------------------------------- 1 | import type { Social as SocialTypes } from "../.."; 2 | 3 | export interface Social { 4 | "social.online": number; 5 | 6 | "social.dm": SocialTypes.DM; 7 | "social.dm.fail": "they.fail" | "they.ban" | string; 8 | 9 | "social.presence": { 10 | user: string; 11 | presence: { 12 | status: SocialTypes.Status; 13 | detail: SocialTypes.Detail | String; // keep this as String for autocomplete 14 | invitable: boolean; 15 | }; 16 | }; 17 | 18 | "social.relation.remove": string; 19 | "social.relation.add": { 20 | _id: string; 21 | from: { 22 | _id: string; 23 | username: string; 24 | avatar_revision: string | null; 25 | }; 26 | to: { 27 | _id: string; 28 | username: string; 29 | avatar_revision: string | null; 30 | }; 31 | }; 32 | 33 | "social.notification": SocialTypes.Notification; 34 | 35 | "social.invite": { 36 | sender: string; 37 | roomid: string; 38 | roomname: string; 39 | roomname_safe: string; 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/types/events/in/staff.ts: -------------------------------------------------------------------------------- 1 | export interface Staff { 2 | /** In a normal TETR.IO client, this allows an admin to "eval" any code on any client. The code to evaluate is the string data passed. */ 3 | "staff.xrc": string; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/events/in/wrapper.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from "./client"; 2 | import type { Game } from "./game"; 3 | import type { Ribbon } from "./ribbon"; 4 | import type { Room } from "./room"; 5 | import type { Social } from "./social"; 6 | import type { Staff } from "./staff"; 7 | 8 | export * from "./client"; 9 | export * from "./game"; 10 | export * from "./ribbon"; 11 | export * from "./room"; 12 | export * from "./social"; 13 | export * from "./staff"; 14 | 15 | export type all = Client & Game & Ribbon & Room & Social & Staff; 16 | -------------------------------------------------------------------------------- /src/types/events/index.ts: -------------------------------------------------------------------------------- 1 | export * as Events from "./wrapper"; 2 | -------------------------------------------------------------------------------- /src/types/events/out/game.ts: -------------------------------------------------------------------------------- 1 | import type { Game as GameTypes } from "../.."; 2 | 3 | export interface Game { 4 | "game.scope.start": number; 5 | 6 | "game.scope.end": number; 7 | 8 | "game.spectate": void; 9 | 10 | "game.replay": { 11 | gameid: number; 12 | frames: GameTypes.Replay.Frame[]; 13 | provisioned: number; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/types/events/out/index.ts: -------------------------------------------------------------------------------- 1 | export * as out from "./wrapper"; 2 | -------------------------------------------------------------------------------- /src/types/events/out/ribbon.ts: -------------------------------------------------------------------------------- 1 | import type { Game } from "../.."; 2 | 3 | export interface Ribbon { 4 | "config.handling": Game.Handling; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/events/out/room.ts: -------------------------------------------------------------------------------- 1 | import type { Room as RoomTypes } from "../../room"; 2 | import type { Utils } from "../../utils"; 3 | 4 | export interface Room { 5 | "room.join": string; 6 | "room.create": boolean; 7 | "room.leave": void; 8 | 9 | "room.kick": { uid: string; duration: number }; 10 | "room.ban": string; 11 | "room.unban": string; 12 | 13 | "room.setid": string; 14 | "room.setconfig": { 15 | index: Utils.DeepKeys; 16 | value: Utils.DeepKeyValue< 17 | RoomTypes.SetConfig, 18 | Room["room.setconfig"][number]["index"] 19 | >; 20 | }[]; 21 | 22 | "room.start": void; 23 | "room.abort": void; 24 | 25 | "room.chat.send": { 26 | content: string; 27 | pinned: boolean; 28 | }; 29 | "room.chat.clear": void; 30 | 31 | "room.owner.transfer": string; 32 | "room.owner.revoke": void; 33 | 34 | "room.bracket.switch": "player" | "spectator"; 35 | "room.bracket.move": { uid: string; bracket: "player" | "spectator" }; 36 | } 37 | -------------------------------------------------------------------------------- /src/types/events/out/social.ts: -------------------------------------------------------------------------------- 1 | import type { Social as SocialTypes } from "../../social"; 2 | 3 | export interface Social { 4 | "social.presence": { 5 | status: SocialTypes.Status; 6 | detail: SocialTypes.Detail | String; // keep this as String for autocomplete 7 | }; 8 | 9 | "social.dm": { 10 | recipient: string; 11 | msg: string; 12 | }; 13 | 14 | "social.invite": string; 15 | 16 | "social.notification.ack": void; 17 | "social.relation.ack": string; 18 | } 19 | -------------------------------------------------------------------------------- /src/types/events/out/wrapper.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from "../in/client"; 2 | import type { Game } from "./game"; 3 | import type { Ribbon } from "./ribbon"; 4 | import type { Room } from "./room"; 5 | import type { Social } from "./social"; 6 | 7 | export * from "./social"; 8 | export * from "./room"; 9 | export * from "./game"; 10 | export * from "./ribbon"; 11 | 12 | export * from "../in/client"; 13 | 14 | export type all = Social & Room & Game & Client & Ribbon; 15 | -------------------------------------------------------------------------------- /src/types/events/wrapper.ts: -------------------------------------------------------------------------------- 1 | export * from "./in"; 2 | export * from "./out"; 3 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./events"; 2 | export * from "./replay"; 3 | 4 | export * from "./game"; 5 | export * from "./room"; 6 | export * from "./user"; 7 | export * from "./utils"; 8 | export * from "./social"; 9 | -------------------------------------------------------------------------------- /src/types/replay/index.ts: -------------------------------------------------------------------------------- 1 | import type { VersusReplay } from "./versus"; 2 | 3 | export type Replay = VersusReplay; 4 | 5 | export * from "./versus"; 6 | -------------------------------------------------------------------------------- /src/types/replay/versus.ts: -------------------------------------------------------------------------------- 1 | import type { BagType, KickTable } from "../../engine"; 2 | import type { Game } from "../game"; 3 | 4 | export interface Stats { 5 | altitude: number; 6 | apm: number; 7 | btb: number; 8 | garbagereceived: number; 9 | garbagesent: number; 10 | kills: number; 11 | pps: number; 12 | rank: number; 13 | revives: number; 14 | targetingfactor: number; 15 | targetinggrace: number; 16 | vsscore: number; 17 | } 18 | 19 | export interface VersusReplay { 20 | gamemode: "league" | null; 21 | id: string | null; 22 | replay: { 23 | leaderboard: { 24 | id: string; 25 | username: string; 26 | wins: number; 27 | active: boolean; 28 | naturalorder: number; 29 | shadowedBy: [null, null]; 30 | shadows: []; 31 | stats: Stats & { 32 | escapeartist: null | unknown; 33 | blockrationing_app: null | unknown; 34 | blockrationing_final: null | unknown; 35 | }; 36 | }[]; 37 | rounds: { 38 | active: boolean; 39 | alive: boolean; 40 | id: string; 41 | lifetime: number; 42 | naturalorder: number; 43 | replay: { 44 | frames: number; 45 | events: Game.Replay.Frame[]; 46 | options: { 47 | allow_harddrop: boolean; 48 | allclear_b2b: number; 49 | allclear_b2b_dupes: boolean; 50 | allclear_b2b_sends: boolean; 51 | allclear_garbage: number; 52 | allow180: boolean; 53 | b2bcharge_base: number; 54 | boardheight: number; 55 | boardwidth: number; 56 | bagtype: BagType; 57 | b2bchaining: boolean; 58 | b2bcharging: boolean; 59 | bgmnoreset: boolean; 60 | countdown: boolean; 61 | combotable: Game.ComboTable; 62 | countdown_count: number; 63 | clutch: boolean; 64 | display_username: boolean; 65 | display_hold: boolean; 66 | forfeit_time: number; 67 | fullinterval: number; 68 | fulloffset: number; 69 | gameid: number; 70 | garbagemultiplier: number; 71 | garbageincrease: number; 72 | garbageblocking: Game.GarbageBlocking; 73 | garbagemargin: number; 74 | garbagespeed: number; 75 | garbageholesize: number; 76 | garbagecap: number; 77 | garbagecapincrease: number; 78 | garbagecapmargin: number; 79 | garbagecapmax: number; 80 | garbageabsolutecap: number; 81 | garbagespecialbonus: boolean; 82 | garbagetargetbonus: Game.GarbageTargetBonus; 83 | g: number; 84 | gincrease: number; 85 | gmargin: number; 86 | gravitymay20g: boolean; 87 | handling: Game.Handling; 88 | hasgarbage: boolean; 89 | infinite_hold: boolean; 90 | kickset: KickTable; 91 | latencymode: string; 92 | lockresets: number; 93 | manual_allowed: boolean; 94 | messiness_change: number; 95 | messiness_nosame: boolean; 96 | messiness_timeout: number; 97 | messiness_inner: number; 98 | messiness_center?: boolean; 99 | mission: string; 100 | mission_type: string; 101 | neverstopbgm: boolean; 102 | noextrawidth: boolean; 103 | nolockout: boolean; 104 | openerphase: number; 105 | passthrough: Game.Passthrough; 106 | precountdown: number; 107 | prestart: number; 108 | roundmode: Game.RoundingMode; 109 | seed: number; 110 | seed_random: boolean; 111 | slot_bar1: string; 112 | slot_counter1: string; 113 | slot_counter2: string; 114 | slot_counter3: string; 115 | slot_counter4?: string; 116 | slot_counter5?: string; 117 | song: string; 118 | spinbonuses: Game.SpinBonuses; 119 | usebombs: boolean; 120 | username: string; 121 | version: number; 122 | zoominto: string; 123 | }; 124 | results: { 125 | aggregatestats: { 126 | apm: number; 127 | pps: number; 128 | vsscore: number; 129 | }; 130 | gameoverreason: "garbagesmash" | "topout" | "winner"; 131 | stats: { 132 | btb: number; 133 | btbpower: number; 134 | clears: { 135 | allclear: number; 136 | doubles: number; 137 | minitspindoubles: number; 138 | minitspinquads: number; 139 | minitspins: number; 140 | minitspinsingles: number; 141 | minitspintriples: number; 142 | pentas: number; 143 | quads: number; 144 | realtspins: number; 145 | singles: number; 146 | triples: number; 147 | tspindoubles: number; 148 | tspinpentas: number; 149 | tspinquads: number; 150 | tspinsingles: number; 151 | tspintriples: number; 152 | }; 153 | combo: number; 154 | combopower: number; 155 | finaltime: number; 156 | finesse: { 157 | combo: number; 158 | faults: number; 159 | perfectpieces: number; 160 | }; 161 | garbage: { 162 | attack: number; 163 | cleared: number; 164 | maxspike: number; 165 | maxspike_nomult: number; 166 | received: number; 167 | sent: number; 168 | sent_nomult: number; 169 | }; 170 | holds: number; 171 | inputs: number; 172 | kills: number; 173 | level: number; 174 | level_lines: number; 175 | level_lines_needed: number; 176 | lines: number; 177 | piecesplaced: number; 178 | score: number; 179 | topbtb: number; 180 | topcombo: number; 181 | tspins: number; 182 | zenith: { 183 | altitude: number; 184 | avgrankpts: number; 185 | floor: number; 186 | peakrank: number; 187 | rank: number; 188 | revives: number; 189 | revivesMaxOfBoth: number; 190 | revivesTotal: number; 191 | speedrun: boolean; 192 | speedrun_seen: boolean; 193 | splits: number[]; 194 | targetingfactor: number; 195 | targetinggrace: number; 196 | totalbonus: number; 197 | }; 198 | zenlevel: number; 199 | zenprogress: number; 200 | }; 201 | }; 202 | }; 203 | shadowedBy: [null, null]; 204 | shadows: []; 205 | stats: Stats; 206 | }[][]; 207 | }; 208 | ts: string; 209 | users: { 210 | id: string; 211 | username: string; 212 | flags: number; 213 | country: string | null; 214 | avatar_revision: number; 215 | banner_revision: number; 216 | doesNotExist?: true; 217 | }[]; 218 | version: number; 219 | } 220 | -------------------------------------------------------------------------------- /src/types/room.ts: -------------------------------------------------------------------------------- 1 | import type { BagType, KickTable } from "../engine"; 2 | import type { Game } from "./game"; 3 | import type { User } from "./user"; 4 | 5 | export namespace Room { 6 | export type Type = "custom"; 7 | 8 | export type State = "ingame" | "lobby"; 9 | 10 | export type Bracket = "player" | "spectator" | "observer"; 11 | 12 | export interface Player { 13 | _id: string; 14 | username: string; 15 | anon: boolean; 16 | bot: boolean; 17 | role: string; 18 | xp: number; 19 | badges: User.Badge[]; 20 | record: { 21 | games: number; 22 | wins: number; 23 | streak: number; 24 | }; 25 | bracket: Bracket; 26 | supporter: boolean; 27 | verified: boolean; 28 | country: string | null; 29 | } 30 | 31 | export interface Autostart { 32 | enabled: boolean; 33 | status: string; 34 | time: number; 35 | maxtime: number; 36 | } 37 | 38 | export interface Match { 39 | gamemode: string; 40 | modename: string; 41 | ft: number; 42 | wb: number; 43 | record_replays: boolean; 44 | winningKey: string; 45 | keys: { 46 | primary: string; 47 | primaryLabel: string; 48 | primaryLabelSingle: string; 49 | primaryIsAvg: boolean; 50 | secondary: string; 51 | secondaryLabel: string; 52 | secondaryLabelSingle: string; 53 | secondaryIsAvg: boolean; 54 | tertiary: string; 55 | tertiaryLabel: string; 56 | tertiaryLabelSingle: string; 57 | tertiaryIsAvg: boolean; 58 | }; 59 | extra: {}; 60 | } 61 | 62 | export interface SetConfig { 63 | name: string; 64 | options: { 65 | g: number | string; 66 | stock: number | string; 67 | display_next: boolean; 68 | display_hold: boolean; 69 | gmargin: number | string; 70 | gincrease: number | string; 71 | garbagemultiplier: number | string; 72 | garbagemargin: number | string; 73 | garbageincrease: number | string; 74 | garbagecap: number | string; 75 | garbagecapincrease: number | string; 76 | garbagecapmax: number | string; 77 | garbageattackcap: number | string; 78 | garbageabsolutecap: boolean; 79 | garbagephase: number | string; 80 | garbagequeue: boolean; 81 | garbageare: number | string; 82 | garbageentry: string; 83 | garbageblocking: string; 84 | garbagetargetbonus: string; 85 | presets: Game.Preset; 86 | bagtype: BagType; 87 | spinbonuses: Game.SpinBonuses; 88 | combotable: Game.ComboTable; 89 | kickset: KickTable; 90 | nextcount: number | string; 91 | allow_harddrop: boolean; 92 | display_shadow: boolean; 93 | locktime: number | string; 94 | garbagespeed: number | string; 95 | are: number | string; 96 | lineclear_are: number | string; 97 | infinitemovement: boolean; 98 | lockresets: number | string; 99 | allow180: boolean; 100 | room_handling: boolean; 101 | room_handling_arr: number | string; 102 | room_handling_das: number | string; 103 | room_handling_sdf: number | string; 104 | manual_allowed: boolean; 105 | b2bchaining: boolean; 106 | b2bcharging: boolean; 107 | openerphase: boolean; 108 | allclear_garbage: number | string; 109 | allclear_b2b: number | string; 110 | garbagespecialbonus: number | string; 111 | roundmode: string; 112 | allclears: boolean; 113 | clutch: boolean; 114 | nolockout: boolean; 115 | passthrough: Game.Passthrough; 116 | boardwidth: number | string; 117 | boardheight: number | string; 118 | messiness_change: number | string; 119 | messiness_inner: number | string; 120 | messiness_nosame: boolean; 121 | messiness_timeout: number | string; 122 | usebombs: boolean; 123 | }; 124 | userLimit: number | string; 125 | autoStart: number | string; 126 | allowAnonymous: boolean; 127 | allowUnranked: boolean; 128 | userRankLimit: string; 129 | useBestRankAsLimit: boolean; 130 | forceRequireXPToChat: boolean; 131 | gamebgm: string; 132 | match: { 133 | gamemode: Game.GameMode; 134 | modename: string; 135 | ft: number | string; 136 | wb: number | string; 137 | }; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/types/social.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "./user"; 2 | 3 | export namespace Social { 4 | export interface Config { 5 | /** 6 | * When set to `true`, errors when sending DMs will not be thrown. 7 | * @default false 8 | */ 9 | suppressDMErrors: boolean; 10 | /** 11 | * When set to `true`, DMs will be automatically loaded upon receiving a new DM from a user. 12 | * You can always manually load DMs using `Relationship.loadDMs()` 13 | * @default true 14 | */ 15 | autoLoadDMs: boolean; 16 | } 17 | 18 | export type Status = "online" | "away" | "busy" | "offline"; 19 | 20 | export type Detail = 21 | | "" 22 | | "menus" 23 | | "40l" 24 | | "blitz" 25 | | "zen" 26 | | "custom" 27 | | "lobby_end:X-QP" 28 | | "lobby_spec:X-QP" 29 | | "lobby_ig:X-QP" 30 | | "lobby:X-QP" 31 | | "lobby_end:X-PRIV" 32 | | "lobby_spec:X-PRIV" 33 | | "lobby_ig:X-PRIV" 34 | | "lobby:X-PRIV" 35 | | "tl_mm" 36 | | "tl" 37 | | "tl_end" 38 | | "tl_mm_complete"; 39 | 40 | export interface DM { 41 | data: { 42 | content: string; 43 | content_safe: string; 44 | user: string; 45 | userdata: { 46 | role: User.Role; 47 | supporter: boolean; 48 | supporter_tier: number; 49 | verified: boolean; 50 | }; 51 | system: boolean; 52 | }; 53 | stream: string; 54 | ts: Date; 55 | id: string; 56 | } 57 | 58 | export type NotificationType = 59 | | "friend" 60 | | "pending" 61 | | "noop_forfeit_notice" 62 | | "announcement" 63 | | "supporter_new" 64 | | "supporter_gift" 65 | | ""; 66 | 67 | export interface Notification { 68 | _id: string; 69 | data: { 70 | relationship: Relationship; 71 | }; 72 | seen: boolean; 73 | stream: string; 74 | ts: string; 75 | type: string; 76 | } 77 | 78 | export type RelationshipType = "friend" | "block" | "pending"; 79 | export interface Relationship { 80 | _id: string; 81 | from: { 82 | _id: string; 83 | username: string; 84 | avatar_revision: number; 85 | }; 86 | to: { 87 | _id: string; 88 | username: string; 89 | avatar_revision: number; 90 | }; 91 | type: Social.RelationshipType; 92 | unread: number; 93 | updated: string; 94 | } 95 | 96 | export interface Blocked { 97 | id: string; 98 | username: string; 99 | avatar: number; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/types/user.ts: -------------------------------------------------------------------------------- 1 | export namespace User { 2 | export type Role = 3 | | "banned" 4 | | "user" 5 | | "bot" 6 | | "halfmod" 7 | | "mod" 8 | | "admin" 9 | | "sysop"; 10 | 11 | export interface Badge { 12 | id: string; 13 | label: string; 14 | group: string | null; 15 | ts: string; 16 | } 17 | 18 | export interface Records { 19 | "40l": RecordDetail; 20 | blitz: RecordDetail; 21 | } 22 | 23 | export interface RecordDetail { 24 | record: Record; 25 | rank: number | null; 26 | } 27 | 28 | export interface Record { 29 | _id: string; 30 | endcontext: EndContext; 31 | ismulti: boolean; 32 | replayid: string; 33 | stream: string; 34 | ts: string; 35 | user: { 36 | _id: string; 37 | username: string; 38 | }; 39 | } 40 | 41 | export interface EndContext { 42 | seed: number; 43 | lines: number; 44 | level_lines: number; 45 | level_lines_needed: number; 46 | inputs: number; 47 | holds: number; 48 | time: { 49 | start: number; 50 | zero: boolean; 51 | locked: boolean; 52 | prev: number; 53 | frameoffset: number; 54 | }; 55 | score: number; 56 | zenlevel: number; 57 | zenprogress: number; 58 | level: number; 59 | combo: number; 60 | currentcombopower: number; 61 | topcombo: number; 62 | btb: number; 63 | topbtb: number; 64 | currentbtbchainpower: number; 65 | tspins: number; 66 | piecesplaced: number; 67 | clears: { 68 | singles: number; 69 | doubles: number; 70 | triples: number; 71 | quads: number; 72 | pentas: number; 73 | realtspins: number; 74 | minitspins: number; 75 | minitspinsingles: number; 76 | tspinsingles: number; 77 | minitspindoubles: number; 78 | tspindoubles: number; 79 | tspintriples: number; 80 | tspinquads: number; 81 | tspinpentas: number; 82 | allclear: number; 83 | }; 84 | garbage: { 85 | sent: number; 86 | received: number; 87 | attack: number; 88 | cleared: number; 89 | }; 90 | kills: number; 91 | finesse: { 92 | combo: number; 93 | faults: number; 94 | perfectpieces: number; 95 | }; 96 | finalTime: number; 97 | gametype: string; 98 | } 99 | 100 | export interface League { 101 | gamesplayed: number; 102 | gameswon: number; 103 | rating: number; 104 | glicko: number; 105 | rd: number; 106 | rank: string; 107 | apm: number; 108 | pps: number; 109 | vs: number; 110 | decaying: boolean; 111 | standing: number; 112 | standing_local: number; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/types/utils.ts: -------------------------------------------------------------------------------- 1 | export namespace Utils { 2 | export type DeepKeysInner = T extends object 3 | ? T extends any[] 4 | ? never 5 | : { 6 | [K in keyof T]-?: T[K] extends object 7 | ? // @ts-expect-error 8 | `${K}.${DeepKeysInner}` 9 | : K; 10 | }[keyof T] 11 | : ""; 12 | 13 | export type RemoveDotOptions = T extends `${infer _}.` 14 | ? never 15 | : T; 16 | 17 | // @ts-expect-error 18 | export type DeepKeys = RemoveDotOptions>; 19 | 20 | export type DeepKey = K extends keyof T 21 | ? T[K] 22 | : K extends `${infer First}.${infer Rest}` 23 | ? First extends keyof T 24 | ? DeepKey 25 | : never 26 | : never; 27 | 28 | export type DeepKeyValue = K extends keyof T 29 | ? T[K] 30 | : DeepKey; 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/adapters/adapter-io.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from ".."; 2 | import type { Engine, Mino } from "../../engine"; 3 | import { Adapter } from "./core"; 4 | import type * as Messages from "./types/messages"; 5 | 6 | import chalk from "chalk"; 7 | import { spawn, type ChildProcessWithoutNullStreams } from "child_process"; 8 | 9 | export interface AdapterIOConfig { 10 | /** The name of the adapter. Used when logging to the terminal. */ 11 | name: string; 12 | /** Whether to log all messages and non-json output to the terminal */ 13 | verbose: boolean; 14 | /** The path to the binary executable. */ 15 | path: string; 16 | /** 17 | * The environment variables to set for the child process. 18 | * For example, when using rust, you might set RUST_BACKTRACE=1 19 | */ 20 | env: NodeJS.ProcessEnv; 21 | /** Any additional command-line arguments to pass tothe executable */ 22 | args: string[]; 23 | } 24 | 25 | /** 26 | * Communicates with a binary engine executable using Standard Input/Output. 27 | * Uses JSON messages. 28 | * @see {@link Messages.CustomMessageData} 29 | */ 30 | export class AdapterIO< 31 | T extends Messages.CustomMessageData 32 | > extends Adapter { 33 | static defaultConfig: Omit = { 34 | name: "AdapterIO", 35 | verbose: false, 36 | env: {}, 37 | args: [] 38 | }; 39 | 40 | log( 41 | msg: string, 42 | { 43 | force = false, 44 | level = "info" 45 | }: { force: boolean; level: "info" | "warning" | "error" } = { 46 | force: false, 47 | level: "info" 48 | } 49 | ) { 50 | if (!this.cfg.verbose && !force) return; 51 | const func = 52 | level === "info" 53 | ? chalk.blue 54 | : level === "warning" 55 | ? chalk.yellow 56 | : chalk.red; 57 | console[level === "error" ? "error" : "log"]( 58 | `${func(`[${this.cfg.name}]`)} ${msg}` 59 | ); 60 | } 61 | 62 | cfg: AdapterIOConfig; 63 | 64 | events = new EventEmitter>(); 65 | 66 | dead = false; 67 | #dataBuffer = ""; 68 | 69 | process!: ChildProcessWithoutNullStreams; 70 | 71 | constructor( 72 | config: Partial & { 73 | path: string; 74 | } 75 | ) { 76 | super(); 77 | 78 | if (!config.path) { 79 | throw new Error("AdapterIO requires a path to the binary executable."); 80 | } 81 | 82 | this.cfg = { 83 | ...AdapterIO.defaultConfig, 84 | ...config 85 | }; 86 | } 87 | 88 | #start() { 89 | this.process = spawn(this.cfg.path, this.cfg.args, { 90 | env: this.cfg.env, 91 | stdio: ["pipe", "pipe", "pipe"] 92 | }); 93 | 94 | this.process.stderr.on("data", (data) => { 95 | this.log("Error: " + data.toString(), { level: "error", force: true }); 96 | }); 97 | 98 | this.process.stdout.on("data", (message) => this.#handle(message)); 99 | } 100 | 101 | initialize(): Promise> { 102 | return new Promise((resolve) => { 103 | this.events.once("info", (info) => { 104 | this.log("READY"); 105 | resolve(info); 106 | }); 107 | 108 | this.#start(); 109 | }); 110 | } 111 | 112 | send(message: Messages.Outgoing.all) { 113 | if (this.dead) return; 114 | this.process.stdin.write(JSON.stringify(message) + "\n"); 115 | this.log("OUTGOING:\n" + JSON.stringify(message, null, 2)); 116 | } 117 | 118 | #handle(message: string | Buffer) { 119 | const text = message.toString(); 120 | this.#dataBuffer += text; 121 | 122 | let newlineIndex; 123 | while ((newlineIndex = this.#dataBuffer.indexOf("\n")) >= 0) { 124 | const line = this.#dataBuffer.slice(0, newlineIndex); 125 | this.#dataBuffer = this.#dataBuffer.slice(newlineIndex + 1); 126 | 127 | if (line === "") continue; 128 | 129 | try { 130 | const message = JSON.parse(line.trim()); 131 | this.log("INCOMING:\n" + JSON.stringify(message, null, 2)); 132 | this.events.emit(message.type, message); 133 | } catch { 134 | this.log(line); 135 | } 136 | } 137 | } 138 | 139 | config(engine: Engine, data?: T["config"]): void { 140 | this.send({ 141 | type: "config", 142 | ...this.configFromEngine(engine), 143 | data 144 | }); 145 | } 146 | 147 | update(engine: Engine, data?: T["state"]): void { 148 | this.send({ 149 | type: "state", 150 | ...this.stateFromEngine(engine), 151 | data 152 | }); 153 | } 154 | 155 | addPieces(pieces: Mino[], data?: T["pieces"]): void { 156 | this.send({ 157 | type: "pieces", 158 | pieces, 159 | data 160 | }); 161 | } 162 | 163 | play(engine: Engine, data?: T["play"]): Promise> { 164 | this.send({ 165 | type: "play", 166 | ...this.playFromEngine(engine), 167 | data 168 | }); 169 | 170 | return new Promise((resolve) => { 171 | this.events.once("move", (move) => { 172 | resolve(move); 173 | }); 174 | }); 175 | } 176 | 177 | stop() { 178 | this.process.kill("SIGINT"); 179 | this.events.removeAllListeners(); 180 | this.process.removeAllListeners(); 181 | this.process.stdout.removeAllListeners(); 182 | this.process.stderr.removeAllListeners(); 183 | this.process.stdin.removeAllListeners(); 184 | this.process.unref(); 185 | 186 | this.dead = true; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/utils/adapters/core/index.ts: -------------------------------------------------------------------------------- 1 | import type { Engine, Mino } from "../../../engine"; 2 | import type * as Messages from "../types/messages"; 3 | 4 | export abstract class Adapter< 5 | T extends Messages.CustomMessageData = Messages.CustomMessageData 6 | > { 7 | abstract initialize(): Promise>; 8 | 9 | protected configFromEngine( 10 | engine: Engine 11 | ): Omit, "type" | "data"> { 12 | return { 13 | boardWidth: engine.board.width, 14 | boardHeight: engine.board.height, 15 | kicks: engine.kickTableName, 16 | spins: engine.gameOptions.spinBonuses, 17 | comboTable: engine.gameOptions.comboTable, 18 | b2bCharing: !!engine.b2b.charging, 19 | b2bChargeAt: engine.b2b.charging ? engine.b2b.charging.at : 0, 20 | b2bChargeBase: engine.b2b.charging ? engine.b2b.charging.base : 0, 21 | b2bChaining: engine.b2b.chaining, 22 | garbageMultiplier: engine.dynamic.garbageMultiplier.get(), 23 | garbageCap: engine.dynamic.garbageMultiplier.get(), 24 | garbageSpecialBonus: engine.garbageQueue.options.specialBonus, 25 | pcB2b: engine.pc ? engine.pc.b2b : 0, 26 | pcGarbage: engine.pc ? engine.pc.garbage : 0, 27 | queue: engine.queue.value 28 | }; 29 | } 30 | 31 | protected stateFromEngine( 32 | engine: Engine 33 | ): Omit, "type" | "data"> { 34 | return { 35 | board: engine.board.state, 36 | 37 | current: engine.falling.symbol, 38 | hold: engine.held, 39 | queue: engine.queue.value, 40 | 41 | garbage: engine.garbageQueue.queue.map((item) => item.amount), 42 | 43 | combo: engine.stats.combo, 44 | b2b: engine.stats.b2b 45 | }; 46 | } 47 | 48 | protected playFromEngine( 49 | engine: Engine 50 | ): Omit, "type" | "data"> { 51 | return { 52 | garbageCap: Math.floor(engine.dynamic.garbageCap.get()), 53 | garbageMultiplier: engine.dynamic.garbageMultiplier.get() 54 | }; 55 | } 56 | 57 | abstract config(engine: Engine, data?: T["config"]): void; 58 | 59 | abstract update(engine: Engine, data?: T["state"]): void; 60 | 61 | abstract addPieces(pieces: Mino[], data?: T["pieces"]): void; 62 | 63 | abstract play( 64 | engine: Engine, 65 | data?: T["play"] 66 | ): Promise>; 67 | 68 | abstract stop(): void; 69 | } 70 | -------------------------------------------------------------------------------- /src/utils/adapters/index.ts: -------------------------------------------------------------------------------- 1 | import * as AdapterIO from "./adapter-io"; 2 | import * as Core from "./core"; 3 | import * as _Types from "./types"; 4 | 5 | export namespace adapters { 6 | export import IO = AdapterIO.AdapterIO; 7 | export import Adapter = Core.Adapter; 8 | 9 | export import Types = _Types; 10 | } 11 | 12 | export { AdapterIO }; 13 | -------------------------------------------------------------------------------- /src/utils/adapters/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { Game } from "../../../types"; 2 | 3 | export * from "./messages"; 4 | 5 | export type AdapterKey = Game.Key | "dasLeft" | "dasRight"; 6 | -------------------------------------------------------------------------------- /src/utils/adapters/types/messages.ts: -------------------------------------------------------------------------------- 1 | import type { AdapterKey } from "."; 2 | import type { BoardSquare, Engine, Mino } from "../../../engine"; 3 | import type { KickTableName } from "../../../engine/utils/kicks/data"; 4 | import type { Game } from "../../../types"; 5 | 6 | export interface CustomMessageData { 7 | info: void; 8 | move: void; 9 | 10 | config: void; 11 | state: void; 12 | pieces: void; 13 | play: void; 14 | } 15 | 16 | export namespace Incoming { 17 | export interface Info { 18 | type: "info"; 19 | name: string; 20 | version: string; 21 | author: string; 22 | data: Data["info"]; 23 | } 24 | 25 | export interface Move { 26 | type: "move"; 27 | keys: AdapterKey[]; 28 | data: Data["move"]; 29 | } 30 | 31 | export type all = Info | Move; 32 | 33 | export type EventMap = { 34 | [key in all["type"]]: Extract, { type: key }>; 35 | }; 36 | } 37 | 38 | export namespace Outgoing { 39 | export interface Config { 40 | type: "config"; 41 | 42 | boardWidth: number; 43 | boardHeight: number; 44 | 45 | kicks: KickTableName; 46 | spins: Game.SpinBonuses; 47 | comboTable: Game.ComboTable; 48 | 49 | b2bCharing: boolean; 50 | b2bChargeAt: number; 51 | b2bChargeBase: number; 52 | b2bChaining: boolean; 53 | 54 | garbageMultiplier: number; 55 | garbageCap: number; 56 | garbageSpecialBonus: boolean; 57 | 58 | pcB2b: number; 59 | pcGarbage: number; 60 | 61 | queue: Engine["queue"]["value"]; 62 | 63 | data: Data["config"]; 64 | } 65 | 66 | export interface State { 67 | type: "state"; 68 | 69 | board: BoardSquare[][]; 70 | 71 | current: Mino; 72 | hold: Mino | null; 73 | queue: Mino[]; 74 | 75 | garbage: number[]; 76 | 77 | combo: number; 78 | b2b: number; 79 | 80 | data: Data["state"]; 81 | } 82 | 83 | export interface Pieces { 84 | type: "pieces"; 85 | pieces: Mino[]; 86 | data: Data["pieces"]; 87 | } 88 | 89 | export interface Play { 90 | type: "play"; 91 | 92 | garbageMultiplier: number; 93 | garbageCap: number; 94 | 95 | data: Data["play"]; 96 | } 97 | 98 | export type all = 99 | | Config 100 | | State 101 | | Pieces 102 | | Play; 103 | 104 | export type EventMap = { 105 | [key in all["type"]]: Extract, { type: key }>; 106 | }; 107 | } 108 | -------------------------------------------------------------------------------- /src/utils/api/basic.ts: -------------------------------------------------------------------------------- 1 | import { type APIDefaults } from "."; 2 | import { pack } from ".."; 3 | 4 | import fs from "node:fs/promises"; 5 | import os from "node:os"; 6 | import path from "node:path"; 7 | 8 | export type Res = 9 | | { success: false; error: { msg: string; [key: string]: any } } 10 | | ({ success: true } & T); 11 | 12 | export const basic = (defaults: APIDefaults) => { 13 | return { 14 | get: async ({ 15 | token, 16 | uri, 17 | headers = {}, 18 | json = false 19 | }: { 20 | token?: string | null; 21 | uri: string; 22 | headers?: Record; 23 | json?: boolean; 24 | }): Promise> => { 25 | let res: Response; 26 | try { 27 | res = await fetch(`https://tetr.io/api/${uri}`, { 28 | headers: { 29 | Accept: json 30 | ? "application/json" 31 | : "application/vnd.osk.theorypack", 32 | "User-Agent": defaults.userAgent, 33 | cookie: `${defaults.turnstile ? "cf_clearance=" + defaults.turnstile : ""}`, 34 | Authorization: 35 | token === null ? undefined : `Bearer ${token || defaults.token}`, 36 | ...headers 37 | } as any 38 | }); 39 | } catch (err: any) { 40 | throw new Error(`Fetch failed on GET to ${uri}: ${err.message || err}`); 41 | } 42 | 43 | const copy = res.clone(); 44 | try { 45 | return json 46 | ? ((await res.json()) as Res) 47 | : pack.unpack(Buffer.from(await res.arrayBuffer())); 48 | } catch { 49 | if (res.status === 429) 50 | throw new Error(`Rate limit (429) on GET to ${uri}`); 51 | else { 52 | try { 53 | await fs.readdir(path.join(os.homedir(), ".trianglejs", "errors")); 54 | } catch { 55 | await fs.mkdir(path.join(os.homedir(), ".trianglejs", "errors"), { 56 | recursive: true 57 | }); 58 | } 59 | const now = Date.now(); 60 | const text = await copy.text(); 61 | await fs.writeFile( 62 | path.join(os.homedir(), ".trianglejs", "errors", `${now}.html`), 63 | text 64 | ); 65 | if (copy.status === 404) throw new Error(`404 on GET to ${uri}`); 66 | 67 | if (text.includes("Server error")) 68 | throw new Error( 69 | `The TETR.IO Servers are currently unavailable. Check https://status.osk.sh for updates. You can view more information at ${path.join( 70 | os.homedir(), 71 | ".trianglejs", 72 | "errors", 73 | `${now}.html` 74 | )}` 75 | ); 76 | if (text.includes("Maintenance")) 77 | throw new Error( 78 | `The TETR.IO Servers are under maintanence. Check https://status.osk.sh for updates. You can view more information at ${path.join( 79 | os.homedir(), 80 | ".trianglejs", 81 | "errors", 82 | `${now}.html` 83 | )}` 84 | ); 85 | else 86 | throw new Error( 87 | `An error occured. This was likely because it the request was blocked by Cloudflare Turnstile on GET to ${uri}. Try passing in a turnstile token. View the error at ${path.join( 88 | os.homedir(), 89 | ".trianglejs", 90 | "errors", 91 | `${now}.html` 92 | )}` 93 | ); 94 | } 95 | } 96 | }, 97 | 98 | post: async ({ 99 | token, 100 | uri, 101 | body, 102 | headers = {}, 103 | json = false 104 | }: { 105 | token?: string; 106 | uri: string; 107 | body: Record; 108 | headers?: Record; 109 | json?: boolean; 110 | }): Promise> => { 111 | let res: Response; 112 | try { 113 | res = await fetch(`https://tetr.io/api/${uri}`, { 114 | method: "POST", 115 | //@ts-ignore 116 | body: json ? JSON.stringify(body) : pack.pack(body), 117 | headers: { 118 | Accept: json 119 | ? "application/json" 120 | : "application/vnd.osk.theorypack", 121 | "User-Agent": defaults.userAgent, 122 | cookie: `${defaults.turnstile ? "cf_clearance=" + defaults.turnstile : ""}`, 123 | "Content-Type": json 124 | ? "application/json" 125 | : "application/vnd.osk.theorypack", 126 | 127 | Authorization: 128 | token === null ? undefined : `Bearer ${token || defaults.token}`, 129 | ...headers 130 | } as any 131 | }); 132 | } catch (err: any) { 133 | throw new Error( 134 | `Fetch failed on POST to ${uri}: ${err.message || err}` 135 | ); 136 | } 137 | 138 | const copy = res.clone(); 139 | try { 140 | return json 141 | ? ((await res.json()) as Res) 142 | : pack.unpack(Buffer.from(await res.arrayBuffer())); 143 | } catch { 144 | if (res.status === 429) 145 | throw new Error(`Rate limit (429) on GET to ${uri}`); 146 | else { 147 | try { 148 | await fs.readdir(path.join(os.homedir(), ".trianglejs", "errors")); 149 | } catch { 150 | await fs.mkdir(path.join(os.homedir(), ".trianglejs", "errors"), { 151 | recursive: true 152 | }); 153 | } 154 | const now = Date.now(); 155 | const text = await copy.text(); 156 | await fs.writeFile( 157 | path.join(os.homedir(), ".trianglejs", "errors", `${now}.html`), 158 | text 159 | ); 160 | console.log(text); 161 | if (copy.status === 404) throw new Error(`404 on POST to ${uri}`); 162 | if (text.includes("Maintenance")) 163 | throw new Error( 164 | `The TETR.IO Servers are under maintanence. Check https://status.osk.sh for updates. You can view more information at ${path.join( 165 | os.homedir(), 166 | ".trianglejs", 167 | "errors", 168 | `${now}.html` 169 | )}` 170 | ); 171 | else 172 | throw new Error( 173 | `An error occured. This was likely because the request was blocked by Cloudflare Turnstile on GET to ${uri}. Try passing in a turnstile token. View the error at ${path.join( 174 | os.homedir(), 175 | ".trianglejs", 176 | "errors", 177 | `${now}.html` 178 | )}` 179 | ); 180 | } 181 | } 182 | } 183 | }; 184 | }; 185 | 186 | export type Get = ReturnType["get"]; 187 | export type Post = ReturnType["post"]; 188 | -------------------------------------------------------------------------------- /src/utils/api/channel.ts: -------------------------------------------------------------------------------- 1 | import { type APIDefaults } from "."; 2 | import type { Get, Post } from "./basic"; 3 | 4 | // @ts-expect-error post is unused 5 | export const channel = (get: Get, post: Post, __: APIDefaults) => { 6 | return { 7 | replay: async (id: string) => { 8 | const res = await get<{ game: any }>({ 9 | // TODO: type 10 | uri: `games/${id}` 11 | }); 12 | if (res.success === false) throw new Error(res.error.msg); 13 | return res.game; 14 | } 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/api/index.ts: -------------------------------------------------------------------------------- 1 | // export * as API from "./wrapper"; 2 | import { CONSTANTS } from "../constants"; 3 | import { type Get, type Post, basic } from "./basic"; 4 | import { channel } from "./channel"; 5 | import { relationship } from "./relationship"; 6 | import { rooms } from "./rooms"; 7 | import { server } from "./server"; 8 | import { users } from "./users"; 9 | 10 | export interface APIDefaults { 11 | token: string; 12 | userAgent: string; 13 | /** a cf_clearance Cloudflare turnstile token. */ 14 | turnstile: string | null; 15 | } 16 | 17 | export class API { 18 | readonly defaults: APIDefaults = { 19 | token: "", 20 | userAgent: CONSTANTS.userAgent, 21 | turnstile: null 22 | }; 23 | 24 | get!: Get; 25 | post!: Post; 26 | 27 | rooms!: ReturnType; 28 | server!: ReturnType; 29 | users!: ReturnType; 30 | social!: ReturnType; 31 | channel!: ReturnType; 32 | 33 | /** @hideconstructor */ 34 | constructor(options: Partial = {}) { 35 | this.update(options); 36 | } 37 | 38 | update(options: Partial = {}) { 39 | (Object.keys(options) as (keyof APIDefaults)[]).forEach((key) => { 40 | this.defaults[key] = options[key]!; 41 | }); 42 | 43 | const b = basic(this.defaults); 44 | this.get = b.get; 45 | this.post = b.post; 46 | 47 | this.rooms = rooms(this.get, this.post, this.defaults); 48 | this.server = server(this.get, this.post, this.defaults); 49 | this.users = users(this.get, this.post, this.defaults); 50 | this.social = relationship(this.get, this.post, this.defaults); 51 | this.channel = channel(this.get, this.post, this.defaults); 52 | } 53 | } 54 | 55 | export * as APITypes from "./wrapper"; 56 | -------------------------------------------------------------------------------- /src/utils/api/relationship.ts: -------------------------------------------------------------------------------- 1 | import { type APIDefaults } from "."; 2 | import type { Social } from "../../types"; 3 | import type { Get, Post } from "./basic"; 4 | 5 | export const relationship = (get: Get, post: Post, __: APIDefaults) => { 6 | const removeRelationship = async (id: string) => { 7 | const res = await post<{ success: boolean }>({ 8 | uri: "relationships/remove", 9 | body: { user: id } 10 | }); 11 | 12 | if (res.success === false) throw new Error("Failed to remove relationship"); 13 | return true; 14 | }; 15 | 16 | return { 17 | /** Block a user */ 18 | block: async (id: string) => { 19 | const res = await post<{}>({ 20 | uri: "relationships/block", 21 | body: { user: id } 22 | }); 23 | 24 | if (res.success === false) throw new Error(res.error.msg); 25 | return res.success; 26 | }, 27 | 28 | /** Unblock a user. Note: unblocking a user will unfriend them if they are friended. */ 29 | unblock: removeRelationship, 30 | 31 | /** Friend a user */ 32 | friend: async (id: string) => { 33 | const res = await post<{}>({ 34 | uri: "relationships/friend", 35 | body: { user: id } 36 | }); 37 | 38 | if (res.success === false) throw new Error(res.error.msg); 39 | return res.success; 40 | }, 41 | 42 | /** Unfriend a user. Note: unfriending a user will unblock them if they are blocked. */ 43 | unfriend: removeRelationship, 44 | 45 | dms: async (id: string) => { 46 | const res = await get<{ 47 | dms: Social.DM[]; 48 | }>({ 49 | uri: `dms/${id}` 50 | }); 51 | if (res.success === false) throw new Error(res.error.msg); 52 | return res.dms; 53 | } 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /src/utils/api/rooms.ts: -------------------------------------------------------------------------------- 1 | import { type APIDefaults } from "."; 2 | import type { Get, Post } from "./basic"; 3 | 4 | export namespace Rooms { 5 | export interface Room { 6 | id: string; 7 | name: string; 8 | name_safe: string; 9 | type: string; 10 | userLimit: number; 11 | userRankLimit: string; 12 | state: string; 13 | allowAnonymous: boolean; 14 | allowUnranked: boolean; 15 | players: number; 16 | count: number; 17 | } 18 | } 19 | 20 | export const rooms = (get: Get, _: Post, __: APIDefaults) => { 21 | return async () => { 22 | const res = await get<{ rooms: Rooms.Room[] }>({ uri: "rooms/" }); 23 | 24 | if (res === null) return []; 25 | else { 26 | if (res.success) return res.rooms; 27 | else return []; 28 | } 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/utils/api/server.ts: -------------------------------------------------------------------------------- 1 | import { type APIDefaults } from "."; 2 | import type { Get, Post } from "./basic"; 3 | 4 | import chalk from "chalk"; 5 | 6 | export namespace Server { 7 | export interface Signature { 8 | version: string; 9 | countdown: boolean; 10 | novault: boolean; 11 | noceriad: boolean; 12 | norichpresence: boolean; 13 | noreplaydispute: boolean; 14 | supporter_specialthanks_goal: number; 15 | xp_multiplier: number; 16 | catalog: { 17 | supporter: { 18 | price: number; 19 | price_bulk: number; 20 | price_gift: number; 21 | price_gift_bulk: number; 22 | bulk_after: number; 23 | normal_price: number; 24 | normal_price_bulk: number; 25 | normal_price_gift: number; 26 | normal_price_gift_bulk: number; 27 | normal_bulk_after: number; 28 | }; 29 | "zenith-tower-ost": { 30 | price: number; 31 | normal_price: number; 32 | }; 33 | }; 34 | league_mm_roundtime_min: number; 35 | league_mm_roundtime_max: number; 36 | league_additional_settings: Record; 37 | league_season: { 38 | current: string; 39 | prev: string; 40 | next: string | null; 41 | next_at: string | null; 42 | ranked: boolean; 43 | }; 44 | zenith_duoisfree: boolean; 45 | zenith_freemod: boolean; 46 | zenith_cpu_count: number; 47 | zenith_additional_settings: { 48 | TEMP_zenith_grace: string; 49 | messiness_timeout: number; 50 | }; 51 | domain: string; 52 | ch_domain: string; 53 | mode: string; 54 | sentry_enabled: boolean; 55 | serverCycle: string; 56 | domain_hash: string; 57 | client: { 58 | commit: { 59 | id: string; 60 | time: number; 61 | }; 62 | branch: string; 63 | build: { 64 | id: string; 65 | time: number; 66 | }; 67 | }; 68 | } 69 | 70 | export interface Environment { 71 | stats: { 72 | players: number; 73 | users: number; 74 | gamesplayed: number; 75 | gametime: number; 76 | }; 77 | signature: Signature; 78 | vx: string; 79 | } 80 | } 81 | 82 | export const server = (get: Get, _: Post, options: APIDefaults) => { 83 | const getDespool = async (endpoint: string, index: string) => { 84 | const req = await fetch( 85 | encodeURI( 86 | `https://${endpoint}/spool?${Date.now()}-${index}-${Math.floor(1e6 * Math.random())}` 87 | ), 88 | { 89 | method: "GET", 90 | headers: { 91 | // Authorization: `Bearer ${options.token}`, 92 | "User-Agent": options.userAgent 93 | } 94 | } 95 | ); 96 | const res = new Uint8Array(await req.arrayBuffer()); 97 | 98 | const parseSpoolData = (binary: Uint8Array) => { 99 | const version = binary[0]; 100 | const flags = { 101 | online: binary[1] & 0b10000000, 102 | avoidDueToHighLoad: binary[1] & 0b01000000, 103 | recentlyRestarted: binary[1] & 0b00100000, 104 | backhaulDisrupted: binary[1] & 0b00010000, 105 | unused5: binary[1] & 0b00001000, 106 | unused6: binary[1] & 0b00000100, 107 | unused7: binary[1] & 0b00000010, 108 | unused8: binary[1] & 0b00000001 109 | } as const; 110 | const load = [0, 0, 0]; 111 | const latency = binary[5] * 2; 112 | 113 | load[0] = (binary[2] / 0x100) * 4; 114 | load[1] = (binary[3] / 0x100) * 4; 115 | load[2] = (binary[4] / 0x100) * 4; 116 | 117 | return { 118 | version, 119 | flags, 120 | load, 121 | latency, 122 | str: `v${version} /${flags.avoidDueToHighLoad ? "Av" : ""}${flags.recentlyRestarted ? "Rr" : ""}${flags.backhaulDisrupted ? "Bd" : ""}/ ${load[0]}, ${load[1]}, ${load[2]}` 123 | }; 124 | }; 125 | 126 | return parseSpoolData(res); 127 | }; 128 | 129 | const findFastestAvailableSpool = async ( 130 | spools: { name: string; host: string; flag: string; location: string }[] 131 | ): Promise<{ 132 | name: string; 133 | host: string; 134 | flag: string; 135 | location: string; 136 | }> => { 137 | try { 138 | return await Promise.any( 139 | spools.map(async (s, index) => { 140 | const spool = await getDespool(s.host, index.toString()); 141 | if (spool.flags.avoidDueToHighLoad || spool.flags.recentlyRestarted) 142 | throw new Error("Spool is unstable"); 143 | return s; 144 | }) 145 | ); 146 | } catch { 147 | console.log( 148 | `${chalk.yellow("[🎀\u2009Ribbon]")}: All spools down or recently restarted (unstable). Falling back to root TETR.IO host.` 149 | ); 150 | } 151 | return { 152 | name: "tetr.io", 153 | host: "tetr.io", 154 | flag: "NL", 155 | location: "osk" 156 | }; 157 | }; 158 | 159 | return { 160 | environment: async (): Promise => { 161 | const result = await get({ 162 | uri: "server/environment" 163 | }); 164 | 165 | if (result.success === false) throw new Error(result.error.msg); 166 | return result; 167 | }, 168 | spool: async (useSpools: boolean) => { 169 | const res = await get<{ 170 | endpoint: string; 171 | spools: { 172 | token: string; 173 | spools: { 174 | name: string; 175 | host: string; 176 | flag: string; 177 | location: string; 178 | }[]; 179 | } | null; 180 | }>({ 181 | uri: "server/ribbon" 182 | }); 183 | 184 | if (res.success === false) { 185 | throw new Error(res.error.msg); 186 | } 187 | 188 | if (!useSpools || res.spools === null) 189 | return { 190 | host: "tetr.io", 191 | endpoint: res.endpoint.replace("/ribbon/", ""), 192 | token: "" 193 | }; 194 | else { 195 | const lowestPingSpool = await findFastestAvailableSpool( 196 | res.spools.spools 197 | ); 198 | return { 199 | host: lowestPingSpool.host, 200 | endpoint: res.endpoint.replace("/ribbon/", ""), 201 | token: res.spools.token 202 | }; 203 | } 204 | } 205 | }; 206 | }; 207 | -------------------------------------------------------------------------------- /src/utils/api/users.ts: -------------------------------------------------------------------------------- 1 | import type { APIDefaults } from "."; 2 | import type { User as UserTypes } from "../../types"; 3 | import type { Get, Post } from "./basic"; 4 | 5 | export namespace Users { 6 | /** Data returned from /api/users/me */ 7 | export interface Me { 8 | _id: string; 9 | username: string; 10 | country: string | null; 11 | email?: string | undefined; 12 | role: UserTypes.Role; 13 | ts: Date; 14 | badges: UserTypes.Badge[]; 15 | xp: number; 16 | privacy_showwon: boolean; 17 | privacy_showplayed: boolean; 18 | privacy_showgametime: boolean; 19 | privacy_showcountry: boolean; 20 | privacy_privatemode: string; 21 | privacy_status_shallow: string; 22 | privacy_status_deep: string; 23 | privacy_status_exact: string; 24 | privacy_dm: string; 25 | privacy_invite: string; 26 | thanked: boolean; 27 | banlist: any[]; // You may want to define a type for this array's contents 28 | warnings: any[]; // You may want to define a type for this array's contents 29 | bannedstatus: string; 30 | records?: UserTypes.Records; // You may want to define a type for this 31 | supporter: boolean; 32 | supporter_expires: number; 33 | total_supported: number; 34 | league: UserTypes.League; 35 | avatar_revision?: number; 36 | banner_revision?: number; 37 | bio?: string; 38 | zen?: any; // TODO: type 39 | distinguishment?: any; 40 | totp: { 41 | enabled?: boolean; 42 | codes_remaining: number; 43 | }; 44 | connections: { 45 | [key: string]: any; // You may want to define a type for the values 46 | }; 47 | } 48 | 49 | export interface User { 50 | _id: string; 51 | username: string; 52 | role: string; 53 | ts: string; 54 | badges: UserTypes.Badge[]; 55 | xp: number; 56 | gamesplayed: number; 57 | gameswon: number; 58 | gametime: number; 59 | country: string; 60 | badstanding: boolean; 61 | records: UserTypes.Records; 62 | supporter: boolean; 63 | supporter_tier: number; 64 | verified: boolean; 65 | league: UserTypes.League; 66 | avatar_revision: number; 67 | banner_revision: number; 68 | bio: string; 69 | friendCount: number; 70 | friendedYou: boolean; 71 | } 72 | } 73 | 74 | export const users = (get: Get, post: Post, __: APIDefaults) => { 75 | /** Checks whethere a user exists */ 76 | const exists = async (username: string): Promise => { 77 | const res = await get<{ exists: boolean }>({ 78 | token: undefined, 79 | uri: `users/${username}/exists` 80 | }); 81 | if (res.success === false) throw new Error(res.error.msg); 82 | return res.exists; 83 | }; 84 | 85 | /** Resolves a username to a user ID */ 86 | const resolve = async (username: string) => { 87 | const res = await get<{ _id: string }>({ 88 | token: undefined, 89 | uri: `users/${encodeURIComponent(username.trim())}/resolve` 90 | }); 91 | if (res.success === false) throw new Error(res.error.msg + ": " + username); 92 | return res._id; 93 | }; 94 | 95 | return { 96 | /** Checks whether a user exists */ 97 | exists, 98 | authenticate: async ( 99 | username: string, 100 | password: string 101 | ): Promise<{ token: string; id: string }> => { 102 | const res = await post<{ token: string; userid: string }>({ 103 | token: undefined, 104 | uri: "users/authenticate", 105 | body: { 106 | username, 107 | password, 108 | totp: "" 109 | } 110 | }); 111 | 112 | if (res.success === false) throw new Error(res.error.msg); 113 | return { 114 | token: res.token, 115 | id: res.userid 116 | }; 117 | }, 118 | me: async (): Promise => { 119 | const res = await get<{ user: Users.Me }>({ uri: "users/me" }); 120 | 121 | if (res.success === false) 122 | throw new Error("Failure loading profile: " + res.error.msg); 123 | return res.user; 124 | }, 125 | resolve, 126 | 127 | /** Get a user's profile */ 128 | get: async (options: { username: string } | { id: string }) => { 129 | const res = await get<{ user: Users.User }>({ 130 | uri: 131 | "users/" + 132 | ("username" in options ? await resolve(options.username) : options.id) 133 | }); 134 | 135 | if (res.success === false) 136 | throw new Error("Failure loading profile: " + res.error.msg); 137 | return res.user; 138 | } 139 | }; 140 | }; 141 | -------------------------------------------------------------------------------- /src/utils/api/wrapper.ts: -------------------------------------------------------------------------------- 1 | export type * from "./rooms"; 2 | export type * from "./server"; 3 | export type * from "./users"; 4 | export type * from "./basic"; 5 | export type * from "./relationship"; 6 | -------------------------------------------------------------------------------- /src/utils/bot-wrapper/index.ts: -------------------------------------------------------------------------------- 1 | import type { Engine } from "../../engine"; 2 | import type { Game } from "../../types"; 3 | import { adapters } from "../adapters"; 4 | 5 | export interface BotWrapperConfig { 6 | pps: number; 7 | } 8 | 9 | export class BotWrapper< 10 | T extends adapters.Types.CustomMessageData = adapters.Types.CustomMessageData 11 | > { 12 | config: BotWrapperConfig; 13 | adapter: adapters.Adapter; 14 | 15 | nextFrame!: number; 16 | needsNewMove = false; 17 | 18 | static nextFrame(engine: Engine, target: number) { 19 | return ((engine.stats.pieces + 1) / target) * 60; 20 | } 21 | 22 | static frames( 23 | engine: Engine, 24 | keys: adapters.Types.AdapterKey[] 25 | ): Game.Tick.Keypress[] { 26 | const round = (r: number) => Math.round(r * 10) / 10; 27 | 28 | let running = engine.frame + engine.subframe; 29 | 30 | return keys.flatMap((key): [Game.Tick.Keypress, Game.Tick.Keypress] => { 31 | const firstFrame = { 32 | type: "keydown" as const, 33 | frame: Math.floor(running), 34 | data: { 35 | key: 36 | key === "dasLeft" 37 | ? "moveLeft" 38 | : key === "dasRight" 39 | ? "moveRight" 40 | : key, 41 | subframe: round(running - Math.floor(running)) 42 | } 43 | }; 44 | 45 | if (key === "dasLeft" || key === "dasRight") { 46 | running = round(running + engine.handling.das); 47 | } else if (key === "softDrop") { 48 | running = round(running + 0.1); 49 | } 50 | 51 | const secondFrame = { 52 | type: "keyup" as const, 53 | frame: Math.floor(running), 54 | data: { 55 | key: 56 | key === "dasLeft" 57 | ? "moveLeft" 58 | : key === "dasRight" 59 | ? "moveRight" 60 | : key, 61 | subframe: round(running - Math.floor(running)) 62 | } 63 | }; 64 | 65 | return [firstFrame, secondFrame]; 66 | }); 67 | } 68 | 69 | constructor(adapter: adapters.Adapter, config: BotWrapperConfig) { 70 | this.config = config; 71 | this.adapter = adapter; 72 | 73 | this.init = this.init.bind(this); 74 | this.tick = this.tick.bind(this); 75 | this.stop = this.stop.bind(this); 76 | } 77 | 78 | async init(engine: Engine, config?: T["config"]) { 79 | if (engine.handling.arr !== 0) 80 | throw new Error("BotWrapper requires 0 ARR handling."); 81 | if (engine.handling.sdf !== 41) 82 | throw new Error("BotWrapper requires 41 SDF handling."); 83 | 84 | await this.adapter.initialize(); 85 | this.adapter.config(engine, config); 86 | 87 | engine.events.on("queue.add", (pieces) => this.adapter.addPieces(pieces)); 88 | 89 | this.nextFrame = BotWrapper.nextFrame(engine, this.config.pps); 90 | } 91 | 92 | async tick( 93 | engine: Engine, 94 | events: Game.Client.Event[], 95 | data?: Partial> 96 | ) { 97 | const fullData = { 98 | state: undefined, 99 | play: undefined, 100 | ...data 101 | }; 102 | if (events.find((event) => event.type === "garbage")) { 103 | this.adapter.update(engine, fullData.state); 104 | } 105 | if (engine.frame >= this.nextFrame) { 106 | if (this.needsNewMove) { 107 | this.nextFrame = BotWrapper.nextFrame(engine, this.config.pps); 108 | this.needsNewMove = false; 109 | } else { 110 | const { keys } = await this.adapter.play(engine, fullData.play); 111 | 112 | const frames = BotWrapper.frames(engine, keys); 113 | 114 | this.needsNewMove = true; 115 | 116 | return frames; 117 | } 118 | } 119 | 120 | return []; 121 | } 122 | 123 | stop() { 124 | this.adapter.stop(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import { version } from "./version"; 2 | 3 | /** defaults/constants */ 4 | export const CONSTANTS = { 5 | userAgent: `Triangle.js/${version} (+https://triangle.haelp.dev)` 6 | } as const; 7 | -------------------------------------------------------------------------------- /src/utils/docs.ts: -------------------------------------------------------------------------------- 1 | export const docLink = (target: string, doc = "Troubleshooting") => 2 | `https://triangle.haelp.dev/documents/${doc}.html#md:${target}`; 3 | -------------------------------------------------------------------------------- /src/utils/events.ts: -------------------------------------------------------------------------------- 1 | export class EventEmitter> { 2 | #listeners: [keyof T, Function, boolean][]; 3 | #maxListeners: number = 10; 4 | 5 | /** Enables more debugging logs for memory leaks */ 6 | verbose = false; 7 | 8 | constructor() { 9 | this.#listeners = []; 10 | } 11 | 12 | on(event: K, cb: (data: T[K]) => void) { 13 | this.#listeners.push([event, cb, false]); 14 | 15 | const listeners = this.#listeners.filter(([e]) => e === event); 16 | if (listeners.length > this.#maxListeners) { 17 | console.warn( 18 | `Max listeners exceeded for event "${String(event)}". Current: ${ 19 | this.#listeners.filter(([e]) => e === event).length 20 | }, Max: ${this.#maxListeners}` 21 | ); 22 | if (this.verbose) 23 | console.warn( 24 | `Trace: ${new Error().stack}\n\nListeners:\n`, 25 | listeners.map(([_, fn]) => fn.toString()).join("\n\n") 26 | ); 27 | } 28 | return this; 29 | } 30 | 31 | off(event: K, cb: (data: T[K]) => void) { 32 | this.#listeners = this.#listeners.filter( 33 | ([e, c]) => e !== event || c !== cb 34 | ); 35 | return this; 36 | } 37 | 38 | emit(event: K, data: T[K]) { 39 | const toRemove = new Set(); 40 | 41 | this.#listeners.forEach(([e, cb, once], idx) => { 42 | if (e !== event) return; 43 | cb(data); 44 | if (once) toRemove.add(idx); 45 | }); 46 | 47 | this.#listeners = this.#listeners.filter((_, idx) => !toRemove.has(idx)); 48 | 49 | return this; 50 | } 51 | 52 | once(event: K, cb: (data: T[K]) => any | Promise) { 53 | this.#listeners.push([event, cb, true]); 54 | 55 | return this; 56 | } 57 | 58 | removeAllListeners(event?: K) { 59 | if (event) { 60 | this.#listeners = this.#listeners.filter(([e]) => e !== event); 61 | } else { 62 | this.#listeners = []; 63 | } 64 | } 65 | 66 | set maxListeners(n: number) { 67 | if (n <= 0 || !Number.isInteger(n)) { 68 | throw new RangeError("Max listeners must be a positive integer"); 69 | } 70 | 71 | this.#maxListeners = n; 72 | } 73 | 74 | get maxListeners() { 75 | return this.#maxListeners; 76 | } 77 | 78 | export() { 79 | return { 80 | listeners: this.#listeners.map(([event, cb, once]) => ({ 81 | event, 82 | cb, 83 | once 84 | })), 85 | maxListeners: this.#maxListeners, 86 | verbose: this.verbose 87 | }; 88 | } 89 | 90 | import(data: ReturnType["export"]>) { 91 | data.listeners.forEach(({ event, cb, once }) => { 92 | if (once) { 93 | this.once(event, cb as any); 94 | } else { 95 | this.on(event, cb as any); 96 | } 97 | }); 98 | this.#maxListeners = data.maxListeners; 99 | this.verbose = data.verbose; 100 | return this; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./adapters"; 2 | export * from "./api"; 3 | export * from "./bot-wrapper"; 4 | export * from "./theorypack"; 5 | 6 | export * from "./constants"; 7 | export * from "./docs"; 8 | export * from "./events"; 9 | export * from "./jwt"; 10 | export * from "./version"; 11 | -------------------------------------------------------------------------------- /src/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse a jwt token 3 | * @param token - jwt 4 | * @returns user id from jwt 5 | */ 6 | export const parseToken = (token: string) => { 7 | var base64Url = token.split(".")[1]; 8 | var base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); 9 | var jsonPayload = decodeURIComponent( 10 | atob(base64) 11 | .split("") 12 | .map(function (c) { 13 | return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); 14 | }) 15 | .join("") 16 | ); 17 | 18 | return JSON.parse(jsonPayload).sub as string; 19 | }; 20 | -------------------------------------------------------------------------------- /src/utils/theorypack/index.ts: -------------------------------------------------------------------------------- 1 | import { Packr, Unpackr, addExtension } from "./ts-wrap"; 2 | 3 | // require theorypack extensions 4 | addExtension({ 5 | Class: undefined!, 6 | type: 1, 7 | read: (e) => (null === e ? { success: true } : { success: true, ...e }) 8 | }); 9 | 10 | addExtension({ 11 | Class: undefined!, 12 | type: 2, 13 | read: (e) => (null === e ? { success: false } : { success: false, error: e }) 14 | }); 15 | 16 | const unpacker = new Unpackr({ 17 | int64AsType: "number", 18 | bundleStrings: true, 19 | sequential: false 20 | }); 21 | 22 | const packer = new Packr({ 23 | int64AsType: "number", 24 | bundleStrings: true, 25 | sequential: false 26 | }); 27 | 28 | export namespace pack { 29 | /** unpack a single theorypack message */ 30 | export const unpack = unpacker.unpack.bind( 31 | unpacker 32 | ) as typeof unpacker.unpack; 33 | /** unpack multiple theorypack messages */ 34 | export const unpackMultiple = unpacker.unpackMultiple.bind( 35 | unpacker 36 | ) as typeof unpacker.unpackMultiple; 37 | /** decode a theorypack message */ 38 | export const decode = packer.decode.bind(unpacker) as typeof unpacker.decode; 39 | /** pack a single theorypack messages */ 40 | export const pack = packer.pack.bind(packer) as typeof packer.pack; 41 | /** encode a theorypack message */ 42 | export const encode = packer.encode.bind(packer) as typeof packer.encode; 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/theorypack/ts-wrap.ts: -------------------------------------------------------------------------------- 1 | import msgpackr_raw from "./msgpackr"; 2 | 3 | const msgpackr = msgpackr_raw as typeof import("msgpackr"); 4 | 5 | export const addExtension = msgpackr.addExtension; 6 | export const Packr = msgpackr.Packr; 7 | export const Unpackr = msgpackr.Unpackr; 8 | -------------------------------------------------------------------------------- /src/utils/version.ts: -------------------------------------------------------------------------------- 1 | export const version = "3.3.5"; 2 | -------------------------------------------------------------------------------- /test/client.test.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "../src"; 2 | 3 | import { test, expect } from "bun:test"; 4 | 5 | test("Client connect", async () => { 6 | const client = await Client.connect({ 7 | token: process.env.TOKEN! 8 | }); 9 | 10 | expect(client.user).toBeDefined(); 11 | }); 12 | -------------------------------------------------------------------------------- /test/data/replays.tar.gz: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:3840c4a0d6dc679cc305bde0ef22b02ad012e70905f59ff3d68967d2645d2eff 3 | size 482903789 4 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["./**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "esModuleInterop": true, 5 | "allowSyntheticDefaultImports": true, 6 | "lib": ["ESNext"], 7 | "skipLibCheck": true, 8 | "target": "ESNext", 9 | "moduleResolution": "Node", 10 | "module": "ESNext", 11 | "strict": true, 12 | "verbatimModuleSyntax": true, 13 | "declaration": true, 14 | "emitDeclarationOnly": true, 15 | "declarationMap": true, 16 | 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["./src/**/*"] 22 | } 23 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "name": "Triangle.js Docs", 4 | "entryPoints": ["./src"], 5 | "sort": ["kind", "instance-first", "alphabetical"], 6 | "navigationLinks": { 7 | "GitHub": "https://github.com/halp1/triangle" 8 | }, 9 | "highlightLanguages": [ 10 | "bash", 11 | "console", 12 | "css", 13 | "html", 14 | "javascript", 15 | "json", 16 | "jsonc", 17 | "json5", 18 | "yaml", 19 | "tsx", 20 | "typescript", 21 | "powershell", 22 | "python" 23 | ], 24 | "projectDocuments": [ 25 | "documents/Adapters.md", 26 | "documents/Channel.md", 27 | "documents/Engine.md", 28 | "documents/Gameplay.md", 29 | "documents/Protocol.md", 30 | "documents/Quickstart.md", 31 | "documents/Troubleshooting.md" 32 | ] 33 | } 34 | --------------------------------------------------------------------------------