diff --git a/.vscode/new_achievement.code-snippets b/.vscode/new_achievement.code-snippets new file mode 100644 index 00000000..1de08eb3 --- /dev/null +++ b/.vscode/new_achievement.code-snippets @@ -0,0 +1,27 @@ +{ + // Place your fish-commands workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and + // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope + // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is + // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: + // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. + // Placeholders with the same ids are connected. + // Example: + // "Print to console": { + // "scope": "javascript,typescript", + // "prefix": "log", + // "body": [ + // "console.log('$1');", + // "$2" + // ], + // "description": "Log output to console" + // } + "Achievement": { + "scope": "typescript", + "prefix": "achv", + "body": [ + "${1:string_id}: new Achievement(\"${2:icon}\", \"${3:name}\", \"${4:description}\", {$5})," + ], + "isFileTemplate": false, + "description": "achievement for achievements.ts", + } +} \ No newline at end of file diff --git a/build/scripts/achievements.js b/build/scripts/achievements.js new file mode 100644 index 00000000..59f70ef1 --- /dev/null +++ b/build/scripts/achievements.js @@ -0,0 +1,726 @@ +"use strict"; +var __makeTemplateObject = (this && this.__makeTemplateObject) || function (cooked, raw) { + if (Object.defineProperty) { Object.defineProperty(cooked, "raw", { value: raw }); } else { cooked.raw = raw; } + return cooked; +}; +var __read = (this && this.__read) || function (o, n) { + var m = typeof Symbol === "function" && o[Symbol.iterator]; + if (!m) return o; + var i = m.call(o), r, ar = [], e; + try { + while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value); + } + catch (error) { e = { error: error }; } + finally { + try { + if (r && !r.done && (m = i["return"])) m.call(i); + } + finally { if (e) throw e.error; } + } + return ar; +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +var __values = (this && this.__values) || function(o) { + var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0; + if (m) return m.call(o); + if (o && typeof o.length === "number") return { + next: function () { + if (o && i >= o.length) o = void 0; + return { value: o && o[i++], done: !o }; + } + }; + throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined."); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Achievements = exports.Achievement = void 0; +var config_1 = require("/config"); +var funcs_1 = require("/funcs"); +var globals_1 = require("/globals"); +var players_1 = require("/players"); +var ranks_1 = require("/ranks"); +var utils_1 = require("/utils"); +//scrap doesn't count +var serpuloItems = [Items.copper, Items.lead, Items.graphite, Items.silicon, Items.metaglass, Items.titanium, Items.plastanium, Items.thorium, Items.surgeAlloy, Items.phaseFabric]; +var erekirItems = [Items.beryllium, Items.graphite, Items.silicon, Items.tungsten, Items.oxide, Items.surgeAlloy, Items.thorium, Items.carbide, Items.phaseFabric]; +var usefulItems10k = { + serpulo: serpuloItems.map(function (i) { return new ItemStack(i, 10000); }), + erekir: erekirItems.map(function (i) { return new ItemStack(i, 10000); }), + sun: __spreadArray(__spreadArray([], __read(serpuloItems), false), __read(erekirItems), false).map(function (i) { return new ItemStack(i, 10000); }), +}; +var allItems1k = Vars.content.items().select(function (i) { return !i.hidden; }).toArray().map(function (i) { return new ItemStack(i, 1000); }); +var mixtechItems = Items.serpuloItems.copy(); +Items.erekirItems.each(function (i) { return mixtechItems.add(i); }); +var Achievement = /** @class */ (function () { + function Achievement(icon, name, description, options) { + var _a; + if (options === void 0) { options = {}; } + this.name = name; + this.notify = "player"; + this.hidden = false; + this.disabled = false; + if (Array.isArray(icon)) { + this.icon = (icon[0].startsWith("[") ? icon[0] : "[".concat(icon[0], "]")) + (typeof icon[1] == "number" ? String.fromCharCode(icon[1]) : icon[1]); + } + else if (typeof icon == "number") { + this.icon = String.fromCharCode(icon); + } + else { + this.icon = icon; + } + if (Array.isArray(description)) { + _a = __read(description, 2), this.description = _a[0], this.extendedDescription = _a[1]; + } + else + this.description = description; + this.nid = Achievement._id++; + Object.assign(this, options); + if (options.modes) { + var _b = __read(options.modes), type = _b[0], modes_1 = _b.slice(1); + if (type == "only") { + this.allowedModes = modes_1; + this.modesText = modes_1.join(", "); + } + else { + this.allowedModes = config_1.GamemodeNames.filter(function (m) { return !modes_1.includes(m); }); + this.modesText = "all except ".concat(modes_1.join(", ")); + } + } + else { + this.allowedModes = config_1.GamemodeNames; + this.modesText = "all"; + } + if (!this.disabled) { + Achievement.all.push(this); + if (this.checkPlayerFrequent || this.checkFrequent) + Achievement.checkFrequent.push(this); + if (this.checkPlayerInfrequent || this.checkInfrequent) + Achievement.checkInfrequent.push(this); + if (this.checkPlayerJoin) + Achievement.checkJoin.push(this); + if (this.checkPlayerGameover || this.checkGameover) + Achievement.checkGameover.push(this); + } + } + Achievement.prototype.message = function () { + return config_1.FColor.achievement(templateObject_1 || (templateObject_1 = __makeTemplateObject(["Achievement granted!\n[accent]", "[white]: ", ""], ["Achievement granted!\\n[accent]", "[white]: ", ""])), this.name, this.description); + }; + Achievement.prototype.messageToEveryone = function (player) { + return config_1.FColor.achievement(templateObject_2 || (templateObject_2 = __makeTemplateObject(["Player ", " has completed the achievement \"", "\"."], ["Player ", " has completed the achievement \"", "\"."])), player.prefixedName, this.name); + }; + Achievement.prototype.allowedInMode = function () { + return this.allowedModes.includes(config_1.Gamemode.name()); + }; + Achievement.prototype.grantToAllOnline = function (team) { + var _this = this; + players_1.FishPlayer.forEachPlayer(function (p) { + if (!_this.has(p) && (!team || p.team() == team)) { + if (_this.notify != "nobody") + p.sendMessage(_this.message()); + _this.setObtained(p); + } + }); + }; + /** Do not call this in a loop on an achievement set to notify everyone. */ + Achievement.prototype.grantTo = function (player) { + if (this.notify == "everyone") + Call.sendMessage(this.messageToEveryone(player)); + else if (this.notify == "player") + player.sendMessage(this.message()); + if (!this.has(player)) + this.setObtained(player); + }; + Achievement.prototype.setObtained = function (player) { + //void player.updateSynced(fishP => fishP.achievements.set(this.nid)); + player.achievements.set(this.nid); + }; + Achievement.prototype.has = function (player) { + return player.achievements.get(this.nid); + }; + Achievement.all = []; + /** Checked every second. */ + Achievement.checkFrequent = []; + /** Checked every 10 seconds. Use for states that can be gained but not lost, such as "x wins". */ + Achievement.checkInfrequent = []; + Achievement.checkJoin = []; + Achievement.checkGameover = []; + Achievement._id = 0; + return Achievement; +}()); +exports.Achievement = Achievement; +Events.on(EventType.PlayerJoin, function (_a) { + var e_1, _b; + var _c; + var player = _a.player; + var _loop_1 = function (ach) { + if (ach.allowedInMode()) { + var fishP_1 = players_1.FishPlayer.get(player); + if (!ach.has(fishP_1) && ((_c = ach.checkPlayerJoin) === null || _c === void 0 ? void 0 : _c.call(ach, fishP_1))) { + if (fishP_1.dataSynced) + ach.grantTo(fishP_1); + else + Timer.schedule(function () { return ach.grantTo(fishP_1); }, 2); //2 seconds should be enough + } + } + }; + try { + for (var _d = __values(Achievement.checkJoin), _e = _d.next(); !_e.done; _e = _d.next()) { + var ach = _e.value; + _loop_1(ach); + } + } + catch (e_1_1) { e_1 = { error: e_1_1 }; } + finally { + try { + if (_e && !_e.done && (_b = _d.return)) _b.call(_d); + } + finally { if (e_1) throw e_1.error; } + } +}); +globals_1.FishEvents.on("gameOver", function (_, winner) { + var e_2, _a; + var _b; + var _loop_2 = function (ach) { + if (ach.allowedInMode()) { + if ((_b = ach.checkGameover) === null || _b === void 0 ? void 0 : _b.call(ach, winner)) + ach.grantToAllOnline(); + else + players_1.FishPlayer.forEachPlayer(function (fishP) { + var _a; + if (!ach.has(fishP) && ((_a = ach.checkPlayerGameover) === null || _a === void 0 ? void 0 : _a.call(ach, fishP, winner))) { + ach.grantTo(fishP); + } + }); + } + }; + try { + for (var _c = __values(Achievement.checkGameover), _d = _c.next(); !_d.done; _d = _c.next()) { + var ach = _d.value; + _loop_2(ach); + } + } + catch (e_2_1) { e_2 = { error: e_2_1 }; } + finally { + try { + if (_d && !_d.done && (_a = _c.return)) _a.call(_c); + } + finally { if (e_2) throw e_2.error; } + } +}); +Timer.schedule(function () { + var e_3, _a; + var _loop_3 = function (ach) { + if (ach.allowedInMode()) { + if (ach.checkFrequent) { + if (config_1.Gamemode.pvp()) { + Vars.state.teams.active.each(function (_a) { + var team = _a.team; + if (ach.checkFrequent(team)) + ach.grantToAllOnline(team); + }); + } + else { + if (ach.checkFrequent(Vars.state.rules.defaultTeam)) + ach.grantToAllOnline(); + } + } + else { + players_1.FishPlayer.forEachPlayer(function (fishP) { + var _a; + if (!ach.has(fishP) && ((_a = ach.checkPlayerFrequent) === null || _a === void 0 ? void 0 : _a.call(ach, fishP))) + ach.grantTo(fishP); + }); + } + } + }; + try { + for (var _b = __values(Achievement.checkFrequent), _c = _b.next(); !_c.done; _c = _b.next()) { + var ach = _c.value; + _loop_3(ach); + } + } + catch (e_3_1) { e_3 = { error: e_3_1 }; } + finally { + try { + if (_c && !_c.done && (_a = _b.return)) _a.call(_b); + } + finally { if (e_3) throw e_3.error; } + } +}, 1, 1); +Timer.schedule(function () { + var e_4, _a; + var _loop_4 = function (ach) { + if (ach.allowedInMode()) { + if (ach.checkInfrequent) { + if (config_1.Gamemode.pvp()) { + Vars.state.teams.active.each(function (_a) { + var team = _a.team; + if (ach.checkInfrequent(team)) + ach.grantToAllOnline(team); + }); + } + else { + if (ach.checkInfrequent(Vars.state.rules.defaultTeam)) + ach.grantToAllOnline(); + } + } + else { + players_1.FishPlayer.forEachPlayer(function (fishP) { + var _a; + if (!ach.has(fishP) && ((_a = ach.checkPlayerInfrequent) === null || _a === void 0 ? void 0 : _a.call(ach, fishP))) + ach.grantTo(fishP); + }); + } + } + }; + try { + for (var _b = __values(Achievement.checkInfrequent), _c = _b.next(); !_c.done; _c = _b.next()) { + var ach = _c.value; + _loop_4(ach); + } + } + catch (e_4_1) { e_4 = { error: e_4_1 }; } + finally { + try { + if (_c && !_c.done && (_a = _b.return)) _a.call(_b); + } + finally { if (e_4) throw e_4.error; } + } +}, 10, 10); +exports.Achievements = { + // =========================== + // ╦ ╦ ╔═╗ ╦═╗ ╔╗╔ ╦ ╔╗╔ ╔═╗ ┬ + // ║║║ ╠═╣ ╠╦╝ ║║║ ║ ║║║ ║ ╦ │ + // ╚╩╝ ╩ ╩ ╩╚═ ╝╚╝ ╩ ╝╚╝ ╚═╝ o + // =========================== + // Do not change the order of any achievements. + // Do not remove any achievements: instead, set the "disabled" option to true. + // Reordering achievements will cause ID shifts. + //Joining based + welcome: new Achievement(["gold", Iconc.infoCircle], "Welcome", "Join the server.", { + checkPlayerJoin: function () { return true; }, + notify: "nobody" + }), + migratory_fish: new Achievement(Iconc.exit, "Migratory Fish", "Join all of our servers.", { + disabled: true + }), //TODO + frequent_visitor: new Achievement(Iconc.planeOutline, "Frequent Visitor", ["Join the server 100 times.", "Note: Do not reconnect frequently, that will not work. This achievement requires that you have been playing for 1 month."], { + checkPlayerJoin: function (p) { return p.info().timesJoined >= 100 && (Date.now() - p.globalFirstJoined > funcs_1.Duration.months(1)); } + }), + //Gamemode based + attack: new Achievement(Iconc.modeAttack, "Attack", ["Defeat an attack map.", "You must be present for the beginning and end of the game."], { + modes: ["only", "attack"], + checkPlayerGameover: function (player, winTeam) { + return Vars.state.rules.defaultTeam == winTeam && player.tstats.lastMapStartTime == players_1.FishPlayer.lastMapStartTime; + }, + }), + survival: new Achievement(Iconc.modeSurvival, "Survival", ["Survive 50 waves in a survival map.", "Must be during the same game."], { + modes: ["only", "survival"], + checkPlayerInfrequent: function (player) { + return player.tstats.wavesSurvived >= 50; + }, + }), + pvp: new Achievement(Iconc.modePvp, "PVP", ["Win a match of PVP.", "You must be present for the beginning and end of the game."], { + modes: ["only", "pvp"], + checkPlayerGameover: function (player, winTeam) { + return player.team() == winTeam && player.tstats.lastMapStartTime == players_1.FishPlayer.lastMapStartTime; + }, + }), + sandbox: new Achievement(Iconc.image, "Sandbox", "Spend 1 hour in Sandbox.", { + modes: ["only", "sandbox"], + checkPlayerInfrequent: function (p) { return p.stats.timeInGame > funcs_1.Duration.hours(1); }, + }), + hexed: new Achievement(Iconc.layers, "Hexed", ["Play a match of Hexed.", "You must be present for the beginning and end of the game."], { + modes: ["only", "hexed"], + checkPlayerGameover: function (player) { + return player.tstats.lastMapStartTime == players_1.FishPlayer.lastMapStartTime; + }, + }), + minigame: new Achievement(Iconc.play, "Minigame", ["Win a Minigame.", "You must be present for the beginning and end of the game."], { + modes: ["only", "minigame"], + checkPlayerGameover: function (player, winTeam) { + return player.team() == winTeam && player.tstats.lastMapStartTime == players_1.FishPlayer.lastMapStartTime; + }, + }), + //playtime based + playtime_1: new Achievement(["white", Iconc.googleplay], "Playtime 1", "Spend 1 hour in-game.", { + checkPlayerInfrequent: function (p) { return p.globalStats.timeInGame >= funcs_1.Duration.hours(1); } + }), + playtime_2: new Achievement(["red", Iconc.googleplay], "Playtime 2", "Spend 12 hours in-game.", { + checkPlayerInfrequent: function (p) { return p.globalStats.timeInGame >= funcs_1.Duration.hours(12); } + }), + playtime_3: new Achievement(["orange", Iconc.googleplay], "Playtime 3", "Spend 2 days in-game.", { + checkPlayerInfrequent: function (p) { return p.globalStats.timeInGame >= funcs_1.Duration.days(2); } + }), + playtime_4: new Achievement(["yellow", Iconc.googleplay], "Playtime 4", "Spend 10 days in-game.", { + checkPlayerInfrequent: function (p) { return p.globalStats.timeInGame >= funcs_1.Duration.days(10); } + }), + //victories based + victory_1: new Achievement(["white", Iconc.star], "First Victory", "Win a map run.", { + checkPlayerGameover: function (p) { return p.globalStats.gamesWon >= 1; } + }), + victory_2: new Achievement(["red", Iconc.star], "Victories 2", "Win 5 map runs.", { + checkPlayerGameover: function (p) { return p.globalStats.gamesWon >= 5; } + }), + victory_3: new Achievement(["orange", Iconc.star], "Victories 3", "Win 30 map runs.", { + checkPlayerGameover: function (p) { return p.globalStats.gamesWon >= 30; } + }), + victory_4: new Achievement(["yellow", Iconc.star], "Victories 4", "Win 100 map runs.", { + checkPlayerGameover: function (p) { return p.globalStats.gamesWon >= 100; }, + notify: "everyone" + }), + //games based + games_1: new Achievement(["white", Iconc.itchio], "Games 1", "Play 10 map runs.", { + checkPlayerGameover: function (p) { return p.globalStats.gamesFinished >= 10; } + }), + games_2: new Achievement(["red", Iconc.itchio], "Games 2", "Play 40 map runs.", { + checkPlayerGameover: function (p) { return p.globalStats.gamesFinished >= 40; } + }), + games_3: new Achievement(["orange", Iconc.itchio], "Games 3", "Play 100 map runs.", { + checkPlayerGameover: function (p) { return p.globalStats.gamesFinished >= 100; } + }), + games_4: new Achievement(["yellow", Iconc.itchio], "Games 4", "Play 200 map runs.", { + checkPlayerGameover: function (p) { return p.globalStats.gamesFinished >= 200; }, + notify: "everyone" + }), + //messages based + messages_1: new Achievement(["white", Iconc.chat], "Hello", "Send your first chat message.", { + checkPlayerInfrequent: function (p) { return p.globalStats.chatMessagesSent >= 1; }, + notify: "nobody" + }), + messages_2: new Achievement(["red", Iconc.chat], "Chat 2", ["Send 100 chat messages.", "Warning: you will be kicked if you spam the chat."], { + checkPlayerInfrequent: function (p) { return p.globalStats.chatMessagesSent >= 100; } + }), + messages_3: new Achievement(["orange", Iconc.chat], "Chat 3", ["Send 500 chat messages.", "Warning: you will be kicked if you spam the chat."], { + checkPlayerInfrequent: function (p) { return p.globalStats.chatMessagesSent >= 500; } + }), + messages_4: new Achievement(["yellow", Iconc.chat], "Chat 4", ["Send 2000 chat messages.", "Warning: you will be kicked if you spam the chat."], { + checkPlayerInfrequent: function (p) { return p.globalStats.chatMessagesSent >= 2000; } + }), + messages_5: new Achievement(["lime", Iconc.chat], "Chat 4", ["Send 5000 chat messages.", "Warning: you will be kicked if you spam the chat."], { + checkPlayerInfrequent: function (p) { return p.globalStats.chatMessagesSent >= 5000; }, + notify: "everyone" + }), + //blocks built based + builds_1: new Achievement(["white", Iconc.fileText], "The Factory Must Prepare", "Construct 1 buildings.", { + checkPlayerInfrequent: function (p) { return p.globalStats.blocksPlaced >= 1; }, + notify: "nobody" + }), + builds_2: new Achievement(["red", Iconc.fileText], "The Factory Must Begin", "Construct 200 buildings.", { + checkPlayerInfrequent: function (p) { return p.globalStats.blocksPlaced > 200; } + }), + builds_3: new Achievement(["orange", Iconc.fileText], "The Factory Must Produce", "Construct 1000 buildings.", { + checkPlayerInfrequent: function (p) { return p.globalStats.blocksPlaced > 1000; } + }), + builds_4: new Achievement(["yellow", Iconc.fileText], "The Factory Must Grow", "Construct 5000 buildings.", { + checkPlayerInfrequent: function (p) { return p.globalStats.blocksPlaced > 5000; }, + }), + //units + t5: new Achievement(Blocks.tetrativeReconstructor.emoji(), "T5", "Control a T5 unit.", { + modes: ["not", "sandbox"], + checkPlayerFrequent: function (player) { + var _a; + return globals_1.unitsT5.includes((_a = player.unit()) === null || _a === void 0 ? void 0 : _a.type); + }, + }), + dibs: new Achievement(["green", Blocks.tetrativeReconstructor.emoji()], "Dibs", "Be the first player to control the first T5 unit made by a reconstructor that you placed.", { + modes: ["not", "sandbox"], + disabled: true + }), //TODO + worm: new Achievement(UnitTypes.latum.emoji(), "Worm", "Control a Latum.", { + checkPlayerFrequent: function (player) { + var _a; + return ((_a = player.unit()) === null || _a === void 0 ? void 0 : _a.type) == UnitTypes.latum; + } + }), + //pvp + above_average: new Achievement(Iconc.chartBar, "Above Average", ["Reach a win rate above 50%.", "Must be over at least 20 games of PVP."], { + modes: ["only", "pvp"], + checkPlayerInfrequent: function (p) { return p.stats.gamesWon / p.stats.gamesFinished > 0.5 && p.stats.gamesFinished >= 20; } + }), + head_start: new Achievement(Iconc.commandAttack, "Head Start", ["Win a match of PVP where your opponents have a 5 minute head start.", "Your team must wait for the first 5 minutes without building or descontructing any buildings."], { + modes: ["only", "pvp"], + disabled: true + }), //TODO + one_v_two: new Achievement(["red", Iconc.modePvp], "1v2", "Defeat two (or more) opponents in PVP without help from other players.", { + modes: ["only", "pvp"], + disabled: true + }), //TODO + //sandbox + underpowered: new Achievement(["red", Blocks.powerSource.emoji()], "Underpowered", "Overload a power source.", { + modes: ["only", "sandbox"], + checkFrequent: function () { + var found = false; + //deliberate ordering for performance reasons + Groups.powerGraph.each(function (_a) { + var graph = _a.graph; + //we don't need to actually check for power sources, just assume that ~1mil power is a source + if (graph.lastPowerNeeded > graph.lastPowerProduced && graph.lastPowerNeeded < 1e10 && graph.lastPowerProduced >= 999900) + found = true; + }); + return found; + } + }), + //easter eggs + memory_corruption: new Achievement(["red", Iconc.host], "Is the server OK?", "Witness a memory corruption.", { + notify: "nobody" + }), + run_js_without_perms: new Achievement(["yellow", Iconc.warning], "XKCD 838", ["Receive a warning from the server that an incident will be reported.", "One of the admin commands has a custom error message."], { + notify: "everyone" + }), + script_kiddie: new Achievement(["red", Iconc.warning], "Script Kiddie", ["Pretend to be a hacker. The server will disagree.", "Change your name to something including \"hacker\"."], { + notify: "nobody" + }), + hacker: new Achievement(["lightgray", Iconc.host], "Hacker", "Find a bug in the server and report it responsibly.", { + hidden: true + }), + //items based + items_10k: new Achievement(["green", Iconc.distribution], "Cornucopia", "Obtain 10k of every useful resource.", { + modes: ["not", "sandbox"], + checkPlayerFrequent: function (player) { + var _a; + if (!Vars.state.planet) + return false; + return ((_a = player.team().items()) === null || _a === void 0 ? void 0 : _a.has(usefulItems10k[Vars.state.planet.name])) || false; + }, + }), + fullVault: new Achievement(["green", Blocks.vault.emoji()], "Well Stocked", ["Fill a vault with every obtainable item.", "Requires mixtech."], { + modes: ["not", "sandbox"], + checkInfrequent: function (team) { + return Vars.indexer.getFlagged(team, BlockFlag.storage).contains(boolf(function (b) { return b.block == Blocks.vault && b.items.has(allItems1k); })); + }, + }), + full_core: new Achievement(["green", Blocks.coreAcropolis.emoji()], "Multiblock Incinerator", "Completely fill the core with all obtainable items on a map with core incineration enabled.", { + modes: ["not", "sandbox"], + checkFrequent: function (team) { + var _a; + if (!Vars.state.planet) + return false; + var items; + switch (Vars.state.planet.name) { + case "serpulo": + items = Items.serpuloItems; + break; + case "erekir": + items = Items.erekirItems; + break; + case "sun": + items = mixtechItems; + break; + } + var capacity = (_a = team.core()) === null || _a === void 0 ? void 0 : _a.storageCapacity; + if (!capacity) + return false; + var module = team.items(); + return items.allMatch(function (i) { return module.has(i, capacity); }); + }, + }), + siligone: new Achievement(["red", Items.silicon.emoji()], "Siligone", ["Run out of silicon.", "You must have reached 2000 silicon before running out."], { + modes: ["not", "sandbox"] + }), + silicon_100k: new Achievement(["green", Items.silicon.emoji()], "Silicon for days", "Obtain 100k silicon.", { + modes: ["not", "sandbox"], + checkFrequent: function (team) { return team.items().has(Items.silicon, 100000); } + }), + //other players based + alone: new Achievement(["red", Iconc.players], "Alone", "Be the only player online for more than two minutes", { + notify: "nobody" + }), + join_playercount_20: new Achievement(["lime", Iconc.players], "Is there enough room?", "Join a server with 20 players online", { + checkPlayerJoin: function () { return Groups.player.size() > 20; }, + }), + meet_staff: new Achievement(["lime", Iconc.hammer], "Griefer Beware", "Meet a staff member in-game", { + checkPlayerJoin: function () { return Groups.player.contains(function (p) { return players_1.FishPlayer.get(p).ranksAtLeast("mod"); }); }, + }), + meet_fish: new Achievement(["blue", Iconc.admin], "The Big Fish", "Meet >|||>Fish himself in-game", { + checkPlayerJoin: function () { return Groups.player.contains(function (p) { return players_1.FishPlayer.get(p).ranksAtLeast("fish"); }); }, + hidden: true, + }), + server_speak: new Achievement(["pink", Iconc.host], "It Speaks!", "Hear the server talk in chat."), + see_marked_griefer: new Achievement(["red", Iconc.hammer], "Flying Tonk", "See a marked griefer in-game.", { + checkInfrequent: function () { return Groups.player.contains(function (p) { return players_1.FishPlayer.get(p).marked(); }); }, + }), + //maps based + beat_map_not_in_rotation: new Achievement(["pink", Iconc.map], "How?", "Beat a map that isn't in the list of maps.", { + notify: "everyone", + modes: ["not", "pvp"], + checkGameover: function (team) { return team == Vars.state.rules.defaultTeam && !Vars.state.map.custom; } + }), + //misc + power_1mil: new Achievement(["green", Blocks.powerSource.emoji()], "Who needs sources?", "Reach a power production of 1 million without using power sources.", { + modes: ["not", "sandbox"], + checkFrequent: function () { + var found = false; + //deliberate ordering for performance reasons + Groups.powerGraph.each(function (_a) { + var graph = _a.graph; + //we don't need to actually check for power sources, just assume that ~1mil power is a source + if (graph.lastPowerProduced > 1e6 && !graph.producers.contains(boolf(function (b) { return b.block == Blocks.powerSource; }))) + found = true; + }); + return found; + } + }), + pacifist_crawler: new Achievement(UnitTypes.crawler.emoji(), "Pacifist Crawler", "Control a crawler for 15 minutes without exploding.", { + modes: ["not", "sandbox"], + disabled: true + }), //TODO + core_low_hp: new Achievement(["yellow", Blocks.coreNucleus.emoji()], "Close Call", "Have your core reach less than 50 health, but survive.", { + modes: ["not", "sandbox"], + }), + enemy_core_low_hp: new Achievement(["red", Blocks.coreNucleus.emoji()], "So Close", "Cause the enemy core to reach less than 50 health, but survive.", { + modes: ["not", "sandbox"], + }), + verified: new Achievement([ranks_1.Rank.active.color, Iconc.ok], "Verified", "Be promoted automatically to ".concat(ranks_1.Rank.active.coloredName(), " rank."), { + checkPlayerJoin: function (p) { return p.ranksAtLeast("active"); }, notify: "nobody" + }), + click_me: new Achievement(Iconc.bookOpen, "Clicked", "Run /achievementgrid and click this achievement."), + afk: new Achievement(["yellow", Iconc.lock], "AFK?", "Win a game without interacting with any blocks.", { + modes: ["not", "sandbox"], + checkPlayerGameover: function (player, winTeam) { + return player.team() == winTeam && player.tstats.blockInteractionsThisMap == 0; + }, + }), + status_effects_5: new Achievement(StatusEffects.electrified.emoji(), "A Furious Cocktail", "Have at least 5 status effects at once.", { + checkPlayerFrequent: function (p) { + var unit = p.unit(); + if (!unit) + return false; + var statuses = (0, utils_1.getStatuses)(unit); + return statuses.size >= 5; + }, + modes: ["not", "sandbox"] + }), + drown_big_tank: new Achievement(["blue", UnitTypes.conquer.emoji()], "Not Waterproof", "Drown an enemy Conquer or Vanquish.", { + notify: "everyone", + modes: ["not", "sandbox"] + }), + drown_mace_in_cryo: new Achievement(["cyan", UnitTypes.mace.emoji()], "Cooldown", "Drown a Mace in ".concat(Blocks.cryofluid.emoji(), " Cryofluid."), { + notify: "everyone", + modes: ["not", "sandbox"] + }), + max_boost_duo: new Achievement(["yellow", Blocks.duo.emoji()], "In Duo We Trust", "Control a Duo with maximum boosts.", { + checkPlayerFrequent: function (player) { + var _a, _b; + var tile = (_b = (_a = player.unit()) === null || _a === void 0 ? void 0 : _a.tile) === null || _b === void 0 ? void 0 : _b.call(_a); + if (!tile) + return false; + return tile.block == Blocks.duo && tile.ammo.peek().item == Items.silicon && tile.liquids.current() == Liquids.cryofluid && tile.timeScale() >= 2.5; + }, + notify: "everyone", + modes: ["not", "sandbox"] + }), + foreshadow_overkill: new Achievement(["yellow", Blocks.foreshadow.emoji()], "Overkill", ["Kill a Dagger with a maximally boosted Foreshadow.", "Hint: the maximum overdrive is not +150%..."], { + notify: "everyone", + modes: ["not", "sandbox"] + }), + impacts_15: new Achievement(["green", Blocks.impactReactor.emoji()], "Darthscion's Nightmare", "Run 15 impact reactors at full efficiency.", { + modes: ["not", "sandbox"], + notify: "everyone", + checkInfrequent: function (team) { + var found = false; + //deliberate ordering for performance reasons + Groups.powerGraph.each(function (_a) { + var graph = _a.graph; + //assume that any network running 15 impacts has at least 2 other power sources + if (graph.producers.size >= 17 && graph.producers.count(function (b) { return b.block == Blocks.impactReactor && b.warmup > 0.99999; }) > 15 && graph.producers.first().team == team) + found = true; + }); + return found; + }, + }), +}; +Object.entries(exports.Achievements).forEach(function (_a) { + var _b = __read(_a, 2), id = _b[0], a = _b[1]; + return a.sid = id; +}); +globals_1.FishEvents.on("commandUnauthorized", function (_, player, name) { + if (name == "js" || name == "fjs") + exports.Achievements.run_js_without_perms.grantTo(player); +}); +Events.on(EventType.UnitDrownEvent, function (_a) { + var _b; + var unit = _a.unit; + if (!config_1.Gamemode.sandbox()) { + if (unit.type == UnitTypes.mace && ((_b = unit.tileOn()) === null || _b === void 0 ? void 0 : _b.floor()) == Blocks.cryofluid) + exports.Achievements.drown_mace_in_cryo.grantToAllOnline(); + else if (unit.type == UnitTypes.conquer || unit.type == UnitTypes.vanquish) { + if (config_1.Gamemode.pvp()) { + Vars.state.teams.active.map(function (t) { return t.team; }).select(function (t) { return t !== unit.team; }).each(function (t) { return exports.Achievements.drown_big_tank.grantToAllOnline(t); }); + } + else { + if (unit.team !== Vars.state.rules.defaultTeam) + exports.Achievements.drown_big_tank.grantToAllOnline(); + } + } + } +}); +Events.on(EventType.UnitBulletDestroyEvent, function (_a) { + var unit = _a.unit, bullet = _a.bullet; + if (!config_1.Gamemode.sandbox() && unit.type == UnitTypes.dagger && bullet.owner.block == Blocks.foreshadow) { + var build = bullet.owner; + if (build.liquids.current() == Liquids.cryofluid && build.timeScale() >= 3) + exports.Achievements.foreshadow_overkill.grantToAllOnline(build.team); + } +}); +var siliconReached = Team.all.map(function (_) { return false; }); +Events.on(EventType.GameOverEvent, function () { return siliconReached = Team.all.map(function (_) { return false; }); }); +var isAlone = 0; +Timer.schedule(function () { + if (!Vars.state.gameOver && !config_1.Gamemode.sandbox()) { + Vars.state.teams.active.each(function (_a) { + var team = _a.team; + if (team.items().has(Items.silicon, 2000)) + siliconReached[team.id] = true; + else if (siliconReached[team.id] && team.items().get(Items.silicon) == 0) + exports.Achievements.siligone.grantToAllOnline(team); + }); + } + if (Groups.player.size() == 1) { + if (isAlone == 0) + isAlone = Date.now(); + else if (Date.now() > isAlone + funcs_1.Duration.minutes(2)) + exports.Achievements.alone.grantToAllOnline(); + } + else + isAlone = 0; +}, 2, 2); +var coreHealthTime = new ObjectIntMap(); +if (!config_1.Gamemode.sandbox()) + Timer.schedule(function () { + coreHealthTime.forEach(function (_a) { + var core = _a.key, value = _a.value; + if (Date.now() > value) { + if (core.dead) { + coreHealthTime.remove(core); + } + else if (core.health > 50) { + //grant achievement + players_1.FishPlayer.forEachPlayer(function (p) { + if (core.team == p.team()) + exports.Achievements.core_low_hp.grantTo(p); + else + exports.Achievements.enemy_core_low_hp.grantTo(p); + }); + coreHealthTime.remove(core); + } + } + }); + Vars.state.teams.active.flatMap(function (t) { return t.cores; }).each(function (core) { + if (core.health < 50 && !coreHealthTime.get(core)) + coreHealthTime.put(core, Date.now() + 12000); + }); + }, 1, 1); +Events.on(EventType.GameOverEvent, function () { return coreHealthTime.clear(); }); +Events.on(EventType.WorldLoadEvent, function () { return coreHealthTime.clear(); }); +globals_1.FishEvents.on("scriptKiddie", function (_, p) { return Timer.schedule(function () { return exports.Achievements.script_kiddie.grantTo(p); }, 2); }); +globals_1.FishEvents.on("memoryCorruption", function () { return exports.Achievements.memory_corruption.grantToAllOnline(); }); +globals_1.FishEvents.on("serverSays", function () { return exports.Achievements.server_speak.grantToAllOnline(); }); +var templateObject_1, templateObject_2; diff --git a/build/scripts/api.js b/build/scripts/api.js index e223487c..8503b71f 100644 --- a/build/scripts/api.js +++ b/build/scripts/api.js @@ -179,8 +179,6 @@ function getFishPlayerData(uuid) { } /** Pushes fish player data to the backend. */ function setFishPlayerData(data, repeats, ignoreActivelySyncedFields) { - if (repeats === void 0) { repeats = 1; } - if (ignoreActivelySyncedFields === void 0) { ignoreActivelySyncedFields = false; } var _a = promise_1.Promise.withResolvers(), promise = _a.promise, resolve = _a.resolve, reject = _a.reject; if (config_1.Mode.noBackend) { resolve(); diff --git a/build/scripts/commands/console.js b/build/scripts/commands/console.js index babbf695..540774bc 100644 --- a/build/scripts/commands/console.js +++ b/build/scripts/commands/console.js @@ -161,7 +161,9 @@ exports.commands = (0, commands_1.consoleCommandList)({ var outputString = [""]; var _loop_1 = function (playerInfo, fishP) { var flagsText = [ - (fishP === null || fishP === void 0 ? void 0 : fishP.marked()) && "&lris marked&fr until ".concat((0, utils_1.formatTimeRelative)(fishP.unmarkTime)), + (fishP === null || fishP === void 0 ? void 0 : fishP.marked()) && (globals_1.maxTime - fishP.unmarkTime < 20000 ? + "&lris marked forever&fr" + : "&lris marked&fr until ".concat((0, utils_1.formatTimeRelative)(fishP.unmarkTime))), (fishP === null || fishP === void 0 ? void 0 : fishP.muted) && "&lris muted&fr", (fishP === null || fishP === void 0 ? void 0 : fishP.hasFlag("member")) && "&lmis member&fr", (fishP === null || fishP === void 0 ? void 0 : fishP.autoflagged) && "&lris autoflagged&fr", @@ -391,7 +393,7 @@ exports.commands = (0, commands_1.consoleCommandList)({ } } else if ((range = (0, utils_1.getIPRange)(args.target)) != null) { - if (admins.subnetBans.contains(function (ip) { return ip.replace(/\.$/, "") == range; })) { + if (admins.subnetBans.contains(boolf(function (ip) { return ip.replace(/\.$/, "") == range; }))) { output("Subnet &c\"".concat(range, "\"&fr is already banned.")); } else { @@ -592,7 +594,7 @@ exports.commands = (0, commands_1.consoleCommandList)({ (0, commands_1.fail)("Please use the setusid command instead."); var oldusid = player.usid; player.usid = null; - api.setFishPlayerData(player.getData()).then(function () { + api.setFishPlayerData(player.getData(), 1, true).then(function () { outputSuccess("Removed the usid of player ".concat(player.name, "/").concat(player.uuid, " (was ").concat(oldusid, ")")); }).catch(function (err) { Log.err(err); @@ -611,7 +613,7 @@ exports.commands = (0, commands_1.consoleCommandList)({ var player = (_b = players_1.FishPlayer.lastAuthKicked) !== null && _b !== void 0 ? _b : (0, commands_1.fail)("No authorization failures have occurred since the last restart."); var oldusid = player.usid; player.usid = args.usid; - api.setFishPlayerData(player.getData()).then(function () { + api.setFishPlayerData(player.getData(), 1, true).then(function () { outputSuccess("Set the usid of player ".concat(player.name, "/").concat(player.uuid, " to ").concat(args.usid, " (was ").concat(oldusid, ")")); }).catch(function (err) { Log.err(err); @@ -741,7 +743,7 @@ exports.commands = (0, commands_1.consoleCommandList)({ description: "Checks memory usage of various objects.", handler: function (_a) { var output = _a.output; - output("Memory usage:\nTotal: ".concat(Math.round(Core.app.getJavaHeap() / (Math.pow(2, 10))), " KB\nNumber of cached fish players: ").concat(Object.keys(players_1.FishPlayer.cachedPlayers).length, " (has data: ").concat(Object.values(players_1.FishPlayer.cachedPlayers).filter(function (p) { return p.hasData(); }).length, ")\nFish player data string length: ").concat(players_1.FishPlayer.getFishPlayersString.length, " (").concat(Core.settings.getInt("fish-subkeys"), " subkeys)\nLength of tilelog entries: ").concat(Math.round(Object.values(globals_1.tileHistory).reduce(function (acc, a) { return acc + a.length; }, 0) / (Math.pow(2, 10))), " KB")); + output("Memory usage:\nTotal: ".concat(Math.round(Core.app.getJavaHeap() / (Math.pow(2, 10))), " KB\nNumber of cached fish players: ").concat(Object.keys(players_1.FishPlayer.cachedPlayers).length, " (stored locally: ").concat(Object.values(players_1.FishPlayer.cachedPlayers).filter(function (p) { return p.shouldCache(); }).length, ")\nFish player data string length: ").concat(players_1.FishPlayer.getFishPlayersString.length, " (").concat(Core.settings.getInt("fish-subkeys"), " subkeys)\nLength of tilelog entries: ").concat(Math.round(Object.values(globals_1.tileHistory).reduce(function (acc, a) { return acc + a.length; }, 0) / (Math.pow(2, 10))), " KB")); } }, stopplayer: { @@ -1049,5 +1051,15 @@ exports.commands = (0, commands_1.consoleCommandList)({ }); } }, + say: { + args: ["message:string"], + description: "Sends a message to the in-game chat.", + handler: function (_a) { + var message = _a.args.message; + Call.sendMessage("[scarlet][[Server]:[] ".concat(message)); + Log.info("&fi&lcServer: &fr&lw".concat(message)); + globals_1.FishEvents.fire("serverSays", []); + } + } }); var templateObject_1, templateObject_2, templateObject_3, templateObject_4, templateObject_5, templateObject_6, templateObject_7, templateObject_8, templateObject_9, templateObject_10, templateObject_11, templateObject_12, templateObject_13, templateObject_14, templateObject_15; diff --git a/build/scripts/commands/general.js b/build/scripts/commands/general.js index 8ec231de..4d7b2392 100644 --- a/build/scripts/commands/general.js +++ b/build/scripts/commands/general.js @@ -81,6 +81,7 @@ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { }; Object.defineProperty(exports, "__esModule", { value: true }); exports.commands = void 0; +var achievements_1 = require("/achievements"); var api = require("/api"); var config_1 = require("/config"); var commands_1 = require("/frameworks/commands"); @@ -1068,12 +1069,13 @@ exports.commands = (0, commands_1.commandList)(__assign(__assign({ about: { }, }; }), stats: { - args: ["target:player"], + args: ["target:player", "global:boolean?"], perm: commands_1.Perm.none, description: "Views a player's stats.", handler: function (_a) { - var target = _a.args.target, output = _a.output, f = _a.f; - output(f(templateObject_18 || (templateObject_18 = __makeTemplateObject(["[accent]Statistics for player ", ":\n(note: we started recording statistics on 22 Jan 2024)\n[white]--------------[]\nBlocks broken: ", "\nBlocks placed: ", "\nChat messages sent: ", "\nGames finished: ", "\nTime in-game: ", "\nWin rate: ", ""], ["[accent]\\\nStatistics for player ", ":\n(note: we started recording statistics on 22 Jan 2024)\n[white]--------------[]\nBlocks broken: ", "\nBlocks placed: ", "\nChat messages sent: ", "\nGames finished: ", "\nTime in-game: ", "\nWin rate: ", ""])), target, target.stats.blocksBroken, target.stats.blocksPlaced, target.stats.chatMessagesSent, target.stats.gamesFinished, (0, utils_1.formatTime)(target.stats.timeInGame), target.stats.gamesWon / target.stats.gamesFinished)); + var _b = _a.args, target = _b.target, _c = _b.global, global = _c === void 0 ? false : _c, output = _a.output, f = _a.f; + var stats = global ? target.globalStats : target.stats; + output(f(templateObject_18 || (templateObject_18 = __makeTemplateObject(["[accent]Statistics for player ", " ", ":\n(note: we started recording statistics on 22 Jan 2024)\n[white]--------------[]\nBlocks broken: ", "\nBlocks placed: ", "\nChat messages sent: ", "\nGames finished: ", "\nTime in-game: ", "\nWin rate: ", ""], ["[accent]\\\nStatistics for player ", " ", ":\n(note: we started recording statistics on 22 Jan 2024)\n[white]--------------[]\nBlocks broken: ", "\nBlocks placed: ", "\nChat messages sent: ", "\nGames finished: ", "\nTime in-game: ", "\nWin rate: ", ""])), target, global ? "on this server" : "across all servers", stats.blocksBroken, stats.blocksPlaced, stats.chatMessagesSent, stats.gamesFinished, (0, utils_1.formatTime)(stats.timeInGame), stats.gamesWon / stats.gamesFinished)); } }, showworld: { args: ["x:number?", "y:number?", "size:number?"], @@ -1091,7 +1093,7 @@ exports.commands = (0, commands_1.commandList)(__assign(__assign({ about: { data: null, }); }), Vars.world.width()).reverse(); var height = Vars.world.height(); - void menus_1.Menu.scroll(sender, "The World", "Use the arrow keys to navigate around the world. Click a blank square to exit.", options, { + void menus_1.Menu.scroll2D(sender, "The World", "Use the arrow keys to navigate around the world. Click a blank square to exit.", options, { columns: size, rows: size, x: x ? x - Math.trunc(size / 2) : 0, @@ -1183,5 +1185,111 @@ exports.commands = (0, commands_1.commandList)(__assign(__assign({ about: { unit.add(); outputSuccess(f(templateObject_19 || (templateObject_19 = __makeTemplateObject(["Spawned a ", " that is partly a ", "."], ["Spawned a ", " that is partly a ", "."])), args.type, args.base)); } + }, achievement: { + args: ["name:string?", "verbose:boolean?"], + description: "Displays information on a specific achievement.", + perm: commands_1.Perm.none, + handler: function (_a) { + return __awaiter(this, arguments, void 0, function (_b) { + var matching, achievement, _c; + var _d = _b.args, _e = _d.name, name = _e === void 0 ? "" : _e, _f = _d.verbose, verbose = _f === void 0 ? false : _f, sender = _b.sender, f = _b.f, output = _b.output; + return __generator(this, function (_g) { + switch (_g.label) { + case 0: + name = Strings.stripColors(name.toLowerCase()); + matching = achievements_1.Achievement.all.filter(function (a) { return Strings.stripColors(a.name).toLowerCase().includes(name); }); + if (matching.length == 0) + (0, commands_1.fail)(f(templateObject_20 || (templateObject_20 = __makeTemplateObject(["No achievements found with name ", ". To view all achievements, run [accent]/achievements[]."], ["No achievements found with name ", ". To view all achievements, run [accent]/achievements[]."])), name)); + if (!(matching.length > 2)) return [3 /*break*/, 2]; + return [4 /*yield*/, menus_1.Menu.pagedList(sender, "Achievement", "Select an achievement to view", matching, { + onCancel: "reject", + columns: 2, + optionStringifier: function (a) { return "".concat(a.icon, "[] ").concat(a.name); } + })]; + case 1: + _c = _g.sent(); + return [3 /*break*/, 3]; + case 2: + _c = matching[0]; + _g.label = 3; + case 3: + achievement = _c; + output(config_1.FColor.achievement(templateObject_21 || (templateObject_21 = __makeTemplateObject(["Achievement ", " ", "\n[white]--------------[]\n", "\nAllowed modes: ", "\nUnlocked: ", "\n", "", "", ""], ["\\\nAchievement ", " ", "\n[white]--------------[]\n", "\nAllowed modes: ", "\nUnlocked: ", "\n", "\\\n", "\\\n", "\\\n"])), achievement.icon, achievement.name, achievement.description + (achievement.extendedDescription ? ("\n" + "[gray]".concat(achievement.extendedDescription)) : ""), achievement.modesText, f.boolGood(achievement.has(sender)), verbose ? "[gray]ID: (".concat(achievement.nid, ")").concat(achievement.sid, "\n") : "", verbose ? "[gray]Notifies: ".concat(achievement.notify, "\n") : "", achievement.hidden ? "This achievement is secret." : "")); + return [2 /*return*/]; + } + }); + }); + } + }, achievementlist: { + args: ["target:player?"], + description: "Shows all achievements in a paged menu.", + perm: commands_1.Perm.none, + handler: function (_a) { + return __awaiter(this, arguments, void 0, function (_b) { + var sender = _b.sender, _c = _b.args.target, target = _c === void 0 ? sender : _c, f = _b.f; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: return [4 /*yield*/, menus_1.Menu.textPages(sender, achievements_1.Achievement.all.filter(function (a) { return !a.hidden || a.has(target); }) + .map(function (a) { return [ + "".concat(a.icon, "[] ").concat(a.name), + function () { return config_1.FColor.achievement(templateObject_22 || (templateObject_22 = __makeTemplateObject(["", "\nAllowed modes: ", "\nUnlocked: ", "\n", ""], ["\\\n", "\nAllowed modes: ", "\nUnlocked: ", "\n", "\\\n"])), a.description + (a.extendedDescription ? ("\n" + "[gray]".concat(a.extendedDescription)) : ""), a.modesText, f.boolGood(a.has(target)), a.hidden ? "This achievement is secret." : ""); } + ]; }))]; + case 1: + _d.sent(); + return [2 /*return*/]; + } + }); + }); + } + }, achievementgrid: { + args: ["target:player?"], + description: "Shows all achievements in a 2D scrolling menu.", + perm: commands_1.Perm.none, + handler: function (_a) { + return __awaiter(this, arguments, void 0, function (_b) { + var visibleAchievements, options, numberAchievements, totalAchievements, x, y, a, err_1; + var _c; + var sender = _b.sender, _d = _b.args.target, target = _d === void 0 ? sender : _d, f = _b.f; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + visibleAchievements = achievements_1.Achievement.all.filter(function (a) { return !a.hidden || a.has(target); }); + options = (0, funcs_1.to2DArray)(visibleAchievements, 7).map(function (row) { return row.map(function (a) { return ({ + data: a, + text: a.has(target) ? a.icon : "[gray]".concat(Strings.stripColors(a.icon)), + }); }); }); + numberAchievements = achievements_1.Achievement.all.filter(function (a) { return a.has(target); }).length; + totalAchievements = visibleAchievements.length; + x = 0, y = 0; + a = null; + _e.label = 1; + case 1: + if (!true) return [3 /*break*/, 6]; + _e.label = 2; + case 2: + _e.trys.push([2, 4, , 5]); + return [4 /*yield*/, menus_1.Menu.scroll2D(sender, "Achievements", a ? config_1.FColor.achievement(templateObject_23 || (templateObject_23 = __makeTemplateObject(["\t", " ", "\n\t\n\t", "\n\t\n\tAllowed modes: ", "\n\tUnlocked: ", "\n\t", "\t"], ["\\\n\t", " ", "\n\t\n\t", "\n\t\n\tAllowed modes: ", "\n\tUnlocked: ", "\n\t", "\\\n\t"])), a.icon, a.name, a.description + (a.extendedDescription ? ("\n" + "[gray]".concat(a.extendedDescription)) : ""), a.modesText, f.boolGood(a.has(target)), a.hidden ? "This achievement is secret." : "") : + (target == sender ? "You have ".concat(numberAchievements, "/").concat(totalAchievements, " achievements.") + : config_1.FColor.achievement(templateObject_24 || (templateObject_24 = __makeTemplateObject(["Player ", " has ", "/", " achievements."], ["Player ", " has ", "/", " achievements."])), target.prefixedName, numberAchievements, totalAchievements)) + + "\nClick an achievement icon to show more information.", options, { onCancel: "reject", columns: 5, rows: 4, getCenterText: function () { return String.fromCharCode(Iconc.settings); }, x: x, y: y })]; + case 3: + _c = __read.apply(void 0, [_e.sent(), 3]), a = _c[0], x = _c[1], y = _c[2]; + return [3 /*break*/, 5]; + case 4: + err_1 = _e.sent(); + if (err_1 == "cancel") + return [2 /*return*/]; //TODO replace this string "cancel" with a symbol + else + throw err_1; + return [3 /*break*/, 5]; + case 5: + if (a == achievements_1.Achievements.click_me && target == sender) + a.grantTo(sender); + return [3 /*break*/, 1]; + case 6: return [2 /*return*/]; + } + }); + }); + } } })); -var templateObject_1, templateObject_2, templateObject_3, templateObject_4, templateObject_5, templateObject_6, templateObject_7, templateObject_8, templateObject_9, templateObject_10, templateObject_11, templateObject_12, templateObject_13, templateObject_14, templateObject_15, templateObject_16, templateObject_17, templateObject_18, templateObject_19; +var templateObject_1, templateObject_2, templateObject_3, templateObject_4, templateObject_5, templateObject_6, templateObject_7, templateObject_8, templateObject_9, templateObject_10, templateObject_11, templateObject_12, templateObject_13, templateObject_14, templateObject_15, templateObject_16, templateObject_17, templateObject_18, templateObject_19, templateObject_20, templateObject_21, templateObject_22, templateObject_23, templateObject_24; diff --git a/build/scripts/commands/staff.js b/build/scripts/commands/staff.js index a850104a..9592e7f9 100644 --- a/build/scripts/commands/staff.js +++ b/build/scripts/commands/staff.js @@ -566,6 +566,7 @@ exports.commands = (0, commands_1.commandList)({ handler: function (_a) { var outputSuccess = _a.outputSuccess; players_1.FishPlayer.saveAll(); + players_1.FishPlayer.uploadAll(); globals_1.FishEvents.fire("saveData", []); var file = Vars.saveDirectory.child("1.".concat(Vars.saveExtension)); SaveIO.save(file); diff --git a/build/scripts/config.js b/build/scripts/config.js index 2e331d6b..5f65ab75 100644 --- a/build/scripts/config.js +++ b/build/scripts/config.js @@ -33,7 +33,7 @@ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { return to.concat(ar || Array.prototype.slice.call(from)); }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.rules = exports.tips = exports.FColor = exports.text = exports.prefixes = exports.Gamemode = exports.FishServer = exports.mapRepoURLs = exports.Mode = exports.backendIP = exports.stopAntiEvadeTime = exports.heuristics = exports.adminNames = exports.multiCharSubstitutions = exports.substitutions = exports.bannedWords = void 0; +exports.rules = exports.tips = exports.FColor = exports.text = exports.prefixes = exports.GamemodeNames = exports.Gamemode = exports.FishServer = exports.mapRepoURLs = exports.Mode = exports.backendIP = exports.stopAntiEvadeTime = exports.heuristics = exports.adminNames = exports.multiCharSubstitutions = exports.substitutions = exports.bannedWords = void 0; var globals_1 = require("/globals"); var ranks_1 = require("/ranks"); var funcs_1 = require("/funcs"); @@ -219,6 +219,7 @@ exports.Gamemode = { minigame: function () { return exports.Gamemode.name() == "minigame"; }, name: function () { return Core.settings.get("mode", Vars.state.rules.mode().name()); }, }; +exports.GamemodeNames = Object.keys(exports.Gamemode).filter(function (x) { return x !== "name"; }); //#endregion //#region text content exports.prefixes = { @@ -267,7 +268,7 @@ exports.FColor = (function (data) { varChunks[_i - 1] = arguments[_i]; } return str != null ? - "".concat(c).concat(Array.isArray(str) ? String.raw.apply(String, __spreadArray([{ raw: str }], __read(varChunks), false)) : str, "[]") + "".concat(c).concat(Array.isArray(str) ? String.raw.apply(String, __spreadArray([{ raw: str }], __read(varChunks.map(function (v) { return String(v) + c; })), false)) : str, "[]") : c; } ]; @@ -277,6 +278,7 @@ exports.FColor = (function (data) { /** Used for tips and welcome messages. */ tip: "[gold]", member: "[pink]", + achievement: "[lime]", }); /** Tips that are shown to players randomly. */ exports.tips = { diff --git a/build/scripts/frameworks/commands/commands.js b/build/scripts/frameworks/commands/commands.js index fe133097..977caf3e 100644 --- a/build/scripts/frameworks/commands/commands.js +++ b/build/scripts/frameworks/commands/commands.js @@ -525,8 +525,10 @@ function register(commands, clientHandler, serverHandler) { //Verify authorization //as a bonus, this crashes if data.perm is undefined if (!data.perm.check(fishSender)) { - if (data.customUnauthorizedMessage) + if (data.customUnauthorizedMessage) { (0, utils_1.outputFail)(data.customUnauthorizedMessage, sender); + globals_1.FishEvents.fire("commandUnauthorized", [fishSender, name]); + } else if (data.isHidden) (0, utils_1.outputMessage)(hiddenUnauthorizedMessage, sender); else diff --git a/build/scripts/frameworks/menus.js b/build/scripts/frameworks/menus.js index aafd8244..a03ec72e 100644 --- a/build/scripts/frameworks/menus.js +++ b/build/scripts/frameworks/menus.js @@ -163,7 +163,7 @@ exports.Menu = { Call.menu(target.con, registeredListeners.generic, title, description, stringifiedOptions); return promise; }, - /** Displays a menu to a player, returning a Promise. Arranges options into a 2D array, and can add a Cancel option. */ + /** Displays a menu to a player, returning a Promise. Arranges provided options into a 2D array, and can add a Cancel option. */ menu: function (title, description, options, target, _a) { var _b = _a === void 0 ? {} : _a, _c = _b.includeCancel, includeCancel = _c === void 0 ? false : _c, _d = _b.optionStringifier, optionStringifier = _d === void 0 ? String : _d, _e = _b.columns, columns = _e === void 0 ? 3 : _e, _f = _b.onCancel, onCancel = _f === void 0 ? "ignore" : _f, _g = _b.cancelOptionId, cancelOptionId = _g === void 0 ? -1 : _g; //Set up the 2D array of options, and maybe add cancel @@ -196,10 +196,19 @@ exports.Menu = { var _b = _a.confirmText, confirmText = _b === void 0 ? "[red]Confirm" : _b, _c = _a.cancelText, cancelText = _c === void 0 ? "[green]Cancel" : _c, rest = __rest(_a, ["confirmText", "cancelText"]); return exports.Menu.confirm(target, description, __assign({ cancelText: cancelText, confirmText: confirmText }, rest)); }, + /** + * Displays a menu to a player, returning a Promise. + * Accepts pre-generated data and text. Alternative to optionStringifier if the text is already generated. + */ buttons: function (target, title, description, options, cfg) { if (cfg === void 0) { cfg = {}; } return exports.Menu.raw(title, description, options, target, __assign(__assign({}, cfg), { optionStringifier: function (o) { return o.text; } })).then(function (o) { return o === null || o === void 0 ? void 0 : o.data; }); }, + /** + * Displays a menu to a player, returning a Promise. + * Adds left and right arrows to switch pages. + * Shows different options based on the page. + */ pages: function (target, title, description, options, cfg) { var _a = promise_1.Promise.withResolvers(), promise = _a.promise, reject = _a.reject, resolve = _a.resolve; function showPage(index) { @@ -230,6 +239,12 @@ exports.Menu = { showPage(0); return promise; }, + /** + * Displays a menu to a player, returning a Promise. + * Adds left and right arrows to switch pages. + * Does not support options. + * Shows different text based on the current page. + */ textPages: function (target, pages, cfg) { if (cfg === void 0) { cfg = {}; } var _a = promise_1.Promise.withResolvers(), promise = _a.promise, reject = _a.reject, resolve = _a.resolve; @@ -281,7 +296,13 @@ exports.Menu = { showPage(index); return promise; }, - scroll: function (target, title, description, options, cfg) { + /** + * Displays a menu to a player, returning a Promise. + * Accepts a 2D array of options and shows a region of that 2D grid. + * Adds arrows to scroll left/right/up/down. + * Resolves to the selected option. + */ + scroll2D: function (target, title, description, options, cfg) { var _a, _b; if (cfg === void 0) { cfg = {}; } var _c = promise_1.Promise.withResolvers(), promise = _c.promise, reject = _c.reject, resolve = _c.resolve; @@ -290,24 +311,29 @@ exports.Menu = { var width = options[0].length; function showPage(x, y) { var _a, _b; - var opts = __spreadArray(__spreadArray([], __read(options.slice(y, y + rows).map(function (r) { return r.slice(x, x + cols).map(function (d) { return ({ text: d.text, data: [d.data] }); }); })), false), [ + var opts = __spreadArray(__spreadArray([], __read(options.slice(y, y + rows).map(function (r) { return r.concat(Array(width - r.length).fill({ data: "blank", text: "" })); }).map(function (r) { + return r.slice(x, x + cols).map(function (d) { return ({ text: d.text, data: [d.data] }); }); + })), false), [ [ { data: "blank", text: "" }, - { data: "up", text: "[".concat(y == 0 ? "gray" : "accent", "]^\n|") }, + ], + [ + { data: "blank", text: "" }, + { data: "up", text: "[".concat(y == 0 ? "gray" : "accent", "]").concat(String.fromCharCode(Iconc.up)) }, { data: "blank", text: "" }, ], [ - { data: "left", text: "[".concat(x == 0 ? "gray" : "accent", "]<--") }, + { data: "left", text: "[".concat(x == 0 ? "gray" : "accent", "]").concat(String.fromCharCode(Iconc.left)) }, { data: "blank", text: (_b = (_a = cfg.getCenterText) === null || _a === void 0 ? void 0 : _a.call(cfg, x, y)) !== null && _b !== void 0 ? _b : '' }, - { data: "right", text: "[".concat(x == width - cols ? "gray" : "accent", "]-->") } + { data: "right", text: "[".concat(x == width - cols ? "gray" : "accent", "]").concat(String.fromCharCode(Iconc.right)) }, ], [ { data: "blank", text: "" }, - { data: "down", text: "[".concat(y == height - rows ? "gray" : "accent", "]|\nV") }, + { data: "down", text: "[".concat(y == height - rows ? "gray" : "accent", "]").concat(String.fromCharCode(Iconc.down)) }, { data: "blank", text: "" }, ] ], false); - void exports.Menu.buttons(target, title, description, opts, __assign(__assign({}, cfg), { onCancel: "null" })).then(function (response) { + void exports.Menu.buttons(target, title, description, opts, cfg).then(function (response) { if (response instanceof Array) - resolve(response[0]); + resolve([response[0], x, y]); else if (response === "right") showPage(Math.min(x + 1, width - cols), y); else if (response === "left") @@ -329,6 +355,11 @@ exports.Menu = { showPage(Math.min((_a = cfg.x) !== null && _a !== void 0 ? _a : 0, width - cols), Math.min((_b = cfg.y) !== null && _b !== void 0 ? _b : 0, height - rows)); return promise; }, + /** + * Displays a menu to a player, returning a Promise. + * Adds left and right arrows to switch pages. Automatically paginates provided options. + * Accepts pre-generated data and text. Alternative to optionStringifier if the text is already generated. + */ pagedListButtons: function (target, title, description, options, _a) { var _b; var _c = _a.rowsPerPage, rowsPerPage = _c === void 0 ? 10 : _c, _d = _a.columns, columns = _d === void 0 ? 3 : _d, cfg = __rest(_a, ["rowsPerPage", "columns"]); @@ -338,6 +369,10 @@ exports.Menu = { return exports.Menu.buttons(target, title, description, (_b = pages[0]) !== null && _b !== void 0 ? _b : [], cfg); return exports.Menu.pages(target, title, description, pages, cfg); }, + /** + * Displays a menu to a player, returning a Promise. + * Adds left and right arrows to switch pages. Automatically paginates provided options. + */ pagedList: function (target, title, description, options, _a) { var _b; if (_a === void 0) { _a = {}; } diff --git a/build/scripts/globals.js b/build/scripts/globals.js index 86463f04..41058d4c 100644 --- a/build/scripts/globals.js +++ b/build/scripts/globals.js @@ -4,7 +4,7 @@ Copyright © BalaM314, 2026. All Rights Reserved. This file contains mutable global variables, and global constants. */ Object.defineProperty(exports, "__esModule", { value: true }); -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; +exports.FishEvents = exports.unitsT5 = exports.maxTime = exports.ipRangeWildcardPattern = exports.ipRangeCIDRPattern = exports.ipPortPattern = exports.ipPattern = exports.uuidPattern = exports.ipJoins = exports.fishPlugin = exports.fishState = exports.recentWhispers = exports.tileHistory = void 0; var funcs_1 = require("/funcs"); exports.tileHistory = {}; exports.recentWhispers = {}; @@ -29,4 +29,5 @@ exports.ipPortPattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{1,5}$/; 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 exports.ipRangeWildcardPattern = /^(\d{1,3}\.\d{1,3})\.(?:(\d{1,3}\.\*)|\*)$/; //Disallow anything bigger than a /16 exports.maxTime = 9999999999999; +exports.unitsT5 = [UnitTypes.reign, UnitTypes.toxopid, UnitTypes.corvus, UnitTypes.eclipse, UnitTypes.oct, UnitTypes.omura, UnitTypes.navanax, UnitTypes.conquer, UnitTypes.collaris, UnitTypes.disrupt]; exports.FishEvents = new funcs_1.EventEmitter(); diff --git a/build/scripts/index.js b/build/scripts/index.js index 1a35ecc4..8f9fcdd2 100644 --- a/build/scripts/index.js +++ b/build/scripts/index.js @@ -172,6 +172,7 @@ Events.on(EventType.ServerLoadEvent, function (e) { globals_1.FishEvents.fire("dataLoaded", []); Core.app.addListener({ dispose: function () { + players_1.FishPlayer.uploadAll(); globals_1.FishEvents.fire("saveData", []); players_1.FishPlayer.saveAll(false); Log.info("Saved on exit."); @@ -212,6 +213,7 @@ Events.on(EventType.GameOverEvent, function (e) { } players_1.FishPlayer.onGameOver(e.winner); }); +Events.on(EventType.WorldLoadEvent, function () { return players_1.FishPlayer.onGameBegin(); }); Events.on(EventType.PlayerChatEvent, function (e) { players_1.FishPlayer.onPlayerChat(e.player, e.message); }); diff --git a/build/scripts/main.js b/build/scripts/main.js index 3f537048..555c48a1 100644 --- a/build/scripts/main.js +++ b/build/scripts/main.js @@ -66,5 +66,6 @@ if(12.34.toFixed(1) !== '12.3'){ }; } +this.ArcReflect = Reflect; this.Promise = require('/promise').Promise; require("index"); diff --git a/build/scripts/players.js b/build/scripts/players.js index 5cd24b0e..77739a2a 100644 --- a/build/scripts/players.js +++ b/build/scripts/players.js @@ -121,6 +121,10 @@ var FishPlayer = /** @class */ (function () { this.tstats = { //remember to clear this in updateSavedInfoFromPlayer! blocksBroken: 0, + blockInteractionsThisMap: 0, + lastMapStartTime: 0, + lastMapPlayedTime: 0, + wavesSurvived: 0, }; /** Whether the player has manually marked themselves as AFK. */ this.manualAfk = false; @@ -167,6 +171,10 @@ var FishPlayer = /** @class */ (function () { this.lastJoined = -1; /** -1 represents unknown */ this.firstJoined = -1; + /** -1 represents unknown */ + this.globalLastJoined = -1; + /** -1 represents unknown */ + this.globalFirstJoined = -1; this.stats = { blocksBroken: 0, blocksPlaced: 0, @@ -175,8 +183,10 @@ var FishPlayer = /** @class */ (function () { gamesFinished: 0, gamesWon: 0, }; + this.globalStats = this.stats; /** Used for the /vanish command. */ this.showRankPrefix = true; + this.achievements = new Bits(); this.uuid = uuid; this.player = player; this.updateData(data); @@ -405,7 +415,7 @@ var FishPlayer = /** @class */ (function () { else { this.originalName = this.name = player.name; } - if (this.firstJoined == -1) + if (this.firstJoined < 1) this.firstJoined = Date.now(); //Do not update USID here this.manualAfk = false; @@ -418,9 +428,11 @@ var FishPlayer = /** @class */ (function () { this.shouldUpdateName = true; this.changedTeam = false; this.ipDetectedVpn = false; - this.tstats = { - blocksBroken: 0 - }; + this.tstats.blocksBroken = 0; + if (this.tstats.lastMapPlayedTime != FishPlayer.lastMapStartTime) { + this.tstats.blockInteractionsThisMap = 0; + this.tstats.lastMapPlayedTime = FishPlayer.lastMapStartTime; + } this.infoUpdated = true; }; FishPlayer.prototype.updateData = function (data) { @@ -435,6 +447,10 @@ var FishPlayer = /** @class */ (function () { this.lastJoined = data.lastJoined; if (data.firstJoined != undefined) this.firstJoined = data.firstJoined; + if (data.globalLastJoined != undefined) + this.globalLastJoined = data.globalLastJoined; + if (data.globalFirstJoined != undefined) + this.globalFirstJoined = data.globalFirstJoined; if (data.highlight != undefined) this.highlight = data.highlight; if (data.history != undefined) @@ -447,12 +463,16 @@ var FishPlayer = /** @class */ (function () { this.chatStrictness = data.chatStrictness; if (data.stats != undefined) this.stats = data.stats; + if (data.globalStats != undefined) + this.globalStats = data.globalStats; if (data.showRankPrefix != undefined) this.showRankPrefix = data.showRankPrefix; if (data.rank != undefined) this.rank = (_a = ranks_1.Rank.getByName(data.rank)) !== null && _a !== void 0 ? _a : ranks_1.Rank.player; if (data.flags != undefined) this.flags = new Set(data.flags.map(ranks_1.RoleFlag.getByName).filter(Boolean)); + if (data.achievements != undefined) + this.achievements = JsonIO.read(Bits, "{bits:".concat(data.achievements, "}")); }; FishPlayer.prototype.getData = function () { var _a = this, uuid = _a.uuid, name = _a.name, muted = _a.muted, unmarkTime = _a.unmarkTime, rank = _a.rank, flags = _a.flags, highlight = _a.highlight, rainbow = _a.rainbow, history = _a.history, usid = _a.usid, chatStrictness = _a.chatStrictness, lastJoined = _a.lastJoined, firstJoined = _a.firstJoined, stats = _a.stats, showRankPrefix = _a.showRankPrefix; @@ -471,7 +491,8 @@ var FishPlayer = /** @class */ (function () { stats: stats, showRankPrefix: showRankPrefix, rank: rank.name, - flags: __spreadArray([], __read(flags.values()), false).map(function (f) { return f.name; }) + flags: __spreadArray([], __read(flags.values()), false).map(function (f) { return f.name; }), + achievements: JsonIO.write(Reflect.get(this.achievements, "bits")) }; }; /** Warning: the "update" callback is run twice. */ @@ -493,7 +514,7 @@ var FishPlayer = /** @class */ (function () { //but it's unlikely to happen //could be fixed by transmitting the update operation to the server as a mongo update command afterFetch === null || afterFetch === void 0 ? void 0 : afterFetch(this); - return [4 /*yield*/, api.setFishPlayerData(this.getData())]; + return [4 /*yield*/, api.setFishPlayerData(this.getData(), 1, false)]; case 2: _a.sent(); return [2 /*return*/]; @@ -656,6 +677,7 @@ var FishPlayer = /** @class */ (function () { } else if ((0, utils_1.cleanText)(player.name, true).includes("hacker")) { fishPlayer.sendMessage("[scarlet]\u26A0 Don't be a script kiddie!"); + globals_1.FishEvents.fire("scriptKiddie", [fishPlayer]); } } fishPlayer.updateAdminStatus(); @@ -725,7 +747,7 @@ var FishPlayer = /** @class */ (function () { //Clear temporary states such as menu and taphandler fishP.activeMenus = []; fishP.tapInfo.commandName = null; - fishP.stats.timeInGame += (Date.now() - fishP.lastJoined); //Time between joining and leaving + fishP.updateStats(function (stats) { return stats.timeInGame += (Date.now() - fishP.lastJoined); }); //Time between joining and leaving fishP.lastJoined = Date.now(); this.recentLeaves.unshift(fishP); if (this.recentLeaves.length > 10) @@ -803,7 +825,7 @@ var FishPlayer = /** @class */ (function () { } } fishP.lastActive = Date.now(); - fishP.stats.chatMessagesSent++; + fishP.updateStats(function (stats) { return stats.chatMessagesSent++; }); }; FishPlayer.onPlayerCommand = function (player, command, unjoinedRawArgs) { if (command == "msg" && unjoinedRawArgs[1] == "Please do not use that logic, as it is attem83 logic and is bad to use. For more information please read www.mindustry.dev/attem") @@ -812,22 +834,25 @@ var FishPlayer = /** @class */ (function () { }; FishPlayer.onGameOver = function (winningTeam) { var _this = this; + globals_1.FishEvents.fire("gameOver", [winningTeam]); this.forEachPlayer(function (fishPlayer) { //Clear temporary states such as menu and taphandler fishPlayer.activeMenus = []; fishPlayer.tapInfo.commandName = null; //Update stats if (!_this.ignoreGameOver && fishPlayer.team() != Team.derelict && winningTeam != Team.derelict) { - fishPlayer.stats.gamesFinished++; + fishPlayer.updateStats(function (stats) { return stats.gamesFinished++; }); if (fishPlayer.changedTeam) { fishPlayer.sendMessage("Refusing to update stats due to a team change."); } else { if (fishPlayer.team() == winningTeam) - fishPlayer.stats.gamesWon++; + fishPlayer.updateStats(function (stats) { return stats.gamesWon++; }); } } fishPlayer.changedTeam = false; + fishPlayer.tstats.wavesSurvived = 0; + fishPlayer.tstats.blockInteractionsThisMap = 0; }); }; FishPlayer.ignoreGameover = function (callback) { @@ -835,6 +860,12 @@ var FishPlayer = /** @class */ (function () { callback(); this.ignoreGameOver = false; }; + FishPlayer.onGameBegin = function () { + var startTime = Date.now(); + FishPlayer.lastMapStartTime = startTime; + //wait 7 seconds for players to join + Timer.schedule(function () { return FishPlayer.forEachPlayer(function (p) { return p.tstats.lastMapStartTime = startTime; }); }, 7); + }; /** Must be run on UnitChangeEvent. */ FishPlayer.onUnitChange = function (player, unit) { if (unit === null || unit === void 0 ? void 0 : unit.spawnedByCore) @@ -1128,12 +1159,11 @@ var FishPlayer = /** @class */ (function () { return; var _loop_1 = function (rankToAssign) { if (!this_1.ranksAtLeast(rankToAssign) && rankToAssign.autoRankData) { - if ( //TODO: use global stats - this_1.joinsAtLeast(rankToAssign.autoRankData.joins) && - this_1.stats.blocksPlaced >= rankToAssign.autoRankData.blocksPlaced && - this_1.stats.timeInGame >= rankToAssign.autoRankData.playtime && - this_1.stats.chatMessagesSent >= rankToAssign.autoRankData.chatMessagesSent && - (Date.now() - this_1.firstJoined) >= rankToAssign.autoRankData.timeSinceFirstJoin) { + if (this_1.joinsAtLeast(rankToAssign.autoRankData.joins) && + this_1.globalStats.blocksPlaced >= rankToAssign.autoRankData.blocksPlaced && + this_1.globalStats.timeInGame >= rankToAssign.autoRankData.playtime && + this_1.globalStats.chatMessagesSent >= rankToAssign.autoRankData.chatMessagesSent && + (Date.now() - this_1.globalFirstJoined) >= rankToAssign.autoRankData.timeSinceFirstJoin) { void this_1.setRank(rankToAssign).then(function () { return _this.sendMessage("You have been automatically promoted to rank ".concat(rankToAssign.coloredName(), "!")); }); @@ -1338,6 +1368,11 @@ var FishPlayer = /** @class */ (function () { FishPlayer.prototype.shouldCache = function () { return this.ranksAtLeast("mod"); }; + FishPlayer.uploadAll = function () { + FishPlayer.forEachPlayer(function (fishP) { + return void api.setFishPlayerData(fishP.getData(), 1, true); + }); + }; /** Does not include stats */ FishPlayer.prototype.hasData = function () { return (this.rank != ranks_1.Rank.player) || this.muted || (this.flags.size > 0) || this.chatStrictness != "chat"; @@ -1568,6 +1603,10 @@ var FishPlayer = /** @class */ (function () { FishPlayer.prototype.joinsLessThan = function (amount) { return this.info().timesJoined < amount; }; + FishPlayer.prototype.updateStats = function (func) { + func(this.stats); + func(this.globalStats); + }; /** * Returns a score between 0 and 1, as an estimate of the player's skill level. * Defaults to 0.2 (guessing that the best trusted players can beat 5 noobs) @@ -1730,6 +1769,7 @@ var FishPlayer = /** @class */ (function () { FishPlayer.antiBotModePersist = false; FishPlayer.antiBotModeOverride = false; FishPlayer.lastBotWhacked = 0; + FishPlayer.lastMapStartTime = 0; FishPlayer.search = (0, funcs_1.search)(function (p, str) { return p.uuid === str; }, function (p, str) { return p.player.id.toString() === str; }, function (p, str) { return p.name.toLowerCase() === str.toLowerCase(); }, // (p, str) => p.cleanedName === str, function (p, str) { return p.cleanedName.toLowerCase() === str.toLowerCase(); }, function (p, str) { return p.name.toLowerCase().includes(str.toLowerCase()); }, @@ -1744,4 +1784,6 @@ var FishPlayer = /** @class */ (function () { return FishPlayer; }()); exports.FishPlayer = FishPlayer; +//TODO convert all the unnecessary event handlers to simple calls to Events.on +Events.on(EventType.WaveEvent, function () { return FishPlayer.forEachPlayer(function (p) { return p.tstats.wavesSurvived++; }); }); var templateObject_1, templateObject_2, templateObject_3, templateObject_4; diff --git a/build/scripts/timers.js b/build/scripts/timers.js index a7d2b00a..f7c28265 100644 --- a/build/scripts/timers.js +++ b/build/scripts/timers.js @@ -33,6 +33,7 @@ function initializeTimers() { Core.app.post(function () { SaveIO.save(file); players_1.FishPlayer.saveAll(); + players_1.FishPlayer.uploadAll(); Call.sendMessage('[#4fff8f9f]Game saved.'); globals_1.FishEvents.fire("saveData", []); }); diff --git a/build/scripts/utils.js b/build/scripts/utils.js index df185b72..68f0be26 100644 --- a/build/scripts/utils.js +++ b/build/scripts/utils.js @@ -86,6 +86,7 @@ exports.getHash = getHash; exports.match = match; exports.fishCommandsRootDirPath = fishCommandsRootDirPath; exports.applyEffectMode = applyEffectMode; +exports.getStatuses = getStatuses; var api = require("/api"); var config_1 = require("/config"); var commands_1 = require("/frameworks/commands"); @@ -581,6 +582,7 @@ function definitelyRealMemoryCorruption() { var hexString = Math.floor(Math.random() * 0xFFFFFFFF).toString(16).padStart(8, "0"); Call.sendMessage("[scarlet]Error: internal server error."); Call.sendMessage("[scarlet]Error: memory corruption: mindustry.world.modules.ItemModule@".concat(hexString)); + globals_1.FishEvents.fire("memoryCorruption", []); } function getEnemyTeam() { if (config_1.Gamemode.pvp()) @@ -773,7 +775,8 @@ exports.addToTileHistory = logErrors("Error while saving a tilelog entry", funct var fishP = players_1.FishPlayer.get(e.unit.player); //TODO move this code fishP.tstats.blocksBroken++; - fishP.stats.blocksBroken++; + fishP.tstats.blockInteractionsThisMap++; + fishP.updateStats(function (stats) { return stats.blocksBroken++; }); } } else { @@ -782,19 +785,30 @@ exports.addToTileHistory = logErrors("Error while saving a tilelog entry", funct if ((_k = (_j = e.unit) === null || _j === void 0 ? void 0 : _j.player) === null || _k === void 0 ? void 0 : _k.uuid()) { var fishP = players_1.FishPlayer.get(e.unit.player); //TODO move this code - fishP.stats.blocksPlaced++; + fishP.updateStats(function (stats) { return stats.blocksPlaced++; }); + fishP.tstats.blockInteractionsThisMap++; } } } else if (e instanceof EventType.ConfigEvent) { tile = e.tile.tile; uuid = (_m = (_l = e.player) === null || _l === void 0 ? void 0 : _l.uuid()) !== null && _m !== void 0 ? _m : "unknown"; + if (uuid != "unknown") { + var fishP = players_1.FishPlayer.getById(uuid); + if (fishP) + fishP.tstats.blockInteractionsThisMap++; + } action = "configured"; type = e.tile.block.name; } else if (e instanceof EventType.BuildRotateEvent) { tile = e.build.tile; uuid = (_s = (_q = (_p = (_o = e.unit) === null || _o === void 0 ? void 0 : _o.player) === null || _p === void 0 ? void 0 : _p.uuid()) !== null && _q !== void 0 ? _q : (_r = e.unit) === null || _r === void 0 ? void 0 : _r.type.name) !== null && _s !== void 0 ? _s : "unknown"; + if (uuid != "unknown") { + var fishP = players_1.FishPlayer.getById(uuid); + if (fishP) + fishP.tstats.blockInteractionsThisMap++; + } action = "rotated"; type = e.build.block.name; } @@ -1048,3 +1062,34 @@ function applyEffectMode(mode, unit, ticks) { } } } +var sources = [ + Packages.mindustry.gen.UnitEntity, + Packages.mindustry.gen.MechUnit, + Packages.mindustry.gen.LegsUnit, + Packages.mindustry.gen.CrawlUnit, + Packages.mindustry.gen.UnitWaterMove, + Packages.mindustry.gen.BlockUnitUnit, + Packages.mindustry.gen.ElevationMoveUnit, + Packages.mindustry.gen.BuildingTetherPayloadUnit, + Packages.mindustry.gen.TimedKillUnit, + Packages.mindustry.gen.PayloadUnit, + Packages.mindustry.gen.TankUnit, +]; +function getStatuses(unit) { + var e_8, _a; + try { + for (var sources_1 = __values(sources), sources_1_1 = sources_1.next(); !sources_1_1.done; sources_1_1 = sources_1.next()) { + var clazz = sources_1_1.value; + if (unit instanceof clazz) + return ArcReflect.get(clazz, unit, "statuses"); + } + } + catch (e_8_1) { e_8 = { error: e_8_1 }; } + finally { + try { + if (sources_1_1 && !sources_1_1.done && (_a = sources_1.return)) _a.call(sources_1); + } + finally { if (e_8) throw e_8.error; } + } + return new Seq(); +} diff --git a/spec/src/env.ts b/spec/src/env.ts index 69761cc8..64a30b64 100644 --- a/spec/src/env.ts +++ b/spec/src/env.ts @@ -687,7 +687,9 @@ const Vars = { modDirectory: new Fi('/mods'), customMapDirectory: new Fi('/maps'), content: { - + items(){ + return new Seq(Object.values(Items).filter(i => i instanceof Item)); + } }, tilesize: 8, world: { @@ -843,6 +845,73 @@ const Strings = { }, }; +class UnitType { + emoji(){return "e";} +} +const UnitTypes = Object.fromEntries( + ["mace", "dagger", "crawler", "fortress", "scepter", "reign", "vela", "nova", "pulsar", "quasar", "corvus", "atrax", "merui", "cleroi", "anthicus", "tecta", "collaris", "spiroct", "arkyid", "toxopid", "elude", "flare", "eclipse", "horizon", "zenith", "antumbra", "avert", "obviate", "mono", "poly", "mega", "evoke", "incite", "emanate", "quell", "disrupt", "quad", "oct", "alpha", "beta", "gamma", "risso", "minke", "bryde", "sei", "omura", "retusa", "oxynoe", "cyerce", "aegires", "navanax", "block", "manifold", "assemblyDrone", "stell", "locus", "precept", "vanquish", "conquer", "missile", "latum", "renale"] + .map(n => [n, new UnitType()]) +); + +class Item { + emoji(){return "e";} +} +const Items = Object.fromEntries( + ["scrap", "copper", "lead", "graphite", "coal", "titanium", "thorium", "silicon", "plastanium", "phaseFabric", "surgeAlloy", "sporePod", "sand", "blastCompound", "pyratite", "metaglass", "beryllium", "tungsten", "oxide", "carbide", "fissileMatter", "dormantCyst"] + .map(n => [n, new Item()]) +) as any; +Items.serpuloItems = new Seq(Object.values(Items).slice(0, -6)); +Items.erekirItems = new Seq([Items.graphite, Items.thorium, Items.silicon, Items.phaseFabric, Items.surgeAlloy, Items.beryllium, Items.tungsten, Items.oxide, Items.carbide, Items.sand]); + +class Block { + emoji(){return "e";} +} +const Blocks = Object.fromEntries( + ["air", "spawn", "removeWall", "removeOre", "cliff", "deepwater", "water", "taintedWater", "deepTaintedWater", "tar", "slag", "cryofluid", "stone", "craters", "charr", "sand", "darksand", "dirt", "mud", "ice", "snow", "darksandTaintedWater", "space", "empty", "dacite", "rhyolite", "rhyoliteCrater", "roughRhyolite", "regolith", "yellowStone", "redIce", "redStone", "denseRedStone", "arkyciteFloor", "arkyicStone", "redmat", "bluemat", "stoneWall", "dirtWall", "sporeWall", "iceWall", "daciteWall", "sporePine", "snowPine", "pine", "shrubs", "whiteTree", "whiteTreeDead", "sporeCluster", "redweed", "purbush", "yellowCoral", "rhyoliteVent", "carbonVent", "arkyicVent", "yellowStoneVent", "redStoneVent", "crystallineVent", "stoneVent", "basaltVent", "regolithWall", "yellowStoneWall", "rhyoliteWall", "carbonWall", "redIceWall", "ferricStoneWall", "beryllicStoneWall", "arkyicWall", "crystallineStoneWall", "redStoneWall", "redDiamondWall", "ferricStone", "ferricCraters", "carbonStone", "beryllicStone", "crystallineStone", "crystalFloor", "yellowStonePlates", "iceSnow", "sandWater", "darksandWater", "duneWall", "sandWall", "moss", "sporeMoss", "shale", "shaleWall", "grass", "salt", "coreZone", "shaleBoulder", "sandBoulder", "daciteBoulder", "boulder", "snowBoulder", "basaltBoulder", "carbonBoulder", "ferricBoulder", "beryllicBoulder", "yellowStoneBoulder", "arkyicBoulder", "crystalCluster", "vibrantCrystalCluster", "crystalBlocks", "crystalOrbs", "crystallineBoulder", "redIceBoulder", "rhyoliteBoulder", "redStoneBoulder", "metalFloor", "metalFloorDamaged", "metalFloor2", "metalFloor3", "metalFloor4", "metalFloor5", "basalt", "magmarock", "hotrock", "snowWall", "saltWall", "darkPanel1", "darkPanel2", "darkPanel3", "darkPanel4", "darkPanel5", "darkPanel6", "darkMetal", "metalTiles1", "metalTiles2", "metalTiles3", "metalTiles4", "metalTiles5", "metalTiles6", "metalTiles7", "metalTiles8", "metalTiles9", "metalTiles10", "metalTiles11", "metalTiles12", "metalTiles13", "metalWall1", "metalWall2", "metalWall3", "metalWall4", "coloredFloor", "coloredWall", "characterOverlayGray", "characterOverlayWhite", "runeOverlay", "cruxRuneOverlay", "pebbles", "tendrils", "oreCopper", "oreLead", "oreScrap", "oreCoal", "oreTitanium", "oreThorium", "oreBeryllium", "oreTungsten", "oreCrystalThorium", "wallOreThorium", "wallOreBeryllium", "graphiticWall", "wallOreTungsten", "siliconSmelter", "siliconCrucible", "kiln", "graphitePress", "plastaniumCompressor", "multiPress", "phaseWeaver", "surgeSmelter", "pyratiteMixer", "blastMixer", "cryofluidMixer", "melter", "separator", "disassembler", "sporePress", "pulverizer", "incinerator", "coalCentrifuge", "siliconArcFurnace", "electrolyzer", "oxidationChamber", "atmosphericConcentrator", "electricHeater", "slagHeater", "phaseHeater", "heatRedirector", "smallHeatRedirector", "heatRouter", "slagIncinerator", "carbideCrucible", "slagCentrifuge", "surgeCrucible", "cyanogenSynthesizer", "phaseSynthesizer", "heatReactor", "powerSource", "powerVoid", "itemSource", "itemVoid", "liquidSource", "liquidVoid", "payloadSource", "payloadVoid", "illuminator", "heatSource", "copperWall", "copperWallLarge", "titaniumWall", "titaniumWallLarge", "plastaniumWall", "plastaniumWallLarge", "thoriumWall", "thoriumWallLarge", "door", "doorLarge", "phaseWall", "phaseWallLarge", "surgeWall", "surgeWallLarge", "berylliumWall", "berylliumWallLarge", "tungstenWall", "tungstenWallLarge", "blastDoor", "reinforcedSurgeWall", "reinforcedSurgeWallLarge", "carbideWall", "carbideWallLarge", "shieldedWall", "mender", "mendProjector", "overdriveProjector", "overdriveDome", "forceProjector", "shockMine", "scrapWall", "scrapWallLarge", "scrapWallHuge", "scrapWallGigantic", "thruster", "ok", "these", "names", "are", "getting", "ridiculous", "but", "at", "least", "I", "don", "t", "have", "humongous", "walls", "yet", "radar", "buildTower", "regenProjector", "barrierProjector", "shockwaveTower", "shieldProjector", "largeShieldProjector", "shieldBreaker", "conveyor", "titaniumConveyor", "plastaniumConveyor", "armoredConveyor", "distributor", "junction", "itemBridge", "phaseConveyor", "sorter", "invertedSorter", "router", "overflowGate", "underflowGate", "unloader", "massDriver", "duct", "armoredDuct", "ductRouter", "overflowDuct", "underflowDuct", "ductBridge", "ductUnloader", "surgeConveyor", "surgeRouter", "unitCargoLoader", "unitCargoUnloadPoint", "mechanicalPump", "rotaryPump", "impulsePump", "conduit", "pulseConduit", "platedConduit", "liquidRouter", "liquidContainer", "liquidTank", "liquidJunction", "bridgeConduit", "phaseConduit", "reinforcedPump", "reinforcedConduit", "reinforcedLiquidJunction", "reinforcedBridgeConduit", "reinforcedLiquidRouter", "reinforcedLiquidContainer", "reinforcedLiquidTank", "combustionGenerator", "thermalGenerator", "steamGenerator", "differentialGenerator", "rtgGenerator", "solarPanel", "largeSolarPanel", "thoriumReactor", "impactReactor", "battery", "batteryLarge", "powerNode", "powerNodeLarge", "surgeTower", "diode", "turbineCondenser", "ventCondenser", "chemicalCombustionChamber", "pyrolysisGenerator", "fluxReactor", "neoplasiaReactor", "beamNode", "beamTower", "beamLink", "mechanicalDrill", "pneumaticDrill", "laserDrill", "blastDrill", "waterExtractor", "oilExtractor", "cultivator", "cliffCrusher", "largeCliffCrusher", "plasmaBore", "largePlasmaBore", "impactDrill", "eruptionDrill", "coreShard", "coreFoundation", "coreNucleus", "vault", "container", "coreBastion", "coreCitadel", "coreAcropolis", "reinforcedContainer", "reinforcedVault", "duo", "scatter", "scorch", "hail", "arc", "wave", "lancer", "swarmer", "salvo", "fuse", "ripple", "cyclone", "foreshadow", "spectre", "meltdown", "segment", "parallax", "tsunami", "breach", "diffuse", "sublimate", "titan", "disperse", "afflict", "lustre", "scathe", "smite", "malign", "groundFactory", "airFactory", "navalFactory", "additiveReconstructor", "multiplicativeReconstructor", "exponentialReconstructor", "tetrativeReconstructor", "repairPoint", "repairTurret", "tankFabricator", "shipFabricator", "mechFabricator", "tankRefabricator", "shipRefabricator", "mechRefabricator", "primeRefabricator", "tankAssembler", "shipAssembler", "mechAssembler", "basicAssemblerModule", "unitRepairTower", "payloadConveyor", "payloadRouter", "reinforcedPayloadConveyor", "reinforcedPayloadRouter", "payloadMassDriver", "largePayloadMassDriver", "smallDeconstructor", "deconstructor", "constructor", "largeConstructor", "payloadLoader", "payloadUnloader", "message", "switchBlock", "microProcessor", "logicProcessor", "hyperProcessor", "largeLogicDisplay", "logicDisplay", "tileLogicDisplay", "memoryCell", "memoryBank", "canvas", "reinforcedMessage", "worldProcessor", "worldCell", "worldMessage", "worldSwitch", "launchPad", "advancedLaunchPad", "landingPad", "interplanetaryAccelerator"] + .map(n => [n, new Block()]) +) as any; + +class StatusEffect { + emoji(){return "e";} +} +const StatusEffects = Object.fromEntries( + ["none", "burning", "freezing", "unmoving", "slow", "fast", "wet", "muddy", "melting", "sapped", "tarred", "overdrive", "overclock", "shielded", "shocked", "blasted", "corroded", "boss", "sporeSlowed", "disarmed", "electrified", "invincible", "dynamic"] + .map(n => [n, new StatusEffect()]) +) as any; + +class ItemStack { + constructor( + public item: Item, public amount: number, + ){} +} + +class Bits { + bits = new BigUint64Array(1); + constructor(capacity?: number){ /*empty*/ } + get(index:number):boolean { + const word = index >>> 6; + return word < this.bits.length && Boolean(this.bits[word] & BigInt(1 << index)); + } + set(index:number, value:boolean = true){ + const word = index >>> 6; + if(value){ + this.checkCapacity(word); + this.bits[word] |= BigInt(1 << (index & 0x3F)); + } else { + if(word >= this.bits.length) return; + this.bits[word] &= BigInt(~(1 << (index & 0x3F))); + } + } + checkCapacity(len:number){ + if(len >= this.bits.length){ + const newBits = new BigUint64Array(len + 1); + newBits.set(this.bits); + this.bits = newBits; + } + } +} + +const Iconc = Object.fromEntries(["rotate", "modeSurvival", "power", "left", "redditAlien", "edit", "downOpen", "pencil", "file", "lockOpen", "right", "infoCircle", "pick", "settings", "spray1", "terrain", "exit", "wrench", "lock", "discord", "eye", "none", "play", "diagonal", "eraser", "trash", "liquid", "fileImage", "defense", "layers", "grid", "admin", "steam", "star", "chartBar", "chat", "android", "image", "map", "logic", "menu", "commandRally", "editor", "folder", "units", "commandAttack", "copy", "filter", "cancel", "terminal", "upload", "eyeOff", "save", "planeOutline", "fill", "distribution", "upOpen", "rightOpen", "modePvp", "download", "list", "flipX", "flipY", "effect", "paste", "planet", "waves", "up", "warning", "tree", "add", "down", "host", "spray", "info", "players", "resize", "refresh1", "production", "crafting", "pause", "googleplay", "hammer", "fileText", "modeAttack", "move", "zoom", "bookOpen", "refresh", "ok", "home", "githubSquare", "powerOld", "github", "undo", "box", "trello", "book", "export", "fileTextFill", "rightOpenOut", "turret", "leftOpen", "line", "itchio", "link", "filters", "redo"].map(n => [n, 0xFE60])); const Packages = { java: { @@ -856,4 +925,4 @@ const Packages = { gen: { Map: MMap } } }; -Object.assign(globalThis, {Pattern, ObjectIntMap, Seq, Fi, Packages, Events, Trigger, Team, EventType, Timer, EffectCallPacket2, LabelReliableCallPacket, Vars, ServerControl, Core, Log, Menus, Time, CommandHandler, Gamemode, Fx, Effect, Vec2, Tmp, Paths, Path, Threads, CommandRunner, Strings}); +Object.assign(globalThis, {Pattern, ObjectIntMap, Seq, Fi, Packages, Events, Trigger, Team, EventType, Timer, EffectCallPacket2, LabelReliableCallPacket, Vars, ServerControl, Core, Log, Menus, Time, CommandHandler, Gamemode, Fx, Effect, Vec2, Tmp, Paths, Path, Threads, CommandRunner, Strings, UnitTypes, Bits, Items, ItemStack, Iconc, Blocks, StatusEffects}); diff --git a/src/achievements.ts b/src/achievements.ts new file mode 100644 index 00000000..dcc5b28d --- /dev/null +++ b/src/achievements.ts @@ -0,0 +1,598 @@ +import { FColor, Gamemode, GamemodeName, GamemodeNames } from "/config"; +import { Duration } from "/funcs"; +import { FishEvents, unitsT5 } from "/globals"; +import { FishPlayer } from "/players"; +import { Rank } from "/ranks"; +import { getStatuses } from "/utils"; + +//scrap doesn't count +const serpuloItems = [Items.copper, Items.lead, Items.graphite, Items.silicon, Items.metaglass, Items.titanium, Items.plastanium, Items.thorium, Items.surgeAlloy, Items.phaseFabric]; +const erekirItems = [Items.beryllium, Items.graphite, Items.silicon, Items.tungsten, Items.oxide, Items.surgeAlloy, Items.thorium, Items.carbide, Items.phaseFabric]; +const usefulItems10k = { + serpulo: serpuloItems.map(i => new ItemStack(i, 10_000)), + erekir: erekirItems.map(i => new ItemStack(i, 10_000)), + sun: [...serpuloItems, ...erekirItems].map(i => new ItemStack(i, 10_000)), +}; +const allItems1k = Vars.content.items().select(i => !i.hidden).toArray().map(i => new ItemStack(i, 1000)); +const mixtechItems = Items.serpuloItems.copy(); +Items.erekirItems.each(i => mixtechItems.add(i)); + +export class Achievement { + nid: number; + sid!: string; + + icon: string; + description: string; + extendedDescription?: string; + + checkPlayerInfrequent?: (player:FishPlayer) => boolean; + checkPlayerFrequent?: (player:FishPlayer) => boolean; + checkPlayerJoin?: (player:FishPlayer) => boolean; + checkPlayerGameover?: (player:FishPlayer, winTeam:Team) => boolean; + checkInfrequent?: (team: Team) => boolean; + checkFrequent?: (team: Team) => boolean; + checkGameover?: (winTeam:Team) => boolean; + + notify: "nobody" | "player" | "everyone" = "player"; + hidden = false; + disabled = false; + allowedModes: GamemodeName[]; + modesText: string; + + static all: Achievement[] = []; + /** Checked every second. */ + static checkFrequent: Achievement[] = []; + /** Checked every 10 seconds. Use for states that can be gained but not lost, such as "x wins". */ + static checkInfrequent: Achievement[] = []; + static checkJoin: Achievement[] = []; + static checkGameover: Achievement[] = []; + + private static _id = 0; + constructor( + icon: string | number | [string, number | string], + public name: string, + description: string | [string, string], + options: Partial & { + modes: ["only" | "not", ...GamemodeName[]]; + }> = {}, + ){ + if(Array.isArray(icon)){ + this.icon = (icon[0].startsWith("[") ? icon[0] : `[${icon[0]}]`) + (typeof icon[1] == "number" ? String.fromCharCode(icon[1]) : icon[1]); + } else if(typeof icon == "number"){ + this.icon = String.fromCharCode(icon); + } else { + this.icon = icon; + } + if(Array.isArray(description)){ + [this.description, this.extendedDescription] = description; + } else this.description = description; + this.nid = Achievement._id ++; + Object.assign(this, options); + if(options.modes){ + const [type, ...modes] = options.modes; + if(type == "only"){ + this.allowedModes = modes; + this.modesText = modes.join(", "); + } else { + this.allowedModes = GamemodeNames.filter(m => !modes.includes(m)); + this.modesText = `all except ${modes.join(", ")}`; + } + } else { + this.allowedModes = GamemodeNames; + this.modesText = `all`; + } + if(!this.disabled){ + Achievement.all.push(this); + if(this.checkPlayerFrequent || this.checkFrequent) Achievement.checkFrequent.push(this); + if(this.checkPlayerInfrequent || this.checkInfrequent) Achievement.checkInfrequent.push(this); + if(this.checkPlayerJoin) Achievement.checkJoin.push(this); + if(this.checkPlayerGameover || this.checkGameover) Achievement.checkGameover.push(this); + } + } + + message():string { + return FColor.achievement`Achievement granted!\n[accent]${this.name}[white]: ${this.description}`; + } + messageToEveryone(player:FishPlayer):string { + return FColor.achievement`Player ${player.prefixedName} has completed the achievement "${this.name}".`; + } + allowedInMode(){ + return this.allowedModes.includes(Gamemode.name()); + } + + public grantToAllOnline(team?: Team){ + FishPlayer.forEachPlayer(p => { + if(!this.has(p) && (!team || p.team() == team)){ + if(this.notify != "nobody") p.sendMessage(this.message()); + this.setObtained(p); + } + }); + } + /** Do not call this in a loop on an achievement set to notify everyone. */ + public grantTo(player:FishPlayer){ + if(this.notify == "everyone") Call.sendMessage(this.messageToEveryone(player)); + else if(this.notify == "player") player.sendMessage(this.message()); + if(!this.has(player)) this.setObtained(player); + } + + private setObtained(player:FishPlayer){ + //void player.updateSynced(fishP => fishP.achievements.set(this.nid)); + player.achievements.set(this.nid); + } + public has(player:FishPlayer){ + return player.achievements.get(this.nid); + } +} + +Events.on(EventType.PlayerJoin, ({player}: {player: mindustryPlayer}) => { + for(const ach of Achievement.checkJoin){ + if(ach.allowedInMode()){ + const fishP = FishPlayer.get(player); + if(!ach.has(fishP) && ach.checkPlayerJoin?.(fishP)){ + if(fishP.dataSynced) ach.grantTo(fishP); + else Timer.schedule(() => ach.grantTo(fishP), 2); //2 seconds should be enough + } + } + } +}); +FishEvents.on("gameOver", (_, winner) => { + for(const ach of Achievement.checkGameover){ + if(ach.allowedInMode()){ + if(ach.checkGameover?.(winner)) ach.grantToAllOnline(); + else FishPlayer.forEachPlayer(fishP => { + if(!ach.has(fishP) && ach.checkPlayerGameover?.(fishP, winner)){ + ach.grantTo(fishP); + } + }); + } + } +}); +Timer.schedule(() => { + for(const ach of Achievement.checkFrequent){ + if(ach.allowedInMode()){ + if(ach.checkFrequent){ + if(Gamemode.pvp()){ + Vars.state.teams.active.each(({team}) => { + if(ach.checkFrequent!(team)) ach.grantToAllOnline(team); + }); + } else { + if(ach.checkFrequent(Vars.state.rules.defaultTeam)) ach.grantToAllOnline(); + } + } else { + FishPlayer.forEachPlayer(fishP => { + if(!ach.has(fishP) && ach.checkPlayerFrequent?.(fishP)) ach.grantTo(fishP); + }); + } + } + } +}, 1, 1); +Timer.schedule(() => { + for(const ach of Achievement.checkInfrequent){ + if(ach.allowedInMode()){ + if(ach.checkInfrequent){ + if(Gamemode.pvp()){ + Vars.state.teams.active.each(({team}) => { + if(ach.checkInfrequent!(team)) ach.grantToAllOnline(team); + }); + } else { + if(ach.checkInfrequent(Vars.state.rules.defaultTeam)) ach.grantToAllOnline(); + } + } else { + FishPlayer.forEachPlayer(fishP => { + if(!ach.has(fishP) && ach.checkPlayerInfrequent?.(fishP)) ach.grantTo(fishP); + }); + } + } + } +}, 10, 10); + +export const Achievements = { + // =========================== + // ╦ ╦ ╔═╗ ╦═╗ ╔╗╔ ╦ ╔╗╔ ╔═╗ ┬ + // ║║║ ╠═╣ ╠╦╝ ║║║ ║ ║║║ ║ ╦ │ + // ╚╩╝ ╩ ╩ ╩╚═ ╝╚╝ ╩ ╝╚╝ ╚═╝ o + // =========================== + // Do not change the order of any achievements. + // Do not remove any achievements: instead, set the "disabled" option to true. + // Reordering achievements will cause ID shifts. + + + //Joining based + welcome: new Achievement(["gold", Iconc.infoCircle], "Welcome", "Join the server.", { + checkPlayerJoin: () => true, + notify: "nobody" + }), + migratory_fish: new Achievement(Iconc.exit, "Migratory Fish", "Join all of our servers.", { + disabled: true + }), //TODO + frequent_visitor: new Achievement(Iconc.planeOutline, "Frequent Visitor", ["Join the server 100 times.", "Note: Do not reconnect frequently, that will not work. This achievement requires that you have been playing for 1 month."], { + checkPlayerJoin: p => p.info().timesJoined >= 100 && (Date.now() - p.globalFirstJoined > Duration.months(1)) + }), + + //Gamemode based + attack: new Achievement(Iconc.modeAttack, "Attack", ["Defeat an attack map.", "You must be present for the beginning and end of the game."], { + modes: ["only", "attack"], + checkPlayerGameover: (player, winTeam) => + Vars.state.rules.defaultTeam == winTeam && player.tstats.lastMapStartTime == FishPlayer.lastMapStartTime, + }), + survival: new Achievement(Iconc.modeSurvival, "Survival", ["Survive 50 waves in a survival map.", "Must be during the same game."], { + modes: ["only", "survival"], + checkPlayerInfrequent: (player) => + player.tstats.wavesSurvived >= 50, + }), + pvp: new Achievement(Iconc.modePvp, "PVP", ["Win a match of PVP.", "You must be present for the beginning and end of the game."], { + modes: ["only", "pvp"], + checkPlayerGameover: (player, winTeam) => + player.team() == winTeam && player.tstats.lastMapStartTime == FishPlayer.lastMapStartTime, + }), + sandbox: new Achievement(Iconc.image, "Sandbox", "Spend 1 hour in Sandbox.", { + modes: ["only", "sandbox"], + checkPlayerInfrequent: p => p.stats.timeInGame > Duration.hours(1), + }), + hexed: new Achievement(Iconc.layers, "Hexed", ["Play a match of Hexed.", "You must be present for the beginning and end of the game."], { + modes: ["only", "hexed"], + checkPlayerGameover: (player) => + player.tstats.lastMapStartTime == FishPlayer.lastMapStartTime, + }), + minigame: new Achievement(Iconc.play, "Minigame", ["Win a Minigame.", "You must be present for the beginning and end of the game."], { + modes: ["only", "minigame"], + checkPlayerGameover: (player, winTeam) => + player.team() == winTeam && player.tstats.lastMapStartTime == FishPlayer.lastMapStartTime, + }), + + //playtime based + playtime_1: new Achievement(["white", Iconc.googleplay], "Playtime 1", "Spend 1 hour in-game.", { + checkPlayerInfrequent: p => p.globalStats.timeInGame >= Duration.hours(1) + }), + playtime_2: new Achievement(["red", Iconc.googleplay], "Playtime 2", "Spend 12 hours in-game.", { + checkPlayerInfrequent: p => p.globalStats.timeInGame >= Duration.hours(12) + }), + playtime_3: new Achievement(["orange", Iconc.googleplay], "Playtime 3", "Spend 2 days in-game.", { + checkPlayerInfrequent: p => p.globalStats.timeInGame >= Duration.days(2) + }), + playtime_4: new Achievement(["yellow", Iconc.googleplay], "Playtime 4", "Spend 10 days in-game.", { + checkPlayerInfrequent: p => p.globalStats.timeInGame >= Duration.days(10) + }), + + //victories based + victory_1: new Achievement(["white", Iconc.star], "First Victory", "Win a map run.", { + checkPlayerGameover: p => p.globalStats.gamesWon >= 1 + }), + victory_2: new Achievement(["red", Iconc.star], "Victories 2", "Win 5 map runs.", { + checkPlayerGameover: p => p.globalStats.gamesWon >= 5 + }), + victory_3: new Achievement(["orange", Iconc.star], "Victories 3", "Win 30 map runs.", { + checkPlayerGameover: p => p.globalStats.gamesWon >= 30 + }), + victory_4: new Achievement(["yellow", Iconc.star], "Victories 4", "Win 100 map runs.", { + checkPlayerGameover: p => p.globalStats.gamesWon >= 100, + notify: "everyone" + }), + + //games based + games_1: new Achievement(["white", Iconc.itchio], "Games 1", "Play 10 map runs.", { + checkPlayerGameover: p => p.globalStats.gamesFinished >= 10 + }), + games_2: new Achievement(["red", Iconc.itchio], "Games 2", "Play 40 map runs.", { + checkPlayerGameover: p => p.globalStats.gamesFinished >= 40 + }), + games_3: new Achievement(["orange", Iconc.itchio], "Games 3", "Play 100 map runs.", { + checkPlayerGameover: p => p.globalStats.gamesFinished >= 100 + }), + games_4: new Achievement(["yellow", Iconc.itchio], "Games 4", "Play 200 map runs.", { + checkPlayerGameover: p => p.globalStats.gamesFinished >= 200, + notify: "everyone" + }), + + //messages based + messages_1: new Achievement(["white", Iconc.chat], "Hello", "Send your first chat message.", { + checkPlayerInfrequent: p => p.globalStats.chatMessagesSent >= 1, + notify: "nobody" + }), + messages_2: new Achievement(["red", Iconc.chat], "Chat 2", ["Send 100 chat messages.", "Warning: you will be kicked if you spam the chat."], { + checkPlayerInfrequent: p => p.globalStats.chatMessagesSent >= 100 + }), + messages_3: new Achievement(["orange", Iconc.chat], "Chat 3", ["Send 500 chat messages.", "Warning: you will be kicked if you spam the chat."], { + checkPlayerInfrequent: p => p.globalStats.chatMessagesSent >= 500 + }), + messages_4: new Achievement(["yellow", Iconc.chat], "Chat 4", ["Send 2000 chat messages.", "Warning: you will be kicked if you spam the chat."], { + checkPlayerInfrequent: p => p.globalStats.chatMessagesSent >= 2000 + }), + messages_5: new Achievement(["lime", Iconc.chat], "Chat 4", ["Send 5000 chat messages.", "Warning: you will be kicked if you spam the chat."], { + checkPlayerInfrequent: p => p.globalStats.chatMessagesSent >= 5000, + notify: "everyone" + }), + + //blocks built based + builds_1: new Achievement(["white", Iconc.fileText], "The Factory Must Prepare", "Construct 1 buildings.", { + checkPlayerInfrequent: p => p.globalStats.blocksPlaced >= 1, + notify: "nobody" + }), + builds_2: new Achievement(["red", Iconc.fileText], "The Factory Must Begin", "Construct 200 buildings.", { + checkPlayerInfrequent: p => p.globalStats.blocksPlaced > 200 + }), + builds_3: new Achievement(["orange", Iconc.fileText], "The Factory Must Produce", "Construct 1000 buildings.", { + checkPlayerInfrequent: p => p.globalStats.blocksPlaced > 1000 + }), + builds_4: new Achievement(["yellow", Iconc.fileText], "The Factory Must Grow", "Construct 5000 buildings.", { + checkPlayerInfrequent: p => p.globalStats.blocksPlaced > 5000, + }), + + //units + t5: new Achievement(Blocks.tetrativeReconstructor.emoji(), "T5", "Control a T5 unit.", { + modes: ["not", "sandbox"], checkPlayerFrequent(player) { + return (unitsT5 as Array).includes(player.unit()?.type); + }, + }), + dibs: new Achievement(["green", Blocks.tetrativeReconstructor.emoji()], "Dibs", "Be the first player to control the first T5 unit made by a reconstructor that you placed.", { + modes: ["not", "sandbox"], + disabled: true + }), //TODO + worm: new Achievement(UnitTypes.latum.emoji(), "Worm", "Control a Latum.", { + checkPlayerFrequent(player) { + return player.unit()?.type == UnitTypes.latum; + } + }), + + //pvp + above_average: new Achievement(Iconc.chartBar, "Above Average", ["Reach a win rate above 50%.", "Must be over at least 20 games of PVP."], { + modes: ["only", "pvp"], + checkPlayerInfrequent: p => p.stats.gamesWon / p.stats.gamesFinished > 0.5 && p.stats.gamesFinished >= 20 + }), + head_start: new Achievement(Iconc.commandAttack, "Head Start", ["Win a match of PVP where your opponents have a 5 minute head start.", "Your team must wait for the first 5 minutes without building or descontructing any buildings."], { + modes: ["only", "pvp"], + disabled: true + }), //TODO + one_v_two: new Achievement(["red", Iconc.modePvp], "1v2", "Defeat two (or more) opponents in PVP without help from other players.", { + modes: ["only", "pvp"], + disabled: true + }), //TODO + + //sandbox + underpowered: new Achievement(["red", Blocks.powerSource.emoji()], "Underpowered", "Overload a power source.", { + modes: ["only", "sandbox"], + checkFrequent(){ + let found = false; + //deliberate ordering for performance reasons + Groups.powerGraph.each(({graph}) => { + //we don't need to actually check for power sources, just assume that ~1mil power is a source + if(graph.lastPowerNeeded > graph.lastPowerProduced && graph.lastPowerNeeded < 1e10 && graph.lastPowerProduced >= 999_900) + found = true; + }); + return found; + } + }), + + //easter eggs + memory_corruption: new Achievement(["red", Iconc.host], "Is the server OK?", "Witness a memory corruption.", { + notify: "nobody" + }), + run_js_without_perms: new Achievement(["yellow", Iconc.warning], "XKCD 838", ["Receive a warning from the server that an incident will be reported.", "One of the admin commands has a custom error message."], { + notify: "everyone" + }), + script_kiddie: new Achievement(["red", Iconc.warning], "Script Kiddie", ["Pretend to be a hacker. The server will disagree.", "Change your name to something including \"hacker\"."], { + notify: "nobody" + }), + hacker: new Achievement(["lightgray", Iconc.host], "Hacker", "Find a bug in the server and report it responsibly.", { + hidden: true + }), + + //items based + items_10k: new Achievement(["green", Iconc.distribution], "Cornucopia", "Obtain 10k of every useful resource.", { + modes: ["not", "sandbox"], + checkPlayerFrequent(player) { + if(!Vars.state.planet) return false; + return player.team().items()?.has(usefulItems10k[Vars.state.planet.name as "serpulo" | "erekir" | "sun"]) || false; + }, + }), + fullVault: new Achievement(["green", Blocks.vault.emoji()], "Well Stocked", ["Fill a vault with every obtainable item.", "Requires mixtech."], { + modes: ["not", "sandbox"], + checkInfrequent(team) { + return Vars.indexer.getFlagged(team, BlockFlag.storage).contains(boolf(b => b.block == Blocks.vault && b.items.has(allItems1k))); + }, + }), + full_core: new Achievement(["green", Blocks.coreAcropolis.emoji()], "Multiblock Incinerator", "Completely fill the core with all obtainable items on a map with core incineration enabled.", { + modes: ["not", "sandbox"], + checkFrequent(team) { + if(!Vars.state.planet) return false; + let items; + switch(Vars.state.planet.name as "serpulo" | "erekir" | "sun"){ + case "serpulo": items = Items.serpuloItems; break; + case "erekir": items = Items.erekirItems; break; + case "sun": items = mixtechItems; break; + } + const capacity = team.core()?.storageCapacity; + if(!capacity) return false; + const module = team.items(); + return items.allMatch(i => module.has(i, capacity)); + }, + }), + siligone: new Achievement(["red", Items.silicon.emoji()], "Siligone", ["Run out of silicon.", "You must have reached 2000 silicon before running out."], { + modes: ["not", "sandbox"] + }), + silicon_100k: new Achievement(["green", Items.silicon.emoji()], "Silicon for days", "Obtain 100k silicon.", { + modes: ["not", "sandbox"], + checkFrequent: team => team.items().has(Items.silicon, 100_000) + }), + + //other players based + alone: new Achievement(["red", Iconc.players], "Alone", "Be the only player online for more than two minutes", { + notify: "nobody" + }), + join_playercount_20: new Achievement(["lime", Iconc.players], "Is there enough room?", "Join a server with 20 players online", { + checkPlayerJoin: () => Groups.player.size() > 20, + }), + meet_staff: new Achievement(["lime", Iconc.hammer], "Griefer Beware", "Meet a staff member in-game", { + checkPlayerJoin: () => Groups.player.contains(p => FishPlayer.get(p).ranksAtLeast("mod")), + }), + meet_fish: new Achievement(["blue", Iconc.admin], "The Big Fish", "Meet >|||>Fish himself in-game", { + checkPlayerJoin: () => Groups.player.contains(p => FishPlayer.get(p).ranksAtLeast("fish")), + hidden: true, + }), + server_speak: new Achievement(["pink", Iconc.host], "It Speaks!", "Hear the server talk in chat."), + see_marked_griefer: new Achievement(["red", Iconc.hammer], "Flying Tonk", "See a marked griefer in-game.", { + checkInfrequent: () => Groups.player.contains(p => FishPlayer.get(p).marked()), + }), + + //maps based + beat_map_not_in_rotation: new Achievement(["pink", Iconc.map], "How?", "Beat a map that isn't in the list of maps.", { + notify: "everyone", + modes: ["not", "pvp"], + checkGameover: (team) => team == Vars.state.rules.defaultTeam && !Vars.state.map.custom + }), + + //misc + power_1mil: new Achievement(["green", Blocks.powerSource.emoji()], "Who needs sources?", "Reach a power production of 1 million without using power sources.", { + modes: ["not", "sandbox"], + checkFrequent(){ + let found = false; + //deliberate ordering for performance reasons + Groups.powerGraph.each(({graph}) => { + //we don't need to actually check for power sources, just assume that ~1mil power is a source + if(graph.lastPowerProduced > 1e6 && !graph.producers.contains(boolf(b => b.block == Blocks.powerSource))) + found = true; + }); + return found; + } + }), + pacifist_crawler: new Achievement(UnitTypes.crawler.emoji(), "Pacifist Crawler", "Control a crawler for 15 minutes without exploding.", { + modes: ["not", "sandbox"], + disabled: true + }), //TODO + core_low_hp: new Achievement(["yellow", Blocks.coreNucleus.emoji()], "Close Call", "Have your core reach less than 50 health, but survive.", { + modes: ["not", "sandbox"], + }), + enemy_core_low_hp: new Achievement(["red", Blocks.coreNucleus.emoji()], "So Close", "Cause the enemy core to reach less than 50 health, but survive.", { + modes: ["not", "sandbox"], + }), + verified: new Achievement([Rank.active.color, Iconc.ok], "Verified", `Be promoted automatically to ${Rank.active.coloredName()} rank.`, { + checkPlayerJoin: p => p.ranksAtLeast("active"), notify: "nobody" + }), + click_me: new Achievement(Iconc.bookOpen, "Clicked", `Run /achievementgrid and click this achievement.`), + afk: new Achievement(["yellow", Iconc.lock], "AFK?", "Win a game without interacting with any blocks.", { + modes: ["not", "sandbox"], + checkPlayerGameover(player, winTeam) { + return player.team() == winTeam && player.tstats.blockInteractionsThisMap == 0; + }, + }), + status_effects_5: new Achievement(StatusEffects.electrified.emoji(), "A Furious Cocktail", "Have at least 5 status effects at once.", { + checkPlayerFrequent: p => { + const unit = p.unit(); + if(!unit) return false; + const statuses = getStatuses(unit); + return statuses.size >= 5; + }, + modes: ["not", "sandbox"] + }), + drown_big_tank: new Achievement(["blue", UnitTypes.conquer.emoji()], "Not Waterproof", "Drown an enemy Conquer or Vanquish.", { + notify: "everyone", + modes: ["not", "sandbox"] + }), + drown_mace_in_cryo: new Achievement(["cyan", UnitTypes.mace.emoji()], "Cooldown", `Drown a Mace in ${Blocks.cryofluid.emoji()} Cryofluid.`, { + notify: "everyone", + modes: ["not", "sandbox"] + }), + max_boost_duo: new Achievement(["yellow", Blocks.duo.emoji()], "In Duo We Trust", "Control a Duo with maximum boosts.", { + checkPlayerFrequent(player) { + const tile = player.unit()?.tile?.(); + if(!tile) return false; + return tile.block == Blocks.duo && tile.ammo!.peek().item == Items.silicon && tile.liquids.current() == Liquids.cryofluid && tile.timeScale() >= 2.5; + }, + notify: "everyone", + modes: ["not", "sandbox"] + }), + foreshadow_overkill: new Achievement(["yellow", Blocks.foreshadow.emoji()], "Overkill", ["Kill a Dagger with a maximally boosted Foreshadow.", "Hint: the maximum overdrive is not +150%..."], { + notify: "everyone", + modes: ["not", "sandbox"] + }), + impacts_15: new Achievement(["green", Blocks.impactReactor.emoji()], "Darthscion's Nightmare", "Run 15 impact reactors at full efficiency.", { + modes: ["not", "sandbox"], + notify: "everyone", + checkInfrequent(team){ + let found = false; + //deliberate ordering for performance reasons + Groups.powerGraph.each(({graph}) => { + //assume that any network running 15 impacts has at least 2 other power sources + if(graph.producers.size >= 17 && graph.producers.count(b => b.block == Blocks.impactReactor && b.warmup! > 0.99999) > 15 && graph.producers.first().team == team) + found = true; + }); + return found; + }, + }), + +} satisfies Record; +Object.entries(Achievements).forEach(([id, a]) => a.sid = id); + +FishEvents.on("commandUnauthorized", (_, player, name) => { + if(name == "js" || name == "fjs") Achievements.run_js_without_perms.grantTo(player); +}); + + +Events.on(EventType.UnitDrownEvent, ({unit}:{unit: Unit}) => { + if(!Gamemode.sandbox()){ + if(unit.type == UnitTypes.mace && unit.tileOn()?.floor() == Blocks.cryofluid) Achievements.drown_mace_in_cryo.grantToAllOnline(); + else if(unit.type == UnitTypes.conquer || unit.type == UnitTypes.vanquish){ + if(Gamemode.pvp()){ + Vars.state.teams.active.map(t => t.team).select(t => t !== unit.team).each(t => Achievements.drown_big_tank.grantToAllOnline(t)); + } else { + if(unit.team !== Vars.state.rules.defaultTeam) Achievements.drown_big_tank.grantToAllOnline(); + } + } + } +}); + +Events.on(EventType.UnitBulletDestroyEvent, ({unit, bullet}:{unit:Unit; bullet: Bullet}) => { + if(!Gamemode.sandbox() && unit.type == UnitTypes.dagger && (bullet.owner as Building).block == Blocks.foreshadow){ + const build = bullet.owner as Building; + if(build.liquids.current() == Liquids.cryofluid && build.timeScale() >= 3) Achievements.foreshadow_overkill.grantToAllOnline(build.team); + } +}); + +let siliconReached = Team.all.map(_ => false); +Events.on(EventType.GameOverEvent, () => siliconReached = Team.all.map(_ => false)); +let isAlone = 0; + +Timer.schedule(() => { + if(!Vars.state.gameOver && !Gamemode.sandbox()){ + Vars.state.teams.active.each(({team}) => { + if(team.items().has(Items.silicon, 2000)) siliconReached[team.id] = true; + else if(siliconReached[team.id] && team.items().get(Items.silicon) == 0) + Achievements.siligone.grantToAllOnline(team); + }); + } + if(Groups.player.size() == 1){ + if(isAlone == 0) isAlone = Date.now(); + else if(Date.now() > isAlone + Duration.minutes(2)) Achievements.alone.grantToAllOnline(); + } else isAlone = 0; +}, 2, 2); + +const coreHealthTime = new ObjectIntMap(); +if(!Gamemode.sandbox()) Timer.schedule(() => { + coreHealthTime.forEach(({key: core, value}) => { + if(Date.now() > value){ + if(core.dead){ + coreHealthTime.remove(core); + } else if(core.health > 50){ + //grant achievement + FishPlayer.forEachPlayer(p => { + if(core.team == p.team()) Achievements.core_low_hp.grantTo(p); + else Achievements.enemy_core_low_hp.grantTo(p); + }); + coreHealthTime.remove(core); + } + } + }); + Vars.state.teams.active.flatMap(t => t.cores).each(core => { + if(core.health < 50 && !coreHealthTime.get(core)) coreHealthTime.put(core, Date.now() + 12_000); + }); +}, 1, 1); +Events.on(EventType.GameOverEvent, () => coreHealthTime.clear()); +Events.on(EventType.WorldLoadEvent, () => coreHealthTime.clear()); + + +FishEvents.on("scriptKiddie", (_, p) => Timer.schedule(() => Achievements.script_kiddie.grantTo(p), 2)); +FishEvents.on("memoryCorruption", () => Achievements.memory_corruption.grantToAllOnline()); +FishEvents.on("serverSays", () => Achievements.server_speak.grantToAllOnline()); \ No newline at end of file diff --git a/src/api.ts b/src/api.ts index 814c6d68..96ddb5c4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -6,7 +6,7 @@ This file contains wrappers over the API calls to the backend server. import { backendIP, Gamemode, Mode } from "/config"; import { FishPlayer } from "/players"; import { Promise } from "/promise"; -import type { FishPlayerData } from "/types"; +import type { FishPlayerData, UploadedFishPlayerData } from "/types"; const cachedIps:Record = {}; @@ -169,7 +169,7 @@ export function getFishPlayerData(uuid:string){ } /** Pushes fish player data to the backend. */ -export function setFishPlayerData(data: FishPlayerData, repeats = 1, ignoreActivelySyncedFields = false) { +export function setFishPlayerData(data: UploadedFishPlayerData, repeats:number, ignoreActivelySyncedFields:boolean) { const { promise, resolve, reject } = Promise.withResolvers(); if(Mode.noBackend){ resolve(); diff --git a/src/commands/console.ts b/src/commands/console.ts index b22b8277..752ebe3b 100644 --- a/src/commands/console.ts +++ b/src/commands/console.ts @@ -9,7 +9,7 @@ import { updateMaps } from "/files"; import * as fjsContext from "/fjsContext"; import { consoleCommandList, fail } from "/frameworks/commands"; import { Duration, escapeStringColorsServer, to2DArray } from "/funcs"; -import { fishState, ipPattern, ipPortPattern, maxTime, tileHistory, uuidPattern } from "/globals"; +import { FishEvents, fishState, ipPattern, ipPortPattern, maxTime, tileHistory, uuidPattern } from "/globals"; import { FishPlayer } from "/players"; import { Rank } from "/ranks"; import { colorNumber, fishCommandsRootDirPath, formatTime, formatTimeRelative, formatTimestamp, getAntiBotInfo, getIPRange, logAction, serverRestartLoop, updateBans } from "/utils"; @@ -91,7 +91,9 @@ export const commands = consoleCommandList({ const outputString:string[] = [""]; for(const [playerInfo, fishP] of infoList){ const flagsText = [ - fishP?.marked() && `&lris marked&fr until ${formatTimeRelative(fishP.unmarkTime)}`, + fishP?.marked() && (maxTime - fishP.unmarkTime < 20_000 ? + `&lris marked forever&fr` + : `&lris marked&fr until ${formatTimeRelative(fishP.unmarkTime)}`), fishP?.muted && "&lris muted&fr", fishP?.hasFlag("member") && "&lmis member&fr", fishP?.autoflagged && "&lris autoflagged&fr", @@ -201,7 +203,7 @@ export const commands = consoleCommandList({ output(`&lrIP &c"${args.target}"&lr was banned. Ban was synced to other servers.`); } } else if((range = getIPRange(args.target)) != null){ - if(admins.subnetBans.contains(ip => ip.replace(/\.$/, "") == range)){ + if(admins.subnetBans.contains(boolf(ip => ip.replace(/\.$/, "") == range))){ output(`Subnet &c"${range}"&fr is already banned.`); } else { admins.subnetBans.add(range); @@ -381,7 +383,7 @@ export const commands = consoleCommandList({ if(player.ranksAtLeast("admin")) fail(`Please use the setusid command instead.`); const oldusid = player.usid; player.usid = null; - api.setFishPlayerData(player.getData()).then(() => { + api.setFishPlayerData(player.getData(), 1, true).then(() => { outputSuccess(`Removed the usid of player ${player.name}/${player.uuid} (was ${oldusid})`); }).catch(err => { Log.err(err); @@ -397,7 +399,7 @@ export const commands = consoleCommandList({ const player = FishPlayer.lastAuthKicked ?? fail(`No authorization failures have occurred since the last restart.`); const oldusid = player.usid; player.usid = args.usid; - api.setFishPlayerData(player.getData()).then(() => { + api.setFishPlayerData(player.getData(), 1, true).then(() => { outputSuccess(`Set the usid of player ${player.name}/${player.uuid} to ${args.usid} (was ${oldusid})`); }).catch(err => { Log.err(err); @@ -512,7 +514,7 @@ export const commands = consoleCommandList({ output( `Memory usage: Total: ${Math.round(Core.app.getJavaHeap() / (2 ** 10))} KB -Number of cached fish players: ${Object.keys(FishPlayer.cachedPlayers).length} (has data: ${Object.values(FishPlayer.cachedPlayers).filter(p => p.hasData()).length}) +Number of cached fish players: ${Object.keys(FishPlayer.cachedPlayers).length} (stored locally: ${Object.values(FishPlayer.cachedPlayers).filter(p => p.shouldCache()).length}) Fish player data string length: ${FishPlayer.getFishPlayersString.length} (${Core.settings.getInt("fish-subkeys")} subkeys) Length of tilelog entries: ${Math.round(Object.values(tileHistory).reduce((acc, a) => acc + a.length, 0) / (2 ** 10))} KB` ); @@ -755,4 +757,14 @@ ${FishPlayer.mapPlayers(p => } } }, + + say: { + args: ["message:string"], + description: "Sends a message to the in-game chat.", + handler({args: {message}}){ + Call.sendMessage(`[scarlet][[Server]:[] ${message}`); + Log.info(`&fi&lcServer: &fr&lw${message}`); + FishEvents.fire("serverSays", []); + } + } }); diff --git a/src/commands/general.ts b/src/commands/general.ts index 3d9f2d29..b5e89f8f 100644 --- a/src/commands/general.ts +++ b/src/commands/general.ts @@ -3,8 +3,9 @@ Copyright © BalaM314, 2026. All Rights Reserved. This file contains most in-game chat commands that can be run by untrusted players. */ +import { Achievement, Achievements } from "/achievements"; import * as api from "/api"; -import { FishServer, Gamemode, rules, text } from "/config"; +import { FColor, FishServer, Gamemode, rules, text } from "/config"; import { command, commandList, fail, formatArg, Perm, Req } from "/frameworks/commands"; import type { FishCommandData } from "/frameworks/commands/types"; import { Menu } from "/frameworks/menus"; @@ -1013,20 +1014,21 @@ ${highestVotedMaps.map(({key:map, value:votes}) => }; }), stats: { - args: ["target:player"], + args: ["target:player", "global:boolean?"], perm: Perm.none, description: "Views a player's stats.", - handler({args:{target}, output, f}){ + handler({args:{target, global = false}, output, f}){ + const stats = global ? target.globalStats : target.stats; output(f`[accent]\ -Statistics for player ${target}: +Statistics for player ${target} ${global ? "on this server" : "across all servers"}: (note: we started recording statistics on 22 Jan 2024) [white]--------------[] -Blocks broken: ${target.stats.blocksBroken} -Blocks placed: ${target.stats.blocksPlaced} -Chat messages sent: ${target.stats.chatMessagesSent} -Games finished: ${target.stats.gamesFinished} -Time in-game: ${formatTime(target.stats.timeInGame)} -Win rate: ${target.stats.gamesWon / target.stats.gamesFinished}` +Blocks broken: ${stats.blocksBroken} +Blocks placed: ${stats.blocksPlaced} +Chat messages sent: ${stats.chatMessagesSent} +Games finished: ${stats.gamesFinished} +Time in-game: ${formatTime(stats.timeInGame)} +Win rate: ${stats.gamesWon / stats.gamesFinished}` ); } }, @@ -1043,7 +1045,7 @@ Win rate: ${target.stats.gamesWon / target.stats.gamesFinished}` data: null, })), Vars.world.width()).reverse(); const height = Vars.world.height(); - void Menu.scroll(sender, "The World", "Use the arrow keys to navigate around the world. Click a blank square to exit.", options, { + void Menu.scroll2D(sender, "The World", "Use the arrow keys to navigate around the world. Click a blank square to exit.", options, { columns: size, rows: size, x: x ? x - Math.trunc(size / 2) : 0, @@ -1133,5 +1135,101 @@ Win rate: ${target.stats.gamesWon / target.stats.gamesFinished}` unit.add(); outputSuccess(f`Spawned a ${args.type} that is partly a ${args.base}.`); } - } + }, + + achievement: { + args: ["name:string?", "verbose:boolean?"], + description: "Displays information on a specific achievement.", + perm: Perm.none, + async handler({args: {name = "", verbose = false}, sender, f, output}){ + name = Strings.stripColors(name.toLowerCase()); + + const matching = Achievement.all.filter(a => Strings.stripColors(a.name).toLowerCase().includes(name)); + if(matching.length == 0) + fail(f`No achievements found with name ${name}. To view all achievements, run [accent]/achievements[].`); + const achievement = matching.length > 2 ? + await Menu.pagedList(sender, "Achievement", "Select an achievement to view", matching, { + onCancel: "reject", + columns: 2, + optionStringifier: a => `${a.icon}[] ${a.name}` + }) + : matching[0]; + + output(FColor.achievement`\ +Achievement ${achievement.icon} ${achievement.name} +[white]--------------[] +${achievement.description + (achievement.extendedDescription ? ("\n" + `[gray]${achievement.extendedDescription}`) : "")} +Allowed modes: ${achievement.modesText} +Unlocked: ${f.boolGood(achievement.has(sender))} +${verbose ? `[gray]ID: (${achievement.nid})${achievement.sid}\n` : ""}\ +${verbose ? `[gray]Notifies: ${achievement.notify}\n` : ""}\ +${achievement.hidden ? "This achievement is secret." : ""}\ +`); + //TODO "x% of players have this achievement" tracking, requires backend aggregation endpoint + } + }, + + achievementlist: { + args: ["target:player?"], + description: "Shows all achievements in a paged menu.", + perm: Perm.none, + async handler({sender, args: { target = sender }, f}){ + await Menu.textPages( + sender, + Achievement.all.filter(a => !a.hidden || a.has(target)) + .map(a => [ + `${a.icon}[] ${a.name}`, + () => FColor.achievement`\ +${a.description + (a.extendedDescription ? ("\n" + `[gray]${a.extendedDescription}`) : "")} +Allowed modes: ${a.modesText} +Unlocked: ${f.boolGood(a.has(target))} +${a.hidden ? "This achievement is secret." : ""}\ +` + ]) + ); + } + }, + + achievementgrid: { + args: ["target:player?"], + description: "Shows all achievements in a 2D scrolling menu.", + perm: Perm.none, + async handler({sender, args: { target = sender }, f}){ + const visibleAchievements = Achievement.all.filter(a => !a.hidden || a.has(target)); + const options = to2DArray(visibleAchievements, 7).map(row => row.map(a => ({ + data: a, + text: a.has(target) ? a.icon : `[gray]${Strings.stripColors(a.icon)}`, + }))); + const numberAchievements = Achievement.all.filter(a => a.has(target)).length; + const totalAchievements = visibleAchievements.length; + let x = 0, y = 0; + let a: Achievement | null = null; + while(true){ + try { + [a, x, y] = await Menu.scroll2D( + sender, "Achievements", + a ? FColor.achievement`\ + ${a.icon} ${a.name} + + ${a.description + (a.extendedDescription ? ("\n" + `[gray]${a.extendedDescription}`) : "")} + + Allowed modes: ${a.modesText} + Unlocked: ${f.boolGood(a.has(target))} + ${a.hidden ? "This achievement is secret." : ""}\ + ` : + (target == sender ? `You have ${numberAchievements}/${totalAchievements} achievements.` + : FColor.achievement`Player ${target.prefixedName} has ${numberAchievements}/${totalAchievements} achievements.`) + + "\nClick an achievement icon to show more information.", + options, + { onCancel: "reject", columns: 5, rows: 4, getCenterText: () => String.fromCharCode(Iconc.settings), x, y } + ); + } catch(err){ + if(err == "cancel") return; //TODO replace this string "cancel" with a symbol + else throw err; + } + if(a == Achievements.click_me && target == sender) a.grantTo(sender); + } + } + }, + }); diff --git a/src/commands/staff.ts b/src/commands/staff.ts index a93a97e0..52a83fbf 100644 --- a/src/commands/staff.ts +++ b/src/commands/staff.ts @@ -344,6 +344,7 @@ export const commands = commandList({ perm: Perm.mod, handler({outputSuccess}){ FishPlayer.saveAll(); + FishPlayer.uploadAll(); FishEvents.fire("saveData", []); const file = Vars.saveDirectory.child(`1.${Vars.saveExtension}`); SaveIO.save(file); diff --git a/src/config.ts b/src/config.ts index 4d404cc9..3a05a30d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -228,6 +228,7 @@ export const Gamemode = { minigame: () => Gamemode.name() == "minigame", name: () => Core.settings.get("mode", Vars.state.rules.mode().name()) as "attack" | "survival" | "pvp" | "sandbox" | "hexed" | "hardcore" | "testsrv" | "minigame", }; +export const GamemodeNames: GamemodeName[] = Object.keys(Gamemode).filter((x): x is GamemodeName => x !== "name"); //#endregion //#region text content @@ -283,7 +284,7 @@ export const FColor = ( Object.fromEntries(Object.entries(data).map(([k, c]) => [k, (str?:string | readonly string[], ...varChunks: ReadonlyArray) => str != null ? - `${c}${Array.isArray(str) ? String.raw({ raw: str }, ...varChunks) : (str as string)}[]` + `${c}${Array.isArray(str) ? String.raw({ raw: str }, ...varChunks.map(v => String(v) + c)) : (str as string)}[]` : c ] )) @@ -292,6 +293,7 @@ export const FColor = ( /** Used for tips and welcome messages. */ tip: "[gold]", member: "[pink]", + achievement: "[lime]", }); /** Tips that are shown to players randomly. */ export const tips = { diff --git a/src/frameworks/commands/commands.ts b/src/frameworks/commands/commands.ts index d7538e63..44ca8348 100644 --- a/src/frameworks/commands/commands.ts +++ b/src/frameworks/commands/commands.ts @@ -12,7 +12,7 @@ import type { FishCommandArgType, FishCommandData, FishCommandHandlerData, FishC import { CommandArgType, commandArgTypes } from "/frameworks/commands/types"; import { Menu } from "/frameworks/menus"; import { crash, escapeStringColorsClient, parseError, setToArray } from "/funcs"; -import { uuidPattern } from "/globals"; +import { FishEvents, uuidPattern } from "/globals"; import { FishPlayer } from "/players"; import { Rank, RoleFlag } from "/ranks"; import type { ClientCommandHandler, CommandArg, ServerCommandHandler } from "/types"; @@ -345,9 +345,10 @@ export function register(commands: Record | //Verify authorization //as a bonus, this crashes if data.perm is undefined if(!data.perm.check(fishSender)){ - if(data.customUnauthorizedMessage) + if(data.customUnauthorizedMessage){ outputFail(data.customUnauthorizedMessage, sender); - else if(data.isHidden) + FishEvents.fire("commandUnauthorized", [fishSender, name]); + } else if(data.isHidden) outputMessage(hiddenUnauthorizedMessage, sender); else outputFail(data.perm.unauthorizedMessage, sender); diff --git a/src/frameworks/menus.ts b/src/frameworks/menus.ts index aa4b5613..c05fac58 100644 --- a/src/frameworks/menus.ts +++ b/src/frameworks/menus.ts @@ -126,7 +126,7 @@ export const Menu = { Call.menu(target.con, registeredListeners.generic, title, description, stringifiedOptions); return promise; }, - /** Displays a menu to a player, returning a Promise. Arranges options into a 2D array, and can add a Cancel option. */ + /** Displays a menu to a player, returning a Promise. Arranges provided options into a 2D array, and can add a Cancel option. */ menu( this:void, title:string, description:string, options:TOption[], target:FishPlayer, { @@ -175,6 +175,10 @@ export const Menu = { }:MenuConfirmProps = {}){ return Menu.confirm(target, description, { cancelText, confirmText, ...rest }); }, + /** + * Displays a menu to a player, returning a Promise. + * Accepts pre-generated data and text. Alternative to optionStringifier if the text is already generated. + */ buttons( this:void, target:FishPlayer, title:string, description:string, options:{ data: TButtonData; text: string; }[][], @@ -185,6 +189,11 @@ export const Menu = { optionStringifier: o => o.text, }).then(o => o?.data as TButtonData | (TCancelBehavior extends "null" ? null : never)); }, + /** + * Displays a menu to a player, returning a Promise. + * Adds left and right arrows to switch pages. + * Shows different options based on the page. + */ pages( this:void, target:FishPlayer, title:string, description:string, options:{ data: TOption; text: string; }[][][], @@ -218,15 +227,21 @@ export const Menu = { showPage(0); return promise; }, - textPages( + /** + * Displays a menu to a player, returning a Promise. + * Adds left and right arrows to switch pages. + * Does not support options. + * Shows different text based on the current page. + */ + textPages( this:void, target:FishPlayer, pages:Array string]>, - cfg: Pick, "onCancel"> & { + cfg: Pick, "onCancel"> & { /** Index or title of the initial page. */ startPage?: number | string; } = {}, ){ const { promise, reject, resolve } = Promise.withResolvers< - (TCancelBehavior extends "null" ? null : never) | TOption, + (TCancelBehavior extends "null" ? null : never), TCancelBehavior extends "reject" ? "cancel" : never >(); const pageSkipSize = Math.max(Math.floor(pages.length / 8), 5); @@ -267,7 +282,13 @@ export const Menu = { showPage(index); return promise; }, - scroll( + /** + * Displays a menu to a player, returning a Promise. + * Accepts a 2D array of options and shows a region of that 2D grid. + * Adds arrows to scroll left/right/up/down. + * Resolves to the selected option. + */ + scroll2D( this:void, target:FishPlayer, title:string, description:string, options:{ data: TOption; text: string; }[][], cfg: Pick, "onCancel" | "columns"> & { @@ -278,7 +299,7 @@ export const Menu = { } = {}, ){ const { promise, reject, resolve } = Promise.withResolvers< - (TCancelBehavior extends "null" ? null : never) | TOption, + (TCancelBehavior extends "null" ? null : never) | [TOption, x:number, y:number], TCancelBehavior extends "reject" ? "cancel" : never >(); const { rows = 5, columns: cols = 5 } = cfg; @@ -286,23 +307,28 @@ export const Menu = { const width = options[0].length; function showPage(x:number, y:number){ const opts:{ data: "left" | "blank" | "right" | "up" | "down" | readonly [TOption]; text: string; }[][] = [ - ...options.slice(y, y + rows).map(r => r.slice(x, x + cols).map(d => ({ text: d.text, data: [d.data] as const }))), + ...options.slice(y, y + rows).map(r => r.concat(Array(width - r.length).fill({ data: "blank", text: `` }))).map(r => + r.slice(x, x + cols).map(d => ({ text: d.text, data: [d.data] as const })) + ), [ { data: "blank", text: `` }, - { data: "up", text: `[${y == 0 ? "gray" : "accent"}]^\n|` }, + ], + [ + { data: "blank", text: `` }, + { data: "up", text: `[${y == 0 ? "gray" : "accent"}]${String.fromCharCode(Iconc.up)}` }, { data: "blank", text: `` }, ],[ - { data: "left", text: `[${x == 0 ? "gray" : "accent"}]<--` }, + { data: "left", text: `[${x == 0 ? "gray" : "accent"}]${String.fromCharCode(Iconc.left)}` }, { data: "blank", text: cfg.getCenterText?.(x, y) ?? '' }, - { data: "right", text: `[${x == width - cols ? "gray" : "accent"}]-->` } + { data: "right", text: `[${x == width - cols ? "gray" : "accent"}]${String.fromCharCode(Iconc.right)}` }, ],[ { data: "blank", text: `` }, - { data: "down", text: `[${y == height - rows ? "gray" : "accent"}]|\nV` }, + { data: "down", text: `[${y == height - rows ? "gray" : "accent"}]${String.fromCharCode(Iconc.down)}` }, { data: "blank", text: `` }, ] ]; - void Menu.buttons(target, title, description, opts, {...cfg, onCancel: "null"}).then(response => { - if(response instanceof Array) resolve(response[0]); + void Menu.buttons(target, title, description, opts, cfg).then(response => { + if(response instanceof Array) resolve([response[0], x, y]); else if(response === "right") showPage(Math.min(x + 1, width - cols), y); else if(response === "left") showPage(Math.max(x - 1, 0), y); else if(response === "up") showPage(x, Math.max(y - 1, 0)); @@ -318,6 +344,11 @@ export const Menu = { showPage(Math.min(cfg.x ?? 0, width - cols), Math.min(cfg.y ?? 0, height - rows)); return promise; }, + /** + * Displays a menu to a player, returning a Promise. + * Adds left and right arrows to switch pages. Automatically paginates provided options. + * Accepts pre-generated data and text. Alternative to optionStringifier if the text is already generated. + */ pagedListButtons( this:void, target:FishPlayer, title:string, description:string, options:Array<{ data: TButtonData; text: string; }>, @@ -331,6 +362,10 @@ export const Menu = { if(pages.length <= 1) return Menu.buttons(target, title, description, pages[0] ?? [], cfg); return Menu.pages(target, title, description, pages, cfg); }, + /** + * Displays a menu to a player, returning a Promise. + * Adds left and right arrows to switch pages. Automatically paginates provided options. + */ pagedList( this:void, target:FishPlayer, title:string, description:string, options:TButtonData[], diff --git a/src/globals.ts b/src/globals.ts index f10e45d9..68e9c908 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -4,7 +4,7 @@ This file contains mutable global variables, and global constants. */ import { EventEmitter } from "/funcs"; -import type { FishPlayer } from "/players"; +import { FishPlayer } from "/players"; export const tileHistory:Record = {}; export const recentWhispers:Record = {}; @@ -30,6 +30,7 @@ export const ipPortPattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{1,5}$/; 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 export const ipRangeWildcardPattern = /^(\d{1,3}\.\d{1,3})\.(?:(\d{1,3}\.\*)|\*)$/; //Disallow anything bigger than a /16 export const maxTime = 9999999999999; +export const unitsT5 = [UnitTypes.reign, UnitTypes.toxopid, UnitTypes.corvus, UnitTypes.eclipse, UnitTypes.oct, UnitTypes.omura, UnitTypes.navanax, UnitTypes.conquer, UnitTypes.collaris, UnitTypes.disrupt]; export const FishEvents = new EventEmitter<{ /** Fired after a team change. The current team is player.team() */ @@ -40,4 +41,11 @@ export const FishEvents = new EventEmitter<{ saveData: []; /** Use this event to mutate things after all the data is loaded */ dataLoaded: []; + commandUnauthorized: [player: FishPlayer, name: string]; + scriptKiddie: [player: FishPlayer]; + memoryCorruption: []; + /** Called when the "say" console command is run. */ + serverSays: []; + /** Fired on gameover, but before player data is reset. */ + gameOver: [winningTeam: Team]; }>(); diff --git a/src/index.ts b/src/index.ts index ad1c0a07..fe3d22cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -169,6 +169,7 @@ Events.on(EventType.ServerLoadEvent, (e) => { Core.app.addListener({ dispose(){ + FishPlayer.uploadAll(); FishEvents.fire("saveData", []); FishPlayer.saveAll(false); Log.info("Saved on exit."); @@ -204,6 +205,7 @@ Events.on(EventType.GameOverEvent, (e) => { } FishPlayer.onGameOver(e.winner as Team); }); +Events.on(EventType.WorldLoadEvent, () => FishPlayer.onGameBegin()); Events.on(EventType.PlayerChatEvent, e => { FishPlayer.onPlayerChat(e.player, e.message); }); diff --git a/src/main.js b/src/main.js index 3f537048..555c48a1 100644 --- a/src/main.js +++ b/src/main.js @@ -66,5 +66,6 @@ if(12.34.toFixed(1) !== '12.3'){ }; } +this.ArcReflect = Reflect; this.Promise = require('/promise').Promise; require("index"); diff --git a/src/mindustry.d.ts b/src/mindustry.d.ts deleted file mode 100644 index 1e3e81d0..00000000 --- a/src/mindustry.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* -Copyright © BalaM314, 2026. All Rights Reserved. -This file contains one single declaration from mindustryTypes.ts that cannot go in there. -*/ - - -//Use a .d.ts to suppress the error from this override -declare const Reflect: { - get(thing:any, key:string):any; - set(thing:any, key:string, value:any):void; -}; diff --git a/src/mindustryTypes.ts b/src/mindustryTypes.ts index 3383954c..bf60edf7 100644 --- a/src/mindustryTypes.ts +++ b/src/mindustryTypes.ts @@ -70,6 +70,7 @@ const Vars: { maps: Maps; state: { rules: Rules; + planet: Planet | null; set(state:State):void; gameOver:boolean; wave:number; @@ -79,7 +80,9 @@ const Vars: { enemies:number; /** Time in ticks, 60/s */ tick:number; - } + teams: Teams; + }; + indexer: BlockIndexer; saveExtension: string; saveDirectory: Fi; modDirectory: Fi; @@ -88,7 +91,19 @@ const Vars: { tilesize: 8; world: World; }; +class Teams { + active: Seq; +} +class BlockIndexer { + getFlagged(team: Team, flag: BlockFlag): Seq; +} +class BlockFlag { + static storage: BlockFlag; +} type State = {__state: ""}; +class Planet { + name: string; +} type Scripts = any; type CommandHandler = any; const CommandHandler: CommandHandler; @@ -179,20 +194,56 @@ class Building { block: Block; tile: Tile; items: ItemModule; + power: PowerModule; + liquids: LiquidModule; team: Team; changeTeam: Team; + enabled: boolean; + health: number; + ammo?: Seq<{item: Item}>; + warmup?: number; + storageCapacity?: number; + dead: boolean; + timeScale(): number; kill():void; tileX():number; tileY():number; } -const Items: Record; +const Items: Record<"scrap" | "copper" | "lead" | "graphite" | "coal" | "titanium" | "thorium" | "silicon" | "plastanium" | "phaseFabric" | "surgeAlloy" | "sporePod" | "sand" | "blastCompound" | "pyratite" | "metaglass" | "beryllium" | "tungsten" | "oxide" | "carbide" | "fissileMatter" | "dormantCyst", Item> & { + serpuloItems: Seq; + erekirItems: Seq; +}; class Item { name: string; + hidden: boolean; + emoji(): string; +} +const Liquids: Record; +class Liquid { + name: string; + gas: boolean; +} +class LiquidModule { + current(): Liquid; } class ItemModule { get(item: Item):number; set(item: Item, value: number):void; add(item: Item, value: number):void; + has(item: Item, amount: number): boolean; + has(stacks: ItemStack[]): boolean; +} +class PowerModule { + graph: PowerGraph; +} +class PowerGraph { + lastPowerProduced: number; + lastPowerNeeded: number; + producers: Seq; + consumers: Seq; +} +class ItemStack { + constructor(item: Item, amount: number); } class Team { static derelict:Team; @@ -207,13 +258,14 @@ class Team { active():boolean; data():TeamData; core():Building | null; - items():ItemModule | null; + items():ItemModule; coloredName():string; id:number; static get(index:number):Team; cores(): Seq; } type TeamData = { + team: Team; units: Seq; buildings: Seq; cores: Seq; @@ -232,6 +284,7 @@ const Groups: { unit: EntityGroup; fire: EntityGroup; build: EntityGroup; + powerGraph: EntityGroup<{graph: PowerGraph}>; }; type Fire = any; class Vec2 { @@ -428,9 +481,11 @@ class Seq { static with(...items:T[]):Seq; static with(items:Iterable):Seq; add(item:T):this; + addUnique(item:T):this; contains(item:T):boolean; - contains(pred:(item:T) => boolean):boolean; + contains(pred:Boolf):boolean; count(pred:(item:T) => boolean):number; + allMatch(pred:(item:T) => boolean):boolean; /** @deprecated Use select() or retainAll() */ filter(pred:(item:T) => boolean):Seq; retainAll(pred:(item:T) => boolean):Seq; @@ -443,6 +498,7 @@ class Seq { each(pred:(item:T) => boolean, func:(item:T) => unknown):void; isEmpty():boolean; map(mapFunc:(item:T) => R):Seq; + flatMap(mapFunc:(item:T) => Seq):Seq; toString(separator?:string, stringifier?:(item:T) => string):string; toArray():T[]; copy():Seq; @@ -451,6 +507,7 @@ class Seq { random():T | null; get(index:number):T; first():T; + peek():T; firstOpt():T | null; clear():void; } @@ -483,7 +540,9 @@ class ObjectIntMap { get(key:K):number; increment(key:K):void; clear():void; + remove(key:K):number | null; size:number; + forEach(func:(_:{key:K, value:number}) => void):void; entries(): { toArray():Seq>; }; @@ -532,6 +591,8 @@ type Unit = { spawnedByCore: boolean; added: boolean; id: number; + tileOn():Tile | null; + tile?: () => Building; kill():void; add():void; isAdded():boolean; @@ -571,7 +632,9 @@ class Fi { name():string; readBytes():number[]; } - +class Bullet { + owner: Unit | Building; +} class Pattern { static matches(regex:string, target:string):boolean; static compile(regex:string):Pattern; @@ -638,6 +701,7 @@ class UnitType { spawn(team:Team, x:number, y:number):Unit; create(team:Team):Unit; supportsEnv(env:number):boolean; + emoji():string; health: number; hidden: boolean; internal: boolean; @@ -808,4 +872,34 @@ class WorldReloader { end():void; } +class Bits { + constructor(capacity?: number); + get(index:number):boolean; + /** + * @param value Default true + */ + set(index:number, value?:boolean):void; + set(index:number, value:number):void; +} + +type JavaClass = any; + +const JsonIO: { + write(object:{}): string; + read(clazz: JavaClass, data: string): T; +}; + +class Boolf { + constructor(_: {get: (value: T) => boolean}); +} +function boolf(func: (value: T) => boolean): Boolf; + +const Iconc: Record<"rotate" | "modeSurvival" | "power" | "left" | "redditAlien" | "edit" | "downOpen" | "pencil" | "file" | "lockOpen" | "right" | "infoCircle" | "pick" | "settings" | "spray1" | "terrain" | "exit" | "wrench" | "lock" | "discord" | "eye" | "none" | "play" | "diagonal" | "eraser" | "trash" | "liquid" | "fileImage" | "defense" | "layers" | "grid" | "admin" | "steam" | "star" | "chartBar" | "chat" | "android" | "image" | "map" | "logic" | "menu" | "commandRally" | "editor" | "folder" | "units" | "commandAttack" | "copy" | "filter" | "cancel" | "terminal" | "upload" | "eyeOff" | "save" | "planeOutline" | "fill" | "distribution" | "upOpen" | "rightOpen" | "modePvp" | "download" | "list" | "flipX" | "flipY" | "effect" | "paste" | "planet" | "waves" | "up" | "warning" | "tree" | "add" | "down" | "host" | "spray" | "info" | "players" | "resize" | "refresh1" | "production" | "crafting" | "pause" | "googleplay" | "hammer" | "fileText" | "modeAttack" | "move" | "zoom" | "bookOpen" | "refresh" | "ok" | "home" | "githubSquare" | "powerOld" | "github" | "undo" | "box" | "trello" | "book" | "export" | "fileTextFill" | "rightOpenOut" | "turret" | "leftOpen" | "line" | "itchio" | "link" | "filters" | "redo", number>; + +const ArcReflect: { + get(thing:any, key:string):any; + get(clazz:any, thing:any, key:string):any; + set(thing:any, key:string, value:any):void; +}; + } \ No newline at end of file diff --git a/src/players.ts b/src/players.ts index a0a59384..7c903ef7 100644 --- a/src/players.ts +++ b/src/players.ts @@ -3,15 +3,16 @@ Copyright © BalaM314, 2026. All Rights Reserved. This file contains the FishPlayer class, and many player-related functions. */ +import type { Achievement } from "/achievements"; import * as api from "/api"; import { FColor, Gamemode, heuristics, Mode, prefixes, rules, stopAntiEvadeTime, text, tips } from "/config"; import { FishCommandArgType, Perm, PermType } from "/frameworks/commands"; import { Menu } from "/frameworks/menus"; import { crash, Duration, escapeStringColorsClient, parseError, search, setToArray, StringIO } from "/funcs"; import * as globals from "/globals"; -import { uuidPattern } from "/globals"; +import { uuidPattern, FishEvents } from "/globals"; import { Rank, RankName, RoleFlag, RoleFlagName } from "/ranks"; -import type { FishPlayerData, PlayerHistoryEntry } from "/types"; +import type { FishPlayerData, PlayerHistoryEntry, Stats, UploadedFishPlayerData } from "/types"; import { cleanText, formatTime, formatTimeRelative, isImpersonator, logAction, logHTrip, matchFilter } from "/utils"; @@ -48,6 +49,7 @@ export class FishPlayer { static antiBotModePersist = false; static antiBotModeOverride = false; static lastBotWhacked = 0; + static lastMapStartTime = 0; //#endregion //#region Transient properties @@ -87,6 +89,10 @@ export class FishPlayer { tstats = { //remember to clear this in updateSavedInfoFromPlayer! blocksBroken: 0, + blockInteractionsThisMap: 0, + lastMapStartTime: 0, + lastMapPlayedTime: 0, + wavesSurvived: 0, }; /** Whether the player has manually marked themselves as AFK. */ manualAfk = false; @@ -141,15 +147,11 @@ export class FishPlayer { lastJoined:number = -1; /** -1 represents unknown */ firstJoined:number = -1; - stats: { - blocksBroken: number; - blocksPlaced: number; - timeInGame: number; - chatMessagesSent: number; - /** Does not include RTVs */ - gamesFinished: number; - gamesWon: number; - } = { + /** -1 represents unknown */ + globalLastJoined:number = -1; + /** -1 represents unknown */ + globalFirstJoined:number = -1; + stats: Stats = { blocksBroken: 0, blocksPlaced: 0, timeInGame: 0, @@ -157,8 +159,10 @@ export class FishPlayer { gamesFinished: 0, gamesWon: 0, }; + globalStats: Stats = this.stats; /** Used for the /vanish command. */ showRankPrefix:boolean = true; + achievements: Bits = new Bits(); //#endregion constructor(uuid:string, data:Partial, player:mindustryPlayer | null){ @@ -346,7 +350,7 @@ export class FishPlayer { } else { this.originalName = this.name = player.name; } - if(this.firstJoined == -1) this.firstJoined = Date.now(); + if(this.firstJoined < 1) this.firstJoined = Date.now(); //Do not update USID here this.manualAfk = false; @@ -358,9 +362,11 @@ export class FishPlayer { this.shouldUpdateName = true; this.changedTeam = false; this.ipDetectedVpn = false; - this.tstats = { - blocksBroken: 0 - }; + this.tstats.blocksBroken = 0; + if(this.tstats.lastMapPlayedTime != FishPlayer.lastMapStartTime){ + this.tstats.blockInteractionsThisMap = 0; + this.tstats.lastMapPlayedTime = FishPlayer.lastMapStartTime; + } this.infoUpdated = true; } updateData(data: Partial){ @@ -369,22 +375,27 @@ export class FishPlayer { if(data.unmarkTime != undefined) this.unmarkTime = data.unmarkTime; if(data.lastJoined != undefined) this.lastJoined = data.lastJoined; if(data.firstJoined != undefined) this.firstJoined = data.firstJoined; + if(data.globalLastJoined != undefined) this.globalLastJoined = data.globalLastJoined; + if(data.globalFirstJoined != undefined) this.globalFirstJoined = data.globalFirstJoined; if(data.highlight != undefined) this.highlight = data.highlight; if(data.history != undefined) this.history = data.history; if(data.rainbow != undefined) this.rainbow = data.rainbow; if(data.usid != undefined) this.usid = data.usid; if(data.chatStrictness != undefined) this.chatStrictness = data.chatStrictness; if(data.stats != undefined) this.stats = data.stats; + if(data.globalStats != undefined) this.globalStats = data.globalStats; if(data.showRankPrefix != undefined) this.showRankPrefix = data.showRankPrefix; if(data.rank != undefined) this.rank = Rank.getByName(data.rank) ?? Rank.player; if(data.flags != undefined) this.flags = new Set(data.flags.map(RoleFlag.getByName).filter(Boolean)); + if(data.achievements != undefined) this.achievements = JsonIO.read(Bits, `{bits:${data.achievements}}`); } - getData():FishPlayerData { + getData():UploadedFishPlayerData { const { uuid, name, muted, unmarkTime, rank, flags, highlight, rainbow, history, usid, chatStrictness, lastJoined, firstJoined, stats, showRankPrefix } = this; return { uuid, name, muted, unmarkTime, highlight, rainbow, history, usid, chatStrictness, lastJoined, firstJoined, stats, showRankPrefix, rank: rank.name, - flags: [...flags.values()].map(f => f.name) + flags: [...flags.values()].map(f => f.name), + achievements: JsonIO.write(Reflect.get(this.achievements, "bits")) }; } /** Warning: the "update" callback is run twice. */ @@ -402,7 +413,7 @@ export class FishPlayer { //but it's unlikely to happen //could be fixed by transmitting the update operation to the server as a mongo update command afterFetch?.(this); - await api.setFishPlayerData(this.getData()); + await api.setFishPlayerData(this.getData(), 1, false); } //#endregion @@ -526,6 +537,7 @@ export class FishPlayer { fishPlayer.sendMessage(`[scarlet]\u26A0[] [gold]Oh no! Our systems think you are a [scarlet]SUSSY IMPERSONATOR[]!\n[gold]Reason: ${message}\n[gold]Change your name to remove the tag.`); } else if(cleanText(player.name, true).includes("hacker")){ fishPlayer.sendMessage("[scarlet]\u26A0 Don't be a script kiddie!"); + FishEvents.fire("scriptKiddie", [fishPlayer]); } } fishPlayer.updateAdminStatus(); @@ -596,7 +608,7 @@ export class FishPlayer { //Clear temporary states such as menu and taphandler fishP.activeMenus = []; fishP.tapInfo.commandName = null; - fishP.stats.timeInGame += (Date.now() - fishP.lastJoined); //Time between joining and leaving + fishP.updateStats(stats => stats.timeInGame += (Date.now() - fishP.lastJoined)); //Time between joining and leaving fishP.lastJoined = Date.now(); this.recentLeaves.unshift(fishP); if(this.recentLeaves.length > 10) this.recentLeaves.pop(); @@ -689,7 +701,7 @@ export class FishPlayer { } } fishP.lastActive = Date.now(); - fishP.stats.chatMessagesSent ++; + fishP.updateStats(stats => stats.chatMessagesSent ++); } static onPlayerCommand(player:FishPlayer, command:string, unjoinedRawArgs:string[]){ if(command == "msg" && unjoinedRawArgs[1] == "Please do not use that logic, as it is attem83 logic and is bad to use. For more information please read www.mindustry.dev/attem") @@ -698,20 +710,23 @@ export class FishPlayer { } private static ignoreGameOver = false; static onGameOver(winningTeam:Team){ + FishEvents.fire("gameOver", [winningTeam]); this.forEachPlayer((fishPlayer) => { //Clear temporary states such as menu and taphandler fishPlayer.activeMenus = []; fishPlayer.tapInfo.commandName = null; //Update stats if(!this.ignoreGameOver && fishPlayer.team() != Team.derelict && winningTeam != Team.derelict){ - fishPlayer.stats.gamesFinished ++; + fishPlayer.updateStats(stats => stats.gamesFinished ++); if(fishPlayer.changedTeam){ fishPlayer.sendMessage(`Refusing to update stats due to a team change.`); } else { - if(fishPlayer.team() == winningTeam) fishPlayer.stats.gamesWon ++; + if(fishPlayer.team() == winningTeam) fishPlayer.updateStats(stats => stats.gamesWon ++); } } fishPlayer.changedTeam = false; + fishPlayer.tstats.wavesSurvived = 0; + fishPlayer.tstats.blockInteractionsThisMap = 0; }); } static ignoreGameover(callback:() => unknown){ @@ -719,6 +734,12 @@ export class FishPlayer { callback(); this.ignoreGameOver = false; } + static onGameBegin(){ + const startTime = Date.now(); + FishPlayer.lastMapStartTime = startTime; + //wait 7 seconds for players to join + Timer.schedule(() => FishPlayer.forEachPlayer(p => p.tstats.lastMapStartTime = startTime), 7); + } /** Must be run on UnitChangeEvent. */ static onUnitChange(player:mindustryPlayer, unit:Unit | null){ if(unit?.spawnedByCore) @@ -982,12 +1003,12 @@ We apologize for the inconvenience.` if(this.stelled()) return; for(const rankToAssign of Rank.autoRanks){ if(!this.ranksAtLeast(rankToAssign) && rankToAssign.autoRankData){ - if( //TODO: use global stats + if( this.joinsAtLeast(rankToAssign.autoRankData.joins) && - this.stats.blocksPlaced >= rankToAssign.autoRankData.blocksPlaced && - this.stats.timeInGame >= rankToAssign.autoRankData.playtime && - this.stats.chatMessagesSent >= rankToAssign.autoRankData.chatMessagesSent && - (Date.now() - this.firstJoined) >= rankToAssign.autoRankData.timeSinceFirstJoin + this.globalStats.blocksPlaced >= rankToAssign.autoRankData.blocksPlaced && + this.globalStats.timeInGame >= rankToAssign.autoRankData.playtime && + this.globalStats.chatMessagesSent >= rankToAssign.autoRankData.chatMessagesSent && + (Date.now() - this.globalFirstJoined) >= rankToAssign.autoRankData.timeSinceFirstJoin ){ void this.setRank(rankToAssign).then(() => this.sendMessage(`You have been automatically promoted to rank ${rankToAssign.coloredName()}!`) @@ -1156,6 +1177,11 @@ We apologize for the inconvenience.` shouldCache(){ return this.ranksAtLeast("mod"); } + static uploadAll(){ + FishPlayer.forEachPlayer(fishP => + void api.setFishPlayerData(fishP.getData(), 1, true) + ); + } /** Does not include stats */ hasData(){ return (this.rank != Rank.player) || this.muted || (this.flags.size > 0) || this.chatStrictness != "chat"; @@ -1379,6 +1405,11 @@ We apologize for the inconvenience.` return this.info().timesJoined < amount; } + updateStats(func:(stats:Stats) => void):void { + func(this.stats); + func(this.globalStats); + } + /** * Returns a score between 0 and 1, as an estimate of the player's skill level. * Defaults to 0.2 (guessing that the best trusted players can beat 5 noobs) @@ -1499,3 +1530,5 @@ Please look at ${this.position()} and see if they were actually griefing. If the } +//TODO convert all the unnecessary event handlers to simple calls to Events.on +Events.on(EventType.WaveEvent, () => FishPlayer.forEachPlayer(p => p.tstats.wavesSurvived ++)); diff --git a/src/timers.ts b/src/timers.ts index 919a7d08..d9b193ea 100644 --- a/src/timers.ts +++ b/src/timers.ts @@ -21,6 +21,7 @@ export function initializeTimers(){ Core.app.post(() => { SaveIO.save(file); FishPlayer.saveAll(); + FishPlayer.uploadAll(); Call.sendMessage('[#4fff8f9f]Game saved.'); FishEvents.fire("saveData", []); }); diff --git a/src/types.ts b/src/types.ts index 100c39d0..0cfc066f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,15 @@ export type TileHistoryEntry = { } + +export type Stats = { + blocksBroken: number; + blocksPlaced: number; + timeInGame: number; + chatMessagesSent: number; + gamesFinished: number; + gamesWon: number; +}; export type FishPlayerData = { uuid: string; name: string; @@ -50,16 +59,15 @@ export type FishPlayerData = { chatStrictness: "chat" | "strict"; lastJoined: number; firstJoined: number; - stats: { - blocksBroken: number; - blocksPlaced: number; - timeInGame: number; - chatMessagesSent: number; - gamesFinished: number; - gamesWon: number; - }; + globalLastJoined: number; + globalFirstJoined: number; + stats: Stats; + globalStats: Stats; showRankPrefix: boolean; + /** This field contains long values, store it as a string */ + achievements: string; } +export type UploadedFishPlayerData = Omit; export type PlayerHistoryEntry = { action:string; @@ -100,7 +108,6 @@ export type FlaggedIPData = { moderated: boolean; }; -export type Boolf = (input:T) => boolean; export type Expand = T extends Function ? T : { [K in keyof T]: T[K] }; export type TagFunction = (stringChunks: readonly string[], ...varChunks: readonly Tin[]) =>Tout diff --git a/src/utils.ts b/src/utils.ts index 3f55247e..4067d56f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,9 +8,9 @@ import * as api from "/api"; import { adminNames, bannedWords, Gamemode, GamemodeName, multiCharSubstitutions, substitutions, text } from "/config"; import { fail, PartialFormatString } from "/frameworks/commands"; import { crash, escapeStringColorsServer, escapeTextDiscord, parseError, random, StringIO } from "/funcs"; -import { fishState, ipPattern, ipPortPattern, ipRangeCIDRPattern, ipRangeWildcardPattern, maxTime, tileHistory, uuidPattern } from "/globals"; +import { FishEvents, fishState, ipPattern, ipPortPattern, ipRangeCIDRPattern, ipRangeWildcardPattern, maxTime, tileHistory, uuidPattern } from "/globals"; import { FishPlayer } from "/players"; -import { Boolf, SelectEnumClassKeys } from "/types"; +import { SelectEnumClassKeys } from "/types"; export function memoizeChatFilter(impl:(arg:string) => string){ @@ -151,8 +151,8 @@ export function getTeam(team:string):Team | string { /** Attempts to parse an Item from the input. */ export function getItem(item:string):Item | string { - let temp: Item | null; - if(item in Items && (temp = Items[item]) instanceof Item) return temp; + let temp; + if(item in Items && (temp = Items[item as keyof typeof Items]) instanceof Item) return temp; else if((temp = Vars.content.items().find(t => t.name.includes(item.toLowerCase())))) return temp; return `"${item}" is not a valid item.`; } @@ -213,8 +213,8 @@ export function isImpersonator(name:string, isAdmin:boolean):false | string { const replacedText = cleanText(name); const antiEvasionText = cleanText(name, true); //very clean code i know - const filters:Array<[check:Boolf, message:string]> = ( - (input: Array, string]>) => + const filters:Array<[check: (value:string) => boolean, message:string]> = ( + (input: Array boolean), string]>) => input.map(i => Array.isArray(i) ? [ typeof i[0] == "string" ? replacedText => replacedText.includes((i[0] as string)) : @@ -438,6 +438,7 @@ export function definitelyRealMemoryCorruption(){ const hexString = Math.floor(Math.random() * 0xFFFFFFFF).toString(16).padStart(8, "0"); Call.sendMessage("[scarlet]Error: internal server error."); Call.sendMessage(`[scarlet]Error: memory corruption: mindustry.world.modules.ItemModule@${hexString}`); + FishEvents.fire("memoryCorruption", []); } export function getEnemyTeam():Team { @@ -643,7 +644,8 @@ export const addToTileHistory = logErrors("Error while saving a tilelog entry", const fishP = FishPlayer.get(e.unit.player); //TODO move this code fishP.tstats.blocksBroken ++; - fishP.stats.blocksBroken ++; + fishP.tstats.blockInteractionsThisMap ++; + fishP.updateStats(stats => stats.blocksBroken ++); } } else { action = "built"; @@ -651,17 +653,26 @@ export const addToTileHistory = logErrors("Error while saving a tilelog entry", if(e.unit?.player?.uuid()){ const fishP = FishPlayer.get(e.unit.player); //TODO move this code - fishP.stats.blocksPlaced ++; + fishP.updateStats(stats => stats.blocksPlaced ++); + fishP.tstats.blockInteractionsThisMap ++; } } } else if(e instanceof EventType.ConfigEvent){ tile = e.tile.tile; uuid = e.player?.uuid() ?? "unknown"; + if(uuid != "unknown"){ + const fishP = FishPlayer.getById(uuid); + if(fishP) fishP.tstats.blockInteractionsThisMap ++; + } action = "configured"; type = e.tile.block.name; } else if(e instanceof EventType.BuildRotateEvent){ tile = e.build.tile; uuid = e.unit?.player?.uuid() ?? e.unit?.type.name ?? "unknown"; + if(uuid != "unknown"){ + const fishP = FishPlayer.getById(uuid); + if(fishP) fishP.tstats.blockInteractionsThisMap ++; + } action = "rotated"; type = e.build.block.name; } else if(e instanceof EventType.UnitDestroyEvent){ @@ -879,3 +890,23 @@ export function applyEffectMode(mode:string, unit:Unit, ticks:number){ } } +const sources = [ + Packages.mindustry.gen.UnitEntity, + Packages.mindustry.gen.MechUnit, + Packages.mindustry.gen.LegsUnit, + Packages.mindustry.gen.CrawlUnit, + Packages.mindustry.gen.UnitWaterMove, + Packages.mindustry.gen.BlockUnitUnit, + Packages.mindustry.gen.ElevationMoveUnit, + Packages.mindustry.gen.BuildingTetherPayloadUnit, + Packages.mindustry.gen.TimedKillUnit, + Packages.mindustry.gen.PayloadUnit, + Packages.mindustry.gen.TankUnit, +]; +export function getStatuses(unit:Unit):Seq<{ effect: StatusEffect }> { + for(const clazz of sources){ + if(unit instanceof clazz) + return ArcReflect.get(clazz, unit, "statuses") as Seq<{ effect: StatusEffect }>; + } + return new Seq(); +}