├── .gitattributes ├── .github └── workflows │ ├── pr-trusted.yml │ └── pr-untrusted.yml ├── .gitignore ├── .vscode └── tasks.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── archived └── map-vote.js ├── build ├── LICENSE ├── README.md ├── mod.hjson └── scripts │ ├── api.js │ ├── commands.js │ ├── config.js │ ├── consoleCommands.js │ ├── files.js │ ├── fjsContext.js │ ├── funcs.js │ ├── globals.js │ ├── index.js │ ├── io.js │ ├── main.js │ ├── maps.js │ ├── memberCommands.js │ ├── menus.js │ ├── metrics.js │ ├── mindustryTypes.js │ ├── packetHandlers.js │ ├── playerCommands.js │ ├── players.js │ ├── promise.js │ ├── ranks.js │ ├── staffCommands.js │ ├── timers.js │ ├── types.js │ ├── utils.js │ └── votes.js ├── copy-client.js ├── docs ├── fail.png ├── info.md ├── intellisense.png ├── menus.png └── race-condition.png ├── mod.hjson ├── package.json ├── pnpm-lock.yaml ├── scripts ├── attach.js ├── attach.ts ├── build.js ├── build.ts ├── dev.js ├── dev.ts ├── main.js └── tsconfig.json ├── spec ├── src │ ├── env.ts │ ├── test-utils.ts │ └── utils.spec.ts ├── support │ └── jasmine.json └── tsconfig.json ├── src ├── api.ts ├── client.$ts ├── commands.ts ├── config.ts ├── consoleCommands.ts ├── files.ts ├── fjsContext.ts ├── funcs.ts ├── globals.ts ├── index.ts ├── io.ts ├── main.js ├── maps.ts ├── memberCommands.ts ├── menus.ts ├── metrics.ts ├── mindustry.d.ts ├── mindustryTypes.ts ├── packetHandlers.ts ├── playerCommands.ts ├── players.ts ├── promise.ts ├── ranks.ts ├── rhino-env.d.ts ├── staffCommands.ts ├── timers.ts ├── types.ts ├── utils.ts └── votes.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | build/scripts/*.js linguist-generated 2 | -------------------------------------------------------------------------------- /.github/workflows/pr-trusted.yml: -------------------------------------------------------------------------------- 1 | name: Typescript Compile and Tests 2 | on: 3 | # This event type is dangerous 4 | pull_request_target: 5 | types: [opened, reopened, synchronize] 6 | paths: 7 | - 'src/**.ts' 8 | branches: 9 | - 'master' 10 | jobs: 11 | Typescript_Compile_and_Tests: 12 | # Ensure the PR is not from a fork 13 | if: github.event.pull_request.head.repo.full_name == github.repository 14 | # PR is from the base repository, so it is only running code that was added by someone with write access, so it's trusted 15 | runs-on: ubuntu-latest 16 | env: 17 | # for some reason this isn't the bot ID, it's some other random number 18 | FISH_BOT_ACTOR_ID: 196413533 19 | permissions: 20 | contents: write 21 | steps: 22 | # Create a token that can be used to push commits 23 | - uses: actions/create-github-app-token@v1 24 | id: generate-token 25 | with: 26 | app-id: ${{ secrets.FISH_BOT_ID }} 27 | private-key: ${{ secrets.FISH_BOT_PRIVATE_KEY }} 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: '22.x' 31 | # Checkout the trusted code 32 | - uses: actions/checkout@v1 33 | with: 34 | ref: ${{ github.head_ref }} 35 | - name: Git Config 36 | run: | 37 | git config --global core.autocrlf true 38 | git config --global user.name 'github-actions[bot]' 39 | git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com' 40 | git remote set-url origin https://x-access-token:${{ steps.generate-token.outputs.token }}@github.com/${{ github.repository }} 41 | - name: Install pnpm 42 | uses: pnpm/action-setup@v4 43 | with: 44 | version: 9 45 | run_install: true 46 | - name: Typescript Compile 47 | # Only run this if the commit wasn't created by the fish bot 48 | # If it was, this check has already been completed 49 | if: github.actor_id != env.FISH_BOT_ACTOR_ID 50 | run: pnpm run build 51 | - name: Check for modified files in build directory 52 | id: git-check 53 | # Returns 0 if files changed, and 1 if no files changed 54 | run: | 55 | git add build/scripts 56 | echo "committed=$(git commit -m "Automated TypeScript compile" > /dev/null; echo $?)" >> $GITHUB_OUTPUT 57 | - name: Push changes 58 | # Do not create an infinite loop of workflows 59 | if: github.actor_id != env.FISH_BOT_ACTOR_ID && steps.git-check.outputs.committed == '0' 60 | run: git push 61 | - name: Run Tests 62 | if: github.actor_id == env.FISH_BOT_ACTOR_ID || steps.git-check.outputs.committed != '0' 63 | run: pnpm run test 64 | -------------------------------------------------------------------------------- /.github/workflows/pr-untrusted.yml: -------------------------------------------------------------------------------- 1 | name: PR Tests (Untrusted) 2 | on: 3 | pull_request: 4 | types: [opened, reopened, synchronize] 5 | paths: 6 | - 'src/**.ts' 7 | branches: 8 | - 'master' 9 | jobs: 10 | PR_Tests: 11 | # Ensure the PR IS from a fork: otherwise, pr-trusted will run 12 | if: github.event.pull_request.head.repo.full_name != github.repository 13 | # It's safe to run tests if the PR is from a fork 14 | # This workflow doesn't have write permissions 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Install pnpm 21 | uses: pnpm/action-setup@v4 22 | with: 23 | version: 9 24 | run_install: true 25 | - name: Run Tests 26 | # this also runs tsc 27 | run: pnpm run test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | asd 2 | node_modules 3 | package-lock.json 4 | ts-debugtrace 5 | tsconfig.tsbuildinfo 6 | build/scripts/*.d.ts 7 | spec/build 8 | scripts/client.js 9 | scripts/client.ts 10 | dev-server 11 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "watch", 7 | "group": "build", 8 | "problemMatcher": [], 9 | "label": "tsc: watch", 10 | "detail": "tsc --watch", 11 | "runOptions": { 12 | "runOn": "folderOpen" 13 | }, 14 | "presentation": { 15 | "focus": false, 16 | "panel": "dedicated", 17 | "clear": false, 18 | "close": false, 19 | } 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing 3 | 4 | What kind of changes do you want to make? 5 | 6 | ## I want to make small changes (just change some text) 7 | 8 | You can make these changes entirely through the Github website. You will need a Github account. 9 | 10 | 1. Open the file that you want to edit. If you are unsure of which file to edit, try using Github's search feature. 11 | 2. Click the edit icon in the toolbar. If Github tells you to create a fork, do that. 12 | 3. Make your change, then click "Commit changes..." 13 | 4. Write a message, then click Propose changes. 14 | - If you are a member of the Fish-Community org, you will see a message about branch protection. Select "Create a new branch and start a pull request". 15 | - If you are not a member, you will also need to manually edit the corresponding .js file, which is located in `build/scripts`. For example, if you edited `src/config.ts`, you will need to make the same change to `build/scripts/config.js`. 16 | 5. Create a pull request and request a review. If you see a message about failing checks, there may be a problem with your changes. 17 | 18 | ## I want to make significant code changes (in VSCode) 19 | 20 | First: read [info.md](docs/info.md) 21 | 22 | To get started with development: 23 | 1. Create a fork of fish-commands through the GitHub website. 24 | 2. Clone that fork into a folder of your choice by `cd`ing into the folder and running `git clone https://github.com/`(your username here)`/fish-commands .` 25 | 3. Run `npm install` 26 | 4. Run `npm watch` in one terminal. (Or, open VS Code and tell it to trust the project) 27 | 5. In another terminal, run `npm dev` to start up a Mindustry development server with fish-commands installed. 28 | 29 | ### Making changes 30 | 1. Edit the code, which is in `/src`. 31 | 2. Restart the development server. Close it by pressing Ctrl+C or typing `exit`. 32 | 3. Test your changes. 33 | 34 | To use fish-commands with a server installed somewhere else, run `npm attach [jarfilepath.jar]` 35 | 36 | Once you have made and tested your changes, submit a PR: 37 | 1. Commit your changes to Git by running `git add . && git commit -m "`(description of your changes here)`"` 38 | 2. Upload your changes by running `git push`. 3. You should see a link to create the PR. If not, create one through Github. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fish commands 2 | 3 | A monolithic plugin that handles all custom features for the >|||>Fish servers. Created by Brandons404, rewritten by BalaM314. 4 | 5 | **Before reading the code, see [docs/info.md](docs/info.md).** 6 | 7 | ## Clean and easy to use commands system 8 | Example code: 9 | ![image](docs/intellisense.png) 10 | ![image](docs/menus.png) 11 | ![image](docs/fail.png) 12 | 13 | List of notable features: 14 | * Low-boilerplate argument handling system that supports arguments of various types, and optional arguments. Automatically generates an error if one of the args is invalid (eg, specifying a team that does not exist, or an ambiguous player name). 15 | * Intellisense for the arguments (The IDE will see `args: ["team:team?"]` and correctly type `args.team` as `Team | null`) 16 | * Callback-based menu system with builtin permission safety 17 | * Command handlers are provided with the command's usage stats (how long ago the command was used, etc) 18 | * Tap handling system 19 | * Permission handling system 20 | * Easy failing with fail() and its associated pattern 21 | * Automatically allows using a menu to resolve arguments left blank 22 | 23 | Interested in contributing? See [CONTRIBUTING.md](CONTRIBUTING.md), and thanks in advance! 24 | 25 | Contributors: 26 | 27 | [Brandons404](https://github.com/Brandons404/) 28 | [BalaM314](https://github.com/BalaM314/) 29 | [TheEt1234](https://github.com/TheEt1234/) 30 | [buthed010203](https://github.com/buthed010203/) 31 | 32 | [Jurorno9](https://github.com/Jurorno9/) 33 | [Dart25](https://github.com/Dart25/) 34 | [kenos1](https://github.com/kenos1/) 35 | [omnerom](https://github.com/omnerom/) 36 | 37 | [Darthscion](https://github.com/Darthscion55/) 38 | [cudspent](https://github.com/spentcud/) 39 | 40 | Join our Discord: [https://discord.gg/VpzcYSQ33Y](https://discord.gg/VpzcYSQ33Y) 41 | 42 | -------------------------------------------------------------------------------- /archived/map-vote.js: -------------------------------------------------------------------------------- 1 | const utils = require('helper'); 2 | importPackage(Packages.arc); 3 | 4 | let votes = {}; 5 | let voteOngoing = false; 6 | let alreadyVoted = []; 7 | let voteStartTime = 0; 8 | const voteTime = 1.5 * 60000; // 1.5 mins 9 | let voteChangesMap = false; 10 | let serverCommands; 11 | 12 | const resetVotes = () => { 13 | alreadyVoted = []; 14 | voteStartTime = 0; 15 | voteOngoing = false; 16 | const maps = Vars.maps.customMaps().toArray(); 17 | votes = {}; 18 | for (let i = 0; i < maps.length; i++) { 19 | votes[i] = { 20 | name: maps[i].name(), 21 | total: 0, 22 | }; 23 | } 24 | }; 25 | 26 | const startVoteTimer = () => { 27 | voteOngoing = true; 28 | voteStartTime = Date.now(); 29 | Timer.schedule(() => { 30 | if (!voteOngoing) return; 31 | let highestVotedMap = []; 32 | let highestVotes = 0; 33 | 34 | const maps = Object.keys(votes); 35 | maps.forEach((map) => { 36 | const vote = votes[map]; 37 | if (vote.total > highestVotes) { 38 | highestVotes = vote.total; 39 | highestVotedMap = [map]; 40 | return; 41 | } 42 | 43 | if (vote.total === highestVotes) { 44 | highestVotedMap.push(map); 45 | return; 46 | } 47 | }); 48 | 49 | if (highestVotedMap.length > 1) { 50 | const votedMapNames = []; 51 | maps.forEach((num) => { 52 | if (votes[num].total > 0) { 53 | votedMapNames.push('[cyan]' + votes[num].name + '[yellow]: ' + votes[num].total); 54 | } 55 | }); 56 | 57 | const winner = votes[highestVotedMap[Math.floor(Math.random() * highestVotedMap.length)]]; 58 | 59 | Call.sendMessage( 60 | '[green]There was a tie between the following maps: \n' + 61 | '[yellow]' + 62 | votedMapNames.join('[green],\n[yellow]') + 63 | '\n[green]Picking random winner: [yellow]' + 64 | winner.name 65 | ); 66 | serverCommands.handleMessage('nextmap ' + winner.name.split(' ').join('_')); 67 | resetVotes(); 68 | if (voteChangesMap) { 69 | Call.sendMessage('[green]Changing map.'); 70 | Events.fire(new GameOverEvent(Team.crux)); 71 | } 72 | return; 73 | } 74 | 75 | Call.sendMessage( 76 | '[green]Map voting complete! The next map will be [yellow]' + 77 | votes[highestVotedMap[0]].name + 78 | ' [green]with [yellow]' + 79 | votes[highestVotedMap[0]].total + 80 | '[green] votes.' 81 | ); 82 | serverCommands.handleMessage('nextmap ' + votes[highestVotedMap[0]].name.split(' ').join('_')); 83 | resetVotes(); 84 | if (voteChangesMap) { 85 | Call.sendMessage('[green]Changing map.'); 86 | Events.fire(new GameOverEvent(Team.crux)); 87 | } 88 | return; 89 | }, voteTime / 1000); 90 | }; 91 | 92 | const showVotes = () => { 93 | const totals = ['[green]Current votes: \n------------------------------']; 94 | const maps = Vars.maps.customMaps().toArray(); 95 | for (let i = 0; i < maps.length; i++) { 96 | if (votes[i].total > 0) { 97 | totals.push('[cyan]' + maps[i].name() + '[yellow]: ' + votes[i].total); 98 | } 99 | } 100 | totals.push('[green]_________________________'); 101 | Call.sendMessage(totals.join('\n')); 102 | }; 103 | 104 | const getTimeLeft = () => { 105 | const endTime = voteStartTime + voteTime; 106 | const now = Date.now(); 107 | const timeLeft = endTime - now; 108 | 109 | const minutes = Math.floor(timeLeft / 60000); 110 | const seconds = Math.floor((timeLeft % 60000) / 1000); 111 | return seconds == 60 ? minutes + 1 + ':00' : minutes + ':' + (seconds < 10 ? '0' : '') + seconds; 112 | }; 113 | 114 | Events.on(GameOverEvent, (e) => { 115 | resetVotes(); 116 | }); 117 | 118 | Events.on(ServerLoadEvent, (e) => { 119 | const clientCommands = Vars.netServer.clientCommands; 120 | serverCommands = Core.app.listeners.find( 121 | (l) => l instanceof Packages.mindustry.server.ServerControl 122 | ).handler; 123 | const runner = (method) => new Packages.arc.util.CommandHandler.CommandRunner({ accept: method }); 124 | 125 | const voteChangesMapSaved = Core.settings.get('vnm', ''); 126 | 127 | if (voteChangesMapSaved !== '') { 128 | voteChangesMap = voteChangesMapSaved === 'true' ? true : false; 129 | } 130 | 131 | resetVotes(); 132 | 133 | // maps 134 | clientCommands.register( 135 | 'maps', 136 | 'list maps on server', 137 | runner((args, realP) => { 138 | realP.sendMessage( 139 | '\n[yellow]Use [white]/nextmap [lightgray] [yellow]to vote on a map.\n\n' 140 | ); 141 | const maps = Vars.maps.customMaps().toArray(); 142 | const mapNames = ['[blue]Available maps:', '_________________________ \n']; 143 | for (let i = 0; i < maps.length; i++) { 144 | mapNames.push('[white]' + i + ' - [yellow]' + maps[i].name()); 145 | } 146 | realP.sendMessage(mapNames.join('\n')); 147 | }) 148 | ); 149 | 150 | // nextmap 151 | clientCommands.register( 152 | 'nextmap', 153 | '', 154 | 'vote for the next map. Use /maps to see a list of them.', 155 | runner((args, realP) => { 156 | const map = args[0]; 157 | 158 | if (!votes[map]) { 159 | realP.sendMessage( 160 | '[scarlet]⚠ [yellow]Unknown map number. Use [white]/maps [lightgray]to see a list of maps.' 161 | ); 162 | return; 163 | } 164 | const pid = realP.uuid(); 165 | 166 | if (!voteOngoing) { 167 | alreadyVoted.push(pid); 168 | startVoteTimer(); 169 | Call.sendMessage( 170 | '[cyan]Next Map Vote: ' + 171 | realP.name + 172 | ' [cyan]Started a map vote, and voted for [white](' + 173 | String(map) + 174 | ')' + 175 | '[yellow] ' + 176 | votes[map].name + 177 | '[cyan]. Use /nextmap ' + 178 | String(map) + 179 | ' to add your vote!' 180 | ); 181 | votes[map].total += 1; 182 | 183 | return; 184 | } 185 | 186 | if (alreadyVoted.includes(pid)) { 187 | realP.sendMessage( 188 | '[scarlet]⚠ [yellow]You have already voted. Wait until the vote expires before voting again. [cyan]Time left: [scarlet]' + 189 | getTimeLeft() 190 | ); 191 | return; 192 | } 193 | 194 | alreadyVoted.push(pid); 195 | votes[map].total += 1; 196 | Call.sendMessage( 197 | '[cyan]Next Map Vote: ' + 198 | realP.name + 199 | ' [cyan]voted for[yellow] ' + 200 | votes[map].name + 201 | '[cyan]. Time left: [scarlet]' + 202 | getTimeLeft() 203 | ); 204 | showVotes(); 205 | }) 206 | ); 207 | 208 | // voteChangesMap 209 | serverCommands.register( 210 | 'votechangesmap', 211 | '', 212 | 'set whether a map vote changes the map.', 213 | runner((args) => { 214 | if (['true', 'false'].includes(args[0])) { 215 | voteChangesMap = args[0] === 'true' ? true : false; 216 | Core.settings.put('vnm', args[0]); 217 | Core.settings.manualSave(); 218 | Log.info('Vote changes map set to: ' + args[0]); 219 | return; 220 | } else { 221 | Log.info('"' + args[0] + '"' + ' was not recognized. Please use "true" or "false".'); 222 | } 223 | }) 224 | ); 225 | }); 226 | -------------------------------------------------------------------------------- /build/LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright © BalaM314, 2024. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /build/README.md: -------------------------------------------------------------------------------- 1 | # Fish commands: Build Output 2 | 3 | A custom commands plugin for >|||>Fish servers. Created by Brandons404, rewritten by BalaM314. 4 | 5 | This is the built plugin. [Click here for the source code](https://github.com/BalaM314/fish-commands). 6 | -------------------------------------------------------------------------------- /build/mod.hjson: -------------------------------------------------------------------------------- 1 | name: fish 2 | displayName: Fish Plugins 3 | author: >;;;>Fish 4 | description: "Adds sudo commands to the server." 5 | version: 1.0 6 | minGameVersion: 137 7 | hidden: true -------------------------------------------------------------------------------- /build/scripts/api.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains a wrapper over the API calls to the backend server. 5 | */ 6 | Object.defineProperty(exports, "__esModule", { value: true }); 7 | exports.addStopped = addStopped; 8 | exports.free = free; 9 | exports.getStopped = getStopped; 10 | exports.isVpn = isVpn; 11 | exports.sendModerationMessage = sendModerationMessage; 12 | exports.getStaffMessages = getStaffMessages; 13 | exports.sendStaffMessage = sendStaffMessage; 14 | exports.ban = ban; 15 | exports.unban = unban; 16 | exports.getBanned = getBanned; 17 | var config_1 = require("./config"); 18 | var globals_1 = require("./globals"); 19 | var players_1 = require("./players"); 20 | /** Mark a player as stopped until time */ 21 | function addStopped(uuid, time) { 22 | if (config_1.Mode.localDebug) 23 | return; 24 | var req = Http.post("http://".concat(config_1.backendIP, "/api/addStopped"), JSON.stringify({ id: uuid, time: time })) 25 | .header('Content-Type', 'application/json') 26 | .header('Accept', '*/*'); 27 | req.timeout = 10000; 28 | req.error(function () { return Log.err("[API] Network error when trying to call api.addStopped()"); }); 29 | req.submit(function (response) { 30 | //Log.info(response.getResultAsString()); 31 | }); 32 | } 33 | /** Mark a player as freed */ 34 | function free(uuid) { 35 | if (config_1.Mode.localDebug) 36 | return; 37 | var req = Http.post("http://".concat(config_1.backendIP, "/api/free"), JSON.stringify({ id: uuid })) 38 | .header('Content-Type', 'application/json') 39 | .header('Accept', '*/*'); 40 | req.timeout = 10000; 41 | req.error(function () { return Log.err("[API] Network error when trying to call api.free()"); }); 42 | req.submit(function (response) { 43 | //Log.info(response.getResultAsString()); 44 | }); 45 | } 46 | function getStopped(uuid, callback, callbackError) { 47 | function fail(err) { 48 | Log.err("[API] Network error when trying to call api.getStopped()"); 49 | if (err) 50 | Log.err(err); 51 | if (callbackError) 52 | callbackError(err); 53 | else 54 | callback(null); 55 | } 56 | if (config_1.Mode.localDebug) 57 | return fail("local debug mode"); 58 | var req = Http.post("http://".concat(config_1.backendIP, "/api/getStopped"), JSON.stringify({ id: uuid })) 59 | .header('Content-Type', 'application/json') 60 | .header('Accept', '*/*'); 61 | req.timeout = 10000; 62 | req.error(fail); 63 | req.submit(function (response) { 64 | var temp = response.getResultAsString(); 65 | if (!temp.length) 66 | return fail("reponse empty"); 67 | var time = JSON.parse(temp).time; 68 | if (isNaN(Number(time))) 69 | return fail("API IS BROKEN!!! Invalid unmark time \"".concat(time, "\": not a number")); 70 | if (time.toString().length > 13) 71 | callback(globals_1.maxTime); 72 | callback(Number(time)); 73 | }); 74 | } 75 | var cachedIps = {}; 76 | /** Make an API request to see if an IP is likely VPN. */ 77 | function isVpn(ip, callback, callbackError) { 78 | if (ip in cachedIps) 79 | return callback(cachedIps[ip]); 80 | Http.get("http://ip-api.com/json/".concat(ip, "?fields=proxy,hosting"), function (res) { 81 | var data = res.getResultAsString(); 82 | var json = JSON.parse(data); 83 | var isVpn = json.proxy || json.hosting; 84 | cachedIps[ip] = isVpn; 85 | players_1.FishPlayer.stats.numIpsChecked++; 86 | if (isVpn) 87 | players_1.FishPlayer.stats.numIpsFlagged++; 88 | callback(isVpn); 89 | }, callbackError !== null && callbackError !== void 0 ? callbackError : (function (err) { 90 | Log.err("[API] Network error when trying to call api.isVpn()"); 91 | players_1.FishPlayer.stats.numIpsErrored++; 92 | callback(false); 93 | })); 94 | } 95 | /** Send text to the moderation logs channel in Discord. */ 96 | function sendModerationMessage(message) { 97 | if (config_1.Mode.localDebug) { 98 | Log.info("Sent moderation log message: ".concat(message)); 99 | return; 100 | } 101 | var req = Http.post("http://".concat(config_1.backendIP, "/api/mod-dump"), JSON.stringify({ message: message })).header('Content-Type', 'application/json').header('Accept', '*/*'); 102 | req.timeout = 10000; 103 | req.error(function () { return Log.err("[API] Network error when trying to call api.sendModerationMessage()"); }); 104 | req.submit(function (response) { 105 | //Log.info(response.getResultAsString()); 106 | }); 107 | } 108 | /** Get staff messages from discord. */ 109 | function getStaffMessages(callback) { 110 | if (config_1.Mode.localDebug) 111 | return; 112 | var req = Http.post("http://".concat(config_1.backendIP, "/api/getStaffMessages"), JSON.stringify({ server: config_1.Gamemode.name() })) 113 | .header('Content-Type', 'application/json').header('Accept', '*/*'); 114 | req.timeout = 10000; 115 | req.error(function () { return Log.err("[API] Network error when trying to call api.getStaffMessages()"); }); 116 | req.submit(function (response) { 117 | var temp = response.getResultAsString(); 118 | if (!temp.length) 119 | Log.err("[API] Network error(empty response) when trying to call api.getStaffMessages()"); 120 | else 121 | callback(JSON.parse(temp).messages); 122 | }); 123 | } 124 | /** Send staff messages from server. */ 125 | function sendStaffMessage(message, playerName, callback) { 126 | if (config_1.Mode.localDebug) 127 | return; 128 | var req = Http.post("http://".concat(config_1.backendIP, "/api/sendStaffMessage"), 129 | // need to send both name variants so one can be sent to the other servers with color and discord can use the clean one 130 | JSON.stringify({ message: message, playerName: playerName, cleanedName: Strings.stripColors(playerName), server: config_1.Gamemode.name() })).header('Content-Type', 'application/json').header('Accept', '*/*'); 131 | req.timeout = 10000; 132 | req.error(function () { 133 | Log.err("[API] Network error when trying to call api.sendStaffMessage()"); 134 | callback === null || callback === void 0 ? void 0 : callback(false); 135 | }); 136 | req.submit(function (response) { 137 | var temp = response.getResultAsString(); 138 | if (!temp.length) 139 | Log.err("[API] Network error(empty response) when trying to call api.sendStaffMessage()"); 140 | else 141 | callback === null || callback === void 0 ? void 0 : callback(JSON.parse(temp).data); 142 | }); 143 | } 144 | /** Bans the provided ip and/or uuid. */ 145 | function ban(data, callback) { 146 | if (callback === void 0) { callback = function () { }; } 147 | if (config_1.Mode.localDebug) 148 | return; 149 | var req = Http.post("http://".concat(config_1.backendIP, "/api/ban"), JSON.stringify(data)) 150 | .header('Content-Type', 'application/json') 151 | .header('Accept', '*/*'); 152 | req.timeout = 10000; 153 | req.error(function () { return Log.err("[API] Network error when trying to call api.ban(".concat(data.ip, ", ").concat(data.uuid, ")")); }); 154 | req.submit(function (response) { 155 | var str = response.getResultAsString(); 156 | if (!str.length) 157 | return Log.err("[API] Network error(empty response) when trying to call api.ban()"); 158 | callback(JSON.parse(str).data); 159 | }); 160 | } 161 | /** Unbans the provided ip and/or uuid. */ 162 | function unban(data, callback) { 163 | if (callback === void 0) { callback = function () { }; } 164 | if (config_1.Mode.localDebug) 165 | return; 166 | var req = Http.post("http://".concat(config_1.backendIP, "/api/unban"), JSON.stringify(data)) 167 | .header('Content-Type', 'application/json') 168 | .header('Accept', '*/*'); 169 | req.timeout = 10000; 170 | req.error(function () { return Log.err("[API] Network error when trying to call api.ban({".concat(data.ip, ", ").concat(data.uuid, "})")); }); 171 | req.submit(function (response) { 172 | var str = response.getResultAsString(); 173 | if (!str.length) 174 | return Log.err("[API] Network error(empty response) when trying to call api.unban()"); 175 | var parsedData = JSON.parse(str); 176 | callback(parsedData.status, parsedData.error); 177 | }); 178 | } 179 | /** Gets if either the provided uuid or ip is banned. */ 180 | function getBanned(data, callback) { 181 | if (config_1.Mode.localDebug) { 182 | Log.info("[API] Attempted to getBanned(".concat(data.uuid, "/").concat(data.ip, "), assuming false due to local debug")); 183 | callback(false); 184 | return; 185 | } 186 | //TODO cache 4s 187 | var req = Http.post("http://".concat(config_1.backendIP, "/api/checkIsBanned"), JSON.stringify(data)) 188 | .header('Content-Type', 'application/json') 189 | .header('Accept', '*/*'); 190 | req.timeout = 10000; 191 | req.error(function () { return Log.err("[API] Network error when trying to call api.getBanned()"); }); 192 | req.submit(function (response) { 193 | var str = response.getResultAsString(); 194 | if (!str.length) 195 | return Log.err("[API] Network error(empty response) when trying to call api.getBanned()"); 196 | callback(JSON.parse(str).data); 197 | }); 198 | } 199 | -------------------------------------------------------------------------------- /build/scripts/files.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains the code for automated map syncing. 5 | Original contributor: @author Jurorno9 6 | Maintenance: @author BalaM314 7 | */ 8 | Object.defineProperty(exports, "__esModule", { value: true }); 9 | exports.updateMaps = updateMaps; 10 | var config_1 = require("./config"); 11 | var promise_1 = require("./promise"); 12 | var utils_1 = require("./utils"); 13 | var funcs_1 = require("./funcs"); 14 | //if we switch to a self-hosted setup, just make it respond with the githubfile object for a drop-in replacement 15 | function fetchGithubContents() { 16 | return new promise_1.Promise(function (resolve, reject) { 17 | var url = config_1.mapRepoURLs[config_1.Gamemode.name()]; 18 | if (!url) 19 | return reject("No recognized gamemode detected. please enter \"host \" and try again"); 20 | Http.get(url, function (res) { 21 | try { 22 | //Trust github to return valid JSON data 23 | resolve(JSON.parse(res.getResultAsString())); 24 | } 25 | catch (e) { 26 | reject("Failed to parse GitHub repository contents: ".concat(e)); 27 | } 28 | }, function () { return reject("Network error while fetching github repository contents"); }); 29 | }); 30 | } 31 | function downloadFile(address, filename) { 32 | if (!/^https?:\/\//i.test(address)) { 33 | (0, funcs_1.crash)("Invalid address, please start with 'http://' or 'https://'"); 34 | } 35 | return new promise_1.Promise(function (resolve, reject) { 36 | var instream = null; 37 | var outstream = null; 38 | Log.info("Downloading ".concat(filename, "...")); 39 | Http.get(address, function (res) { 40 | try { 41 | instream = res.getResultAsStream(); 42 | outstream = new Fi(filename).write(); 43 | instream.transferTo(outstream); 44 | resolve(); 45 | } 46 | finally { 47 | instream === null || instream === void 0 ? void 0 : instream.close(); 48 | outstream === null || outstream === void 0 ? void 0 : outstream.close(); 49 | } 50 | }, function () { 51 | Log.err("Download failed."); 52 | reject("Network error while downloading a map file: ".concat(address)); 53 | }); 54 | }); 55 | } 56 | function downloadMaps(githubListing) { 57 | return promise_1.Promise.all(githubListing.map(function (fileEntry) { 58 | if (!(typeof fileEntry.download_url == "string")) { 59 | Log.warn("Map ".concat(fileEntry.name, " has no valid download link, skipped.")); 60 | return promise_1.Promise.resolve(null); 61 | } 62 | return downloadFile(fileEntry.download_url, Vars.customMapDirectory.child(fileEntry.name).absolutePath()); 63 | })).then(function (v) { }); 64 | } 65 | /** 66 | * @returns whether any maps were changed 67 | */ 68 | function updateMaps() { 69 | //get github map listing 70 | return fetchGithubContents().then(function (listing) { 71 | //filter only valid mindustry maps 72 | var mapList = listing 73 | .filter(function (entry) { return entry.type == 'file'; }) 74 | .filter(function (entry) { return /\.msav$/.test(entry.name); }); 75 | var mapFiles = Vars.customMapDirectory.list(); 76 | var mapsToDelete = mapFiles.filter(function (localFile) { 77 | return !mapList.some(function (remoteFile) { 78 | return remoteFile.name === localFile.name(); 79 | }) 80 | && !localFile.name().startsWith("$$"); 81 | }); 82 | mapsToDelete.forEach(function (map) { return map.delete(); }); 83 | var mapsToDownload = mapList 84 | .filter(function (entry) { 85 | var file = Vars.customMapDirectory.child(entry.name); 86 | return !file.exists() || entry.sha !== (0, utils_1.getHash)(file); //sha'd 87 | }); 88 | if (mapsToDownload.length == 0) { 89 | return mapsToDelete.length > 0 ? true : false; 90 | } 91 | return downloadMaps(mapsToDownload).then(function () { 92 | Vars.maps.reload(); 93 | return true; 94 | }); 95 | }); 96 | } 97 | -------------------------------------------------------------------------------- /build/scripts/fjsContext.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains the context for the "fjs" command, which executes code with access to the plugin's internals. 5 | */ 6 | Object.defineProperty(exports, "__esModule", { value: true }); 7 | exports.runJS = runJS; 8 | var api = require("./api"); 9 | var commands = require("./commands"); 10 | var config = require("./config"); 11 | var consoleCommands = require("./consoleCommands").commands; 12 | var files = require("./files"); 13 | var funcs = require("./funcs"); 14 | var globals = require("./globals"); 15 | var maps = require("./maps"); 16 | var memberCommands = require("./memberCommands").commands; 17 | var menus = require("./menus"); 18 | var Metrics = require('./metrics').Metrics; 19 | var packetHandlers = require("./packetHandlers"); 20 | var playerCommands = require("./playerCommands").commands; 21 | var players = require("./players"); 22 | var ranks = require("./ranks"); 23 | var staffCommands = require("./staffCommands").commands; 24 | var timers = require("./timers"); 25 | var utils = require("./utils"); 26 | var votes = require("./votes"); 27 | var Promise = require("./promise").Promise; 28 | var Perm = commands.Perm, allCommands = commands.allCommands; 29 | var FishPlayer = players.FishPlayer; 30 | var FMap = maps.FMap; 31 | var Rank = ranks.Rank, RoleFlag = ranks.RoleFlag; 32 | var Menu = menus.Menu; 33 | Object.assign(this, utils, funcs); //global scope goes brrrrr, I'm sure this will not cause any bugs whatsoever 34 | var Ranks = null; 35 | var $ = Object.assign(function $(input) { 36 | if (typeof input == "string") { 37 | if (Pattern.matches("[a-zA-Z0-9+/]{22}==", input)) { 38 | return FishPlayer.getById(input); 39 | } 40 | } 41 | return null; 42 | }, { 43 | sussy: true, 44 | info: function (input) { 45 | if (typeof input == "string") { 46 | if (Pattern.matches("[a-zA-Z0-9+/]{22}==", input)) { 47 | return Vars.netServer.admins.getInfo(input); 48 | } 49 | } 50 | return null; 51 | }, 52 | create: function (input) { 53 | if (typeof input == "string") { 54 | if (Pattern.matches("[a-zA-Z0-9+/]{22}==", input)) { 55 | return FishPlayer.getFromInfo(Vars.netServer.admins.getInfo(input)); 56 | } 57 | } 58 | return null; 59 | }, 60 | me: null, 61 | meM: null, 62 | }); 63 | /** Used to persist variables. */ 64 | var vars = {}; 65 | function runJS(input, outputFunction, errorFunction, player) { 66 | if (outputFunction === void 0) { outputFunction = Log.info; } 67 | if (errorFunction === void 0) { errorFunction = Log.err; } 68 | if (player) { 69 | $.me = player; 70 | $.meM = player.player; 71 | } 72 | else if (Groups.player.size() == 1) { 73 | $.meM = Groups.player.first(); 74 | $.me = players.FishPlayer.get($.meM); 75 | } 76 | try { 77 | var admins = Vars.netServer.admins; 78 | var output = eval(input); 79 | if (output instanceof Array) { 80 | outputFunction("&cArray: [&fr" + output.join(", ") + "&c]&fr"); 81 | } 82 | else if (output === undefined) { 83 | outputFunction("undefined"); 84 | } 85 | else if (output === null) { 86 | outputFunction("null"); 87 | } 88 | else { 89 | outputFunction(output); 90 | } 91 | } 92 | catch (err) { 93 | errorFunction(err); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /build/scripts/globals.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains mutable global variables, and global constants. 5 | */ 6 | Object.defineProperty(exports, "__esModule", { value: true }); 7 | exports.FishEvents = exports.maxTime = exports.ipRangeWildcardPattern = exports.ipRangeCIDRPattern = exports.ipPortPattern = exports.ipPattern = exports.uuidPattern = exports.ipJoins = exports.fishPlugin = exports.fishState = exports.recentWhispers = exports.tileHistory = void 0; 8 | var funcs_1 = require("./funcs"); 9 | exports.tileHistory = {}; 10 | exports.recentWhispers = {}; 11 | exports.fishState = { 12 | restartQueued: false, 13 | restartLoopTask: null, 14 | corruption_t1: null, 15 | corruption_t2: null, 16 | lastPranked: Date.now(), 17 | labels: [], 18 | peacefulMode: false, 19 | joinBell: false, 20 | }; 21 | exports.fishPlugin = { 22 | directory: null, 23 | version: null, 24 | }; 25 | exports.ipJoins = new ObjectIntMap(); //todo somehow tell java that K is String and not Object 26 | exports.uuidPattern = /^[a-zA-Z0-9+/]{22}==$/; 27 | exports.ipPattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; 28 | exports.ipPortPattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{1,5}$/; 29 | exports.ipRangeCIDRPattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/(1[2-9]|2[0-4])$/; //Disallow anything bigger than a /12 30 | exports.ipRangeWildcardPattern = /^(\d{1,3}\.\d{1,3})\.(?:(\d{1,3}\.\*)|\*)$/; //Disallow anything bigger than a /16 31 | exports.maxTime = 9999999999999; 32 | exports.FishEvents = new funcs_1.EventEmitter(); 33 | -------------------------------------------------------------------------------- /build/scripts/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains the main code, which calls other functions and initializes the plugin. 5 | */ 6 | var __values = (this && this.__values) || function(o) { 7 | var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0; 8 | if (m) return m.call(o); 9 | if (o && typeof o.length === "number") return { 10 | next: function () { 11 | if (o && i >= o.length) o = void 0; 12 | return { value: o && o[i++], done: !o }; 13 | } 14 | }; 15 | throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined."); 16 | }; 17 | Object.defineProperty(exports, "__esModule", { value: true }); 18 | var api = require("./api"); 19 | var commands = require("./commands"); 20 | var commands_1 = require("./commands"); 21 | var consoleCommands_1 = require("./consoleCommands"); 22 | var globals_1 = require("./globals"); 23 | var memberCommands_1 = require("./memberCommands"); 24 | var menus = require("./menus"); 25 | var packetHandlers_1 = require("./packetHandlers"); 26 | var playerCommands_1 = require("./playerCommands"); 27 | var players_1 = require("./players"); 28 | var staffCommands_1 = require("./staffCommands"); 29 | var timers = require("./timers"); 30 | var utils_1 = require("./utils"); 31 | Events.on(EventType.ConnectionEvent, function (e) { 32 | if (Vars.netServer.admins.bannedIPs.contains(e.connection.address)) { 33 | api.getBanned({ 34 | ip: e.connection.address, 35 | }, function (banned) { 36 | if (!banned) { 37 | //If they were previously banned locally, but the API says they aren't banned, then unban them and clear the kick that the outer function already did 38 | Vars.netServer.admins.unbanPlayerIP(e.connection.address); 39 | Vars.netServer.admins.kickedIPs.remove(e.connection.address); 40 | } 41 | }); 42 | } 43 | }); 44 | Events.on(EventType.PlayerConnect, function (e) { 45 | if (players_1.FishPlayer.shouldKickNewPlayers() && e.player.info.timesJoined == 1) { 46 | e.player.kick(Packets.KickReason.kick, 3600000); 47 | } 48 | players_1.FishPlayer.onPlayerConnect(e.player); 49 | }); 50 | Events.on(EventType.PlayerJoin, function (e) { 51 | players_1.FishPlayer.onPlayerJoin(e.player); 52 | }); 53 | Events.on(EventType.PlayerLeave, function (e) { 54 | players_1.FishPlayer.onPlayerLeave(e.player); 55 | }); 56 | Events.on(EventType.ConnectPacketEvent, function (e) { 57 | players_1.FishPlayer.playersJoinedRecent++; 58 | globals_1.ipJoins.increment(e.connection.address); 59 | var info = Vars.netServer.admins.getInfoOptional(e.packet.uuid); 60 | var underAttack = players_1.FishPlayer.antiBotMode(); 61 | var newPlayer = !info || info.timesJoined < 10; 62 | var longModName = e.packet.mods.contains(function (str) { return str.length > 50; }); 63 | var veryLongModName = e.packet.mods.contains(function (str) { return str.length > 100; }); 64 | if ((underAttack && e.packet.mods.size > 2) || 65 | (underAttack && longModName) || 66 | (veryLongModName && (underAttack || newPlayer))) { 67 | Vars.netServer.admins.blacklistDos(e.connection.address); 68 | e.connection.kicked = true; 69 | players_1.FishPlayer.onBotWhack(); 70 | Log.info("&yAntibot killed connection ".concat(e.connection.address, " because ").concat(veryLongModName ? "very long mod name" : longModName ? "long mod name" : "it had mods while under attack")); 71 | return; 72 | } 73 | if (globals_1.ipJoins.get(e.connection.address) >= ((underAttack || veryLongModName) ? 3 : (newPlayer || longModName) ? 7 : 15)) { 74 | Vars.netServer.admins.blacklistDos(e.connection.address); 75 | e.connection.kicked = true; 76 | players_1.FishPlayer.onBotWhack(); 77 | Log.info("&yAntibot killed connection ".concat(e.connection.address, " due to too many connections")); 78 | return; 79 | } 80 | /*if(e.packet.name.includes("discord.gg/GnEdS9TdV6")){ 81 | Vars.netServer.admins.blacklistDos(e.connection.address); 82 | e.connection.kicked = true; 83 | FishPlayer.onBotWhack(); 84 | Log.info(`&yAntibot killed connection ${e.connection.address} due to omni discord link`); 85 | return; 86 | } 87 | if(e.packet.name.includes("счастливого 2024 года!")){ 88 | Vars.netServer.admins.blacklistDos(e.connection.address); 89 | e.connection.kicked = true; 90 | FishPlayer.onBotWhack(); 91 | Log.info(`&yAntibot killed connection ${e.connection.address} due to known bad name`); 92 | return; 93 | }*/ 94 | if (Vars.netServer.admins.isDosBlacklisted(e.connection.address)) { 95 | //threading moment, i think 96 | e.connection.kicked = true; 97 | return; 98 | } 99 | api.getBanned({ 100 | ip: e.connection.address, 101 | uuid: e.packet.uuid 102 | }, function (banned) { 103 | if (banned) { 104 | Log.info("&lrSynced ban of ".concat(e.packet.uuid, "/").concat(e.connection.address, ".")); 105 | e.connection.kick(Packets.KickReason.banned, 1); 106 | Vars.netServer.admins.banPlayerIP(e.connection.address); 107 | Vars.netServer.admins.banPlayerID(e.packet.uuid); 108 | } 109 | else { 110 | Vars.netServer.admins.unbanPlayerIP(e.connection.address); 111 | Vars.netServer.admins.unbanPlayerID(e.packet.uuid); 112 | } 113 | }); 114 | }); 115 | Events.on(EventType.UnitChangeEvent, function (e) { 116 | players_1.FishPlayer.onUnitChange(e.player, e.unit); 117 | }); 118 | Events.on(EventType.ContentInitEvent, function () { 119 | //Unhide latum and renale 120 | UnitTypes.latum.hidden = false; 121 | UnitTypes.renale.hidden = false; 122 | }); 123 | Events.on(EventType.PlayerChatEvent, function (e) { return (0, utils_1.processChat)(e.player, e.message, true); }); 124 | Events.on(EventType.ServerLoadEvent, function (e) { 125 | var clientHandler = Vars.netServer.clientCommands; 126 | var serverHandler = ServerControl.instance.handler; 127 | players_1.FishPlayer.loadAll(); 128 | globals_1.FishEvents.fire("loadData", []); 129 | timers.initializeTimers(); 130 | menus.registerListeners(); 131 | //Cap delta 132 | Time.setDeltaProvider(function () { return Math.min(Core.graphics.getDeltaTime() * 60, 10); }); 133 | // Mute muted players 134 | Vars.netServer.admins.addChatFilter(function (player, message) { return (0, utils_1.processChat)(player, message); }); 135 | // Action filters 136 | Vars.netServer.admins.addActionFilter(function (action) { 137 | var _a, _b; 138 | var player = action.player; 139 | var fishP = players_1.FishPlayer.get(player); 140 | //prevent stopped players from doing anything other than deposit items. 141 | if (!fishP.hasPerm("play")) { 142 | action.player.sendMessage('[scarlet]\u26A0 [yellow]You are stopped, you cant perfom this action.'); 143 | return false; 144 | } 145 | else { 146 | if (action.type === Administration.ActionType.pickupBlock) { 147 | (0, utils_1.addToTileHistory)({ 148 | pos: "".concat(action.tile.x, ",").concat(action.tile.y), 149 | uuid: action.player.uuid(), 150 | action: "picked up", 151 | type: (_b = (_a = action.tile.block()) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : "nothing", 152 | }); 153 | } 154 | return true; 155 | } 156 | }); 157 | commands.register(staffCommands_1.commands, clientHandler, serverHandler); 158 | commands.register(playerCommands_1.commands, clientHandler, serverHandler); 159 | commands.register(memberCommands_1.commands, clientHandler, serverHandler); 160 | commands.register(packetHandlers_1.commands, clientHandler, serverHandler); 161 | commands.registerConsole(consoleCommands_1.commands, serverHandler); 162 | (0, packetHandlers_1.loadPacketHandlers)(); 163 | commands.initialize(); 164 | //Load plugin data 165 | try { 166 | var path = (0, utils_1.fishCommandsRootDirPath)(); 167 | globals_1.fishPlugin.directory = path.toString(); 168 | Threads.daemon(function () { 169 | try { 170 | globals_1.fishPlugin.version = OS.exec("git", "-C", globals_1.fishPlugin.directory, "rev-parse", "HEAD"); 171 | } 172 | catch (_a) { } 173 | }); 174 | } 175 | catch (err) { 176 | Log.err("Failed to get fish plugin information."); 177 | Log.err(err); 178 | } 179 | globals_1.FishEvents.fire("dataLoaded", []); 180 | Core.app.addListener({ 181 | dispose: function () { 182 | globals_1.FishEvents.fire("saveData", []); 183 | players_1.FishPlayer.saveAll(); 184 | Log.info("Saved on exit."); 185 | } 186 | }); 187 | }); 188 | // Keeps track of any action performed on a tile for use in tilelog. 189 | Events.on(EventType.BlockBuildBeginEvent, utils_1.addToTileHistory); 190 | Events.on(EventType.BuildRotateEvent, utils_1.addToTileHistory); 191 | Events.on(EventType.ConfigEvent, utils_1.addToTileHistory); 192 | Events.on(EventType.PickupEvent, utils_1.addToTileHistory); 193 | Events.on(EventType.PayloadDropEvent, utils_1.addToTileHistory); 194 | Events.on(EventType.UnitDestroyEvent, utils_1.addToTileHistory); 195 | Events.on(EventType.BlockDestroyEvent, utils_1.addToTileHistory); 196 | Events.on(EventType.UnitControlEvent, utils_1.addToTileHistory); 197 | Events.on(EventType.TapEvent, commands_1.handleTapEvent); 198 | Events.on(EventType.GameOverEvent, function (e) { 199 | var e_1, _a; 200 | try { 201 | for (var _b = __values(Object.keys(globals_1.tileHistory)), _c = _b.next(); !_c.done; _c = _b.next()) { 202 | var key = _c.value; 203 | //clear tilelog 204 | globals_1.tileHistory[key] = null; 205 | delete globals_1.tileHistory[key]; 206 | } 207 | } 208 | catch (e_1_1) { e_1 = { error: e_1_1 }; } 209 | finally { 210 | try { 211 | if (_c && !_c.done && (_a = _b.return)) _a.call(_b); 212 | } 213 | finally { if (e_1) throw e_1.error; } 214 | } 215 | if (globals_1.fishState.restartQueued) { 216 | //restart 217 | Call.sendMessage("[accent]---[[[coral]+++[]]---\n[accent]Server restart imminent. [green]We'll be back after 15 seconds.[]\n[accent]---[[[coral]+++[]]---"); 218 | (0, utils_1.serverRestartLoop)(20); 219 | } 220 | players_1.FishPlayer.onGameOver(e.winner); 221 | }); 222 | Events.on(EventType.PlayerChatEvent, function (e) { 223 | players_1.FishPlayer.onPlayerChat(e.player, e.message); 224 | }); 225 | -------------------------------------------------------------------------------- /build/scripts/main.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This is a special file which is automatically loaded by the game server. 4 | It only contains polyfills, and requires index.js. 5 | */ 6 | //WARNING: changes to this file must be manually copied to /build/scripts/main.js 7 | 8 | importPackage(Packages.arc); 9 | importClass(Packages.arc.util.CommandHandler); 10 | importPackage(Packages.mindustry.type); 11 | importClass(Packages.mindustry.server.ServerControl); 12 | importPackage(Packages.java.util.regex); 13 | importClass(Packages.java.lang.Runtime); 14 | importClass(Packages.java.lang.ProcessBuilder); 15 | importClass(Packages.java.nio.file.Paths); 16 | importClass(Packages.java.io.ByteArrayOutputStream); 17 | importClass(Packages.java.io.DataOutputStream); 18 | importClass(Packages.java.io.ByteArrayInputStream); 19 | importClass(Packages.java.io.DataInputStream); 20 | 21 | //Polyfills 22 | Object.entries = o => Object.keys(o).map(k => [k, o[k]]); 23 | Object.values = o => Object.keys(o).map(k => o[k]); 24 | Object.fromEntries = a => a.reduce((o, [k, v]) => { o[k] = v; return o; }, {}); 25 | //Arrow functions do not bind to "this" 26 | Array.prototype.at = function(i){ 27 | return this[i < 0 ? this.length + i : i]; 28 | } 29 | String.prototype.at = function(i){ 30 | return this[i < 0 ? this.length + i : i]; 31 | } 32 | Array.prototype.flat = function(depth){ 33 | depth = (depth == undefined) ? 1 : depth; 34 | return depth > 0 ? this.reduce((acc, item) => 35 | acc.concat(Array.isArray(item) ? item.flat(depth - 1) : item) 36 | , []) : this; 37 | } 38 | String.raw = function(callSite){ 39 | const substitutions = Array.prototype.slice.call(arguments, 1); 40 | return Array.from(callSite.raw).map((chunk, i) => { 41 | if (callSite.raw.length <= i) { 42 | return chunk; 43 | } 44 | return substitutions[i - 1] ? substitutions[i - 1] + chunk : chunk; 45 | }).join(''); 46 | } 47 | //Fix rhino regex 48 | if(/ae?a/.test("aeea")){ 49 | RegExp.prototype.test = function(input){ 50 | //overwrite with java regex 51 | return java.util.regex.Pattern.compile(this.source).matcher(input).find(); 52 | }; 53 | } 54 | //Fix rhino Number.prototype.toFixed 55 | if(12.34.toFixed(1) !== '12.3'){ 56 | const toFixed = Number.prototype.toFixed; 57 | Number.prototype.toFixed = function(fractionDigits){ 58 | const floorLog = Math.floor(Math.log10(this)); 59 | const output = toFixed.call(this, Math.max(floorLog, -1) + 1 + fractionDigits); 60 | return output.toString().slice(0, Math.max(floorLog, 0) + 2 + fractionDigits); 61 | } 62 | } 63 | 64 | this.Promise = require('promise').Promise; 65 | require("index"); 66 | -------------------------------------------------------------------------------- /build/scripts/memberCommands.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains member commands, which are fun cosmetics for donators. 5 | */ 6 | Object.defineProperty(exports, "__esModule", { value: true }); 7 | exports.commands = void 0; 8 | var commands_1 = require("./commands"); 9 | exports.commands = (0, commands_1.commandList)({ 10 | pet: { 11 | args: ["name:string?"], 12 | description: 'Spawns a cool pet with a displayed name that follows you around.', 13 | perm: commands_1.Perm.member, 14 | handler: function (_a) { 15 | var args = _a.args, sender = _a.sender, outputSuccess = _a.outputSuccess; 16 | if (!args.name) { 17 | var pet_1 = Groups.unit.find(function (u) { return u.id === sender.pet; }); 18 | if (pet_1) 19 | pet_1.kill(); 20 | sender.pet = ""; 21 | outputSuccess("Your pet has been removed."); 22 | return; 23 | } 24 | if (sender.pet !== '') { 25 | var pet_2 = Groups.unit.find(function (u) { return u.id === sender.pet; }); 26 | if (pet_2) 27 | pet_2.kill(); 28 | sender.pet = ''; 29 | } 30 | var pet = UnitTypes.merui.spawn(sender.team(), sender.unit().x, sender.unit().y); 31 | pet.apply(StatusEffects.disarmed, Number.MAX_SAFE_INTEGER); 32 | sender.pet = pet.id; 33 | Call.infoPopup('[#7FD7FD7f]\uE81B', 5, Align.topRight, 180, 0, 0, 10); 34 | outputSuccess("Spawned a pet."); 35 | function controlUnit(_a) { 36 | var pet = _a.pet, fishPlayer = _a.fishPlayer, petName = _a.petName; 37 | return Timer.schedule(function () { 38 | if (pet.id !== fishPlayer.pet || !fishPlayer.connected()) { 39 | pet.kill(); 40 | return; 41 | } 42 | var distX = fishPlayer.unit().x - pet.x; 43 | var distY = fishPlayer.unit().y - pet.y; 44 | if (distX >= 50 || distX <= -50 || distY >= 50 || distY <= -50) { 45 | pet.approach(new Vec2(distX, distY)); 46 | } 47 | Call.label(petName, 0.07, pet.x, pet.y + 5); 48 | if (fishPlayer.trail) { 49 | Call.effect(Fx[fishPlayer.trail.type], pet.x, pet.y, 0, fishPlayer.trail.color); 50 | } 51 | controlUnit({ petName: petName, pet: pet, fishPlayer: fishPlayer }); 52 | }, 0.05); 53 | } 54 | ; 55 | controlUnit({ petName: args.name, pet: pet, fishPlayer: sender }); 56 | } 57 | }, 58 | highlight: { 59 | args: ['color:string?'], 60 | description: 'Makes your chat text colored by default.', 61 | perm: commands_1.Perm.member, 62 | handler: function (_a) { 63 | var args = _a.args, sender = _a.sender, outputFail = _a.outputFail, outputSuccess = _a.outputSuccess; 64 | if (args.color == null || args.color.length == 0) { 65 | if (sender.highlight != null) { 66 | sender.highlight = null; 67 | outputSuccess("Cleared your highlight."); 68 | } 69 | else { 70 | outputFail("No highlight to clear."); 71 | } 72 | } 73 | else if (Strings.stripColors(args.color) == "") { 74 | sender.highlight = args.color; 75 | outputSuccess("Set highlight to ".concat(args.color.replace("[", "").replace("]", ""), ".")); 76 | } 77 | else if (Strings.stripColors("[".concat(args.color, "]")) == "") { 78 | sender.highlight = "[".concat(args.color, "]"); 79 | outputSuccess("Set highlight to ".concat(args.color, ".")); 80 | } 81 | else { 82 | outputFail("[yellow]\"".concat(args.color, "[yellow]\" was not a valid color!")); 83 | } 84 | } 85 | }, 86 | rainbow: { 87 | args: ["speed:number?"], 88 | description: 'Make your name change colors.', 89 | perm: commands_1.Perm.member, 90 | handler: function (_a) { 91 | var _b; 92 | var args = _a.args, sender = _a.sender, outputSuccess = _a.outputSuccess; 93 | var colors = ['[red]', '[orange]', '[yellow]', '[acid]', '[blue]', '[purple]']; 94 | function rainbowLoop(index, fishP) { 95 | Timer.schedule(function () { 96 | if (!(fishP.rainbow && fishP.player && fishP.connected())) 97 | return; 98 | fishP.player.name = colors[index % colors.length] + Strings.stripColors(fishP.player.name); 99 | rainbowLoop(index + 1, fishP); 100 | }, args.speed / 5); 101 | } 102 | if (!args.speed) { 103 | sender.rainbow = null; 104 | sender.updateName(); 105 | outputSuccess("Turned off rainbow."); 106 | } 107 | else { 108 | if (args.speed > 10 || args.speed <= 0 || !Number.isInteger(args.speed)) { 109 | (0, commands_1.fail)('Speed must be an integer between 0 and 10.'); 110 | } 111 | (_b = sender.rainbow) !== null && _b !== void 0 ? _b : (sender.rainbow = { speed: args.speed }); 112 | rainbowLoop(0, sender); 113 | outputSuccess("Activated rainbow name mode with speed ".concat(args.speed)); 114 | } 115 | } 116 | } 117 | }); 118 | -------------------------------------------------------------------------------- /build/scripts/metrics.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) { 3 | function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; } 4 | var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; 5 | var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null; 6 | var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); 7 | var _, done = false; 8 | for (var i = decorators.length - 1; i >= 0; i--) { 9 | var context = {}; 10 | for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; 11 | for (var p in contextIn.access) context.access[p] = contextIn.access[p]; 12 | context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); }; 13 | var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context); 14 | if (kind === "accessor") { 15 | if (result === void 0) continue; 16 | if (result === null || typeof result !== "object") throw new TypeError("Object expected"); 17 | if (_ = accept(result.get)) descriptor.get = _; 18 | if (_ = accept(result.set)) descriptor.set = _; 19 | if (_ = accept(result.init)) initializers.unshift(_); 20 | } 21 | else if (_ = accept(result)) { 22 | if (kind === "field") initializers.unshift(_); 23 | else descriptor[key] = _; 24 | } 25 | } 26 | if (target) Object.defineProperty(target, contextIn.name, descriptor); 27 | done = true; 28 | }; 29 | var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) { 30 | var useValue = arguments.length > 2; 31 | for (var i = 0; i < initializers.length; i++) { 32 | value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); 33 | } 34 | return useValue ? value : void 0; 35 | }; 36 | var _this = this; 37 | Object.defineProperty(exports, "__esModule", { value: true }); 38 | exports.Metrics = void 0; 39 | var io_1 = require("./io"); 40 | var Metrics = function () { 41 | var _a; 42 | var _static_weeks_decorators; 43 | var _static_weeks_initializers = []; 44 | var _static_weeks_extraInitializers = []; 45 | return _a = /** @class */ (function () { 46 | function Metrics() { 47 | } 48 | Metrics.weekNumber = function (date) { 49 | if (date === void 0) { date = Date.now(); } 50 | return Math.floor((date - this.startDate) / this.millisPerWeek); 51 | }; 52 | Metrics.readingNumber = function (date) { 53 | if (date === void 0) { date = Date.now(); } 54 | return Math.floor(((date - this.startDate) % this.millisPerWeek) / this.millisBetweenReadings); 55 | }; 56 | Metrics.newWeek = function () { 57 | return Array(2520).fill(this.noData); 58 | }; 59 | Metrics.currentWeek = function () { 60 | var _b; 61 | var _c, _d; 62 | return (_b = (_c = this.weeks)[_d = this.weekNumber()]) !== null && _b !== void 0 ? _b : (_c[_d] = this.newWeek()); 63 | }; 64 | Metrics.update = function () { 65 | var playerCount = Groups.player.size(); 66 | this.currentWeek()[this.readingNumber()] = 67 | Math.max(playerCount, this.currentWeek()[this.readingNumber()]); 68 | }; 69 | Metrics.exportRange = function (startDate, endDate) { 70 | var _this = this; 71 | if (startDate === void 0) { startDate = this.startDate; } 72 | if (endDate === void 0) { endDate = Date.now(); } 73 | if (typeof startDate !== "number") 74 | throw new Error('startDate should be a number'); 75 | var startWeek = this.weekNumber(startDate); 76 | var endWeek = this.weekNumber(endDate); 77 | return this.weeks.slice(startWeek, endWeek + 1).map(function (week, weekNumber) { 78 | return week.filter(function (v) { return v >= 0; }).map(function (v, i) { return [ 79 | v, 80 | _this.startDate + 81 | weekNumber * _this.millisPerWeek + 82 | i * _this.millisBetweenReadings 83 | ]; }); 84 | }).flat(); 85 | }; 86 | return Metrics; 87 | }()), 88 | (function () { 89 | var _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0; 90 | _static_weeks_decorators = [(0, io_1.serialize)("player-count-data", function () { return ["version", 0, 91 | ["array", "u16", ["array", 2520, ["number", "i8"]]] 92 | ]; })]; 93 | __esDecorate(null, null, _static_weeks_decorators, { kind: "field", name: "weeks", static: true, private: false, access: { has: function (obj) { return "weeks" in obj; }, get: function (obj) { return obj.weeks; }, set: function (obj, value) { obj.weeks = value; } }, metadata: _metadata }, _static_weeks_initializers, _static_weeks_extraInitializers); 94 | if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata }); 95 | })(), 96 | /** 4 May 2025 */ 97 | _a.startDate = new Date(2025, 4, 4).getTime(), 98 | _a.millisPerWeek = 604800000, 99 | _a.millisBetweenReadings = 240000, 100 | _a.noData = -1, 101 | /** 102 | * Weeks are numbered starting at the week of 4 May 2025. 103 | * A value is taken every 4 minutes, for a total of 15 readings per hour. 104 | */ 105 | _a.weeks = __runInitializers(_a, _static_weeks_initializers, Array.from({ length: _a.weekNumber() + 1 }, function () { return _a.newWeek(); })), 106 | (function () { 107 | __runInitializers(_a, _static_weeks_extraInitializers); 108 | })(), 109 | (function () { 110 | Timer.schedule(function () { return _a.update(); }, 15, 60); 111 | })(), 112 | _a; 113 | }(); 114 | exports.Metrics = Metrics; 115 | -------------------------------------------------------------------------------- /build/scripts/mindustryTypes.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains TypeScript type definitions for Mindustry's code. 5 | Mindustry is written in Java, which has strong types. 6 | Mindustry supports loading Javascript, which does not have types. 7 | Javascript will have access to Mindustry's functions, which have types. 8 | We are writing Typescript, which does have types. We are able to call Mindustry's functions, but because those are written in Java we cannot directly use those types. 9 | This file contains some of those type definitions, ported over from the Java definitions. 10 | */ 11 | //this is fine 12 | Object.defineProperty(exports, "__esModule", { value: true }); 13 | -------------------------------------------------------------------------------- /build/scripts/promise.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains a custom (bad) polyfill for promises with slightly different behavior. 5 | */ 6 | Object.defineProperty(exports, "__esModule", { value: true }); 7 | exports.Promise = void 0; 8 | exports.queueMicrotask = queueMicrotask; 9 | function queueMicrotask(callback, errorHandler) { 10 | if (errorHandler === void 0) { errorHandler = function (err) { 11 | Log.err("Uncaught (in promise)"); 12 | Log.err(err); 13 | }; } 14 | Core.app.post(function () { 15 | try { 16 | callback(); 17 | } 18 | catch (err) { 19 | errorHandler(err); 20 | } 21 | }); 22 | } 23 | /** 24 | * Differences from normal promises: 25 | * If a called-later handler throws an error, it will print an error to the console, and will not call the reject handler. 26 | */ 27 | var Promise = /** @class */ (function () { 28 | function Promise(initializer) { 29 | var _this = this; 30 | this.state = ["pending"]; 31 | this.resolveHandlers = []; 32 | this.rejectHandlers = []; 33 | initializer(function (value) { 34 | _this.state = ["resolved", value]; 35 | queueMicrotask(function () { return _this.resolve(); }); 36 | }, function (error) { 37 | _this.state = ["rejected", error]; 38 | queueMicrotask(function () { return _this.reject(); }); 39 | }); 40 | } 41 | Promise.prototype.resolve = function () { 42 | var state = this.state; 43 | this.resolveHandlers.forEach(function (h) { return h(state[1]); }); 44 | }; 45 | Promise.prototype.reject = function () { 46 | var state = this.state; 47 | this.rejectHandlers.forEach(function (h) { return h(state[1]); }); 48 | }; 49 | Promise.prototype.then = function (onFulfilled, onRejected) { 50 | var _a = Promise.withResolvers(), promise = _a.promise, resolve = _a.resolve, reject = _a.reject; 51 | if (onFulfilled) { 52 | this.resolveHandlers.push(function (value) { 53 | var result = onFulfilled(value); 54 | if (result instanceof Promise) { 55 | result.then(function (nextResult) { return resolve(nextResult); }); 56 | } 57 | else { 58 | resolve(result); 59 | } 60 | }); 61 | } 62 | if (onRejected) { 63 | this.rejectHandlers.push(function (value) { 64 | var result = onRejected(value); 65 | if (result instanceof Promise) { 66 | result.then(function (nextResult) { return resolve(nextResult); }); 67 | } 68 | else { 69 | resolve(result); 70 | } 71 | }); 72 | } 73 | return promise; 74 | }; 75 | Promise.prototype.catch = function (onRejected) { 76 | var _a = Promise.withResolvers(), promise = _a.promise, resolve = _a.resolve, reject = _a.reject; 77 | this.rejectHandlers.push(function (value) { 78 | var result = onRejected(value); 79 | if (result instanceof Promise) { 80 | result.then(function (nextResult) { return resolve(nextResult); }); 81 | } 82 | else { 83 | resolve(result); 84 | } 85 | }); 86 | //If the original promise resolves successfully, the new one also needs to resolve 87 | this.resolveHandlers.push(function (value) { return resolve(value); }); 88 | return promise; 89 | }; 90 | Promise.withResolvers = function () { 91 | var resolve; 92 | var reject; 93 | var promise = new Promise(function (r, j) { 94 | resolve = r; 95 | reject = j; 96 | }); 97 | return { 98 | promise: promise, 99 | resolve: resolve, 100 | reject: reject 101 | }; 102 | }; 103 | Promise.all = function (promises) { 104 | var _a = Promise.withResolvers(), promise = _a.promise, resolve = _a.resolve, reject = _a.reject; 105 | var outputs = new Array(promises.length); 106 | var resolutions = 0; 107 | promises.map(function (p, i) { 108 | return p.then(function (v) { 109 | outputs[i] = v; 110 | resolutions++; 111 | if (resolutions == promises.length) 112 | resolve(outputs); 113 | }); 114 | }); 115 | return promise; 116 | }; 117 | Promise.resolve = function (value) { 118 | return new Promise(function (resolve) { return resolve(value); }); 119 | }; 120 | return Promise; 121 | }()); 122 | exports.Promise = Promise; 123 | -------------------------------------------------------------------------------- /build/scripts/ranks.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains the definitions for ranks and role flags. 5 | */ 6 | Object.defineProperty(exports, "__esModule", { value: true }); 7 | exports.RoleFlag = exports.Rank = void 0; 8 | /** Each player has one rank, which is used to determine their prefix, permissions, and which other players they can perform moderation actions on. */ 9 | var Rank = /** @class */ (function () { 10 | function Rank(name, 11 | /** Used to determine whether a rank outranks another. */ level, description, prefix, shortPrefix, color, autoRankData) { 12 | var _a, _b, _c, _d, _e; 13 | this.name = name; 14 | this.level = level; 15 | this.description = description; 16 | this.prefix = prefix; 17 | this.shortPrefix = shortPrefix; 18 | this.color = color; 19 | Rank.ranks[name] = this; 20 | if (autoRankData) { 21 | this.autoRankData = { 22 | joins: (_a = autoRankData.joins) !== null && _a !== void 0 ? _a : 0, 23 | playtime: (_b = autoRankData.playtime) !== null && _b !== void 0 ? _b : 0, 24 | blocksPlaced: (_c = autoRankData.blocksPlaced) !== null && _c !== void 0 ? _c : 0, 25 | timeSinceFirstJoin: (_d = autoRankData.timeSinceFirstJoin) !== null && _d !== void 0 ? _d : 0, 26 | chatMessagesSent: (_e = autoRankData.chatMessagesSent) !== null && _e !== void 0 ? _e : 0, 27 | }; 28 | Rank.autoRanks.push(this); 29 | } 30 | } 31 | Rank.getByName = function (name) { 32 | var _a; 33 | return (_a = Rank.ranks[name]) !== null && _a !== void 0 ? _a : null; 34 | }; 35 | Rank.getByInput = function (input) { 36 | return Object.values(Rank.ranks).filter(function (rank) { return rank.name.toLowerCase().includes(input.toLowerCase()); }); 37 | }; 38 | Rank.prototype.coloredName = function () { 39 | return this.color + this.name + "[]"; 40 | }; 41 | Rank.ranks = {}; 42 | Rank.autoRanks = []; 43 | Rank.player = new Rank("player", 0, "Ordinary players.", "", "&lk[p]&fr", ""); 44 | Rank.active = new Rank("active", 1, "Assigned automatically to players who have played for some time.", "[black]<[forest]\uE800[]>[]", "&lk[a]&fr", "[forest]", { 45 | joins: 50, 46 | playtime: 24 * 60 * 60 * 1000, //24 hours 47 | blocksPlaced: 5000, 48 | timeSinceFirstJoin: 24 * 60 * 60 * 1000 * 7, //7 days 49 | }); 50 | Rank.trusted = new Rank("trusted", 2, "Trusted players who have gained the trust of a mod or admin.", "[black]<[#E67E22]\uE813[]>[]", "&y[T]&fr", "[#E67E22]"); 51 | Rank.mod = new Rank("mod", 3, "Moderators who can mute, stop, and kick players.", "[black]<[#6FFC7C]\uE817[]>[]", "&lg[M]&fr", "[#6FFC7C]"); 52 | Rank.admin = new Rank("admin", 4, "Administrators with the power to ban players.", "[black]<[cyan]\uE82C[]>[]", "&lr[A]&fr", "[cyan]"); 53 | Rank.manager = new Rank("manager", 10, "Managers have file and console access.", "[black]<[scarlet]\uE88E[]>[]", "&c[E]&fr", "[scarlet]"); 54 | Rank.pi = new Rank("pi", 11, "3.14159265358979323846264338327950288419716 (manager)", "[black]<[#FF8000]\u03C0[]>[]", "&b[+]&fr", "[blue]"); //i want pi rank 55 | Rank.fish = new Rank("fish", 999, "Owner.", "[blue]>|||>[] ", "&b[F]&fr", "[blue]"); 56 | return Rank; 57 | }()); 58 | exports.Rank = Rank; 59 | Object.freeze(Rank.pi); //anti-trolling 60 | /** 61 | * Role flags are used to determine a player's prefix and permissions. 62 | * Players can have any combination of the role flags. 63 | */ 64 | var RoleFlag = /** @class */ (function () { 65 | function RoleFlag(name, prefix, description, color, peristent, assignableByModerators) { 66 | if (peristent === void 0) { peristent = true; } 67 | if (assignableByModerators === void 0) { assignableByModerators = true; } 68 | this.name = name; 69 | this.prefix = prefix; 70 | this.description = description; 71 | this.color = color; 72 | this.peristent = peristent; 73 | this.assignableByModerators = assignableByModerators; 74 | RoleFlag.flags[name] = this; 75 | } 76 | RoleFlag.getByName = function (name) { 77 | var _a; 78 | return (_a = RoleFlag.flags[name]) !== null && _a !== void 0 ? _a : null; 79 | }; 80 | RoleFlag.getByInput = function (input) { 81 | return Object.values(RoleFlag.flags).filter(function (flag) { return flag.name.toLowerCase().includes(input.toLowerCase()); }); 82 | }; 83 | RoleFlag.prototype.coloredName = function () { 84 | return this.color + this.name + "[]"; 85 | }; 86 | RoleFlag.flags = {}; 87 | RoleFlag.developer = new RoleFlag("developer", "[black]<[#B000FF]\uE80E[]>[]", "Awarded to people who contribute to the server's codebase.", "[#B000FF]", true, false); 88 | RoleFlag.member = new RoleFlag("member", "[black]<[yellow]\uE809[]>[]", "Awarded to our awesome donors who support the server.", "[pink]", true, false); 89 | RoleFlag.illusionist = new RoleFlag("illusionist", "", "Assigned to to individuals who have earned access to enhanced visual effect features.", "[lightgrey]", true, true); 90 | RoleFlag.chief_map_analyst = new RoleFlag("chief map analyst", "[black]<[#5800FF]\uE833[]>[]", "Assigned to the chief map analyst, who oversees map management.", "[#5800FF]", true, true); 91 | RoleFlag.no_effects = new RoleFlag("no_effects", "", "Given to people who have abused the visual effects.", "", true, true); 92 | return RoleFlag; 93 | }()); 94 | exports.RoleFlag = RoleFlag; 95 | -------------------------------------------------------------------------------- /build/scripts/timers.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains timers that run code at regular intervals. 5 | */ 6 | var __values = (this && this.__values) || function(o) { 7 | var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0; 8 | if (m) return m.call(o); 9 | if (o && typeof o.length === "number") return { 10 | next: function () { 11 | if (o && i >= o.length) o = void 0; 12 | return { value: o && o[i++], done: !o }; 13 | } 14 | }; 15 | throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined."); 16 | }; 17 | Object.defineProperty(exports, "__esModule", { value: true }); 18 | exports.initializeTimers = initializeTimers; 19 | var api_1 = require("./api"); 20 | var config = require("./config"); 21 | var config_1 = require("./config"); 22 | var files_1 = require("./files"); 23 | var globals_1 = require("./globals"); 24 | var players_1 = require("./players"); 25 | var utils_1 = require("./utils"); 26 | /** Must be called once, and only once, on server start. */ 27 | function initializeTimers() { 28 | Timer.schedule(function () { 29 | var e_1, _a; 30 | //Autosave 31 | var file = Vars.saveDirectory.child('1' + '.' + Vars.saveExtension); 32 | Core.app.post(function () { 33 | SaveIO.save(file); 34 | players_1.FishPlayer.saveAll(); 35 | Call.sendMessage('[#4fff8f9f]Game saved.'); 36 | globals_1.FishEvents.fire("saveData", []); 37 | }); 38 | try { 39 | //Unblacklist trusted players 40 | for (var _b = __values(Object.values(players_1.FishPlayer.cachedPlayers)), _c = _b.next(); !_c.done; _c = _b.next()) { 41 | var fishP = _c.value; 42 | if (fishP.ranksAtLeast("trusted")) { 43 | Vars.netServer.admins.dosBlacklist.remove(fishP.info().lastIP); 44 | } 45 | } 46 | } 47 | catch (e_1_1) { e_1 = { error: e_1_1 }; } 48 | finally { 49 | try { 50 | if (_c && !_c.done && (_a = _b.return)) _a.call(_b); 51 | } 52 | finally { if (e_1) throw e_1.error; } 53 | } 54 | }, 10, 300); 55 | //Memory corruption prank 56 | Timer.schedule(function () { 57 | if (Math.random() < 0.2 && !config_1.Gamemode.hexed()) { 58 | //Timer triggers every 17 hours, and the random chance is 20%, so the average interval between pranks is 85 hours 59 | (0, utils_1.definitelyRealMemoryCorruption)(); 60 | } 61 | }, 3600, 61200); 62 | //Trails 63 | Timer.schedule(function () { 64 | return players_1.FishPlayer.forEachPlayer(function (p) { return p.displayTrail(); }); 65 | }, 5, 0.15); 66 | //Staff chat 67 | if (!config.Mode.localDebug) 68 | Timer.schedule(function () { 69 | (0, api_1.getStaffMessages)(function (messages) { 70 | if (messages.length) 71 | players_1.FishPlayer.messageStaff(messages); 72 | }); 73 | }, 5, 2); 74 | //Tip 75 | Timer.schedule(function () { 76 | var showAd = Math.random() < 0.10; //10% chance every 15 minutes 77 | var messagePool = showAd ? config.tips.ads : (config.Mode.isChristmas && Math.random() > 0.5) ? config.tips.christmas : config.tips.normal; 78 | var messageText = messagePool[Math.floor(Math.random() * messagePool.length)]; 79 | var message = showAd ? "[gold]".concat(messageText, "[]") : "[gold]Tip: ".concat(messageText, "[]"); 80 | Call.sendMessage(message); 81 | }, 60, 15 * 60); 82 | //State check 83 | Timer.schedule(function () { 84 | if (Groups.unit.size() > 10000) { 85 | Call.sendMessage("\n[scarlet]!!!!!\n[scarlet]Way too many units! Game over!\n[scarlet]!!!!!\n"); 86 | Groups.unit.clear(); 87 | (0, utils_1.neutralGameover)(); 88 | } 89 | }, 0, 1); 90 | Timer.schedule(function () { 91 | players_1.FishPlayer.updateAFKCheck(); 92 | }, 0, 1); 93 | //Various bad antibot code TODO fix, dont update state on clock tick 94 | Timer.schedule(function () { 95 | players_1.FishPlayer.antiBotModePersist = false; 96 | //dubious code, will keep antibot mode on for the next minute after it was triggered by high flag count or high join count 97 | if (players_1.FishPlayer.flagCount > 10 || players_1.FishPlayer.playersJoinedRecent > 50) 98 | players_1.FishPlayer.antiBotModePersist = true; 99 | players_1.FishPlayer.flagCount = 0; 100 | globals_1.ipJoins.clear(); 101 | }, 0, 60); 102 | Timer.schedule(function () { 103 | if (players_1.FishPlayer.playersJoinedRecent > 50) 104 | players_1.FishPlayer.antiBotModePersist = true; 105 | players_1.FishPlayer.playersJoinedRecent = 0; 106 | }, 0, 40); 107 | Timer.schedule(function () { 108 | if (players_1.FishPlayer.antiBotMode()) { 109 | Call.infoToast("[scarlet]ANTIBOT ACTIVE!!![] DOS blacklist size: ".concat(Vars.netServer.admins.dosBlacklist.size), 2); 110 | } 111 | }, 0, 1); 112 | Timer.schedule(function () { 113 | players_1.FishPlayer.validateVotekickSession(); 114 | }, 0, 0.5); 115 | } 116 | Timer.schedule(function () { 117 | (0, files_1.updateMaps)() 118 | .then(function (result) { 119 | if (result) { 120 | Call.sendMessage("[orange]Maps have been updated. Run [white]/maps[] to view available maps."); 121 | Log.info("Updated maps."); 122 | } 123 | }) 124 | .catch(function (message) { 125 | Call.sendMessage("[scarlet]Automated maps update failed, please report this to a staff member."); 126 | Log.err("Automated map update failed: ".concat(message)); 127 | }); 128 | }, 60, 600); 129 | -------------------------------------------------------------------------------- /build/scripts/types.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains type definitions that are shared across files. 5 | */ 6 | Object.defineProperty(exports, "__esModule", { value: true }); 7 | ; 8 | -------------------------------------------------------------------------------- /build/scripts/votes.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains the voting system. 5 | Some contributions: @author Jurorno9 6 | */ 7 | var __extends = (this && this.__extends) || (function () { 8 | var extendStatics = function (d, b) { 9 | extendStatics = Object.setPrototypeOf || 10 | ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || 11 | function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; 12 | return extendStatics(d, b); 13 | }; 14 | return function (d, b) { 15 | if (typeof b !== "function" && b !== null) 16 | throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); 17 | extendStatics(d, b); 18 | function __() { this.constructor = d; } 19 | d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); 20 | }; 21 | })(); 22 | var __read = (this && this.__read) || function (o, n) { 23 | var m = typeof Symbol === "function" && o[Symbol.iterator]; 24 | if (!m) return o; 25 | var i = m.call(o), r, ar = [], e; 26 | try { 27 | while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value); 28 | } 29 | catch (error) { e = { error: error }; } 30 | finally { 31 | try { 32 | if (r && !r.done && (m = i["return"])) m.call(i); 33 | } 34 | finally { if (e) throw e.error; } 35 | } 36 | return ar; 37 | }; 38 | var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { 39 | if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { 40 | if (ar || !(i in from)) { 41 | if (!ar) ar = Array.prototype.slice.call(from, 0, i); 42 | ar[i] = from[i]; 43 | } 44 | } 45 | return to.concat(ar || Array.prototype.slice.call(from)); 46 | }; 47 | Object.defineProperty(exports, "__esModule", { value: true }); 48 | exports.VoteManager = void 0; 49 | var players_1 = require("./players"); 50 | var funcs_1 = require("./funcs"); 51 | var funcs_2 = require("./funcs"); 52 | /** Manages a vote. */ 53 | var VoteManager = /** @class */ (function (_super) { 54 | __extends(VoteManager, _super); 55 | function VoteManager(voteTime, goal, isEligible) { 56 | if (goal === void 0) { goal = ["fractionOfVoters", 0.50001]; } 57 | if (isEligible === void 0) { isEligible = function () { return true; }; } 58 | var _this = _super.call(this) || this; 59 | _this.voteTime = voteTime; 60 | _this.goal = goal; 61 | _this.isEligible = isEligible; 62 | /** The ongoing voting session, if there is one. */ 63 | _this.session = null; 64 | if (goal[0] == "fractionOfVoters") { 65 | if (goal[1] < 0 || goal[1] > 1) 66 | (0, funcs_1.crash)("Invalid goal: fractionOfVoters must be between 0 and 1 inclusive"); 67 | } 68 | else if (goal[0] == "absolute") { 69 | if (goal[1] < 0) 70 | (0, funcs_1.crash)("Invalid goal: absolute must be greater than 0"); 71 | } 72 | Events.on(EventType.PlayerLeave, function (_a) { 73 | var player = _a.player; 74 | //Run once the player has been removed, but resolve the player first in case the connection gets nulled 75 | var fishP = players_1.FishPlayer.get(player); 76 | Core.app.post(function () { return _this.unvote(fishP); }); 77 | }); 78 | Events.on(EventType.GameOverEvent, function () { return _this.resetVote(); }); 79 | return _this; 80 | } 81 | VoteManager.prototype.start = function (player, newVote, data) { 82 | var _this = this; 83 | if (data === null) 84 | (0, funcs_1.crash)("Cannot start vote: data not provided"); 85 | this.session = { 86 | timer: Timer.schedule(function () { return _this._checkVote(false); }, this.voteTime / 1000), 87 | votes: new Map(), 88 | data: data, 89 | }; 90 | this.vote(player, newVote, data); 91 | }; 92 | VoteManager.prototype.vote = function (player, newVote, data) { 93 | if (!this.session) 94 | return this.start(player, newVote, data); 95 | var oldVote = this.session.votes.get(player.uuid); 96 | this.session.votes.set(player.uuid, newVote); 97 | if (oldVote == null) 98 | this.fire("player vote", [player, newVote]); 99 | this.fire("player vote change", [player, oldVote !== null && oldVote !== void 0 ? oldVote : 0, newVote]); 100 | this._checkVote(false); 101 | }; 102 | VoteManager.prototype.unvote = function (player) { 103 | if (!this.session) 104 | return; 105 | var fishP = players_1.FishPlayer.resolve(player); 106 | var vote = this.session.votes.get(fishP.uuid); 107 | if (vote) { 108 | this.session.votes.delete(fishP.uuid); 109 | this.fire("player vote removed", [player, vote]); 110 | this._checkVote(false); 111 | } 112 | }; 113 | /** Does not fire the events used to display messages, please print one before calling this */ 114 | VoteManager.prototype.forceVote = function (outcome) { 115 | if (outcome) { 116 | this.fire("success", [true]); 117 | } 118 | else { 119 | this.fire("fail", [true]); 120 | } 121 | this.resetVote(); 122 | }; 123 | VoteManager.prototype.resetVote = function () { 124 | if (this.session == null) 125 | return; 126 | this.session.timer.cancel(); 127 | this.session = null; 128 | }; 129 | VoteManager.prototype.requiredVotes = function () { 130 | if (this.goal[0] == "absolute") 131 | return this.goal[1]; 132 | else 133 | return Math.max(Math.ceil(this.goal[1] * this.getEligibleVoters().length), 1); 134 | }; 135 | VoteManager.prototype.currentVotes = function () { 136 | return this.session ? __spreadArray([], __read(this.session.votes), false).reduce(function (acc, _a) { 137 | var _b = __read(_a, 2), k = _b[0], v = _b[1]; 138 | return acc + v; 139 | }, 0) : 0; 140 | }; 141 | VoteManager.prototype.getEligibleVoters = function () { 142 | var _this = this; 143 | if (!this.session) 144 | return []; 145 | return players_1.FishPlayer.getAllOnline().filter(function (p) { return _this.isEligible(p, _this.session.data); }); 146 | }; 147 | VoteManager.prototype.messageEligibleVoters = function (message) { 148 | this.getEligibleVoters().forEach(function (p) { return p.sendMessage(message); }); 149 | }; 150 | VoteManager.prototype._checkVote = function (end) { 151 | var votes = this.currentVotes(); 152 | var required = this.requiredVotes(); 153 | if (votes >= required) { 154 | this.fire("success", [false]); 155 | this.fire("vote passed", [votes, required]); 156 | this.resetVote(); 157 | } 158 | else if (end) { 159 | this.fire("fail", [false]); 160 | this.fire("vote failed", [votes, required]); 161 | this.resetVote(); 162 | } 163 | }; 164 | return VoteManager; 165 | }(funcs_2.EventEmitter)); 166 | exports.VoteManager = VoteManager; 167 | -------------------------------------------------------------------------------- /copy-client.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | require('child_process') 3 | .spawn('clip') 4 | .stdin.end( 5 | fs.readFileSync("scripts/client.js", "utf-8") 6 | .replace(`"use strict";\r\n`, "") 7 | .replace(/ +/g, " ") 8 | .replace(/\r\n/g, "") 9 | .replace(/\/\*.+\*\//, "") 10 | .replace(`Object.defineProperty(exports, "__esModule", { value: true });`, "") 11 | ); -------------------------------------------------------------------------------- /docs/fail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fish-Community/fish-commands/62bbc31351336f00e31f6d8525797c203b7cdcc0/docs/fail.png -------------------------------------------------------------------------------- /docs/info.md: -------------------------------------------------------------------------------- 1 | # Important information about the codebase 2 | 3 | ## General structure 4 | * The plugin's code is written in Typescript, stored as .ts files in `src/`. 5 | * These files are compiled into `.js` files stored in `build/scripts/`. 6 | * The main.js file is special: it is written in js and needs to be manually copied to build. 7 | * The `build/scripts/*.js` files **are committed**. 8 | * The JS files are run by an old, buggy version of Mozilla Rhino. (ES5.5) This causes a lot of problems. 9 | * With the power of modern developer tooling, we can mostly use modern features anyway, though. 10 | * The `build/` folder is a valid Mindustry plugin and should be installed in the server's mods folder. Use of the included scripts is recommended. (`npm attach [jarfilepath.jar]` to symlink it) 11 | 12 | ## Misc 13 | 14 | * All times are in unix milliseconds. 15 | * Regexps are broken due to the engine being used. (weird behavior, crashes) 16 | * Use Java regexes instead. 17 | 18 | ## Systems 19 | 20 | ### Error handling 21 | 22 | This plugin uses the `fail()` function to easily stop running the current function and print an error message. 23 | 24 | This allows creating guard clauses, which are easier to read than if statements. Example: 25 | ```ts 26 | if(target !== sender){ 27 | if(!sender.hasPerm("warn")) fail(`You do not have permission to show rules to other players.`); 28 | if(target.hasPerm("blockTrolling")) fail(f`Player ${args.player!} is insufficiently trollable.`); 29 | } 30 | ``` 31 | 32 | Additionally, it can be used like this: 33 | 34 | ```ts 35 | const historyData:TileHistory = tileHistory[`${x},${y}`] ?? fail(`There is no recorded history for the selected tile.`); 36 | ``` 37 | 38 | Calling this function is allowed in command handlers and menus. 39 | 40 | ### Menu system 41 | 42 | This plugin uses callback-based menu handling. 43 | 44 | #### Before 45 | ```ts 46 | const fruits = ['🍏', '🍐', '🍊', '🍉']; 47 | let expectingResponse = false; 48 | const chooseFruitHandler = Menus.registerMenu((player, option) => { 49 | const fruit = fruits.concat("Cancel")[option]; 50 | if(!expectingResponse) return; //The player can respond to this menu even when we didn't ask, so validation is necessary 51 | expectingResponse = false; 52 | if(!fruit) return; //The player can provide any number here, so validation is necessary 53 | if(fruit == "Cancel") return; 54 | player.sendMessage(`You chose ${fruit}`); 55 | }); 56 | //... 57 | expectingResponse = true; 58 | Call.menu(player.con, chooseFruitHandler, "Fruit", "Choose a fruit", [fruits, ["Cancel"]]); 59 | ``` 60 | There are a lot of pitfalls that need to stepped over to avoid creating a vulnerability. 61 | 62 | For example, this code will break if multiple players try to exploit it. 63 | 64 | Additionally, this code will break if there are many fruits, because the options are not split up into rows. 65 | 66 | #### After 67 | ```ts 68 | menu("Fruit", "Choose a fruit", ['🍏', '🍐', '🍊', '🍉'], player, ({option, sender}) => { 69 | sender.sendMessage(`You chose ${option}`); 70 | }, true); //"True" automatically includes a "Cancel" option, which does not call the handler when selected. 71 | ``` 72 | 73 | Everything is handled. 74 | 75 | ### Commands system 76 | 77 | This plugin uses a custom commands system, which is much easier to use than the builtin one. 78 | 79 | #### Command arguments 80 | 81 | Arguments to the command are specified in an array, like this: 82 | ```ts 83 | const commands = { 84 | //... 85 | rank: { 86 | args: ["target:player", "extraText:string?"], 87 | description: "Displays the rank of a player.", 88 | handler({args, output}) { 89 | output(`Player ${args.target.name}'s rank is ${args.target.rank.name}.`); 90 | args; //Type definitions available 91 | //^? { target: FishPlayer; extraText: string | null; } 92 | }, 93 | }, 94 | }; 95 | ``` 96 | 97 | When the command is run, the command system will automatically resolve the first argument as a player. If this fails, an error message is printed and the handler is not run. 98 | 99 | If the argument is left blank, the player is shown a menu to select a player. 100 | 101 | Arguments can be marked optional by adding `?` after the type. 102 | 103 | #### Perm 104 | 105 | A Perm represents some permission that is required to run a command. (usually a rank, like "admin" or "trusted") Specifying a Perm is mandatory, so it's not possible to forget setting permissions for a command. 106 | 107 | Perms also make it easier to change the required permission for a command, or have a permission require a different level of trust depending on the gamemode. For example, the "change team" permission requires less trust on Sandbox. 108 | ```ts 109 | const changeTeam = new Perm("changeTeam", fishP => {switch(true){ 110 | case Mode.sandbox(): return fishP.ranksAtLeast("trusted"); 111 | case Mode.attack(): return fishP.ranksAtLeast("admin"); 112 | case Mode.hexed(): return fishP.ranksAtLeast("mod"); 113 | case Mode.pvp(): return fishP.ranksAtLeast("trusted"); 114 | default: return fishP.ranksAtLeast("admin"); 115 | }}); 116 | ``` 117 | 118 | #### Req 119 | 120 | The Req system handles something that must be in a certain state for a command to run. 121 | 122 | For example, `Req.mode("pvp")` returns a requirement function. When called, this function will fail unless the mode is PVP, with the message "This command is only available in PVP." 123 | 124 | Requirements can be specified like this: 125 | ```ts 126 | requirements: [Req.modeNot("survival"), Req.cooldown(60_000)] 127 | ``` 128 | 129 | This will allow the command to run if the mode is not survival and if it has not been run by the same player in the past 60 seconds, otherwise, an appropriate error message is printed. 130 | 131 | ### Formatting system 132 | 133 | Command handlers are passed a function `f`, which can be used as a tag function for a tagged template literal, like this: 134 | 135 | ```ts 136 | handler({args, sender, outputSuccess, f}){ 137 | outputSuccess(f`Player ${args.target}'s rank is ${args.target.rank}.`); 138 | } 139 | ``` 140 | 141 | The tag function formats `args.target` (a FishPlayer) as the player's colored name, and `args.target.rank` (a Rank) as the rank's colored name. 142 | 143 | `outputSuccess()` prints text with a green color, so the text `'s rank is` between the interpolation values should be colored green, too. This is handled correctly, no matter how many color tags are in the player's name. 144 | 145 | The `f` function also behaves differently if it is being run from a chat command or a console command, using the correct color syntax automatically. 146 | 147 | ## History 148 | * This plugin was originally written in js, by Brandons404. It was created in October 2022. 149 | * See https://github.com/Brandons404/fish-commands/tree/e81bbc9036f7b67b6a503d0b1eb8d3c888d9518c for the state in January 2023. 150 | * BalaM314 ported it to Typescript in March and April 2023, adding new systems and abstractions. 151 | * It remains in active development as of October 2024, receiving contributions from other community members. 152 | 153 | ## Contributors (by date) 154 | * [Brandons404](https://github.com/Brandons404/) 155 | * [BalaM314](https://github.com/BalaM314/) 156 | * [TheEt1234](https://github.com/TheEt1234/) 157 | * [buthed010203](https://github.com/buthed010203/) 158 | * [Jurorno9](https://github.com/Jurorno9/) 159 | * [Dart25](https://github.com/Dart25/) 160 | * [kenos1](https://github.com/kenos1/) 161 | * [omnerom](https://github.com/omnerom/) 162 | * [Darthscion](https://github.com/Darthscion55/) 163 | * [cudspent](https://github.com/spentcud/) 164 | -------------------------------------------------------------------------------- /docs/intellisense.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fish-Community/fish-commands/62bbc31351336f00e31f6d8525797c203b7cdcc0/docs/intellisense.png -------------------------------------------------------------------------------- /docs/menus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fish-Community/fish-commands/62bbc31351336f00e31f6d8525797c203b7cdcc0/docs/menus.png -------------------------------------------------------------------------------- /docs/race-condition.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fish-Community/fish-commands/62bbc31351336f00e31f6d8525797c203b7cdcc0/docs/race-condition.png -------------------------------------------------------------------------------- /mod.hjson: -------------------------------------------------------------------------------- 1 | name: fish 2 | displayName: Fish Plugins 3 | author: >;;;>Fish 4 | description: "Adds sudo commands to the server." 5 | version: 1.0 6 | minGameVersion: 137 7 | hidden: true -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fish-commands", 3 | "version": "1.0.0", 4 | "description": "A custom commands plugin for >|||>Fish Mindustry servers. Created by Brandons404, rewritten by BalaM314.", 5 | "main": "scripts/main.js", 6 | "private": "true", 7 | "scripts": { 8 | "attach": "node scripts/attach.js", 9 | "watch": "tsc --watch", 10 | "build": "tsc -p tsconfig.json", 11 | "dev": "node scripts/dev.js", 12 | "test": "tsc -b spec && jasmine" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "UNLICENSED", 17 | "devDependencies": { 18 | "@types/jasmine": "^5.1.5", 19 | "@types/node": "^22.10.9", 20 | "esbuild": "^0.23.1", 21 | "jasmine": "^5.5.0", 22 | "typescript": "^5.7.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /scripts/attach.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const fs = require("node:fs"); 4 | const path = require("node:path"); 5 | function fail(message) { 6 | console.error(message); 7 | process.exit(1); 8 | } 9 | if (!process.argv[2]) 10 | fail(`Please provide the path to your server jar file. If you do not have one, run the "dev" script instead.`); 11 | const filepath = path.resolve(process.argv[2]); 12 | try { 13 | fs.accessSync(filepath, fs.constants.R_OK); 14 | } 15 | catch { 16 | fail(`Path "${filepath}" does not exist or is not accessible.`); 17 | } 18 | if (path.extname(filepath) != ".jar") 19 | fail(`Path must point to a jar file.`); 20 | const configDir = path.join(filepath, "..", "config"); 21 | try { 22 | if (!fs.statSync(configDir).isDirectory()) 23 | fail(`Config folder at "${configDir}" is not a directory. Are you sure this is a Mindustry server directory?`); 24 | } 25 | catch { 26 | fail(`Path "${configDir}" does not exist or is not accessible. Are you sure this is a Mindustry server directory?`); 27 | } 28 | const fishCommandsFolder = path.join(configDir, "mods", "fish-commands"); 29 | try { 30 | if (fs.lstatSync(fishCommandsFolder).isSymbolicLink()) { 31 | //Symlink already exists, delete it 32 | fs.unlinkSync(fishCommandsFolder); 33 | console.log(`Unlinked fish-commands.`); 34 | } 35 | else { 36 | fail(`fish-commands folder at "${fishCommandsFolder}" already exists, but is not a symlink. Consider deleting it.`); 37 | } 38 | } 39 | catch { 40 | //File does not exist, create a symlink 41 | const buildPath = path.join(process.argv[1], "../../build"); 42 | console.log(`Creating symlink from "${fishCommandsFolder}" to "${buildPath}"`); 43 | fs.symlinkSync(buildPath, fishCommandsFolder); 44 | console.log(`Successfully linked fish-commands.`); 45 | } 46 | -------------------------------------------------------------------------------- /scripts/attach.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | function fail(message:string):never { 5 | console.error(message); 6 | process.exit(1); 7 | } 8 | 9 | if(!process.argv[2]) fail(`Please provide the path to your server jar file. If you do not have one, run the "dev" script instead.`); 10 | const filepath = path.resolve(process.argv[2]); 11 | try { 12 | fs.accessSync(filepath, fs.constants.R_OK); 13 | } catch { 14 | fail(`Path "${filepath}" does not exist or is not accessible.`); 15 | } 16 | if(path.extname(filepath) != ".jar") fail(`Path must point to a jar file.`); 17 | const configDir = path.join(filepath, "..", "config"); 18 | try { 19 | if(!fs.statSync(configDir).isDirectory()) fail(`Config folder at "${configDir}" is not a directory. Are you sure this is a Mindustry server directory?`); 20 | } catch { 21 | fail(`Path "${configDir}" does not exist or is not accessible. Are you sure this is a Mindustry server directory?`); 22 | } 23 | const fishCommandsFolder = path.join(configDir, "mods", "fish-commands"); 24 | try { 25 | if(fs.lstatSync(fishCommandsFolder).isSymbolicLink()){ 26 | //Symlink already exists, delete it 27 | fs.unlinkSync(fishCommandsFolder); 28 | console.log(`Unlinked fish-commands.`); 29 | } else { 30 | fail(`fish-commands folder at "${fishCommandsFolder}" already exists, but is not a symlink. Consider deleting it.`); 31 | } 32 | } catch { 33 | //File does not exist, create a symlink 34 | const buildPath = path.join(process.argv[1], "../../build"); 35 | console.log(`Creating symlink from "${fishCommandsFolder}" to "${buildPath}"`); 36 | fs.symlinkSync(buildPath, fishCommandsFolder); 37 | console.log(`Successfully linked fish-commands.`); 38 | } 39 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const esbuild = require("esbuild"); 4 | const fs = require("fs"); 5 | if (/scripts(\/\\)?$/.test(process.cwd())) 6 | process.chdir(".."); 7 | const banner = `/${"*".repeat(30)} 8 | fish-commands compiled output 9 | ${fs.readFileSync("LICENSE", "utf-8")} 10 | Source code available at https://github.com/Fish-Community/fish-commands/ 11 | ${"*".repeat(30)}/`; 12 | esbuild.buildSync({ 13 | entryPoints: ["./src/index.ts"], 14 | banner: { js: banner }, 15 | footer: { js: banner }, 16 | format: "iife", 17 | outfile: "./build/scripts/bundle.js", 18 | target: "es5", 19 | supported: { 20 | "arrow": true, 21 | "const-and-let": true, 22 | "destructuring": true, 23 | "for-of": true, 24 | "function-name-configurable": false, 25 | "generator": true, 26 | }, 27 | minify: false, 28 | treeShaking: false, 29 | }); 30 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from "esbuild"; 2 | import fs from "node:fs"; 3 | 4 | if(/scripts(\/\\)?$/.test(process.cwd())) process.chdir(".."); 5 | 6 | const banner = `/${"*".repeat(30)} 7 | fish-commands compiled output 8 | ${fs.readFileSync("LICENSE", "utf-8")} 9 | Source code available at https://github.com/Fish-Community/fish-commands/ 10 | ${"*".repeat(30)}/`; 11 | 12 | esbuild.buildSync({ 13 | entryPoints: ["./src/index.ts"], 14 | banner: { js: banner }, 15 | footer: { js: banner }, 16 | format: "iife", 17 | outfile: "./build/scripts/bundle.js", 18 | target: "es5", 19 | supported: { 20 | "arrow": true, 21 | "const-and-let": true, 22 | "destructuring": true, 23 | "for-of": true, 24 | "function-name-configurable": false, 25 | "generator": true, 26 | }, 27 | minify: false, 28 | treeShaking: false, 29 | }); 30 | 31 | 32 | -------------------------------------------------------------------------------- /scripts/dev.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const fs = require("node:fs"); 4 | const path = require("node:path"); 5 | const https = require("node:https"); 6 | const node_child_process_1 = require("node:child_process"); 7 | function fail(message) { 8 | console.error(message); 9 | process.exit(1); 10 | } 11 | function resolveRedirect(url) { 12 | return new Promise((resolve, reject) => { 13 | https.get(url, (res) => { 14 | if (res.statusCode != 302) { 15 | if (res.statusCode == 404) { 16 | reject("Version does not exist."); 17 | } 18 | else { 19 | reject(`Error: Expected status 302, got ${res.statusCode}`); 20 | } 21 | } 22 | if (res.headers.location) { 23 | resolve(res.headers.location); 24 | } 25 | else { 26 | reject(`Error: Server did not respond with redirect location.`); 27 | } 28 | }); 29 | }); 30 | } 31 | function downloadFile(url, outputPath) { 32 | return new Promise((resolve, reject) => { 33 | https.get(url, (res) => { 34 | if (res.statusCode == 404) { 35 | reject(`File does not exist.`); 36 | } 37 | else if (res.statusCode != 200) { 38 | reject(`Expected status code 200, got ${res.statusCode}`); 39 | } 40 | const file = fs.createWriteStream(outputPath); 41 | res.pipe(file); 42 | file.on('finish', () => { 43 | file.close(); 44 | resolve(); 45 | }); 46 | }); 47 | }); 48 | } 49 | const fcRootDirectory = path.join(process.argv[1], "..", ".."); 50 | const devServerDirectory = path.join(fcRootDirectory, "dev-server"); 51 | if (!fs.existsSync(devServerDirectory)) { 52 | console.log(`Dev server does not exist yet, creating one...`); 53 | fs.mkdirSync(devServerDirectory, { 54 | recursive: false 55 | }); 56 | console.log(`Finding latest server jar...`); 57 | fetch(`https://api.github.com/repos/Anuken/Mindustry/releases/latest`).then(r => r.json()).then(r => { 58 | const file = r.assets.find(a => a.name == "server-release.jar") ?? fail(`Could not find the server-release.jar file in the latest release`); 59 | console.log(`Downloading latest server jar from ${file.browser_download_url}...`); 60 | return resolveRedirect(file.browser_download_url); 61 | }).then(downloadURL => { 62 | return downloadFile(downloadURL, path.join(devServerDirectory, "server-release.jar")); 63 | }).catch(e => fail(`Failed to download the file: ${e}`)).then(() => { 64 | console.log(`Linking fish-commands...`); 65 | const modsFolder = path.join(devServerDirectory, "config", "mods"); 66 | fs.mkdirSync(modsFolder, { recursive: true }); 67 | const fishCommandsFolder = path.join(modsFolder, "fish-commands"); 68 | const buildFolder = path.join(fcRootDirectory, "build"); 69 | fs.symlinkSync(buildFolder, fishCommandsFolder); 70 | fs.writeFileSync(path.join(devServerDirectory, "config", ".debug"), ""); 71 | console.log(`Successfully set up the development environment.`); 72 | runServer(); 73 | }); 74 | } 75 | else { 76 | runServer(); 77 | } 78 | function runServer() { 79 | console.log("Starting fish-commands Mindustry development server..."); 80 | const { status } = (0, node_child_process_1.spawnSync)(`which`, ["rlwrap"]); 81 | (0, node_child_process_1.execSync)(`${status === 0 ? "rlwrap " : ""}java -Xmx500M -Xms500M -jar "server-release.jar"`, { 82 | stdio: "inherit", 83 | cwd: path.join(fcRootDirectory, "dev-server") 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /scripts/dev.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import https from "node:https"; 4 | import { execSync, spawnSync } from "node:child_process"; 5 | 6 | 7 | function fail(message:string):never { 8 | console.error(message); 9 | process.exit(1); 10 | } 11 | 12 | function resolveRedirect(url:string):Promise { 13 | return new Promise((resolve, reject) => { 14 | https.get(url, (res) => { 15 | if(res.statusCode != 302){ 16 | if(res.statusCode == 404){ 17 | reject("Version does not exist."); 18 | } else { 19 | reject(`Error: Expected status 302, got ${res.statusCode}`); 20 | } 21 | } 22 | if(res.headers.location){ 23 | resolve(res.headers.location); 24 | } else { 25 | reject(`Error: Server did not respond with redirect location.`); 26 | } 27 | }); 28 | }); 29 | } 30 | 31 | function downloadFile(url:string, outputPath:string){ 32 | return new Promise((resolve, reject) => { 33 | https.get(url, (res) => { 34 | if(res.statusCode == 404){ 35 | reject(`File does not exist.`); 36 | } else if(res.statusCode != 200){ 37 | reject(`Expected status code 200, got ${res.statusCode}`); 38 | } 39 | const file = fs.createWriteStream(outputPath); 40 | res.pipe(file); 41 | file.on('finish', () => { 42 | file.close(); 43 | resolve(); 44 | }); 45 | }); 46 | }); 47 | } 48 | 49 | const fcRootDirectory = path.join(process.argv[1], "..", ".."); 50 | const devServerDirectory = path.join(fcRootDirectory, "dev-server"); 51 | 52 | if(!fs.existsSync(devServerDirectory)){ 53 | console.log(`Dev server does not exist yet, creating one...`); 54 | fs.mkdirSync(devServerDirectory, { 55 | recursive: false 56 | }); 57 | console.log(`Finding latest server jar...`); 58 | fetch(`https://api.github.com/repos/Anuken/Mindustry/releases/latest`).then(r => r.json()).then(r => { 59 | const file = r.assets.find(a => a.name == "server-release.jar") ?? fail(`Could not find the server-release.jar file in the latest release`); 60 | console.log(`Downloading latest server jar from ${file.browser_download_url}...`); 61 | return resolveRedirect(file.browser_download_url); 62 | }).then(downloadURL => { 63 | return downloadFile(downloadURL, path.join(devServerDirectory, "server-release.jar")) 64 | }).catch(e => fail(`Failed to download the file: ${e}`)).then(() => { 65 | console.log(`Linking fish-commands...`); 66 | const modsFolder = path.join(devServerDirectory, "config", "mods"); 67 | fs.mkdirSync(modsFolder, { recursive: true }); 68 | const fishCommandsFolder = path.join(modsFolder, "fish-commands"); 69 | const buildFolder = path.join(fcRootDirectory, "build"); 70 | fs.symlinkSync(buildFolder, fishCommandsFolder); 71 | fs.writeFileSync(path.join(devServerDirectory, "config", ".debug"), ""); 72 | console.log(`Successfully set up the development environment.`); 73 | runServer(); 74 | }); 75 | } else { 76 | runServer(); 77 | } 78 | 79 | function runServer(){ 80 | console.log("Starting fish-commands Mindustry development server..."); 81 | const { status } = spawnSync(`which`, ["rlwrap"]); 82 | execSync(`${status === 0 ? "rlwrap " : ""}java -Xmx500M -Xms500M -jar "server-release.jar"`, { 83 | stdio: "inherit", 84 | cwd: path.join(fcRootDirectory, "dev-server") 85 | }); 86 | } -------------------------------------------------------------------------------- /scripts/main.js: -------------------------------------------------------------------------------- 1 | throw new Error("\n!!!!!!!!\nThis is not the correct way to run fish-commands!\n\nIf you are trying to install fish-commands into a production environment:\nPlease download fish-commands somewhere else, not in the mods folder.\nThen, run the \"attach\" npm script in the fish-commands directory, passing the filepath to your server.jar file.\n\nIf you are trying to run a development environment:\nRun the \"dev\" npm script."); 2 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2023", "DOM"], //for fetch() 4 | "target": "ES2023", 5 | "skipLibCheck": true, 6 | "module": "CommonJS", 7 | "esModuleInterop": true, 8 | }, 9 | "include": [ 10 | "./**.ts" 11 | ] 12 | } -------------------------------------------------------------------------------- /spec/src/env.ts: -------------------------------------------------------------------------------- 1 | 2 | class Pattern { 3 | private constructor(public string:string){} 4 | regex = new RegExp(this.string); 5 | static compile(regex:string){ 6 | return new this(`^${regex}$`); 7 | } 8 | static matches(regex:string, target:string):boolean { 9 | return new RegExp(`^${regex}$`).test(target); 10 | } 11 | matcher(text:string){ 12 | return { 13 | replaceAll: (replacement:string) => { 14 | return text.replaceAll(this.regex, replacement); 15 | }, 16 | matches: () => { 17 | return this.regex.test(text); 18 | }, 19 | group: () => { 20 | throw new Error("not implemented"); 21 | } 22 | } 23 | } 24 | } 25 | 26 | class ObjectIntMap { 27 | map = new Map; 28 | get(key:K){ return this.map.get(key); } 29 | set(key:K, value:number){ return this.map.set(key, value); } 30 | get size(){ return this.map.size; } 31 | clear(){ this.map.clear(); } 32 | put(key:K, value:number){ this.map.set(key, value); } 33 | increment(key:K){ 34 | this.map.set(key, (this.map.get(key) ?? 0) + 1); 35 | } 36 | entries(){ 37 | const entries = this.map.entries(); 38 | return Object.assign(entries, { 39 | toArray(){ 40 | return new Seq([...entries].map(e => ({ key: e[0], value: e[1] }))); 41 | } 42 | }); 43 | } 44 | } 45 | 46 | class Seq { 47 | constructor(public items:T[]){} 48 | } 49 | 50 | class Fi { 51 | constructor(public path:string){} 52 | exists(){ 53 | return false; 54 | } 55 | } 56 | 57 | const Collections = { 58 | list(e:Enumeration){ 59 | return new ArrayList(e.items); 60 | } 61 | }; 62 | class Enumeration { 63 | constructor(public items:T[]){} 64 | } 65 | class ArrayList { 66 | constructor(public items:T[]){} 67 | stream(){ 68 | return new Stream(this.items); 69 | } 70 | } 71 | class NetworkInterface { 72 | constructor( 73 | public interfaceAddresses: InterfaceAddress[], 74 | public loopback = false, 75 | public up = true, 76 | ){} 77 | getInterfaceAddresses(){ return new ArrayList(this.interfaceAddresses); } 78 | isUp(){ return this.up; } 79 | isLoopback(){ return this.loopback; } 80 | static getNetworkInterfaces(){ 81 | return new Enumeration([ 82 | new NetworkInterface([new InterfaceAddress(new Inet6Address("0:0:0:0:0:0:0:1")), new InterfaceAddress(new Inet4Address("127.0.0.1"))], true), //loopback 83 | new NetworkInterface([new InterfaceAddress(new Inet6Address("fe80:0:0:0:216:3eff:feaa:b35c")), new InterfaceAddress(new Inet4Address("1.2.3.4"))]), //eth0 84 | ]); 85 | } 86 | } 87 | class Stream { 88 | iterator: IteratorObject; 89 | constructor(items:T[]){ 90 | this.iterator = items.values(); 91 | } 92 | map(operation:(item:T) => U){ 93 | (this as never as Stream).iterator = this.iterator.map(operation); 94 | return this as never as Stream; 95 | } 96 | filter(operation:(item:T) => boolean){ 97 | this.iterator = this.iterator.filter(operation); 98 | return this; 99 | } 100 | findFirst(){ 101 | return new Optional(this.iterator.next()?.value ?? null); 102 | } 103 | } 104 | class Optional { 105 | constructor(public item:T | null){} 106 | orElse(value:U){ 107 | return this.item ?? value; 108 | } 109 | } 110 | class InterfaceAddress { 111 | constructor(public address: InetAddress){} 112 | getAddress(){ return this.address; } 113 | } 114 | class InetAddress { 115 | constructor(public hostAddress: string){} 116 | getHostAddress(){ return this.hostAddress; } 117 | } 118 | class Inet4Address extends InetAddress {} 119 | class Inet6Address extends InetAddress {} 120 | 121 | class InputStream {} 122 | class OutputStream { 123 | write(b:number[], offset?:number, length?:number):void { 124 | throw new Error("not implemented"); 125 | } 126 | } 127 | class DataOutputStream extends OutputStream { 128 | constructor(public stream:OutputStream){ 129 | super(); 130 | } 131 | writeByte(v:number):void { 132 | this.stream.write([v]); 133 | } 134 | writeBoolean(v:boolean):void { 135 | this.stream.write([Number(v)]); 136 | } 137 | writeBytes(s:string):void { 138 | throw new Error("unimplemented"); 139 | } 140 | writeChar(v:number):void { 141 | throw new Error("unimplemented"); 142 | } 143 | writeChars(s:string):void { 144 | throw new Error("unimplemented"); 145 | } 146 | writeDouble(v:number):void { 147 | 148 | } 149 | writeFloat(v:number):void { 150 | 151 | } 152 | writeInt(v:number):void { 153 | 154 | } 155 | writeLong(v:number):void { 156 | 157 | } 158 | writeShort(v:number):void { 159 | 160 | } 161 | writeUTF(s:String):void { 162 | 163 | } 164 | } 165 | class DataInputStream extends InputStream { 166 | constructor(stream:InputStream){ 167 | super(); 168 | } 169 | readBoolean():boolean { 170 | throw new Error("not implemented"); 171 | } 172 | readByte():number { 173 | throw new Error("not implemented"); 174 | } 175 | readChar():number { 176 | throw new Error("not implemented"); 177 | } 178 | readDouble():number { 179 | throw new Error("not implemented"); 180 | } 181 | readFloat():number { 182 | throw new Error("not implemented"); 183 | } 184 | readFully(b:number[], off?:number, len?:number):void { 185 | throw new Error("not implemented"); 186 | } 187 | readInt():number { 188 | throw new Error("not implemented"); 189 | } 190 | readLine():String { 191 | throw new Error("not implemented"); 192 | } 193 | readLong():number { 194 | throw new Error("not implemented"); 195 | } 196 | readShort():number { 197 | throw new Error("not implemented"); 198 | } 199 | readUnsignedByte():number { 200 | throw new Error("not implemented"); 201 | } 202 | readUnsignedShort():number { 203 | throw new Error("not implemented"); 204 | } 205 | readUTF():String { 206 | throw new Error("not implemented"); 207 | } 208 | skipBytes(n:number):number { 209 | throw new Error("not implemented"); 210 | } 211 | } 212 | class ByteArrayInputStream extends InputStream { 213 | constructor(public bytes:number[]){ 214 | super(); 215 | } 216 | } 217 | class ByteArrayOutputStream extends OutputStream { 218 | bytes:number[] = []; 219 | constructor(){ 220 | super(); 221 | } 222 | write(b: number[], offset?: number, length?: number):void { 223 | this.bytes.push(...b); 224 | } 225 | } 226 | 227 | const Packages = { 228 | java: { 229 | net: { NetworkInterface, Inet4Address }, 230 | util: { Collections } 231 | } 232 | }; 233 | 234 | Object.assign(globalThis, {Pattern, ObjectIntMap, Seq, Fi, Packages}); 235 | -------------------------------------------------------------------------------- /spec/src/test-utils.ts: -------------------------------------------------------------------------------- 1 | 2 | const fakeObjectTrap = new Proxy({}, { 3 | get(target, property){ throw new Error(`Attempted to access property ${String(property)} on fake object`); }, 4 | }); 5 | export function fakeObject(input:Partial):T { 6 | Object.setPrototypeOf(input, fakeObjectTrap); 7 | return input as never; 8 | } 9 | -------------------------------------------------------------------------------- /spec/src/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { capitalizeText, getIPAddress } from "../../build/scripts/funcs.js"; 2 | import { formatTime } from "../../build/scripts/utils.js"; 3 | import { maxTime } from "../../build/scripts/globals.js"; 4 | 5 | describe("capitalizeText", () => { 6 | it("should work", () => { 7 | expect(capitalizeText("hello")).toEqual("Hello"); 8 | expect(capitalizeText("Hello")).toEqual("Hello"); 9 | expect(capitalizeText("HELLO")).toEqual("Hello"); 10 | expect(capitalizeText("hEllO")).toEqual("Hello"); 11 | expect(capitalizeText("fish community")).toEqual("Fish Community"); 12 | expect(capitalizeText("fish community is nice")).toEqual("Fish Community is Nice"); 13 | expect(capitalizeText("the fish community")).toEqual("The Fish Community"); 14 | }); 15 | }); 16 | 17 | describe("getIPAddress", () => { 18 | it("should return the correct address from the available network interfaces", () => { 19 | expect(getIPAddress()).toEqual("1.2.3.4"); 20 | }); 21 | }); 22 | 23 | describe("formatTime", () => { 24 | it("should work for normal times", () => { 25 | expect(formatTime(1_000)).toEqual("1 second"); 26 | expect(formatTime(2_000)).toEqual("2 seconds"); 27 | expect(formatTime(15_000)).toEqual("15 seconds"); 28 | expect(formatTime(60_000)).toEqual("1 minute"); 29 | expect(formatTime(120_000)).toEqual("2 minutes"); 30 | expect(formatTime(61_000)).toEqual("1 minute, 1 second"); 31 | expect(formatTime(121_000)).toEqual("2 minutes, 1 second"); 32 | expect(formatTime(86400_000)).toEqual("1 day"); 33 | }); 34 | it("should work for infinite times", () => { 35 | expect(formatTime(maxTime - Date.now())).toEqual("forever"); 36 | }); 37 | it("should work for 0", () => { 38 | expect(formatTime(0)).toEqual("0 seconds"); 39 | }); 40 | }); 41 | 42 | -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*[sS]pec.?(m)js" 5 | ], 6 | "helpers": [ 7 | "build/env.js" 8 | ], 9 | "env": { 10 | "stopSpecOnExpectationFailure": false, 11 | "random": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src/", 5 | "outDir": "./build/", 6 | "types": ["node", "jasmine"], 7 | "composite": false, 8 | "declaration": false, 9 | "lib": ["ESNext"], 10 | }, 11 | "references": [{"path": ".."}], 12 | "include": [ 13 | "./src/**.ts", 14 | ] 15 | } -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains a wrapper over the API calls to the backend server. 4 | */ 5 | 6 | import { Gamemode, backendIP, Mode } from './config'; 7 | import { maxTime } from "./globals"; 8 | import { FishPlayer } from './players'; 9 | 10 | /** Mark a player as stopped until time */ 11 | export function addStopped(uuid: string, time:number) { 12 | if(Mode.localDebug) return; 13 | const req = Http.post(`http://${backendIP}/api/addStopped`, JSON.stringify({ id: uuid, time })) 14 | .header('Content-Type', 'application/json') 15 | .header('Accept', '*/*'); 16 | req.timeout = 10000; 17 | req.error(() => Log.err(`[API] Network error when trying to call api.addStopped()`)); 18 | req.submit((response) => { 19 | //Log.info(response.getResultAsString()); 20 | }); 21 | } 22 | 23 | /** Mark a player as freed */ 24 | export function free(uuid: string) { 25 | if(Mode.localDebug) return; 26 | const req = Http.post(`http://${backendIP}/api/free`, JSON.stringify({ id: uuid })) 27 | .header('Content-Type', 'application/json') 28 | .header('Accept', '*/*'); 29 | req.timeout = 10000; 30 | req.error(() => Log.err(`[API] Network error when trying to call api.free()`)); 31 | req.submit((response) => { 32 | //Log.info(response.getResultAsString()); 33 | }); 34 | } 35 | 36 | /** 37 | * Gets a player's unmark time from the API. 38 | * If callbackError is undefined, callback will be called with null on error. 39 | **/ 40 | export function getStopped(uuid:string, callback: (unmark:number | null) => unknown):void; 41 | export function getStopped(uuid:string, callback: (unmark:number) => unknown, callbackError: (errorMessage:Throwable) => unknown):void; 42 | export function getStopped(uuid:string, callback: (unmark:any) => unknown, callbackError?: (errorMessage:Throwable) => unknown){ 43 | function fail(err:string){ 44 | Log.err(`[API] Network error when trying to call api.getStopped()`); 45 | if(err) Log.err(err); 46 | if(callbackError) callbackError(err) 47 | else callback(null); 48 | } 49 | 50 | if(Mode.localDebug) return fail("local debug mode"); 51 | 52 | const req = Http.post(`http://${backendIP}/api/getStopped`, JSON.stringify({ id: uuid })) 53 | .header('Content-Type', 'application/json') 54 | .header('Accept', '*/*'); 55 | req.timeout = 10000; 56 | req.error(fail); 57 | req.submit((response) => { 58 | const temp = response.getResultAsString(); 59 | if(!temp.length) return fail("reponse empty"); 60 | const time = JSON.parse(temp).time; 61 | if(isNaN(Number(time))) return fail(`API IS BROKEN!!! Invalid unmark time "${time}": not a number`); 62 | if(time.toString().length > 13) callback(maxTime); 63 | callback(Number(time)); 64 | }); 65 | } 66 | 67 | let cachedIps:Record = {}; 68 | /** Make an API request to see if an IP is likely VPN. */ 69 | export function isVpn(ip:string, callback: (isVpn:boolean) => unknown, callbackError?: (errorMessage:Throwable) => unknown){ 70 | if(ip in cachedIps) return callback(cachedIps[ip]!); 71 | Http.get(`http://ip-api.com/json/${ip}?fields=proxy,hosting`, (res) => { 72 | const data = res.getResultAsString(); 73 | const json = JSON.parse(data); 74 | const isVpn = json.proxy || json.hosting; 75 | cachedIps[ip] = isVpn; 76 | FishPlayer.stats.numIpsChecked ++; 77 | if(isVpn) FishPlayer.stats.numIpsFlagged ++; 78 | callback(isVpn); 79 | }, callbackError ?? ((err) => { 80 | Log.err(`[API] Network error when trying to call api.isVpn()`); 81 | FishPlayer.stats.numIpsErrored ++; 82 | callback(false); 83 | })); 84 | } 85 | 86 | /** Send text to the moderation logs channel in Discord. */ 87 | export function sendModerationMessage(message: string) { 88 | if(Mode.localDebug){ 89 | Log.info(`Sent moderation log message: ${message}`); 90 | return; 91 | } 92 | const req = Http.post(`http://${backendIP}/api/mod-dump`, JSON.stringify({ message })).header('Content-Type', 'application/json').header('Accept', '*/*'); 93 | req.timeout = 10000; 94 | 95 | req.error(() => Log.err(`[API] Network error when trying to call api.sendModerationMessage()`)); 96 | req.submit((response) => { 97 | //Log.info(response.getResultAsString()); 98 | }); 99 | } 100 | 101 | /** Get staff messages from discord. */ 102 | export function getStaffMessages(callback: (messages: string) => unknown) { 103 | if(Mode.localDebug) return; 104 | const req = Http.post(`http://${backendIP}/api/getStaffMessages`, JSON.stringify({ server: Gamemode.name() })) 105 | .header('Content-Type', 'application/json').header('Accept', '*/*'); 106 | req.timeout = 10000; 107 | req.error(() => Log.err(`[API] Network error when trying to call api.getStaffMessages()`)); 108 | req.submit((response) => { 109 | const temp = response.getResultAsString(); 110 | if(!temp.length) Log.err(`[API] Network error(empty response) when trying to call api.getStaffMessages()`); 111 | else callback(JSON.parse(temp).messages); 112 | }); 113 | } 114 | 115 | /** Send staff messages from server. */ 116 | export function sendStaffMessage(message:string, playerName:string, callback?: (sent:boolean) => unknown){ 117 | if(Mode.localDebug) return; 118 | const req = Http.post( 119 | `http://${backendIP}/api/sendStaffMessage`, 120 | // need to send both name variants so one can be sent to the other servers with color and discord can use the clean one 121 | JSON.stringify({ message, playerName, cleanedName: Strings.stripColors(playerName), server: Gamemode.name() }) 122 | ).header('Content-Type', 'application/json').header('Accept', '*/*'); 123 | req.timeout = 10000; 124 | req.error(() => { 125 | Log.err(`[API] Network error when trying to call api.sendStaffMessage()`); 126 | callback?.(false); 127 | }); 128 | req.submit((response) => { 129 | const temp = response.getResultAsString(); 130 | if(!temp.length) Log.err(`[API] Network error(empty response) when trying to call api.sendStaffMessage()`); 131 | else callback?.(JSON.parse(temp).data); 132 | }); 133 | } 134 | 135 | /** Bans the provided ip and/or uuid. */ 136 | export function ban(data:{ip?:string; uuid?:string;}, callback:(status:string) => unknown = () => {}){ 137 | if(Mode.localDebug) return; 138 | const req = Http.post(`http://${backendIP}/api/ban`, JSON.stringify(data)) 139 | .header('Content-Type', 'application/json') 140 | .header('Accept', '*/*'); 141 | req.timeout = 10000; 142 | req.error(() => Log.err(`[API] Network error when trying to call api.ban(${data.ip}, ${data.uuid})`)); 143 | req.submit((response) => { 144 | let str = response.getResultAsString(); 145 | if(!str.length) return Log.err(`[API] Network error(empty response) when trying to call api.ban()`); 146 | callback(JSON.parse(str).data); 147 | }); 148 | } 149 | 150 | /** Unbans the provided ip and/or uuid. */ 151 | export function unban(data:{ip?:string; uuid?:string;}, callback:(status:string, error?:string) => unknown = () => {}){ 152 | if(Mode.localDebug) return; 153 | const req = Http.post(`http://${backendIP}/api/unban`, JSON.stringify(data)) 154 | .header('Content-Type', 'application/json') 155 | .header('Accept', '*/*'); 156 | req.timeout = 10000; 157 | req.error(() => Log.err(`[API] Network error when trying to call api.ban({${data.ip}, ${data.uuid}})`)); 158 | req.submit((response) => { 159 | let str = response.getResultAsString(); 160 | if(!str.length) return Log.err(`[API] Network error(empty response) when trying to call api.unban()`); 161 | const parsedData = JSON.parse(str); 162 | callback(parsedData.status, parsedData.error); 163 | }); 164 | } 165 | 166 | /** Gets if either the provided uuid or ip is banned. */ 167 | export function getBanned(data:{uuid?:string, ip?:string}, callback:(banned:boolean) => unknown){ 168 | if(Mode.localDebug){ 169 | Log.info(`[API] Attempted to getBanned(${data.uuid}/${data.ip}), assuming false due to local debug`); 170 | callback(false); 171 | return; 172 | } 173 | //TODO cache 4s 174 | const req = Http.post(`http://${backendIP}/api/checkIsBanned`, JSON.stringify(data)) 175 | .header('Content-Type', 'application/json') 176 | .header('Accept', '*/*'); 177 | req.timeout = 10000; 178 | req.error(() => Log.err(`[API] Network error when trying to call api.getBanned()`)); 179 | req.submit((response) => { 180 | const str = response.getResultAsString(); 181 | if(!str.length) return Log.err(`[API] Network error(empty response) when trying to call api.getBanned()`); 182 | callback(JSON.parse(str).data); 183 | }); 184 | } 185 | -------------------------------------------------------------------------------- /src/client.$ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file is a client environment: you can write typescript code in this file and run a command to copy it to the clipboard, then run it in-game on your client. 4 | */ 5 | 6 | declare let me:mindustryPlayer; 7 | declare let cancel:() => unknown; 8 | 9 | Object.entries = (o:any):any => Object.keys(o).map(k => [k, o[k]]); 10 | if(typeof cancel == "function"){cancel()} 11 | function startEffectLoop(){ 12 | let bufferedLines:string[] = []; 13 | function line(x1:number, y1:number, x2:number, y2:number, color:string){ 14 | bufferedLines.push(`${x1},${y1},${x2},${y2},${color}`); 15 | } 16 | function flushBuffer(){ 17 | if(bufferedLines.length > 1000){ 18 | Log.warn(`Too many lines!`); 19 | } 20 | Call.serverPacketReliable("bulkLineEffect", bufferedLines.slice(0, 1000).join("|")); 21 | bufferedLines = []; 22 | } 23 | 24 | let i = 0; 25 | function main(){ 26 | i ++; 27 | const x = i / 360 * 2 * Math.PI; 28 | let scale = 10; 29 | function lineOffset(x1:number, y1:number, x2:number, y2:number, color:string){ 30 | let mag1 = Mathf.len(x1, y1) * scale; 31 | let theta1 = Mathf.atan2(x1, y1); 32 | let mag2 = Mathf.len(x2, y2) * scale; 33 | let theta2 = Mathf.atan2(x2, y2); 34 | bufferedLines.push(`\ 35 | ${Math.round(mag1 * Math.cos(x + theta1) + me.x)},\ 36 | ${Math.round(mag1 * Math.sin(x + theta1) + me.y)},\ 37 | ${Math.round(mag2 * Math.cos(x + theta2) + me.x)},\ 38 | ${Math.round(mag2 * Math.sin(x + theta2) + me.y)},\ 39 | ${color}` 40 | ); 41 | } 42 | function lineOffsetSym(x1:number, y1:number, x2:number, y2:number, color:string, num = 4){ 43 | let mag1 = Mathf.len(x1, y1) * scale; 44 | let theta1 = Mathf.atan2(x1, y1); 45 | let mag2 = Mathf.len(x2, y2) * scale; 46 | let theta2 = Mathf.atan2(x2, y2); 47 | for(let offset = 0; offset < Mathf.PI2; offset += (Mathf.PI2 / num)){ 48 | bufferedLines.push(`\ 49 | ${mag1 * Math.cos(x + theta1 + offset) + me.x},\ 50 | ${mag1 * Math.sin(x + theta1 + offset) + me.y},\ 51 | ${mag2 * Math.cos(x + theta2 + offset) + me.x},\ 52 | ${mag2 * Math.sin(x + theta2 + offset) + me.y},\ 53 | ${color}` 54 | ); 55 | } 56 | } 57 | function lineOffset1Sym(x1:number, y1:number, x2:number, y2:number, color:string, num = 4){ 58 | let mag1 = Mathf.len(x1, y1) * scale; 59 | let theta1 = Mathf.atan2(x1, y1); 60 | for(let offset = 0; offset < Mathf.PI2; offset += (Mathf.PI2 / num)){ 61 | bufferedLines.push(`\ 62 | ${mag1 * Math.cos(x + theta1 + offset) + me.x},\ 63 | ${mag1 * Math.sin(x + theta1 + offset) + me.y},\ 64 | ${x2},\ 65 | ${y2},\ 66 | ${color}` 67 | ); 68 | } 69 | } 70 | let smallSquareRad = 1.5; 71 | let starInRad = 1.5; 72 | let starOutRad = 10; 73 | let outSquareDist = 3; 74 | let outSquareSize = 2; 75 | let outSquareMax = outSquareDist + outSquareSize; 76 | let outSquareMid = outSquareDist + outSquareSize / 2; 77 | let squareColor = "#3141FF"; 78 | let starColor = "#FFD37F"; 79 | let outSquareColor = "#33FF55"; 80 | let rainbowColor = "#"+Color.HSVtoRGB((i * 2) % 360, 100, 100).toString().slice(0, 6); 81 | lineOffsetSym(0, smallSquareRad, smallSquareRad, 0, squareColor); 82 | lineOffsetSym(starOutRad, 0, starInRad, starInRad, starColor); 83 | lineOffsetSym(starInRad, starInRad, 0, starOutRad, starColor); 84 | lineOffsetSym(outSquareDist, outSquareDist, outSquareDist, outSquareMax, outSquareColor); 85 | lineOffsetSym(outSquareDist, outSquareMax, outSquareMax, outSquareMax, outSquareColor); 86 | lineOffsetSym(outSquareMax, outSquareMax, outSquareMax, outSquareDist, outSquareColor); 87 | lineOffsetSym(outSquareMax, outSquareDist, outSquareDist, outSquareDist, outSquareColor); 88 | if(me.shooting){ 89 | lineOffset1Sym(outSquareMid, outSquareMid, me.mouseX, me.mouseY, rainbowColor); 90 | lineOffsetSym(outSquareDist, outSquareDist, outSquareMid, outSquareMid, rainbowColor); 91 | lineOffsetSym(outSquareDist, outSquareMax, outSquareMid, outSquareMid, rainbowColor); 92 | lineOffsetSym(outSquareMax, outSquareMax, outSquareMid, outSquareMid, rainbowColor); 93 | lineOffsetSym(outSquareMax, outSquareDist, outSquareMid, outSquareMid, rainbowColor); 94 | } 95 | /*for(let i = 0; i < 500; i ++){ 96 | lineOffset(Math.random() * 10 - 5, Math.random() * 10 - 5, Math.random() * 10 - 5, Math.random() * 10 - 5, "#" + new Color().rand().toString().slice(0, 6)); 97 | }*/ 98 | } 99 | 100 | const task = Timer.schedule(() => { 101 | if(!me.dead()){ 102 | main(); 103 | flushBuffer(); 104 | } 105 | }, 0, 1/30); 106 | return () => task.cancel(); 107 | } 108 | cancel = startEffectLoop(); -------------------------------------------------------------------------------- /src/files.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains the code for automated map syncing. 4 | Original contributor: @author Jurorno9 5 | Maintenance: @author BalaM314 6 | */ 7 | 8 | import { mapRepoURLs, Gamemode } from "./config"; 9 | import { Promise } from "./promise"; 10 | import { getHash } from "./utils"; 11 | import { crash } from './funcs'; 12 | 13 | 14 | 15 | 16 | type GitHubFile = { 17 | name: string; 18 | path: string; 19 | sha: string; 20 | size: number; 21 | url: string; 22 | html_url: string; 23 | git_url: string; 24 | download_url: string | null; 25 | type: 'file' | 'dir'; 26 | } 27 | 28 | //if we switch to a self-hosted setup, just make it respond with the githubfile object for a drop-in replacement 29 | function fetchGithubContents(){ 30 | return new Promise((resolve, reject) => { 31 | const url = mapRepoURLs[Gamemode.name()]; 32 | if(!url) return reject(`No recognized gamemode detected. please enter "host " and try again`); 33 | Http.get(url, (res) => { 34 | try { 35 | //Trust github to return valid JSON data 36 | resolve(JSON.parse(res.getResultAsString())); 37 | } catch(e){ 38 | reject(`Failed to parse GitHub repository contents: ${e}`); 39 | } 40 | }, () => reject(`Network error while fetching github repository contents`)); 41 | }); 42 | } 43 | 44 | function downloadFile(address:string, filename:string):Promise { 45 | if(!/^https?:\/\//i.test(address)){ 46 | crash(`Invalid address, please start with 'http://' or 'https://'`); 47 | } 48 | 49 | return new Promise((resolve, reject) => { 50 | let instream:InputStream | null = null; 51 | let outstream:OutputStream | null = null; 52 | Log.info(`Downloading ${filename}...`); 53 | Http.get(address, (res) => { 54 | try { 55 | instream = res.getResultAsStream(); 56 | outstream = new Fi(filename).write(); 57 | instream.transferTo(outstream); 58 | resolve(); 59 | } finally { 60 | instream?.close(); 61 | outstream?.close(); 62 | } 63 | }, 64 | () => { 65 | Log.err(`Download failed.`); 66 | reject(`Network error while downloading a map file: ${address}`); 67 | }); 68 | }); 69 | } 70 | 71 | 72 | function downloadMaps(githubListing:GitHubFile[]):Promise { 73 | return Promise.all(githubListing.map(fileEntry => { 74 | if(!(typeof fileEntry.download_url == "string")){ 75 | Log.warn(`Map ${fileEntry.name} has no valid download link, skipped.`); 76 | return Promise.resolve(null! as void); 77 | } 78 | return downloadFile(fileEntry.download_url, Vars.customMapDirectory.child(fileEntry.name).absolutePath()); 79 | })).then(v => {}); 80 | } 81 | 82 | /** 83 | * @returns whether any maps were changed 84 | */ 85 | export function updateMaps():Promise { 86 | //get github map listing 87 | return fetchGithubContents().then((listing) => { 88 | //filter only valid mindustry maps 89 | const mapList = listing 90 | .filter(entry => entry.type == 'file') 91 | .filter(entry => /\.msav$/.test(entry.name)); 92 | 93 | const mapFiles:Fi[] = Vars.customMapDirectory.list(); 94 | const mapsToDelete = mapFiles.filter(localFile => 95 | !mapList.some(remoteFile => 96 | remoteFile.name === localFile.name() 97 | ) 98 | && !localFile.name().startsWith("$$") 99 | ); 100 | mapsToDelete.forEach((map) => map.delete()); 101 | 102 | const mapsToDownload = mapList 103 | .filter(entry => { 104 | const file = Vars.customMapDirectory.child(entry.name); 105 | return !file.exists() || entry.sha !== getHash(file); //sha'd 106 | }); 107 | 108 | if(mapsToDownload.length == 0){ 109 | return mapsToDelete.length > 0 ? true : false; 110 | } 111 | return downloadMaps(mapsToDownload).then(() => { 112 | Vars.maps.reload(); 113 | return true; 114 | }); 115 | }); 116 | } -------------------------------------------------------------------------------- /src/fjsContext.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains the context for the "fjs" command, which executes code with access to the plugin's internals. 4 | */ 5 | 6 | import type { FishPlayer as tFishPlayer } from "./players"; 7 | type FishPlayer = tFishPlayer; //absurd 8 | 9 | const api = require("./api"); 10 | const commands = require("./commands"); 11 | const config = require("./config"); 12 | const { commands: consoleCommands } = require("./consoleCommands"); 13 | const files = require("./files"); 14 | const funcs = require("./funcs"); 15 | const globals = require("./globals"); 16 | const maps = require("./maps"); 17 | const { commands: memberCommands } = require("./memberCommands"); 18 | const menus = require("./menus"); 19 | const { Metrics } = require('./metrics'); 20 | const packetHandlers = require("./packetHandlers"); 21 | const { commands: playerCommands } = require("./playerCommands"); 22 | const players = require("./players"); 23 | const ranks = require("./ranks"); 24 | const { commands: staffCommands } = require("./staffCommands"); 25 | const timers = require("./timers"); 26 | const utils = require("./utils"); 27 | const votes = require("./votes"); 28 | const { Promise } = require("./promise"); 29 | 30 | const { Perm, allCommands } = commands; 31 | const { FishPlayer } = players; 32 | const { FMap } = maps; 33 | const { Rank, RoleFlag } = ranks; 34 | const { Menu } = menus; 35 | 36 | Object.assign(this as never as typeof globalThis, utils, funcs); //global scope goes brrrrr, I'm sure this will not cause any bugs whatsoever 37 | 38 | const Ranks = null!; 39 | 40 | const $ = Object.assign( 41 | function $(input:unknown){ 42 | if(typeof input == "string"){ 43 | if(Pattern.matches("[a-zA-Z0-9+/]{22}==", input)){ 44 | return FishPlayer.getById(input); 45 | } 46 | } 47 | return null; 48 | }, 49 | { 50 | sussy: true, 51 | info: function(input:unknown){ 52 | if(typeof input == "string"){ 53 | if(Pattern.matches("[a-zA-Z0-9+/]{22}==", input)){ 54 | return Vars.netServer.admins.getInfo(input); 55 | } 56 | } 57 | return null; 58 | }, 59 | create: function(input:unknown){ 60 | if(typeof input == "string"){ 61 | if(Pattern.matches("[a-zA-Z0-9+/]{22}==", input)){ 62 | return FishPlayer.getFromInfo(Vars.netServer.admins.getInfo(input)); 63 | } 64 | } 65 | return null; 66 | }, 67 | me: null as FishPlayer | null, 68 | meM: null as mindustryPlayer | null, 69 | } 70 | ); 71 | 72 | /** Used to persist variables. */ 73 | const vars = {}; 74 | 75 | export function runJS( 76 | input:string, 77 | outputFunction:(data:any) => unknown = Log.info, 78 | errorFunction:(data:any) => unknown = Log.err, 79 | player?:FishPlayer 80 | ){ 81 | if(player){ 82 | $.me = player; 83 | $.meM = player.player; 84 | } else if(Groups.player.size() == 1){ 85 | $.meM = Groups.player.first(); 86 | $.me = players.FishPlayer.get($.meM); 87 | } 88 | try { 89 | const admins = Vars.netServer.admins; 90 | const output = eval(input); 91 | if(output instanceof Array){ 92 | outputFunction("&cArray: [&fr" + output.join(", ") + "&c]&fr"); 93 | } else if(output === undefined){ 94 | outputFunction("undefined"); 95 | } else if(output === null){ 96 | outputFunction("null"); 97 | } else { 98 | outputFunction(output); 99 | } 100 | } catch(err){ 101 | errorFunction(err); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/globals.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains mutable global variables, and global constants. 4 | */ 5 | 6 | import { EventEmitter } from "./funcs"; 7 | import type { FishPlayer } from "./players"; 8 | 9 | export const tileHistory:Record = {}; 10 | export const recentWhispers:Record = {}; 11 | export const fishState = { 12 | restartQueued: false, 13 | restartLoopTask: null as null | TimerTask, 14 | corruption_t1: null as null | TimerTask, 15 | corruption_t2: null as null | TimerTask, 16 | lastPranked: Date.now(), 17 | labels: [] as TimerTask[], 18 | peacefulMode: false, 19 | joinBell: false, 20 | }; 21 | export const fishPlugin = { 22 | directory: null as null | string, 23 | version: null as null | string, 24 | }; 25 | export const ipJoins = new ObjectIntMap(); //todo somehow tell java that K is String and not Object 26 | 27 | export const uuidPattern = /^[a-zA-Z0-9+/]{22}==$/; 28 | export const ipPattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; 29 | export const ipPortPattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{1,5}$/; 30 | export const ipRangeCIDRPattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/(1[2-9]|2[0-4])$/; //Disallow anything bigger than a /12 31 | export const ipRangeWildcardPattern = /^(\d{1,3}\.\d{1,3})\.(?:(\d{1,3}\.\*)|\*)$/; //Disallow anything bigger than a /16 32 | export const maxTime = 9999999999999; 33 | 34 | export const FishEvents = new EventEmitter<{ 35 | /** Fired after a team change. The current team is player.team() */ 36 | playerTeamChange: [player:FishPlayer, previous:Team]; 37 | /** Use this event to load data from Core.settings */ 38 | loadData: []; 39 | /** Use this event to save data to Core.settings */ 40 | saveData: []; 41 | /** Use this event to mutate things after all the data is loaded */ 42 | dataLoaded: []; 43 | }>(); 44 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains the main code, which calls other functions and initializes the plugin. 4 | */ 5 | 6 | import * as api from './api'; 7 | import * as commands from './commands'; 8 | import { handleTapEvent } from './commands'; 9 | import { commands as consoleCommands } from "./consoleCommands"; 10 | import { FishEvents, fishPlugin, fishState, ipJoins, tileHistory } from "./globals"; 11 | import { commands as memberCommands } from './memberCommands'; 12 | import * as menus from "./menus"; 13 | import { loadPacketHandlers, commands as packetHandlerCommands } from './packetHandlers'; 14 | import { commands as playerCommands } from './playerCommands'; 15 | import { FishPlayer } from './players'; 16 | import { commands as staffCommands } from './staffCommands'; 17 | import * as timers from './timers'; 18 | import { addToTileHistory, fishCommandsRootDirPath, foolifyChat, processChat, serverRestartLoop } from "./utils"; 19 | 20 | 21 | Events.on(EventType.ConnectionEvent, (e) => { 22 | if(Vars.netServer.admins.bannedIPs.contains(e.connection.address)){ 23 | api.getBanned({ 24 | ip: e.connection.address, 25 | }, (banned) => { 26 | if(!banned){ 27 | //If they were previously banned locally, but the API says they aren't banned, then unban them and clear the kick that the outer function already did 28 | Vars.netServer.admins.unbanPlayerIP(e.connection.address); 29 | Vars.netServer.admins.kickedIPs.remove(e.connection.address); 30 | } 31 | }); 32 | } 33 | }); 34 | Events.on(EventType.PlayerConnect, (e) => { 35 | if(FishPlayer.shouldKickNewPlayers() && e.player.info.timesJoined == 1){ 36 | e.player.kick(Packets.KickReason.kick, 3600000); 37 | } 38 | FishPlayer.onPlayerConnect(e.player); 39 | }); 40 | Events.on(EventType.PlayerJoin, (e) => { 41 | FishPlayer.onPlayerJoin(e.player); 42 | }); 43 | Events.on(EventType.PlayerLeave, (e) => { 44 | FishPlayer.onPlayerLeave(e.player); 45 | }); 46 | Events.on(EventType.ConnectPacketEvent, (e) => { 47 | FishPlayer.playersJoinedRecent ++; 48 | ipJoins.increment(e.connection.address); 49 | const info = Vars.netServer.admins.getInfoOptional(e.packet.uuid); 50 | const underAttack = FishPlayer.antiBotMode(); 51 | const newPlayer = !info || info.timesJoined < 10; 52 | const longModName = e.packet.mods.contains((str:string) => str.length > 50); 53 | const veryLongModName = e.packet.mods.contains((str:string) => str.length > 100); 54 | if( 55 | (underAttack && e.packet.mods.size > 2) || 56 | (underAttack && longModName) || 57 | (veryLongModName && (underAttack || newPlayer)) 58 | ){ 59 | Vars.netServer.admins.blacklistDos(e.connection.address); 60 | e.connection.kicked = true; 61 | FishPlayer.onBotWhack(); 62 | Log.info(`&yAntibot killed connection ${e.connection.address} because ${veryLongModName ? "very long mod name" : longModName ? "long mod name" : "it had mods while under attack"}`); 63 | return; 64 | } 65 | if(ipJoins.get(e.connection.address) >= ( (underAttack || veryLongModName) ? 3 : (newPlayer || longModName) ? 7 : 15 )){ 66 | Vars.netServer.admins.blacklistDos(e.connection.address); 67 | e.connection.kicked = true; 68 | FishPlayer.onBotWhack(); 69 | Log.info(`&yAntibot killed connection ${e.connection.address} due to too many connections`); 70 | return; 71 | } 72 | /*if(e.packet.name.includes("discord.gg/GnEdS9TdV6")){ 73 | Vars.netServer.admins.blacklistDos(e.connection.address); 74 | e.connection.kicked = true; 75 | FishPlayer.onBotWhack(); 76 | Log.info(`&yAntibot killed connection ${e.connection.address} due to omni discord link`); 77 | return; 78 | } 79 | if(e.packet.name.includes("счастливого 2024 года!")){ 80 | Vars.netServer.admins.blacklistDos(e.connection.address); 81 | e.connection.kicked = true; 82 | FishPlayer.onBotWhack(); 83 | Log.info(`&yAntibot killed connection ${e.connection.address} due to known bad name`); 84 | return; 85 | }*/ 86 | if(Vars.netServer.admins.isDosBlacklisted(e.connection.address)){ 87 | //threading moment, i think 88 | e.connection.kicked = true; 89 | return; 90 | } 91 | api.getBanned({ 92 | ip: e.connection.address, 93 | uuid: e.packet.uuid 94 | }, (banned) => { 95 | if(banned){ 96 | Log.info(`&lrSynced ban of ${e.packet.uuid}/${e.connection.address}.`); 97 | e.connection.kick(Packets.KickReason.banned, 1); 98 | Vars.netServer.admins.banPlayerIP(e.connection.address); 99 | Vars.netServer.admins.banPlayerID(e.packet.uuid); 100 | } else { 101 | Vars.netServer.admins.unbanPlayerIP(e.connection.address); 102 | Vars.netServer.admins.unbanPlayerID(e.packet.uuid); 103 | } 104 | }); 105 | }); 106 | Events.on(EventType.UnitChangeEvent, (e) => { 107 | FishPlayer.onUnitChange(e.player, e.unit); 108 | }); 109 | Events.on(EventType.ContentInitEvent, () => { 110 | //Unhide latum and renale 111 | UnitTypes.latum.hidden = false; 112 | UnitTypes.renale.hidden = false; 113 | }); 114 | Events.on(EventType.PlayerChatEvent, (e) => processChat(e.player, e.message, true)); 115 | 116 | Events.on(EventType.ServerLoadEvent, (e) => { 117 | const clientHandler = Vars.netServer.clientCommands; 118 | const serverHandler = ServerControl.instance.handler; 119 | 120 | FishPlayer.loadAll(); 121 | FishEvents.fire("loadData", []); 122 | timers.initializeTimers(); 123 | menus.registerListeners(); 124 | 125 | //Cap delta 126 | Time.setDeltaProvider(() => Math.min(Core.graphics.getDeltaTime() * 60, 10)); 127 | 128 | // Mute muted players 129 | Vars.netServer.admins.addChatFilter((player, message) => processChat(player, message)); 130 | 131 | // Action filters 132 | Vars.netServer.admins.addActionFilter((action:PlayerAction) => { 133 | const player = action.player; 134 | const fishP = FishPlayer.get(player); 135 | 136 | //prevent stopped players from doing anything other than deposit items. 137 | if(!fishP.hasPerm("play")){ 138 | action.player.sendMessage('[scarlet]\u26A0 [yellow]You are stopped, you cant perfom this action.'); 139 | return false; 140 | } else { 141 | if(action.type === Administration.ActionType.pickupBlock){ 142 | addToTileHistory({ 143 | pos: `${action.tile!.x},${action.tile!.y}`, 144 | uuid: action.player.uuid(), 145 | action: "picked up", 146 | type: action.tile!.block()?.name ?? "nothing", 147 | }); 148 | } 149 | return true; 150 | } 151 | }); 152 | 153 | 154 | commands.register(staffCommands, clientHandler, serverHandler); 155 | commands.register(playerCommands, clientHandler, serverHandler); 156 | commands.register(memberCommands, clientHandler, serverHandler); 157 | commands.register(packetHandlerCommands, clientHandler, serverHandler); 158 | commands.registerConsole(consoleCommands, serverHandler); 159 | loadPacketHandlers(); 160 | 161 | commands.initialize(); 162 | 163 | //Load plugin data 164 | try { 165 | const path = fishCommandsRootDirPath(); 166 | fishPlugin.directory = path.toString(); 167 | Threads.daemon(() => { 168 | try { 169 | fishPlugin.version = OS.exec("git", "-C", fishPlugin.directory!, "rev-parse", "HEAD"); 170 | } catch {} 171 | }); 172 | } catch(err){ 173 | Log.err("Failed to get fish plugin information."); 174 | Log.err(err); 175 | } 176 | 177 | FishEvents.fire("dataLoaded", []); 178 | 179 | Core.app.addListener({ 180 | dispose(){ 181 | FishEvents.fire("saveData", []); 182 | FishPlayer.saveAll(); 183 | Log.info("Saved on exit."); 184 | } 185 | }); 186 | 187 | }); 188 | 189 | // Keeps track of any action performed on a tile for use in tilelog. 190 | 191 | Events.on(EventType.BlockBuildBeginEvent, addToTileHistory); 192 | Events.on(EventType.BuildRotateEvent, addToTileHistory); 193 | Events.on(EventType.ConfigEvent, addToTileHistory); 194 | Events.on(EventType.PickupEvent, addToTileHistory); 195 | Events.on(EventType.PayloadDropEvent, addToTileHistory); 196 | Events.on(EventType.UnitDestroyEvent, addToTileHistory); 197 | Events.on(EventType.BlockDestroyEvent, addToTileHistory); 198 | Events.on(EventType.UnitControlEvent, addToTileHistory); 199 | 200 | 201 | Events.on(EventType.TapEvent, handleTapEvent); 202 | 203 | Events.on(EventType.GameOverEvent, (e) => { 204 | for(const key of Object.keys(tileHistory)){ 205 | //clear tilelog 206 | tileHistory[key] = null!; 207 | delete tileHistory[key]; 208 | } 209 | if(fishState.restartQueued){ 210 | //restart 211 | Call.sendMessage(`[accent]---[[[coral]+++[]]---\n[accent]Server restart imminent. [green]We'll be back after 15 seconds.[]\n[accent]---[[[coral]+++[]]---`); 212 | serverRestartLoop(20); 213 | } 214 | FishPlayer.onGameOver(e.winner as Team); 215 | }); 216 | Events.on(EventType.PlayerChatEvent, e => { 217 | FishPlayer.onPlayerChat(e.player, e.message); 218 | }); 219 | 220 | -------------------------------------------------------------------------------- /src/io.ts: -------------------------------------------------------------------------------- 1 | import { crash, lazy } from "./funcs"; 2 | import { FishEvents } from "./globals"; 3 | 4 | 5 | export class DataClass { 6 | _brand!: symbol; 7 | constructor(data:T){ 8 | Object.assign(this, data); 9 | } 10 | } 11 | export function dataClass(){ 12 | return DataClass as new (data:T) => (DataClass & T); 13 | } 14 | 15 | //Java does not support u64 16 | export type NumberRepresentation = "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "f32" | "f64"; 17 | 18 | export type SerializablePrimitive = string | number | boolean | Team; 19 | export type SerializableDataClassConstructor = new (data:SerializableData) => ClassInstance; 20 | 21 | export type Serializable = SerializablePrimitive | Array | { 22 | [index: string]: Serializable; 23 | } | DataClass; 24 | 25 | export type PrimitiveSchema = 26 | T extends string ? ["string"] : 27 | T extends number ? ["number", bytes:NumberRepresentation] : 28 | T extends boolean ? ["boolean"] : 29 | T extends Team ? ["team"] : 30 | never; 31 | export type ArraySchema> = 32 | ["array", length:number extends T["length"] ? NumberRepresentation & `u${string}` | number : T["length"], element:Schema]; 33 | export type ObjectSchema> = 34 | ["object", children:Array] : never : never>]; 35 | export type DataClassSchema = 36 | ["class", clazz: new (data:any) => ClassInstance, children:Array< 37 | SerializableData extends infer Data extends Record ? 38 | keyof Data extends infer KU extends keyof Data ? KU extends unknown ? [KU, Schema] : never : never 39 | : never 40 | >]; 41 | export type VersionSchema = ["version", number:number, rest:Schema]; 42 | 43 | export type Schema = ( 44 | T extends SerializablePrimitive ? PrimitiveSchema : 45 | T extends Array ? ArraySchema : 46 | T extends infer ClassInstance extends DataClass ? DataClassSchema : 47 | T extends Record ? ObjectSchema : 48 | never) | (AllowVersion extends true ? VersionSchema : never); 49 | 50 | 51 | 52 | export type SerializableData = { 53 | [K in keyof T extends infer KT extends keyof T ? KT extends unknown ? 54 | T[KT] extends Serializable ? KT : never 55 | : never : never]: T[K]; 56 | }; 57 | 58 | function checkBounds(type:NumberRepresentation, value:number, min:number, max:number){ 59 | if(value < min){ 60 | Log.warn(`Integer underflow when serializing ${type}: value ${value} was less than ${min}`); 61 | return min; 62 | } 63 | if(value >= max){ 64 | Log.warn(`Integer overflow when serializing ${type}: value ${value} was greater than ${max}`); 65 | return max; 66 | } 67 | return value; 68 | } 69 | 70 | export class Serializer { 71 | constructor( 72 | public schema: Schema, 73 | public oldSchema?: Schema, 74 | ){} 75 | write(object:T, output:DataOutputStream):void { 76 | Serializer.writeNode(this.schema, object, output); 77 | } 78 | read(input:DataInputStream):T { 79 | input.mark(0xFFFF); //1MB 80 | try { 81 | return Serializer.readNode(this.schema, input); 82 | } catch(err){ 83 | Log.warn(`Using fallback schema: this message should go away after a restart`); 84 | input.reset(); 85 | if(this.oldSchema) return Serializer.readNode(this.oldSchema, input); 86 | else throw err; 87 | } 88 | } 89 | /** SAFETY: Data must not be a union */ 90 | static writeNode(schema:Schema, object:Data, output:DataOutputStream):void; 91 | static writeNode(schema:Schema, value:never, output:DataOutputStream){ 92 | const checkNumbers = false; 93 | switch(schema[0]){ 94 | case 'string': 95 | output.writeUTF(value); 96 | break; 97 | case 'number': 98 | if(checkNumbers){ 99 | switch(schema[1]){ 100 | case 'u8': output.writeByte(checkBounds(schema[1], value, 0, 2**8)); break; 101 | case 'u16': output.writeShort(checkBounds(schema[1], value, 0, 2**16)); break; 102 | case 'u32': output.writeInt(checkBounds(schema[1], value, 0, 2**32) & 0xFFFFFFFF); break; 103 | case 'i8': output.writeByte(checkBounds(schema[1], value, -(2**7), (2**7))); break; 104 | case 'i16': output.writeShort(checkBounds(schema[1], value, -(2**15), (2**15))); break; 105 | case 'i32': output.writeInt(checkBounds(schema[1], value, -(2**31), (2**31))); break; 106 | case 'i64': output.writeLong(checkBounds(schema[1], value, -(2**63), (2**63))); break; 107 | case 'f32': output.writeFloat(isNaN(value) || !isFinite(value) ? (Log.warn('Attempted to write a NaN floating-point value, defaulting to 0'), 0) : value); break; 108 | case 'f64': output.writeDouble(isNaN(value) || !isFinite(value) ? (Log.warn('Attempted to write a NaN floating-point value, defaulting to 0'), 0) : value); break; 109 | } 110 | } else { 111 | switch(schema[1]){ 112 | case 'u8': output.writeByte(value); break; 113 | case 'u16': output.writeShort(value); break; 114 | case 'u32': output.writeInt(value & 0xFFFFFFFF); break; //If the value is greater than 0x7FFFFFFF, make it negative so Java writes it correctly 115 | case 'i8': output.writeByte(value); break; 116 | case 'i16': output.writeShort(value); break; 117 | case 'i32': output.writeInt(value); break; 118 | case 'i64': output.writeLong(value); break; 119 | case 'f32': output.writeFloat(value); break; 120 | case 'f64': output.writeDouble(value); break; 121 | } 122 | } 123 | break; 124 | case 'boolean': 125 | output.writeBoolean(value); 126 | break; 127 | case 'team': 128 | if(!value) Log.err(`attempting to serialize a Team, but it was null`); //temporary debug message 129 | output.writeByte((value as Team).id); 130 | break; 131 | case 'object': 132 | for(const [key, childSchema] of schema[1]){ 133 | //correspondence 134 | this.writeNode(childSchema as ArraySchema, value[key], output); 135 | } 136 | break; 137 | case 'class': 138 | for(const [key, childSchema] of schema[2] as [string, Schema][]){ 139 | //correspondence 140 | this.writeNode(childSchema as ArraySchema, value[key], output); 141 | } 142 | break; 143 | case 'array': 144 | if(typeof schema[1] == "string"){ 145 | this.writeNode(["number", schema[1]], (value as Serializable[]).length, output); 146 | } else { 147 | if(schema[1] !== (value as Serializable[]).length){ 148 | Log.err('SERIALIZATION WARNING: received invalid data: array with greater length than specified by schema'); 149 | (value as Serializable[]).length = schema[1]; 150 | } 151 | } 152 | for(let i = 0; i < (value as Serializable[]).length; i ++){ 153 | this.writeNode(schema[2], (value as Serializable[])[i], output); 154 | } 155 | break; 156 | case 'version': 157 | output.writeByte(schema[1]); 158 | this.writeNode(schema[2], value, output); 159 | break; 160 | } 161 | } 162 | /** SAFETY: Data must not be a union */ 163 | static readNode(schema:Schema, input:DataInputStream):Data; 164 | static readNode(schema:Schema, input:DataInputStream):unknown { 165 | switch(schema[0]){ 166 | case 'string': 167 | return input.readUTF(); 168 | case 'number': 169 | switch(schema[1]){ 170 | case 'u8': return input.readUnsignedByte(); 171 | case 'u16': return input.readUnsignedShort(); 172 | case 'u32': 173 | const value = input.readInt(); //Java does not support unsigned ints 174 | return value < 0 ? value + 2**32 : value; 175 | case 'i8': return input.readByte(); 176 | case 'i16': return input.readShort(); 177 | case 'i32': return input.readInt(); 178 | case 'i64': return input.readLong(); 179 | case 'f32': return input.readFloat(); 180 | case 'f64': return input.readDouble(); 181 | } 182 | case 'boolean': 183 | return input.readBoolean(); 184 | case 'team': 185 | return Team.all[input.readByte()]; 186 | case 'object': 187 | const output:Record = {}; 188 | for(const [key, childSchema] of schema[1]){ 189 | output[key] = this.readNode(childSchema, input); 190 | } 191 | return output; 192 | case 'class': 193 | const classData:Record = {}; 194 | for(const [key, childSchema] of schema[2] as [string, Schema][]){ 195 | classData[key] = this.readNode(childSchema, input); 196 | } 197 | return new schema[1](classData); 198 | case 'array': 199 | const length = typeof schema[1] === "number" ? 200 | schema[1] 201 | : this.readNode(["number", schema[1]], input); 202 | const array = new Array(length); 203 | for(let i = 0; i < length; i ++){ 204 | array[i] = this.readNode(schema[2], input); 205 | } 206 | return array; 207 | case 'version': 208 | const version = input.readByte(); 209 | if(version !== schema[1]) crash(`Expected version ${schema[1]}, but read ${version}`); 210 | return this.readNode(schema[2], input); 211 | } 212 | } 213 | } 214 | 215 | export class SettingsSerializer extends Serializer { 216 | constructor( 217 | public readonly settingsKey: string, 218 | public readonly schema: Schema, 219 | public readonly oldSchema?: Schema, 220 | ){ 221 | super(schema, oldSchema); 222 | } 223 | 224 | writeSettings(object:T):void { 225 | const output = new ByteArrayOutputStream(); 226 | this.write(object, new DataOutputStream(output)); 227 | Core.settings.put(this.settingsKey, output.toByteArray()); 228 | } 229 | readSettings():T | null { 230 | const data = Core.settings.getBytes(this.settingsKey); 231 | if(data) return this.read(new DataInputStream(new ByteArrayInputStream(data))); 232 | else return null; 233 | } 234 | } 235 | 236 | if(!Symbol.metadata) 237 | Object.defineProperty(Symbol, "metadata", { 238 | writable: false, 239 | enumerable: false, 240 | configurable: false, 241 | value: Symbol("Symbol.metadata") 242 | }); 243 | 244 | export function serialize( 245 | settingsKey: string, 246 | schema: () => Schema, oldSchema?: () => Schema 247 | ){ 248 | return function decorate< 249 | This extends { [P in Name]: T }, Name extends string | symbol 250 | >(_: unknown, {addInitializer, access, name}:ClassFieldDecoratorContext & { 251 | name: Name; 252 | static: true; 253 | }){ 254 | addInitializer(function(){ 255 | const serializer = lazy(() => 256 | new SettingsSerializer(settingsKey, schema(), oldSchema?.()) 257 | ); 258 | FishEvents.on("loadData", () => { 259 | const value = serializer().readSettings(); 260 | if(value) access.set(this, value); 261 | }); 262 | FishEvents.on("saveData", () => { 263 | try { 264 | serializer().writeSettings(access.get(this)); 265 | } catch(err){ 266 | Log.err(`Error while saving field ${String(name)} on ${String((this as any as Function)?.name)} using settings key ${settingsKey}`); 267 | throw err; 268 | } 269 | }); 270 | }); 271 | }; 272 | } 273 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This is a special file which is automatically loaded by the game server. 4 | It only contains polyfills, and requires index.js. 5 | */ 6 | //WARNING: changes to this file must be manually copied to /build/scripts/main.js 7 | 8 | importPackage(Packages.arc); 9 | importClass(Packages.arc.util.CommandHandler); 10 | importPackage(Packages.mindustry.type); 11 | importClass(Packages.mindustry.server.ServerControl); 12 | importPackage(Packages.java.util.regex); 13 | importClass(Packages.java.lang.Runtime); 14 | importClass(Packages.java.lang.ProcessBuilder); 15 | importClass(Packages.java.nio.file.Paths); 16 | importClass(Packages.java.io.ByteArrayOutputStream); 17 | importClass(Packages.java.io.DataOutputStream); 18 | importClass(Packages.java.io.ByteArrayInputStream); 19 | importClass(Packages.java.io.DataInputStream); 20 | 21 | //Polyfills 22 | Object.entries = o => Object.keys(o).map(k => [k, o[k]]); 23 | Object.values = o => Object.keys(o).map(k => o[k]); 24 | Object.fromEntries = a => a.reduce((o, [k, v]) => { o[k] = v; return o; }, {}); 25 | //Arrow functions do not bind to "this" 26 | Array.prototype.at = function(i){ 27 | return this[i < 0 ? this.length + i : i]; 28 | } 29 | String.prototype.at = function(i){ 30 | return this[i < 0 ? this.length + i : i]; 31 | } 32 | Array.prototype.flat = function(depth){ 33 | depth = (depth == undefined) ? 1 : depth; 34 | return depth > 0 ? this.reduce((acc, item) => 35 | acc.concat(Array.isArray(item) ? item.flat(depth - 1) : item) 36 | , []) : this; 37 | } 38 | String.raw = function(callSite){ 39 | const substitutions = Array.prototype.slice.call(arguments, 1); 40 | return Array.from(callSite.raw).map((chunk, i) => { 41 | if (callSite.raw.length <= i) { 42 | return chunk; 43 | } 44 | return substitutions[i - 1] ? substitutions[i - 1] + chunk : chunk; 45 | }).join(''); 46 | } 47 | //Fix rhino regex 48 | if(/ae?a/.test("aeea")){ 49 | RegExp.prototype.test = function(input){ 50 | //overwrite with java regex 51 | return java.util.regex.Pattern.compile(this.source).matcher(input).find(); 52 | }; 53 | } 54 | //Fix rhino Number.prototype.toFixed 55 | if(12.34.toFixed(1) !== '12.3'){ 56 | const toFixed = Number.prototype.toFixed; 57 | Number.prototype.toFixed = function(fractionDigits){ 58 | const floorLog = Math.floor(Math.log10(this)); 59 | const output = toFixed.call(this, Math.max(floorLog, -1) + 1 + fractionDigits); 60 | return output.toString().slice(0, Math.max(floorLog, 0) + 2 + fractionDigits); 61 | } 62 | } 63 | 64 | this.Promise = require('promise').Promise; 65 | require("index"); 66 | -------------------------------------------------------------------------------- /src/maps.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | Unfinished. 4 | */ 5 | 6 | import { FFunction } from './commands'; 7 | import { computeStatistics } from './funcs'; 8 | import { FishEvents } from './globals'; 9 | import { dataClass, serialize } from './io'; 10 | import { formatTime, match } from './utils'; 11 | import { Gamemode } from './config'; 12 | 13 | type FinishedMapRunData = { 14 | winTeam:Team; 15 | success:boolean; //winTeam == Vars.state.rules.defaultTeam 16 | startTime:number; 17 | endTime:number; 18 | maxPlayerCount:number; 19 | wave:number; 20 | } 21 | export class FinishedMapRun extends dataClass() { 22 | wave = 0; 23 | //this constructor is useless, but rhino crashes with a bizarre error when trying to run the emitted code 24 | //do not remove this useless constructor 25 | constructor(data:FinishedMapRunData){ 26 | super(data); 27 | } 28 | duration(){ 29 | return this.endTime - this.startTime; 30 | } 31 | outcome(){ 32 | if(Gamemode.pvp()){ 33 | if(this.winTeam === Team.derelict) { 34 | if(this.duration() > 1200_000) return ["rtv", "late rtv"] as const; 35 | else return ["rtv", "early rtv"] as const; 36 | } else return ["win", "win"] as const; 37 | } else { 38 | if(this.success) return ["win", "win"] as const; 39 | else if(this.winTeam === Team.derelict){ 40 | if(this.duration() > 180_000) return ["loss", "late rtv"] as const; 41 | else return ["rtv", "early rtv"] as const; 42 | } else return ["loss", "loss"] as const; 43 | } 44 | } 45 | } 46 | 47 | export class PartialMapRun { 48 | static readonly key = "fish-partial-map-run"; 49 | static current: PartialMapRun | null = null; 50 | static { 51 | FishEvents.on("saveData", () => { 52 | if(this.current) Core.settings.put(this.key, this.current.write()); 53 | }); 54 | FishEvents.on("loadData", () => { 55 | const data = Core.settings.getString(this.key); 56 | if(data){ 57 | this.current = this.read(data); 58 | } else { 59 | //loading a map, but there is no run information, create one 60 | this.current = new this(); 61 | } 62 | }); 63 | Events.on(EventType.SaveLoadEvent, e => { 64 | //create a new run, if there isn't one already 65 | //loadData will have run first if it is a server restart 66 | this.current ??= new this(); 67 | }); 68 | Timer.schedule(() => { 69 | this.current?.update(); 70 | }, 0, 5); 71 | Events.on(EventType.GameOverEvent, e => { 72 | if(this.current){ 73 | //Add a new map run 74 | FMap.getCreate(Vars.state.map)?.runs.push( 75 | this.current.finish({winTeam: e.winner ?? Team.derelict}) 76 | ); 77 | } 78 | Core.settings.remove(this.key); 79 | this.current = null; 80 | }); 81 | } 82 | 83 | startTime:number = Date.now(); 84 | maxPlayerCount:number = 0; 85 | /** In milliseconds */ 86 | duration(){ 87 | return Date.now() - this.startTime; 88 | } 89 | update(){ 90 | this.maxPlayerCount = Math.max(this.maxPlayerCount, Groups.player.size()); 91 | } 92 | finish({winTeam}:{ 93 | winTeam: Team; 94 | }):FinishedMapRun { 95 | return new FinishedMapRun({ 96 | winTeam, 97 | success: Gamemode.pvp() ? true : winTeam == Vars.state.rules.defaultTeam, 98 | startTime: this.startTime, 99 | endTime: Date.now(), 100 | maxPlayerCount: this.maxPlayerCount, 101 | wave: Vars.state.wave, 102 | }); 103 | } 104 | //Used for continuing through a restart 105 | write():string { 106 | return `${Date.now() - this.startTime}/${this.maxPlayerCount}`; 107 | } 108 | static read(data:string):PartialMapRun { 109 | const [duration, maxPlayerCount] = data.split("/").map(Number); 110 | if(isNaN(duration) || isNaN(maxPlayerCount)){ 111 | Log.err(`_FINDTAG_ failed to load map run stats data: ${data}`); 112 | } 113 | const out = new PartialMapRun(); 114 | out.startTime = Date.now() - duration; //move start time forward by time when the server was off 115 | out.maxPlayerCount = maxPlayerCount; 116 | return out; 117 | } 118 | } 119 | 120 | 121 | type FMapData = { 122 | runs: FinishedMapRun[]; 123 | mapFileName: string; 124 | }; 125 | export class FMap extends dataClass() { 126 | constructor( 127 | data:FMapData, 128 | //O(n^2)... should be fine? 129 | public map:MMap | null = Vars.maps.customMaps().find(m => m.file.name() === data.mapFileName) 130 | ){ super(data); } 131 | 132 | @serialize("fish-map-data", () => ["version", 1, ["array", "u16", ["class", FMap, [ 133 | ["runs", ["array", "u32", ["class", FinishedMapRun, [ 134 | ["startTime", ["number", "i64"]], 135 | ["endTime", ["number", "i64"]], 136 | ["maxPlayerCount", ["number", "u8"]], 137 | ["success", ["boolean"]], 138 | ["winTeam", ["team"]], 139 | ["wave", ["number", "u16"]] 140 | ]]]], 141 | ["mapFileName", ["string"]], 142 | ]]]], () => ["array", "u16", ["class", FMap, [ 143 | ["runs", ["array", "u32", ["class", FinishedMapRun, [ 144 | ["startTime", ["number", "i64"]], 145 | ["endTime", ["number", "i64"]], 146 | ["maxPlayerCount", ["number", "u8"]], 147 | ["success", ["boolean"]], 148 | ["winTeam", ["team"]], 149 | ]]]], 150 | ["mapFileName", ["string"]], 151 | ]]]) 152 | static allMaps:FMap[] = []; 153 | private static maps:Record = {}; 154 | static { 155 | FishEvents.on("dataLoaded", () => { 156 | //This event listener runs after the data has been loaded into allMaps 157 | FMap.allMaps.forEach(map => { 158 | FMap.maps[map.mapFileName] = map; 159 | map.runs.forEach(run => { 160 | //this should not even happen, I think GameOverEvent is sending winTeam as null sometimes?? 161 | run.winTeam ??= Team.derelict; 162 | }); 163 | }); 164 | //create all the data 165 | Vars.maps.customMaps().each(m => void FMap.getCreate(m)); 166 | }); 167 | } 168 | 169 | static getCreate(map:MMap){ 170 | const mapFileName = map.file.name(); 171 | if(Object.prototype.hasOwnProperty.call(this.maps, mapFileName)) 172 | return this.maps[mapFileName]; 173 | const fmap = new this({ 174 | runs: [], 175 | mapFileName 176 | }, map); 177 | this.maps[mapFileName] = fmap; 178 | this.allMaps.push(fmap); 179 | return fmap; 180 | } 181 | 182 | rules():Rules | undefined { 183 | return this.map?.rules(); 184 | } 185 | 186 | stats(){ 187 | const runs = this.runs.filter(r => r.maxPlayerCount > 0); //Remove all runs with no players on 188 | const allRunCount = runs.length; 189 | const victories = runs.filter(r => r.outcome()[1] === "win").length; 190 | const losses = runs.filter(r => r.outcome()[0] === "loss").length; 191 | const earlyRTVs = runs.filter(r => r.outcome()[1] === "early rtv").length; 192 | const lateRTVs = runs.filter(r => r.outcome()[1] === "late rtv").length; 193 | const significantRunCount = allRunCount - earlyRTVs; 194 | const totalLosses = losses + lateRTVs; 195 | const durations = runs.filter(r => r.outcome()[0] !== "rtv").map(r => r.duration()); 196 | const durationStats = computeStatistics(durations); 197 | const winDurationStats = computeStatistics(runs.filter(r => r.outcome()[0] === "win").map(r => r.duration())); 198 | const teamWins = runs.filter(r => r.outcome()[1] !== "early rtv").reduce((acc, item) => { 199 | acc[item.winTeam.name] = (acc[item.winTeam.name] ?? 0) + 1; 200 | return acc; 201 | }, {} as Record); 202 | const teamWinRate = Object.fromEntries(Object.entries(teamWins).map(([team, wins]) => [team, wins / significantRunCount])); 203 | const waveStats = computeStatistics(runs.filter(r => r.outcome()[0] !== "rtv").map(r => r.wave)); 204 | return { 205 | allRunCount, 206 | significantRunCount, 207 | victories, 208 | losses, 209 | totalLosses, 210 | earlyRTVs, 211 | lateRTVs, 212 | earlyRTVRate: earlyRTVs / allRunCount, 213 | winRate: victories / significantRunCount, 214 | lossRate: losses / significantRunCount, 215 | averagePlaytime: durationStats.average, 216 | shortestWinTime: winDurationStats.lowest, 217 | longestTime: durationStats.highest, 218 | shortestTime: durationStats.lowest, 219 | averageHighestPlayerCount: computeStatistics(runs.map(r => r.maxPlayerCount)).average, 220 | teamWins, 221 | teamWinRate, 222 | highestWave: waveStats.highest, 223 | averageWave: waveStats.average, 224 | }; 225 | } 226 | displayStats(f:FFunction):string | null { 227 | const map = this.map; if(!map) return null; 228 | const stats = this.stats(); 229 | const rules = this.rules()!; 230 | 231 | const modeSpecificStats = match(Gamemode.name(), { 232 | attack: `\ 233 | [#CCFFCC]Total runs: ${stats.allRunCount} (${stats.victories} wins, ${stats.totalLosses} losses, ${stats.earlyRTVs} RTVs) 234 | [#CCFFCC]Outcomes: ${f.percent(stats.winRate, 1)} wins, ${f.percent(stats.lossRate, 1)} losses, ${f.percent(stats.earlyRTVRate, 1)} RTVs 235 | [#CCFFCC]Average playtime: ${formatTime(stats.averagePlaytime)} 236 | [#CCFFCC]Shortest win time: ${formatTime(stats.shortestWinTime)}`, 237 | survival: `\ 238 | [#CCFFCC]Highest wave reached: ${stats.highestWave} 239 | [#CCFFCC]Average wave reached: ${stats.averageWave} 240 | [#CCFFCC]Total runs: ${stats.allRunCount} (${stats.earlyRTVs} RTVs) 241 | [#CCFFCC]RTV rate: ${f.percent(stats.earlyRTVRate, 1)} 242 | [#CCFFCC]Average duration: ${formatTime(stats.averagePlaytime)} 243 | [#CCFFCC]Longest duration: ${formatTime(stats.longestTime)}`, 244 | pvp: `\ 245 | [#CCFFCC]Total runs: ${stats.allRunCount} (${stats.earlyRTVs} RTVs) 246 | [#CCFFCC]Team win rates: ${Object.entries(stats.teamWinRate).map(([team, rate]) => `${team} ${f.percent(rate, 1)}`).join(", ")} 247 | [#CCFFCC]RTV rate: ${f.percent(stats.earlyRTVRate, 1)} 248 | [#CCFFCC]Average match duration: ${formatTime(stats.averagePlaytime)} 249 | [#CCFFCC]Shortest match duration: ${formatTime(stats.shortestWinTime)}`, 250 | hexed: `\ 251 | [#CCFFCC]Total runs: ${stats.allRunCount} (${stats.earlyRTVs} RTVs) 252 | [#CCFFCC]RTV rate: ${f.percent(stats.earlyRTVRate, 1)} 253 | [#CCFFCC]Average match duration: ${formatTime(stats.averagePlaytime)} 254 | [#CCFFCC]Shortest match duration: ${formatTime(stats.shortestWinTime)}`, 255 | sandbox: `\ 256 | [#CCFFCC]Total plays: ${stats.allRunCount} 257 | [#CCFFCC]Average play time: ${formatTime(stats.averagePlaytime)} 258 | [#CCFFCC]Shortest play time: ${formatTime(stats.shortestTime)}`, 259 | }, ""); 260 | return (`\ 261 | [coral]${map.name()} 262 | [gray](${map.file.name()}) 263 | 264 | [accent]Map by: [white]${map.author()} 265 | [accent]Description: [white]${map.description()} 266 | [accent]Size: [white]${map.width}x${map.height} 267 | [accent]Last updated: [white]${new Date(map.file.lastModified()).toLocaleDateString()} 268 | [accent]BvB allowed: ${f.boolGood(rules.placeRangeCheck)}, unit item transfer allowed: ${f.boolGood(rules.onlyDepositCore)} 269 | 270 | ${modeSpecificStats} 271 | [#CCFFCC]Longest play time: ${formatTime(stats.longestTime)} 272 | [#CCFFCC]Average player count: ${f.number(stats.averageHighestPlayerCount, 1)}` 273 | ); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/memberCommands.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains member commands, which are fun cosmetics for donators. 4 | */ 5 | 6 | import { Perm, commandList, fail } from "./commands"; 7 | import { FishPlayer } from "./players"; 8 | 9 | 10 | export const commands = commandList({ 11 | pet: { 12 | args: ["name:string?"], 13 | description: 'Spawns a cool pet with a displayed name that follows you around.', 14 | perm: Perm.member, 15 | handler({args, sender, outputSuccess}){ 16 | if(!args.name){ 17 | const pet = Groups.unit.find(u => u.id === sender.pet); 18 | if(pet) pet.kill(); 19 | sender.pet = ""; 20 | outputSuccess("Your pet has been removed."); 21 | return; 22 | } 23 | if(sender.pet !== ''){ 24 | const pet = Groups.unit.find(u => u.id === sender.pet); 25 | if(pet) pet.kill(); 26 | sender.pet = ''; 27 | } 28 | 29 | const pet = UnitTypes.merui.spawn(sender.team(), sender.unit().x, sender.unit().y); 30 | pet.apply(StatusEffects.disarmed, Number.MAX_SAFE_INTEGER); 31 | sender.pet = pet.id; 32 | 33 | Call.infoPopup('[#7FD7FD7f]\uE81B', 5, Align.topRight, 180, 0, 0, 10); 34 | outputSuccess(`Spawned a pet.`); 35 | 36 | function controlUnit({pet, fishPlayer, petName}:{ 37 | petName: string; pet: Unit; fishPlayer: FishPlayer; 38 | }){ 39 | return Timer.schedule(() => { 40 | if(pet.id !== fishPlayer.pet || !fishPlayer.connected()){ 41 | pet.kill(); 42 | return; 43 | } 44 | 45 | const distX = fishPlayer.unit().x - pet.x; 46 | const distY = fishPlayer.unit().y - pet.y; 47 | if(distX >= 50 || distX <= -50 || distY >= 50 || distY <= -50){ 48 | pet.approach(new Vec2(distX, distY)); 49 | } 50 | Call.label(petName, 0.07, pet.x, pet.y + 5); 51 | if(fishPlayer.trail){ 52 | Call.effect(Fx[fishPlayer.trail.type], pet.x, pet.y, 0, fishPlayer.trail.color); 53 | } 54 | controlUnit({ petName, pet, fishPlayer }); 55 | }, 0.05); 56 | }; 57 | controlUnit({ petName: args.name, pet, fishPlayer: sender }); 58 | } 59 | }, 60 | 61 | highlight: { 62 | args: ['color:string?'], 63 | description: 'Makes your chat text colored by default.', 64 | perm: Perm.member, 65 | handler({args, sender, outputFail, outputSuccess}){ 66 | if(args.color == null || args.color.length == 0){ 67 | if(sender.highlight != null){ 68 | sender.highlight = null; 69 | outputSuccess("Cleared your highlight."); 70 | } else { 71 | outputFail("No highlight to clear."); 72 | } 73 | } else if(Strings.stripColors(args.color) == ""){ 74 | sender.highlight = args.color; 75 | outputSuccess(`Set highlight to ${args.color.replace("[","").replace("]","")}.`); 76 | } else if(Strings.stripColors(`[${args.color}]`) == ""){ 77 | sender.highlight = `[${args.color}]`; 78 | outputSuccess(`Set highlight to ${args.color}.`); 79 | } else { 80 | outputFail(`[yellow]"${args.color}[yellow]" was not a valid color!`); 81 | } 82 | } 83 | }, 84 | 85 | rainbow: { 86 | args: ["speed:number?"], 87 | description: 'Make your name change colors.', 88 | perm: Perm.member, 89 | handler({args, sender, outputSuccess}){ 90 | const colors = ['[red]', '[orange]', '[yellow]', '[acid]', '[blue]', '[purple]']; 91 | function rainbowLoop(index:number, fishP:FishPlayer){ 92 | Timer.schedule(() => { 93 | if(!(fishP.rainbow && fishP.player && fishP.connected())) return; 94 | fishP.player.name = colors[index % colors.length] + Strings.stripColors(fishP.player.name); 95 | rainbowLoop(index + 1, fishP); 96 | }, args.speed! / 5); 97 | } 98 | 99 | if(!args.speed){ 100 | sender.rainbow = null; 101 | sender.updateName(); 102 | outputSuccess("Turned off rainbow."); 103 | } else { 104 | if(args.speed > 10 || args.speed <= 0 || !Number.isInteger(args.speed)){ 105 | fail('Speed must be an integer between 0 and 10.'); 106 | } 107 | 108 | sender.rainbow ??= { speed: args.speed }; 109 | rainbowLoop(0, sender); 110 | outputSuccess(`Activated rainbow name mode with speed ${args.speed}`); 111 | } 112 | 113 | } 114 | } 115 | }); -------------------------------------------------------------------------------- /src/metrics.ts: -------------------------------------------------------------------------------- 1 | import { serialize } from "./io"; 2 | 3 | 4 | type MetricsWeek = Array & { 5 | length: 2520; /* 15 * 24 * 7 */ 6 | }; 7 | export class Metrics { 8 | /** 4 May 2025 */ 9 | static readonly startDate = new Date(2025, 4, 4).getTime(); 10 | static readonly millisPerWeek = 604800_000; 11 | static readonly millisBetweenReadings = 240_000; 12 | static readonly noData = -1; 13 | /** 14 | * Weeks are numbered starting at the week of 4 May 2025. 15 | * A value is taken every 4 minutes, for a total of 15 readings per hour. 16 | */ 17 | @serialize("player-count-data", () => ["version", 0, 18 | ["array", "u16", ["array", 2520, ["number", "i8"]]] 19 | ]) 20 | static weeks: Array = Array.from({length: this.weekNumber() + 1}, () => this.newWeek()); 21 | 22 | static { 23 | Timer.schedule(() => Metrics.update(), 15, 60); 24 | } 25 | 26 | static weekNumber(date = Date.now()){ 27 | return Math.floor((date - this.startDate) / this.millisPerWeek); 28 | } 29 | static readingNumber(date = Date.now()){ 30 | return Math.floor(((date - this.startDate) % this.millisPerWeek) / this.millisBetweenReadings); 31 | } 32 | static newWeek() { 33 | return Array(2520 satisfies MetricsWeek["length"]).fill(this.noData) as MetricsWeek; 34 | } 35 | static currentWeek(){ 36 | return this.weeks[this.weekNumber()] ??= this.newWeek(); 37 | } 38 | static update(){ 39 | const playerCount = Groups.player.size(); 40 | this.currentWeek()[this.readingNumber()] = 41 | Math.max(playerCount, this.currentWeek()[this.readingNumber()]); 42 | } 43 | 44 | static exportRange(startDate = this.startDate, endDate = Date.now()){ 45 | if(typeof startDate !== "number") throw new Error('startDate should be a number'); 46 | const startWeek = this.weekNumber(startDate); 47 | const endWeek = this.weekNumber(endDate); 48 | return this.weeks.slice(startWeek, endWeek + 1).map((week, weekNumber) => 49 | week.filter(v => v >= 0).map((v, i) => [ 50 | v, 51 | this.startDate + 52 | weekNumber * this.millisPerWeek + 53 | i * this.millisBetweenReadings 54 | ] as [value:number, timestamp:number]) 55 | ).flat(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/mindustry.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains one single declaration from mindustryTypes.ts that cannot go in there. 4 | */ 5 | 6 | 7 | //Use a .d.ts to suppress the error from this override 8 | declare const Reflect: { 9 | get(thing:any, key:string):any; 10 | set(thing:any, key:string, value:any):void; 11 | } 12 | -------------------------------------------------------------------------------- /src/packetHandlers.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains the visual effects system. 4 | Original contributor: @author TheEt1234 5 | Fixes: @author BalaM314 6 | Fixes: @author Dart25 7 | Fixes: @author Jurorno9 8 | */ 9 | 10 | import { Perm, commandList } from './commands'; 11 | import { FishPlayer } from './players'; 12 | 13 | //some much needed restrictions 14 | /** point in which effects will refuse to render */ 15 | const MIN_EFFECT_TPS = 20; 16 | /** maximum duration for user-created labels (seconds) */ 17 | const MAX_LABEL_TIME = 20; 18 | 19 | //info tracker 20 | let lastLabel = ''; 21 | let lastAccessedBulkLabel:FishPlayer | null = null; 22 | let lastAccessedLabel:FishPlayer | null = null; 23 | let lastAccessedBulkLine:FishPlayer | null = null; 24 | let lastAccessedLine:FishPlayer | null = null; 25 | 26 | const bulkLimit = 1000; 27 | 28 | const noPermissionText = "[red]You don't have permission to use this packet."; 29 | const invalidContentText = '[red]Invalid label content.'; 30 | const tooLongText = '[red]Bulk content length exceeded, please use fewer effects.'; 31 | const bulkSeparator = '|'; 32 | const procError = '[red]An error occured while processing your request.'; 33 | const invalidReq = '[red]Invalid request. Please consult the documentation.'; 34 | const lowTPSError = '[red]Low server TPS, skipping request.' 35 | 36 | const tmpLinePacket = new EffectCallPacket2(); 37 | const tmpLabelPacket = new LabelReliableCallPacket(); 38 | 39 | export function loadPacketHandlers() { 40 | //initialize line packet 41 | tmpLinePacket.effect = Fx.pointBeam; 42 | tmpLinePacket.rotation = 0.0; 43 | tmpLinePacket.color = Tmp.c1; 44 | tmpLinePacket.data = Tmp.v1; 45 | 46 | //labels 47 | 48 | //fmt: "content,duration,x,y" 49 | Vars.netServer.addPacketHandler('label', (player:mindustryPlayer, content:string) => { 50 | const p = FishPlayer.get(player); 51 | try { 52 | if(Core.graphics.getFramesPerSecond() < MIN_EFFECT_TPS){ 53 | p.sendMessage(lowTPSError, 1000); 54 | return; 55 | } 56 | if (!p.hasPerm("visualEffects")) { 57 | p.sendMessage(noPermissionText, 1000); 58 | return; 59 | } 60 | 61 | lastAccessedLabel = p; 62 | 63 | handleLabel(player, content, true); 64 | } catch { 65 | p.sendMessage(procError, 1000); 66 | } 67 | }); 68 | 69 | Vars.netServer.addPacketHandler('bulkLabel', (player:mindustryPlayer, content:string) => { 70 | const p = FishPlayer.get(player); 71 | try { 72 | if(Core.graphics.getFramesPerSecond() < MIN_EFFECT_TPS){ 73 | p.sendMessage(lowTPSError, 1000); 74 | return; 75 | } 76 | if (!p.hasPerm('bulkVisualEffects')) { 77 | p.sendMessage(noPermissionText, 1000); 78 | return; 79 | } 80 | 81 | lastAccessedBulkLabel = p; 82 | 83 | //get individual labels 84 | const labels:string[] = []; 85 | let inQuotes = false; 86 | let startIdx = 0; 87 | 88 | for (let i = 0; i < content.length; i++) { 89 | switch (content[i]) { 90 | case '"': 91 | if (i > 0 && content[i-1] == '\\') break; 92 | inQuotes = !inQuotes; 93 | break; 94 | //separate 95 | case bulkSeparator: 96 | if (inQuotes) break; 97 | 98 | labels.push(content.substring(startIdx, i)); 99 | startIdx = i + 1; 100 | break; 101 | default: 102 | break; 103 | } 104 | } 105 | 106 | //last label 107 | if (startIdx < content.length) { 108 | labels.push(content.substring(startIdx, content.length - 1)); 109 | } 110 | 111 | if(labels.length > bulkLimit){ 112 | p.sendMessage(tooLongText, 1000); 113 | return; 114 | } 115 | 116 | //display labels 117 | for (let i = 0; i < labels.length; i++) { 118 | const label = labels[i]; 119 | if (label.trim().length <= 0) continue; 120 | if (!handleLabel(player, label, false)) return; 121 | } 122 | } catch { 123 | p.sendMessage(procError, 1000); 124 | } 125 | }); 126 | 127 | //lines 128 | Vars.netServer.addPacketHandler('lineEffect', (player:mindustryPlayer, content:string) => { 129 | const p = FishPlayer.get(player); 130 | try { 131 | if(Core.graphics.getFramesPerSecond() < MIN_EFFECT_TPS){ 132 | p.sendMessage(lowTPSError, 1000); 133 | return; 134 | } 135 | if (!p.hasPerm("visualEffects")) { 136 | p.sendMessage(noPermissionText, 1000); 137 | return; 138 | } 139 | 140 | if (!handleLine(content, player)) return; 141 | lastAccessedLine = p; 142 | } catch { 143 | p.sendMessage(procError, 1000); 144 | } 145 | }); 146 | 147 | //this is the silas effect but it's way too real 148 | Vars.netServer.addPacketHandler('bulkLineEffect', (player:mindustryPlayer, content:string) => { 149 | const p = FishPlayer.get(player); 150 | if(Core.graphics.getFramesPerSecond() < MIN_EFFECT_TPS){ 151 | p.sendMessage(lowTPSError, 1000); 152 | return; 153 | } 154 | if (!p.hasPerm('bulkVisualEffects')) { 155 | p.sendMessage(noPermissionText, 1000); 156 | return; 157 | } 158 | try { 159 | 160 | const lines = content.split(bulkSeparator); 161 | 162 | if(lines.length > bulkLimit){ 163 | p.sendMessage(tooLongText, 1000); 164 | return; 165 | } 166 | 167 | for (let i = 0; i < lines.length; i++) { 168 | const line = lines[i]; 169 | if (line.trim().length <= 0) continue; 170 | if (!handleLine(line, player)) return; 171 | } 172 | 173 | lastAccessedBulkLine = p; 174 | } catch { 175 | p.sendMessage(procError, 1000); 176 | } 177 | }); 178 | } 179 | 180 | //commands 181 | export const commands = commandList({ 182 | pklast: { 183 | args: [], 184 | description: 'Tells you who last accessed the packet handlers.', 185 | perm: Perm.none, 186 | handler({output}) { 187 | const outputLines:string[] = []; 188 | 189 | if (lastAccessedLabel && lastLabel) { 190 | outputLines.push(`${lastAccessedLabel.name}[white] created label "${lastLabel}".`); 191 | } 192 | if (lastAccessedBulkLabel) { 193 | outputLines.push(`${lastAccessedBulkLabel.name}[white] last used the bulk label effect.`); 194 | } 195 | if (lastAccessedLine) { 196 | outputLines.push(`${lastAccessedLine.name}[white] last used the line effect.`); 197 | } 198 | if (lastAccessedBulkLine) { 199 | outputLines.push(`${lastAccessedBulkLine.name}[white] last used the bulk line effect.`); 200 | } 201 | 202 | output(outputLines.length > 0 ? outputLines.join('\n') : 'No packet handlers have been accessed yet.'); 203 | } 204 | }, 205 | pkdocs: { 206 | description: 'Packet handler documentation.', 207 | args: [], 208 | perm: Perm.none, 209 | handler({sender, output}){ 210 | output( 211 | ` [blue]FISH[white] Packet Handler Docs 212 | [white]Usage:[accent] 213 | - Run the javascript function "Call.serverPacketReliable()" to send these. (!js in foos) 214 | - You need to multiply world coordinates by Vars.tilesize (8) for things to work properly. This is a relic from the v3 days where every tile was 8 pixels. 215 | 216 | [white]Packet types[accent]: 217 | - Line effect: "lineEffect", "x0,y0,x1,y1,hexColor" (for example "20.7,19.3,50.4,28.9,#FF0000") 218 | - Bulk line effect: "bulkLineEffect", equivalent to multiple lineEffect packets, with every line separated by a \'|\' symbol. 219 | - Label effect: "label", "content,duration,x,y" (for example ""Hi!",10,20,28") 220 | - Bulk label effect: "bulkLabel", equivalent to multiple label packets, with every label separated by a \'|\' symbol. 221 | 222 | [white]Limitations[accent]: 223 | - You ${(sender.hasPerm('bulkVisualEffects')?(`[green]have been granted[accent]`):(`[red]do not have[accent]`))} access to bulk effects. 224 | - Effects will no longer be drawn at ${MIN_EFFECT_TPS} for server preformance. 225 | - Labels cannot last longer than ${MAX_LABEL_TIME} seconds. 226 | - There is a set ratelimit for sending packets, be careful ... 227 | 228 | [white]Starter Example[accent]: 229 | 230 | To place a label saying "hello" at (0,0); 231 | Foos users: [lightgray]!js Call.serverPacketReliable("label", ["\\"hello\\"", 10, 0, 0].join(","))[accent] 232 | newConsole users: [lightgrey]Call.serverPacketReliable("label", ["hello", 10, 0, 10].join(","))[accent] 233 | 234 | [white]Comments and Credits[accent]: 235 | - 'These packet handlers and everything related to them were made by [green]frog[accent]. 236 | - 'The code style when submitted was beyond drunk... but it worked... barely' -BalaM314 237 | - "worst error handling i have ever seen, why kick the player???" -ASimpleBeginner' 238 | - Most of the code was rewritten in 2024 by [#6e00fb]D[#9e15de]a[#cd29c2]r[#fd3ea5]t[accent].' 239 | - Small tweaks by [#00cf]s[#00bf]w[#009f]a[#007f]m[#005f]p[accent]`) 240 | } 241 | } 242 | }); 243 | 244 | //#region utils 245 | 246 | function findEndQuote(content:string, startPos:number) { 247 | if (content[startPos] != '"') { 248 | //not a start quote?? 249 | return -1; 250 | } 251 | 252 | for (let i = startPos + 1; i < content.length; i++) { 253 | if (content[i] == '"' && (i < 1 || content[i-1] != '\\')) { 254 | return i; 255 | } 256 | } 257 | 258 | return -1; 259 | } 260 | 261 | function handleLabel(player:mindustryPlayer, content:string, isSingle:boolean):boolean { 262 | const endPos = findEndQuote(content, 0); 263 | if (endPos == -1) { 264 | //invalid content 265 | player.sendMessage(invalidContentText); 266 | return false; 267 | } 268 | 269 | //label, clean up \"s 270 | const message = content.substring(1, endPos).replace('\\"', '"'); 271 | const parts = content.substring(endPos + 2).split(','); 272 | 273 | if (parts.length != 3) { //dur,x,y 274 | player.sendMessage(invalidReq); 275 | return false; 276 | } 277 | 278 | if (isSingle) { 279 | lastLabel = message; 280 | } 281 | 282 | let duration = Number(parts[0]); 283 | const x = Number(parts[1]), y = Number(parts[2]); 284 | if(Number.isNaN(duration) || duration > MAX_LABEL_TIME || Number.isNaN(x) || Number.isNaN(y)){ 285 | player.sendMessage(invalidReq); 286 | return false; 287 | } 288 | 289 | /*Call.labelReliable( 290 | message, //message 291 | Number(parts[0]), //duration 292 | Number(parts[1]), //x 293 | Number(parts[2]) //y 294 | );*/ 295 | tmpLabelPacket.message = message; 296 | tmpLabelPacket.duration = duration; 297 | tmpLabelPacket.worldx = x; 298 | tmpLabelPacket.worldy = y; 299 | Vars.net.send(tmpLabelPacket, false); 300 | return true; 301 | } 302 | 303 | function handleLine(content:string, player:mindustryPlayer):boolean { 304 | const parts = content.split(','); 305 | 306 | if (parts.length != 5) { //x0,y0,x1,y1,color 307 | player.sendMessage(invalidReq); 308 | return false; 309 | } 310 | 311 | Tmp.v1.set(Number(parts[2]), Number(parts[3])); //x1,y1 312 | Color.valueOf(Tmp.c1, parts[4]); //color 313 | 314 | /*Call.effect( 315 | Fx.pointBeam, 316 | Number(parts[0]), Number(parts[1]), //x,y 317 | 0, Tmp.c1, //color 318 | Tmp.v1 //x1,y1 319 | );*/ 320 | tmpLinePacket.x = Number(parts[0]); 321 | tmpLinePacket.y = Number(parts[1]); 322 | Vars.net.send(tmpLinePacket, false); 323 | 324 | return true; 325 | } 326 | 327 | export function bulkInfoMsg(messages:string[], conn:NetConnection) { 328 | for (let i = messages.length - 1; i >= 0; i--) { 329 | Call.infoMessage(conn, messages[i]); 330 | } 331 | } 332 | 333 | 334 | //#endregion -------------------------------------------------------------------------------- /src/promise.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains a custom (bad) polyfill for promises with slightly different behavior. 4 | */ 5 | 6 | 7 | export function queueMicrotask(callback:() => unknown, errorHandler:(err:unknown) => unknown = (err) => { 8 | Log.err("Uncaught (in promise)"); 9 | Log.err(err); 10 | }){ 11 | Core.app.post(() => { 12 | try { 13 | callback(); 14 | } catch(err){ 15 | errorHandler(err); 16 | } 17 | }); 18 | } 19 | 20 | /** 21 | * Differences from normal promises: 22 | * If a called-later handler throws an error, it will print an error to the console, and will not call the reject handler. 23 | */ 24 | export class Promise { 25 | private state: ["resolved", TResolve] | ["rejected", TReject] | ["pending"] = ["pending"]; 26 | private resolveHandlers: ((value:TResolve) => unknown)[] = []; 27 | private rejectHandlers: ((value:TReject) => unknown)[] = []; 28 | constructor(initializer:( 29 | resolve: (value:TResolve) => void, 30 | reject: (error:TReject) => void, 31 | ) => void){ 32 | initializer( 33 | (value) => { 34 | this.state = ["resolved", value]; 35 | queueMicrotask(() => this.resolve()); 36 | }, 37 | (error) => { 38 | this.state = ["rejected", error]; 39 | queueMicrotask(() => this.reject()); 40 | } 41 | ); 42 | } 43 | private resolve(){ 44 | const state = this.state as ["resolved", TResolve]; 45 | this.resolveHandlers.forEach(h => h(state[1])); 46 | } 47 | private reject(){ 48 | const state = this.state as ["rejected", TReject]; 49 | this.rejectHandlers.forEach(h => h(state[1])); 50 | } 51 | then( 52 | onFulfilled:((value:TResolve) => (UResolve | Promise)), 53 | ):Promise; 54 | then( 55 | onFulfilled?:((value:TResolve) => (UResolve1 | Promise)) | null | undefined, 56 | onRejected?:((error:TReject) => (UResolve2 | Promise)) | null | undefined, 57 | ):Promise; 58 | then( 59 | onFulfilled?:((value:TResolve) => (UResolve1 | Promise)) | null | undefined, 60 | onRejected?:((error:TReject) => (UResolve2 | Promise)) | null | undefined 61 | ){ 62 | const {promise, resolve, reject} = Promise.withResolvers(); 63 | if(onFulfilled){ 64 | this.resolveHandlers.push(value => { 65 | const result = onFulfilled(value); 66 | if(result instanceof Promise){ 67 | result.then(nextResult => resolve(nextResult)); 68 | } else { 69 | resolve(result); 70 | } 71 | }); 72 | } 73 | if(onRejected){ 74 | this.rejectHandlers.push( 75 | value => { 76 | const result = onRejected(value); 77 | if(result instanceof Promise){ 78 | result.then(nextResult => resolve(nextResult)); 79 | } else { 80 | resolve(result); 81 | } 82 | } 83 | ); 84 | } 85 | return promise; 86 | } 87 | catch(onRejected:(error:TReject) => (UResolve | Promise)){ 88 | const {promise, resolve, reject} = Promise.withResolvers(); 89 | this.rejectHandlers.push( 90 | value => { 91 | const result = onRejected(value); 92 | if(result instanceof Promise){ 93 | result.then(nextResult => resolve(nextResult)); 94 | } else { 95 | resolve(result); 96 | } 97 | } 98 | ); 99 | //If the original promise resolves successfully, the new one also needs to resolve 100 | this.resolveHandlers.push( 101 | value => resolve(value) 102 | ); 103 | return promise; 104 | } 105 | static withResolvers(){ 106 | let resolve!:(value:TResolve) => void; 107 | let reject!:(error:TReject) => void; 108 | const promise = new Promise((r, j) => { 109 | resolve = r; 110 | reject = j; 111 | }) 112 | return { 113 | promise, resolve, reject 114 | }; 115 | } 116 | static all( 117 | promises:{ 118 | [K in keyof TResolves]: Promise; 119 | } 120 | ):Promise { 121 | const {promise, resolve, reject} = Promise.withResolvers(); 122 | const outputs = new Array(promises.length); 123 | let resolutions = 0; 124 | promises.map((p, i) => 125 | p.then(v => { 126 | outputs[i] = v; 127 | resolutions ++; 128 | if(resolutions == promises.length) resolve(outputs as TResolves); 129 | }) 130 | ); 131 | return promise; 132 | } 133 | static resolve(value:TResolve):Promise { 134 | return new Promise((resolve) => resolve(value)); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/ranks.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains the definitions for ranks and role flags. 4 | */ 5 | 6 | import type { SelectEnumClassKeys } from "./types"; 7 | 8 | /** Each player has one rank, which is used to determine their prefix, permissions, and which other players they can perform moderation actions on. */ 9 | export class Rank { 10 | static ranks:Record = {}; 11 | static autoRanks: Rank[] = []; 12 | 13 | static player = new Rank("player", 0, "Ordinary players.", "", "&lk[p]&fr", ""); 14 | static active = new Rank("active", 1, "Assigned automatically to players who have played for some time.", "[black]<[forest]\uE800[]>[]", "&lk[a]&fr", "[forest]", { 15 | joins: 50, 16 | playtime: 24 * 60 * 60 * 1000, //24 hours 17 | blocksPlaced: 5000, 18 | timeSinceFirstJoin: 24 * 60 * 60 * 1000 * 7, //7 days 19 | }); 20 | static trusted = new Rank("trusted", 2, "Trusted players who have gained the trust of a mod or admin.", "[black]<[#E67E22]\uE813[]>[]", "&y[T]&fr", "[#E67E22]"); 21 | static mod = new Rank("mod", 3, "Moderators who can mute, stop, and kick players.", "[black]<[#6FFC7C]\uE817[]>[]", "&lg[M]&fr", "[#6FFC7C]"); 22 | static admin = new Rank("admin", 4, "Administrators with the power to ban players.", "[black]<[cyan]\uE82C[]>[]", "&lr[A]&fr", "[cyan]"); 23 | static manager = new Rank("manager", 10, "Managers have file and console access.", "[black]<[scarlet]\uE88E[]>[]", "&c[E]&fr", "[scarlet]"); 24 | static pi = new Rank("pi", 11, "3.14159265358979323846264338327950288419716 (manager)", "[black]<[#FF8000]\u03C0[]>[]", "&b[+]&fr", "[blue]");//i want pi rank 25 | static fish = new Rank("fish", 999, "Owner.", "[blue]>|||>[] ", "&b[F]&fr", "[blue]"); 26 | 27 | autoRankData?: { 28 | joins: number; 29 | playtime: number; 30 | blocksPlaced: number; 31 | timeSinceFirstJoin: number; 32 | chatMessagesSent: number; 33 | } 34 | 35 | constructor( 36 | public name:string, 37 | /** Used to determine whether a rank outranks another. */ public level:number, 38 | public description:string, 39 | public prefix:string, 40 | public shortPrefix:string, 41 | public color:string, 42 | autoRankData?: Partial, 43 | ){ 44 | Rank.ranks[name] = this; 45 | if(autoRankData){ 46 | this.autoRankData = { 47 | joins: autoRankData.joins ?? 0, 48 | playtime: autoRankData.playtime ?? 0, 49 | blocksPlaced: autoRankData.blocksPlaced ?? 0, 50 | timeSinceFirstJoin: autoRankData.timeSinceFirstJoin ?? 0, 51 | chatMessagesSent: autoRankData.chatMessagesSent ?? 0, 52 | }; 53 | Rank.autoRanks.push(this); 54 | } 55 | } 56 | static getByName(name:string):Rank | null { 57 | return Rank.ranks[name] ?? null; 58 | } 59 | static getByInput(input:string):Rank[] { 60 | return Object.values(Rank.ranks).filter(rank => rank.name.toLowerCase().includes(input.toLowerCase())); 61 | } 62 | coloredName(){ 63 | return this.color + this.name + "[]"; 64 | } 65 | } 66 | Object.freeze(Rank.pi); //anti-trolling 67 | export type RankName = SelectEnumClassKeys; 68 | 69 | /** 70 | * Role flags are used to determine a player's prefix and permissions. 71 | * Players can have any combination of the role flags. 72 | */ 73 | export class RoleFlag { 74 | static flags:Record = {}; 75 | static developer = new RoleFlag("developer", "[black]<[#B000FF]\uE80E[]>[]", "Awarded to people who contribute to the server's codebase.", "[#B000FF]", true, false); 76 | static member = new RoleFlag("member", "[black]<[yellow]\uE809[]>[]", "Awarded to our awesome donors who support the server.", "[pink]", true, false); 77 | static illusionist = new RoleFlag("illusionist", "", "Assigned to to individuals who have earned access to enhanced visual effect features.","[lightgrey]", true, true); 78 | static chief_map_analyst = new RoleFlag("chief map analyst", "[black]<[#5800FF]\uE833[]>[]", "Assigned to the chief map analyst, who oversees map management.","[#5800FF]", true, true); 79 | static no_effects = new RoleFlag("no_effects", "", "Given to people who have abused the visual effects.", "", true, true); 80 | constructor( 81 | public name:string, 82 | public prefix:string, 83 | public description:string, 84 | public color:string, 85 | public peristent:boolean = true, 86 | public assignableByModerators = true, 87 | ){RoleFlag.flags[name] = this;} 88 | static getByName(name:string):RoleFlag | null { 89 | return RoleFlag.flags[name] ?? null; 90 | } 91 | static getByInput(input:string):RoleFlag[] { 92 | return Object.values(RoleFlag.flags).filter(flag => flag.name.toLowerCase().includes(input.toLowerCase())); 93 | } 94 | coloredName(){ 95 | return this.color + this.name + "[]"; 96 | } 97 | } 98 | export type RoleFlagName = SelectEnumClassKeys; 99 | -------------------------------------------------------------------------------- /src/rhino-env.d.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | Copyright © BalaM314, 2025. All Rights Reserved. 4 | This file contains the require() function. The tests will not import this file. 5 | */ 6 | 7 | function require(id: string): any; 8 | -------------------------------------------------------------------------------- /src/timers.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains timers that run code at regular intervals. 4 | */ 5 | 6 | import { getStaffMessages } from './api'; 7 | import * as config from "./config"; 8 | import { Gamemode } from "./config"; 9 | import { updateMaps } from './files'; 10 | import { FishEvents, ipJoins } from "./globals"; 11 | import { FishPlayer } from "./players"; 12 | import { definitelyRealMemoryCorruption, neutralGameover } from "./utils"; 13 | 14 | 15 | /** Must be called once, and only once, on server start. */ 16 | export function initializeTimers(){ 17 | Timer.schedule(() => { 18 | //Autosave 19 | const file = Vars.saveDirectory.child('1' + '.' + Vars.saveExtension); 20 | Core.app.post(() => { 21 | SaveIO.save(file); 22 | FishPlayer.saveAll(); 23 | Call.sendMessage('[#4fff8f9f]Game saved.'); 24 | FishEvents.fire("saveData", []); 25 | }); 26 | //Unblacklist trusted players 27 | for(const fishP of Object.values(FishPlayer.cachedPlayers)){ 28 | if(fishP.ranksAtLeast("trusted")){ 29 | Vars.netServer.admins.dosBlacklist.remove(fishP.info().lastIP); 30 | } 31 | } 32 | }, 10, 300); 33 | //Memory corruption prank 34 | Timer.schedule(() => { 35 | if(Math.random() < 0.2 && !Gamemode.hexed()){ 36 | //Timer triggers every 17 hours, and the random chance is 20%, so the average interval between pranks is 85 hours 37 | definitelyRealMemoryCorruption(); 38 | } 39 | }, 3600, 61200); 40 | //Trails 41 | Timer.schedule(() => 42 | FishPlayer.forEachPlayer(p => p.displayTrail()), 43 | 5, 0.15); 44 | //Staff chat 45 | if(!config.Mode.localDebug) 46 | Timer.schedule(() => { 47 | getStaffMessages((messages) => { 48 | if(messages.length) FishPlayer.messageStaff(messages); 49 | }) 50 | }, 5, 2); 51 | //Tip 52 | Timer.schedule(() => { 53 | const showAd = Math.random() < 0.10; //10% chance every 15 minutes 54 | let messagePool = showAd ? config.tips.ads : (config.Mode.isChristmas && Math.random() > 0.5) ? config.tips.christmas : config.tips.normal; 55 | const messageText = messagePool[Math.floor(Math.random() * messagePool.length)]; 56 | const message = showAd ? `[gold]${messageText}[]` : `[gold]Tip: ${messageText}[]` 57 | Call.sendMessage(message); 58 | }, 60, 15 * 60); 59 | //State check 60 | Timer.schedule(() => { 61 | if(Groups.unit.size() > 10000){ 62 | Call.sendMessage(`\n[scarlet]!!!!!\n[scarlet]Way too many units! Game over!\n[scarlet]!!!!!\n`); 63 | Groups.unit.clear(); 64 | neutralGameover(); 65 | } 66 | }, 0, 1); 67 | Timer.schedule(() => { 68 | FishPlayer.updateAFKCheck(); 69 | }, 0, 1); 70 | //Various bad antibot code TODO fix, dont update state on clock tick 71 | Timer.schedule(() => { 72 | FishPlayer.antiBotModePersist = false; 73 | //dubious code, will keep antibot mode on for the next minute after it was triggered by high flag count or high join count 74 | if(FishPlayer.flagCount > 10 || FishPlayer.playersJoinedRecent > 50) FishPlayer.antiBotModePersist = true; 75 | FishPlayer.flagCount = 0; 76 | ipJoins.clear(); 77 | }, 0, 60); 78 | Timer.schedule(() => { 79 | if(FishPlayer.playersJoinedRecent > 50) FishPlayer.antiBotModePersist = true; 80 | FishPlayer.playersJoinedRecent = 0; 81 | }, 0, 40); 82 | Timer.schedule(() => { 83 | if(FishPlayer.antiBotMode()){ 84 | Call.infoToast(`[scarlet]ANTIBOT ACTIVE!!![] DOS blacklist size: ${Vars.netServer.admins.dosBlacklist.size}`, 2); 85 | } 86 | }, 0, 1); 87 | Timer.schedule(() => { 88 | FishPlayer.validateVotekickSession(); 89 | }, 0, 0.5); 90 | } 91 | Timer.schedule(() => { 92 | updateMaps() 93 | .then((result) => { 94 | if(result){ 95 | Call.sendMessage(`[orange]Maps have been updated. Run [white]/maps[] to view available maps.`); 96 | Log.info(`Updated maps.`); 97 | } 98 | }) 99 | .catch((message) => { 100 | Call.sendMessage(`[scarlet]Automated maps update failed, please report this to a staff member.`); 101 | Log.err(`Automated map update failed: ${message}`); 102 | }); 103 | }, 60, 600) -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains type definitions that are shared across files. 4 | */ 5 | 6 | import type { CommandArgType, FFunction, Perm } from "./commands"; 7 | import type { FishPlayer } from "./players"; 8 | import type { Rank, RoleFlag } from "./ranks"; 9 | 10 | /** 11 | * Selects the type of the string keys of an enum-like class, like this: 12 | * ``` 13 | * class Foo { 14 | * static foo1 = new Foo("foo1"); 15 | * static foo2 = new Foo("foo2"); 16 | * static foo3 = new Foo("foo3"); 17 | * constructor( 18 | * public bar: string, 19 | * ){} 20 | * } 21 | * type __ = SelectEnumClassKeys; //=> "foo1" | "foo2" | "foo3" 22 | * ``` 23 | */ 24 | export type SelectEnumClassKeys = Key extends unknown ? ( //trigger DCT 27 | C[Key] extends C["prototype"] ? //if C[Key] is a C 28 | Key extends "prototype" ? never : Key //and Key is not the string "prototype", return it 29 | : never 30 | ) : never; 31 | 32 | export type FishCommandArgType = TypeOfArgType | undefined; 33 | 34 | /** Maps an arg type string to the TS type used to store it. Example: returns `number` for "time". */ 35 | export type TypeOfArgType = 36 | T extends "string" ? string : 37 | T extends "boolean" ? boolean : 38 | T extends "number" ? number : 39 | T extends "time" ? number : 40 | T extends "team" ? Team : 41 | T extends "player" ? FishPlayer : 42 | T extends "exactPlayer" ? FishPlayer : 43 | T extends "offlinePlayer" ? FishPlayer : 44 | T extends "unittype" ? UnitType : 45 | T extends "block" ? Block : 46 | T extends "uuid" ? string : 47 | T extends "map" ? MMap : 48 | T extends "rank" ? Rank : 49 | T extends "roleflag" ? RoleFlag : 50 | T extends "item" ? Item : 51 | never; 52 | 53 | /** 54 | * Returns the type of args given a union of the arg string types. 55 | * Example: given `"player:player?" | "force:boolean"` returns `{player: FishPlayer | null; force: boolean;}` 56 | **/ 57 | export type ArgsFromArgStringUnion = { 58 | [Arg in ArgStringUnion as KeyFor]: ValueFor; 59 | }; 60 | 61 | /** Reads the key from an arg string. */ 62 | export type KeyFor = ArgString extends `${infer K}:${string}` ? K : never; 63 | /** Reads the value from an arg string, and determines whether it is optional. */ 64 | export type ValueFor = 65 | //optional 66 | ArgString extends `${string}:${infer V}?` ? TypeOfArgType | undefined : 67 | //required 68 | ArgString extends `${string}:${infer V}` ? TypeOfArgType : 69 | never; 70 | 71 | export type TapHandleMode = "off" | "once" | "on"; 72 | 73 | /** Anything that can be formatted by the `f` tagged template function. */ 74 | export type Formattable = FishPlayer | Rank | RoleFlag | Error | mindustryPlayer | string | boolean | number | PlayerInfo | UnitType | Block | Team | Item; 75 | /** 76 | * A message that requires some other data to complete it. 77 | * For example, format string cannot be fully interpolated without knowing their start color, 78 | * so they return a function that accepts that information. 79 | */ 80 | export type PartialFormatString = ((data:TData) => string) & {__partialFormatString:true}; 81 | /** The data passed to a command handler. */ 82 | export type FishCommandHandlerData = { 83 | /** Raw arguments that were passed to the command. */ 84 | rawArgs:(string | undefined)[]; 85 | /** 86 | * Formatted and parsed args. Access an argument by name, like python's keyword args. 87 | * Example: `args.player.setRank(Rank.mod);`. 88 | * An argument can only be null if it was declared optional, otherwise the command will error before the handler runs. 89 | */ 90 | args:Expand>; 91 | /** The player who ran the command. */ 92 | sender:FishPlayer; 93 | /** Arbitrary data specific to the command. */ 94 | data:StoredData; 95 | currentTapMode:TapHandleMode; 96 | /** List of every registered command, including this one. */ 97 | allCommands:Record>; 98 | /** Timestamp of the last time this command was run successfully by any player. */ 99 | lastUsedSuccessfully:number; 100 | /** Timestamp of the last time this command was run by the current sender. */ 101 | lastUsedSender:number; 102 | /** Timestamp of the last time this command was run succesfully by the current sender. */ 103 | lastUsedSuccessfullySender:number; 104 | }; 105 | /** The utility functions passed to a command handler. */ 106 | export type FishCommandHandlerUtils = { 107 | /** Vars.netServer.admins */ 108 | admins: Administration; 109 | /** Outputs text to the sender, with a check mark symbol and green color. */ 110 | outputSuccess(message:string | PartialFormatString):void; 111 | /** Outputs text to the sender, with a fail symbol and yellow color. */ 112 | outputFail(message:string | PartialFormatString):void; 113 | /** Outputs text to the sender. Tab characters are replaced with 4 spaces. */ 114 | output(message:string | PartialFormatString):void; 115 | /** Use to tag template literals, formatting players, numbers, ranks, and more */ 116 | f:FFunction; 117 | /** Executes a server console command. Be careful! */ 118 | execServer(message:string):void; 119 | /** Call this function to set tap handling mode. */ 120 | handleTaps(mode:TapHandleMode):void; 121 | }; 122 | export type FishCommandHandler = 123 | (fish:FishCommandHandlerData & FishCommandHandlerUtils) => void | Promise; 124 | 125 | export interface FishConsoleCommandRunner { 126 | (_:{ 127 | /** Raw arguments that were passed to the command. */ 128 | rawArgs:(string | undefined)[]; 129 | /** 130 | * Formatted and parsed args. 131 | * Access an argument by name, like python's keyword args. 132 | * Example: `args.player.mod = true`. 133 | * An argument can only be null if it was optional, otherwise the command will error before the handler runs. 134 | **/ 135 | args:ArgsFromArgStringUnion; 136 | data:StoredData; 137 | /** Outputs text to the console. */ 138 | outputSuccess(message:string | PartialFormatString):void; 139 | /** Outputs text to the console, using warn(). */ 140 | outputFail(message:string | PartialFormatString):void; 141 | /** Outputs text to the console. Tab characters are replaced with 4 spaces. */ 142 | output(message:string | PartialFormatString):void; 143 | /** Use to tag template literals, formatting players, numbers, ranks, and more */ 144 | f:FFunction; 145 | /** Executes a server console command. Be careful to not commit recursion as that will cause a crash.*/ 146 | execServer(message:string):void; 147 | /** Vars.netServer.admins */ 148 | admins: Administration; 149 | /** Timestamp of the last time this command was run. */ 150 | lastUsed:number; 151 | /** Timestamp of the last time this command was run succesfully. */ 152 | lastUsedSuccessfully:number; 153 | }): unknown; 154 | } 155 | 156 | export interface TapHandler { 157 | (_:{ 158 | /** Last args used to call the parent command. */ 159 | args:ArgsFromArgStringUnion; 160 | sender:FishPlayer; 161 | x:number; 162 | y:number; 163 | tile:Tile; 164 | data:StoredData; 165 | output(message:string | PartialFormatString):void; 166 | outputFail(message:string | PartialFormatString):void; 167 | outputSuccess(message:string | PartialFormatString):void; 168 | /** Use to tag template literals, formatting players, numbers, ranks, and more */ 169 | f:TagFunction; 170 | /** Timestamp of the last time this command was run. */ 171 | commandLastUsed:number; 172 | /** Timestamp of the last time this command was run succesfully. */ 173 | commandLastUsedSuccessfully:number; 174 | /** Vars.netServer.admins */ 175 | admins: Administration; 176 | /** Timestamp of the last time this tap handler was run. */ 177 | lastUsed:number; 178 | /** Timestamp of the last time this tap handler was run succesfully. (without fail() being called) */ 179 | lastUsedSuccessfully:number; 180 | }):unknown; 181 | } 182 | 183 | export type FishCommandRequirement = (data:FishCommandHandlerData) => unknown; 184 | 185 | export interface FishCommandData { 186 | /** Args for this command, like ["player:player", "reason:string?"] */ 187 | args: ArgType[]; 188 | description: string; 189 | /** 190 | * Permission level required for players to run this command. 191 | * If the player does not have this permission, the handler is not run and an error message is printed. 192 | **/ 193 | perm: Perm; 194 | /** Custom error message for unauthorized players. The default is `You do not have the required permission (mod) to execute this command`. */ 195 | customUnauthorizedMessage?: string; 196 | /** Called exactly once at server start. Use this to add event handlers. */ 197 | init?: () => StoredData; 198 | data?: StoredData; 199 | requirements?: NoInfer>[]; 200 | handler: FishCommandHandler; 201 | tapped?: TapHandler; 202 | /** If true, this command is hidden and pretends to not exist for players that do not have access to it.. */ 203 | isHidden?: boolean; 204 | } 205 | export interface FishConsoleCommandData { 206 | /** Args for this command, like ["player:player", "reason:string?"] */ 207 | args: ArgType[]; 208 | description: string; 209 | /** Called exactly once at server start. Use this to add event handlers. */ 210 | init?: () => StoredData; 211 | data?: StoredData; 212 | handler: FishConsoleCommandRunner; 213 | } 214 | 215 | 216 | export interface TileHistoryEntry { 217 | name:string; 218 | action:string; 219 | type:string; 220 | time:number; 221 | } 222 | 223 | 224 | export interface FishPlayerData { 225 | uuid: string; 226 | name: string; 227 | muted: boolean; 228 | autoflagged: boolean; 229 | unmarkTime: number; 230 | rank: string; 231 | flags: string[]; 232 | highlight: string | null; 233 | rainbow: { speed:number; } | null; 234 | history: PlayerHistoryEntry[]; 235 | usid: string | Partial> | null; 236 | chatStrictness: "chat" | "strict"; 237 | lastJoined: number; 238 | firstJoined: number; 239 | stats: { 240 | blocksBroken: number; 241 | blocksPlaced: number; 242 | timeInGame: number; 243 | chatMessagesSent: number; 244 | gamesFinished: number; 245 | gamesWon: number; 246 | }; 247 | showRankPrefix: boolean; 248 | } 249 | 250 | export interface PlayerHistoryEntry { 251 | action:string; 252 | by:string; 253 | time:number; 254 | } 255 | 256 | export interface ClientCommandHandler { 257 | register(name:string, args:string, description:string, runner:(args:string[], player:mindustryPlayer) => unknown):void; 258 | removeCommand(name:string):void; 259 | } 260 | 261 | export interface ServerCommandHandler { 262 | /** Executes a server console command. */ 263 | handleMessage(command:string):void; 264 | register(name:string, args:string, description:string, runner:(args:string[], player:mindustryPlayer) => unknown):void; 265 | removeCommand(name:string):void; 266 | } 267 | 268 | export interface PreprocessedCommandArg { 269 | type: CommandArgType; 270 | /** Whether the argument is optional (and may be null) */ 271 | optional?: boolean; 272 | } 273 | 274 | export type PreprocessedCommandArgs = Record; 275 | 276 | export interface CommandArg { 277 | name: string; 278 | type: CommandArgType; 279 | isOptional: boolean; 280 | } 281 | 282 | export interface FlaggedIPData { 283 | name: string; 284 | uuid: string; 285 | ip: string; 286 | moderated: boolean; 287 | }; 288 | 289 | export type Boolf = (input:T) => boolean; 290 | export type Expand = T extends Function ? T : { [K in keyof T]: T[K] }; 291 | 292 | export interface TagFunction { 293 | (stringChunks: readonly string[], ...varChunks: readonly Tin[]):Tout; 294 | } 295 | -------------------------------------------------------------------------------- /src/votes.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © BalaM314, 2025. All Rights Reserved. 3 | This file contains the voting system. 4 | Some contributions: @author Jurorno9 5 | */ 6 | 7 | import { FishPlayer } from "./players"; 8 | import { crash } from "./funcs"; 9 | import { EventEmitter } from "./funcs"; 10 | 11 | /** Event data for each voting event. */ 12 | export type VoteEventMapping = { 13 | "success": [forced:boolean]; 14 | "fail": [forced:boolean]; 15 | "vote passed": [votes:number, required:number]; 16 | "vote failed": [votes:number, required:number]; 17 | "player vote": [player:FishPlayer, current:number]; 18 | "player vote change": [player:FishPlayer, previous:number, current:number]; 19 | "player vote removed": [player:FishPlayer, previous:number]; 20 | }; 21 | export type VoteEvent = keyof VoteEventMapping; 22 | export type VoteEventData = VoteEventMapping[T]; 23 | 24 | /** Manages a vote. */ 25 | export class VoteManager extends EventEmitter { 26 | 27 | /** The ongoing voting session, if there is one. */ 28 | session: { 29 | data: SessionData; 30 | votes: Map; 31 | timer: TimerTask; 32 | } | null = null; 33 | 34 | constructor( 35 | public voteTime:number, 36 | public goal:["fractionOfVoters", number] | ["absolute", number] = ["fractionOfVoters", 0.50001], 37 | public isEligible:(fishP:FishPlayer, data: SessionData) => boolean = () => true 38 | ){ 39 | super(); 40 | if(goal[0] == "fractionOfVoters"){ 41 | if(goal[1] < 0 || goal[1] > 1) crash(`Invalid goal: fractionOfVoters must be between 0 and 1 inclusive`); 42 | } else if(goal[0] == "absolute"){ 43 | if(goal[1] < 0) crash(`Invalid goal: absolute must be greater than 0`); 44 | } 45 | Events.on(EventType.PlayerLeave, ({player}) => { 46 | //Run once the player has been removed, but resolve the player first in case the connection gets nulled 47 | const fishP = FishPlayer.get(player); 48 | Core.app.post(() => this.unvote(fishP)); 49 | }); 50 | Events.on(EventType.GameOverEvent, () => this.resetVote()); 51 | } 52 | 53 | start(player:FishPlayer, newVote:number, data:SessionData){ 54 | if(data === null) crash(`Cannot start vote: data not provided`); 55 | this.session = { 56 | timer: Timer.schedule(() => this._checkVote(false), this.voteTime / 1000), 57 | votes: new Map(), 58 | data, 59 | }; 60 | this.vote(player, newVote, data); 61 | } 62 | 63 | vote(player:FishPlayer, newVote:number, data:SessionData | null){ 64 | if(!this.session) return this.start(player, newVote, data!); 65 | const oldVote = this.session.votes.get(player.uuid); 66 | this.session.votes.set(player.uuid, newVote); 67 | if(oldVote == null) this.fire("player vote", [player, newVote]); 68 | this.fire("player vote change", [player, oldVote ?? 0, newVote]); 69 | this._checkVote(false); 70 | } 71 | 72 | unvote(player:FishPlayer){ 73 | if(!this.session) return; 74 | const fishP = FishPlayer.resolve(player); 75 | const vote = this.session.votes.get(fishP.uuid); 76 | if(vote){ 77 | this.session.votes.delete(fishP.uuid); 78 | this.fire("player vote removed", [player, vote]); 79 | this._checkVote(false); 80 | } 81 | } 82 | 83 | /** Does not fire the events used to display messages, please print one before calling this */ 84 | forceVote(outcome:boolean){ 85 | if(outcome){ 86 | this.fire("success", [true]); 87 | } else { 88 | this.fire("fail", [true]); 89 | } 90 | this.resetVote(); 91 | } 92 | 93 | resetVote(){ 94 | if(this.session == null) return; 95 | this.session.timer.cancel(); 96 | this.session = null; 97 | } 98 | 99 | requiredVotes():number { 100 | if(this.goal[0] == "absolute") 101 | return this.goal[1]; 102 | else 103 | return Math.max(Math.ceil(this.goal[1] * this.getEligibleVoters().length), 1); 104 | } 105 | 106 | currentVotes():number { 107 | return this.session ? [...this.session.votes].reduce((acc, [k, v]) => acc + v, 0) : 0; 108 | } 109 | 110 | getEligibleVoters():FishPlayer[] { 111 | if(!this.session) return []; 112 | return FishPlayer.getAllOnline().filter(p => this.isEligible(p, this.session!.data)); 113 | } 114 | messageEligibleVoters(message:string){ 115 | this.getEligibleVoters().forEach(p => p.sendMessage(message)); 116 | } 117 | _checkVote(end:boolean){ 118 | const votes = this.currentVotes(); 119 | const required = this.requiredVotes(); 120 | if(votes >= required){ 121 | this.fire("success", [false]); 122 | this.fire("vote passed", [votes, required]); 123 | this.resetVote(); 124 | } else if(end){ 125 | this.fire("fail", [false]); 126 | this.fire("vote failed", [votes, required]); 127 | this.resetVote(); 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "module": "CommonJS", 5 | "moduleDetection": "force", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "lib": ["ES2022"], 10 | "downlevelIteration": true, //we are targeting rhino and it supports array iterators 11 | "newLine": "crlf", 12 | "outDir": "build/scripts", 13 | "isolatedModules": true, 14 | "composite": true, 15 | "rootDir": "./src/", 16 | "types": [] 17 | }, 18 | "include": [ 19 | "./src/**.ts", 20 | ] 21 | } 22 | --------------------------------------------------------------------------------