From 42d32d1826a58b1d50217626635bff748289ae4e Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:08:01 +0300 Subject: [PATCH 01/32] Add files via upload --- src/Const/Achievements.ts | 192 +++++++++++++++++++++++++++ src/Const/Commands.ts | 20 ++- src/Entity/Live.ts | 2 +- src/Entity/Tank/Projectile/Bullet.ts | 2 +- src/Entity/Tank/TankBody.ts | 32 ++++- src/index.ts | 14 +- 6 files changed, 247 insertions(+), 15 deletions(-) create mode 100644 src/Const/Achievements.ts diff --git a/src/Const/Achievements.ts b/src/Const/Achievements.ts new file mode 100644 index 00000000..79bc0133 --- /dev/null +++ b/src/Const/Achievements.ts @@ -0,0 +1,192 @@ +/* + DiepCustom - custom tank game server that shares diep.io's WebSocket protocol + Copyright (C) 2022 ABCxFF (github.com/ABCxFF) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see +*/ + +import _Achievements from "./Achievements.json"; +import Client from "../Client"; +import Writer from "../Coder/Writer"; +import { ClientBound } from "./Enums"; + +/** The event types achievements can have */ +export type eventId = "kill" | "score" | "levelUp" | "statUpgraded" | "classChange" | "latency"; + +/** The types of achievements */ +export type achievementType = "counter"; + +/** + * Format that the game stores bullet definitions in its memory. + */ +export interface AchievementDefinition { + /** Achievement name */ + name: string; + /** Achievement description */ + desc: string; + /** Conditions needed to unlock */ + conds: AchievementCondition[]; +} + +export interface AchievementTags { + "value"?: string | number; + "total"?: string | number; + /** Value change */ + "delta"?: string | number; + /** Tank ID */ + "class"?: number; + /** Tank level */ + "level"?: number; + /** Stat ID */ + "id"?: number; + /** Is max stat level */ + "isMaxLevel"?: boolean; + "weapon.isTank"?: boolean; + "victim.isTank"?: boolean; + "victim.isBoss"?: boolean; + "victim.isShiny"?: boolean; + "victim.arenaMobID"?: string | null; +} + +export interface AchievementCondition { + event?: eventId; + type?: achievementType; + tags: AchievementTags; + threshold?: number; +} + +const Achievements = _Achievements as AchievementDefinition[]; +export default Achievements; + +// From https://github.com/jtpio/murmurhash2 +export const MurMurHash2 = (str: string, seed: number): number => { + const m = 0x5bd1e995; + const encoder = new TextEncoder(); + + const data = encoder.encode(str); + let len = data.length; + let h = seed ^ len; + let i = 0; + + while (len >= 4) { + let k = + (data[i] & 0xff) | + ((data[++i] & 0xff) << 8) | + ((data[++i] & 0xff) << 16) | + ((data[++i] & 0xff) << 24); + + k = (k & 0xffff) * m + ((((k >>> 16) * m) & 0xffff) << 16); + k ^= k >>> 24; + k = (k & 0xffff) * m + ((((k >>> 16) * m) & 0xffff) << 16); + + h = ((h & 0xffff) * m + ((((h >>> 16) * m) & 0xffff) << 16)) ^ k; + + len -= 4; + ++i; + } + + switch (len) { + case 3: + h ^= (data[i + 2] & 0xff) << 16; + case 2: + h ^= (data[i + 1] & 0xff) << 8; + case 1: + h ^= data[i] & 0xff; + h = (h & 0xffff) * m + ((((h >>> 16) * m) & 0xffff) << 16); + } + + h ^= h >>> 13; + h = (h & 0xffff) * m + ((((h >>> 16) * m) & 0xffff) << 16); + h ^= h >>> 15; + + return h >>> 0; +} + +export const createAchievementHash = (a: AchievementDefinition) => { + const nameSeed = 170; + const descSeed = 221; + + return `${MurMurHash2(a.name, nameSeed).toString(16)}${MurMurHash2(a.desc, descSeed).toString(16)}_1`; +} + +export const achievementHashMap = Achievements.reduce((map, a) => { + map.set(a, createAchievementHash(a)); + return map; +}, new Map()); + +export const sendAchievementEvent = (client: Client, event: eventId, data: AchievementTags) => { + console.time(); + const completed = []; + + for (const a of Achievements) { + if (a.conds.every(c => c.event !== event)) continue; + + if (checkCondition(a, data)) { + completed.push(achievementHashMap.get(a)); + } + } + + if (completed.length) { + sendAchievements(client, completed); + } + console.timeEnd(); +} + +export const checkCondition = (achievement: AchievementDefinition, data: AchievementTags) => { + const conds = achievement.conds; + + return conds.every(condition => parseTags(condition.tags ?? {}, data)); +} + +export const parseTags = (tags: AchievementTags, data: AchievementTags): boolean => { + return Object.entries(tags).every(([key, value]) => { + if (key === "total" || key === "value" || key === "delta") { + const op = value.charCodeAt(0); + const v = value.slice(2); + const dataValue = data[key as keyof AchievementTags]!; + + switch (op) { + case 61: // == + return dataValue == v; + case 62: // >= + return dataValue >= v; + case 60: // <= + return dataValue <= v; + default: + throw new Error(`Invalid operation: ${op}`); + } + } + + return data[key as keyof AchievementTags] === value; + }); +} + +export const sendAchievements = (client: Client, hashes: string[]) => { + if (client.terminated) return; + + const w = client.write(); + + w.u8(ClientBound.Achievement); + w.u8(hashes.length); + + for (let i = 0; i < hashes.length; ++i) { + w.stringNT(hashes[i]); + } + + w.send(); +} + +export const getAchievementByName = (name: string): AchievementDefinition | null => { + return Achievements.find(a => a.name === name) || null; +} \ No newline at end of file diff --git a/src/Const/Commands.ts b/src/Const/Commands.ts index 586f397b..6d0b7a5d 100644 --- a/src/Const/Commands.ts +++ b/src/Const/Commands.ts @@ -53,6 +53,7 @@ import { Entity, EntityStateFlags } from "../Native/Entity"; import { saveToVLog } from "../util"; import { ClientBound, Stat, StatCount, PhysicsFlags, StyleFlags, Tank } from "./Enums"; import { getTankByName } from "./TankDefinitions"; +import { sendAchievements, achievementHashMap, getAchievementByName } from "./Achievements"; const RELATIVE_POS_REGEX = new RegExp(/~(-?\d+)?/); @@ -69,6 +70,7 @@ export const enum CommandID { gameAnnounce = "game_announce", gameGoldenName = "game_golden_name", gameNeutral = "game_neutral", + gameAchievement = "game_achievement", adminSummon = "admin_summon", adminKillAll = "admin_kill_all", adminKillEntity = "admin_kill_entity", @@ -164,12 +166,19 @@ export const commandDefinitions = { permissionLevel: AccessLevel.FullAccess, isCheat: false }, - game_neutral: { + game_neutral: { id: CommandID.gameNeutral, description: "Sets your tank's team to the neutral team", permissionLevel: AccessLevel.FullAccess, isCheat: false }, + game_achievement: { + id: CommandID.gameAchievement, + usage: "[achievementName]", + description: "Increments the given achievement. Example usage: game_achievement \"Shiny!\"", + permissionLevel: AccessLevel.FullAccess, + isCheat: false + }, admin_summon: { id: CommandID.adminSummon, usage: "[entityName] [?count] [?x] [?y]", @@ -313,7 +322,7 @@ export const commandCallbacks = { .float(parseInt(time)) .stringNT(id).send(); }, - game_golden_name: (client: Client, activeArg?: string) => { + game_golden_name: (client: Client) => { client.setHasCheated(!client.hasCheated()); }, game_neutral: (client: Client) => { @@ -325,6 +334,13 @@ export const commandCallbacks = { TeamEntity.setTeam(team, player); }, + game_achievement: (client: Client, nameArg: string) => { + const achievement = getAchievementByName(nameArg); + if (!achievement) return; + + const hash = achievementHashMap.get(achievement); + sendAchievements(client, [hash]); + }, admin_summon: (client: Client, entityArg: string, countArg?: string, xArg?: string, yArg?: string) => { const count = countArg ? parseInt(countArg) : 1; let x = parseInt(xArg || "0", 10); diff --git a/src/Entity/Live.ts b/src/Entity/Live.ts index 845144e5..efa78bbf 100644 --- a/src/Entity/Live.ts +++ b/src/Entity/Live.ts @@ -124,7 +124,7 @@ export default class LivingEntity extends ObjectEntity { } /** Called when the entity kills another via collision. */ - public onKill(entity: LivingEntity) {} + public onKill(entity: LivingEntity, weapon?: LivingEntity) {} /** Called when the entity is killed via collision */ public onDeath(killer: LivingEntity) {} diff --git a/src/Entity/Tank/Projectile/Bullet.ts b/src/Entity/Tank/Projectile/Bullet.ts index 92460611..79ce779a 100644 --- a/src/Entity/Tank/Projectile/Bullet.ts +++ b/src/Entity/Tank/Projectile/Bullet.ts @@ -104,7 +104,7 @@ export default class Bullet extends LivingEntity { /** Extends LivingEntity.onKill - passes kill to the owner. */ public onKill(killedEntity: LivingEntity) { - (this.tank as unknown as LivingEntity)?.onKill?.(killedEntity); + (this.tank as unknown as LivingEntity)?.onKill?.(killedEntity, this); } public tick(tick: number) { diff --git a/src/Entity/Tank/TankBody.ts b/src/Entity/Tank/TankBody.ts index bdf2e4bf..40276bf1 100644 --- a/src/Entity/Tank/TankBody.ts +++ b/src/Entity/Tank/TankBody.ts @@ -22,6 +22,7 @@ import type GameServer from "../../Game"; import type { CameraEntity } from "../../Native/Camera"; import AbstractShape from "../Shape/AbstractShape"; +import AbstractBoss from "../Boss/AbstractBoss"; import NecromancerSquare from "./Projectile/NecromancerSquare"; import LivingEntity from "../Live"; import ObjectEntity from "../Object"; @@ -32,6 +33,7 @@ import { Entity } from "../../Native/Entity"; import { NameGroup, ScoreGroup } from "../../Native/FieldGroups"; import { Addon, AddonById } from "./Addons"; import { getTankById, TankDefinition, visibilityRateDamage } from "../../Const/TankDefinitions"; +import { sendAchievementEvent } from "../../Const/Achievements"; import { DevTank } from "../../Const/DevTankDefinitions"; import { Inputs } from "../AI"; import { ArenaState } from "../../Native/Arena"; @@ -130,7 +132,10 @@ export default class TankBody extends LivingEntity implements BarrelBase { const tank = getTankById(id); const camera = this.cameraEntity; - if (!tank) throw new TypeError("Invalid tank ID"); + if (!tank){ + console.log(id); + throw new TypeError("Invalid tank ID"); + } this.definition = tank; if (!Entity.exists(camera)) throw new Error("No camera"); @@ -156,10 +161,6 @@ export default class TankBody extends LivingEntity implements BarrelBase { else if (this.positionData.flags & PositionFlags.canMoveThroughWalls) this.positionData.flags ^= PositionFlags.canMoveThroughWalls; camera.cameraData.tank = this._currentTank = id; - const client = camera.getClient(); - if (client && tank.upgradeMessage) { - client.notify(tank.upgradeMessage, 0x000000, 10000); - } // Build addons, then tanks, then addons. const preAddon = tank.preAddon; @@ -184,9 +185,28 @@ export default class TankBody extends LivingEntity implements BarrelBase { this.scale(1); // Update addons and etc this.calculateStatData(); // Re-calculate everything once this is done + const client = camera.getClient(); + if (client) { + sendAchievementEvent(client, "classChange", { + "class": id + }); + + if (tank.upgradeMessage) client.notify(tank.upgradeMessage, 0x000000, 10000); + } } /** See LivingEntity.onKill */ - public onKill(entity: LivingEntity) { + public onKill(entity: LivingEntity, weapon?: LivingEntity) { + const client = this.cameraEntity.getClient(); + if (client) { + sendAchievementEvent(client, "kill", { + "weapon.isTank": !weapon, + "victim.arenaMobID": entity.arenaMobID, + "victim.isTank": TankBody.isTank(entity), + "victim.isBoss": AbstractBoss.isBoss(entity), + "victim.isShiny": !!(entity.entityTags & EntityTags.isShiny), + }); + } + if (Entity.exists(this.cameraEntity.cameraData.values.player) && entity !== this) this.cameraEntity.addScore(entity.scoreReward); if ((entity.nameData && !(entity.nameData.values.flags & NameFlags.hiddenName))) { diff --git a/src/index.ts b/src/index.ts index 7a981a30..88e6aca2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import * as config from "./config" import * as util from "./util"; import GameServer from "./Game"; import TankDefinitions from "./Const/TankDefinitions"; +import Achievements from "./Const/Achievements"; import { commandDefinitions } from "./Const/Commands"; import { ColorsHexCode } from "./Const/Enums"; @@ -106,6 +107,9 @@ app.get("/*", (res, req) => { case "/colors": res.writeStatus("200 OK").end(JSON.stringify(ColorsHexCode)); return; + case "/achievements": + res.writeStatus("200 OK").end(JSON.stringify(Achievements)); + return; } } @@ -144,7 +148,7 @@ app.get("/*", (res, req) => { res.writeStatus("404 Not Found").end(fs.readFileSync(config.clientLocation + "/404.html")); return; - } + } }); app.listen(PORT, (success) => { @@ -155,10 +159,10 @@ app.listen(PORT, (success) => { // RULES(0): No two game servers should share the same endpoint // // NOTES(0): As of now, both servers run on the same process (and thread) here - const ffa = new GameServer(FFAArena, "FFA"); - const sbx = new GameServer(SandboxArena, "Sandbox"); - - games.push(ffa, sbx); + const ffa = new GameServer("ffa", "FFA"); + const sandbox = new GameServer("sandbox", "Sandbox"); + + games.push(ffa, sandbox); util.saveToLog("Servers up", "All servers booted up.", 0x37F554); util.log("Dumping endpoint -> gamemode routing table"); From 05d37888dd5d09c6ec7502c7d676cacd608ea13f Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:11:05 +0300 Subject: [PATCH 02/32] Update index.ts --- src/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 88e6aca2..aef74bb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -159,10 +159,11 @@ app.listen(PORT, (success) => { // RULES(0): No two game servers should share the same endpoint // // NOTES(0): As of now, both servers run on the same process (and thread) here - const ffa = new GameServer("ffa", "FFA"); - const sandbox = new GameServer("sandbox", "Sandbox"); + const ffa = new GameServer(FFAArena, "FFA"); + const sbx = new GameServer(SandboxArena, "Sandbox"); + + games.push(ffa, sbx); - games.push(ffa, sandbox); util.saveToLog("Servers up", "All servers booted up.", 0x37F554); util.log("Dumping endpoint -> gamemode routing table"); From 6c00baea2ebbd3d610556299ffee5b60f05e3668 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:11:20 +0300 Subject: [PATCH 03/32] Update TankBody.ts --- src/Entity/Tank/TankBody.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Entity/Tank/TankBody.ts b/src/Entity/Tank/TankBody.ts index 40276bf1..f5afccf2 100644 --- a/src/Entity/Tank/TankBody.ts +++ b/src/Entity/Tank/TankBody.ts @@ -132,10 +132,8 @@ export default class TankBody extends LivingEntity implements BarrelBase { const tank = getTankById(id); const camera = this.cameraEntity; - if (!tank){ - console.log(id); - throw new TypeError("Invalid tank ID"); - } + if (!tank) throw new TypeError("Invalid tank ID:", tank); + this.definition = tank; if (!Entity.exists(camera)) throw new Error("No camera"); From fdae1115e824efacd37af9b58f4b7e056aa0c525 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:14:36 +0300 Subject: [PATCH 04/32] Update TankBody.ts --- src/Entity/Tank/TankBody.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Entity/Tank/TankBody.ts b/src/Entity/Tank/TankBody.ts index f5afccf2..d6621076 100644 --- a/src/Entity/Tank/TankBody.ts +++ b/src/Entity/Tank/TankBody.ts @@ -132,7 +132,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { const tank = getTankById(id); const camera = this.cameraEntity; - if (!tank) throw new TypeError("Invalid tank ID:", tank); + if (!tank) throw new TypeError(`Invalid tank ID: ${tank}`); this.definition = tank; if (!Entity.exists(camera)) throw new Error("No camera"); From 847194d826dd83c041a4d53909d53ad0bf78fba6 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:22:42 +0300 Subject: [PATCH 05/32] Add files via upload --- src/Native/Camera.ts | 47 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/Native/Camera.ts b/src/Native/Camera.ts index 505b32fc..2d767a04 100644 --- a/src/Native/Camera.ts +++ b/src/Native/Camera.ts @@ -26,6 +26,7 @@ import { Entity, EntityStateFlags } from "./Entity"; import { CameraGroup, RelationsGroup } from "./FieldGroups"; import { CameraFlags, ClientBound, levelToScore, levelToScoreTable, PhysicsFlags, Stat } from "../Const/Enums"; import { getTankById } from "../Const/TankDefinitions"; +import { sendAchievementEvent } from "../Const/Achievements"; import { removeFast } from "../util"; import { compileCreation, compileUpdate } from "./UpcreateCompiler"; @@ -76,6 +77,14 @@ export class CameraEntity extends Entity { this.setFieldFactor(getTankById(this.cameraData.values.tank)?.fieldFactor ?? 1); this.calculateLevelData(); + + const client = this.getClient(); + if (!client) return; + + sendAchievementEvent(client, "levelUp", { + "level": level, + "class": this.cameraData.values.tank + }); } /** Returns the camera's client if it exists */ public getClient(): Client | null { @@ -94,8 +103,16 @@ export class CameraEntity extends Entity { if (player?.scoreData) player.scoreData.score += score; this.calculateLevelData(); + + const client = this.getClient(); + if (!client) return; + + sendAchievementEvent(client, "score", { + "total": this.cameraData.values.score, + "delta": score, + }); } - + public setScore(score: number) { this.cameraData.score = score; @@ -103,22 +120,46 @@ export class CameraEntity extends Entity { if (player?.scoreData) player.scoreData.score = score; this.calculateLevelData(); + + const client = this.getClient(); + if (!client) return; + + sendAchievementEvent(client, "score", { + "total": this.cameraData.values.score, + "delta": score, + }); } - + public addStat(statId: Stat, amount: number) { this.cameraData.statLevels[statId] += amount; const player = this.cameraData.values.player; if (TankBody.isTank(player)) player.calculateStatData(); + + const client = this.getClient(); + if (!client) return; + + sendAchievementEvent(client, "statUpgraded", { + "id": statId, + "isMaxLevel": this.cameraData.values.statLevels[statId] >= this.cameraData.values.statLimits[statId] + }); } - + public setStat(statId: Stat, amount: number) { this.cameraData.statLevels[statId] = amount; const player = this.cameraData.values.player; if (TankBody.isTank(player)) player.calculateStatData(); + + const client = this.getClient(); + if (!client) return; + + sendAchievementEvent(client, "statUpgraded", { + "id": statId, + "isMaxLevel": this.cameraData.values.statLevels[statId] >= this.cameraData.values.statLimits[statId] + }); } public calculateLevelData() { From 03f2a4193ff7fcfe63821cac424ea36b7304e53c Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:43:53 +0300 Subject: [PATCH 06/32] Add files via upload --- src/Client.ts | 18 ++++++++++- src/Const/Achievements.ts | 65 +++++++++++++++++++++++++++------------ src/Const/Commands.ts | 7 ++--- 3 files changed, 66 insertions(+), 24 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index d8b6a458..f78807bb 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -35,6 +35,7 @@ import { CameraFlags, ClientBound, ArenaFlags, InputFlags, NameFlags, ServerBoun import { AI, AIState, Inputs } from "./Entity/AI"; import AbstractBoss from "./Entity/Boss/AbstractBoss"; import { executeCommand } from "./Const/Commands"; +import { sendAchievements } from "./Const/Achievements"; import { bannedClients } from "."; /** XORed onto the tank id in the Tank Upgrade packet. */ @@ -109,6 +110,9 @@ export default class Client { /** Wether or not the player is in godmode. */ public isInvulnerable: boolean = false; + /** Achievements unlocked this tick */ + private pendingAchievements: string[] = []; + /** Returns a new writer stream connected to the socket. */ public write() { return new WSWriterStream(this); @@ -511,6 +515,10 @@ export default class Client { this.write().u8(ClientBound.Notification).stringNT(text).u32(color).float(time).stringNT(id).send(); } + public giveAchievements(achievements: string[]) { + this.pendingAchievements.push(...achievements); + } + /** Bans the ip from all servers until restart. */ public ban() { const ws = this.ws; @@ -543,7 +551,6 @@ export default class Client { const tank = camera.cameraData.player = camera.relationsData.owner = camera.relationsData.parent = new TankBody(this.game, camera, this.inputs); tank.setTank(Tank.Basic); tank.nameData.values.name = name; - this.game.arena.spawnPlayer(tank, this); camera.setLevel(camera.cameraData.values.respawnLevel); if (this.hasCheated()) this.setHasCheated(true); @@ -553,6 +560,8 @@ export default class Client { camera.spectatee = null; this.inputs.isPossessing = false; this.inputs.movement.magnitude = 0; + + this.game.arena.spawnPlayer(tank, this); // Should be last } public tick(tick: number) { @@ -592,9 +601,16 @@ export default class Client { this.camera.cameraData.cameraX = this.camera.cameraData.cameraY = 0; this.camera.cameraData.flags &= ~CameraFlags.showingDeathStats; } + if (tick >= this.lastPingTick + 60 * config.tps) { return this.terminate(); } + + if (this.pendingAchievements.length) { + sendAchievements(this, this.pendingAchievements); + + this.pendingAchievements.length = 0; + } } /** toString override from base Object. Adds debug info */ public toString(verbose: boolean = false): string { diff --git a/src/Const/Achievements.ts b/src/Const/Achievements.ts index 79bc0133..5dcf1dea 100644 --- a/src/Const/Achievements.ts +++ b/src/Const/Achievements.ts @@ -37,6 +37,8 @@ export interface AchievementDefinition { desc: string; /** Conditions needed to unlock */ conds: AchievementCondition[]; + /** Achievement hash which is send to clients */ + hash: string; } export interface AchievementTags { @@ -66,9 +68,6 @@ export interface AchievementCondition { threshold?: number; } -const Achievements = _Achievements as AchievementDefinition[]; -export default Achievements; - // From https://github.com/jtpio/murmurhash2 export const MurMurHash2 = (str: string, seed: number): number => { const m = 0x5bd1e995; @@ -120,27 +119,45 @@ export const createAchievementHash = (a: AchievementDefinition) => { return `${MurMurHash2(a.name, nameSeed).toString(16)}${MurMurHash2(a.desc, descSeed).toString(16)}_1`; } -export const achievementHashMap = Achievements.reduce((map, a) => { - map.set(a, createAchievementHash(a)); +export const compileAchievement = (a: AchievementDefinition) => { + return { + ...a, + hash: createAchievementHash(a) + } +} + +const Achievements = _Achievements.map(a => compileAchievement(a as AchievementDefinition)); +export default Achievements; + +export const achievementEventMap = Achievements.reduce((map, a) => { + for (const c of a.conds) { + const byEvent = map.get(c.event); + + if (!byEvent) { + map.set(c.event, [a]); + continue; + } + + byEvent.push(a); + } + return map; }, new Map()); export const sendAchievementEvent = (client: Client, event: eventId, data: AchievementTags) => { - console.time(); const completed = []; + + const achievements = achievementEventMap.get(event); - for (const a of Achievements) { - if (a.conds.every(c => c.event !== event)) continue; - + for (const a of achievements) { if (checkCondition(a, data)) { - completed.push(achievementHashMap.get(a)); + completed.push(a.hash); } } if (completed.length) { - sendAchievements(client, completed); + client.giveAchievements(completed); } - console.timeEnd(); } export const checkCondition = (achievement: AchievementDefinition, data: AchievementTags) => { @@ -150,28 +167,38 @@ export const checkCondition = (achievement: AchievementDefinition, data: Achieve } export const parseTags = (tags: AchievementTags, data: AchievementTags): boolean => { - return Object.entries(tags).every(([key, value]) => { + for (const key in tags) { + const value = tags[key as keyof AchievementTags] as string; + if (key === "total" || key === "value" || key === "delta") { const op = value.charCodeAt(0); - const v = value.slice(2); + const v = parseInt(value.slice(2)); const dataValue = data[key as keyof AchievementTags]!; switch (op) { case 61: // == - return dataValue == v; + if (dataValue !== v) return false; + break; case 62: // >= - return dataValue >= v; + if (dataValue < v) return false; + break; case 60: // <= - return dataValue <= v; + if (dataValue > v) return false; + break; default: throw new Error(`Invalid operation: ${op}`); } + } else { + if (data[key as keyof AchievementTags] !== value) { + return false; + } } + } - return data[key as keyof AchievementTags] === value; - }); + return true; } + export const sendAchievements = (client: Client, hashes: string[]) => { if (client.terminated) return; diff --git a/src/Const/Commands.ts b/src/Const/Commands.ts index 6d0b7a5d..30719448 100644 --- a/src/Const/Commands.ts +++ b/src/Const/Commands.ts @@ -53,7 +53,7 @@ import { Entity, EntityStateFlags } from "../Native/Entity"; import { saveToVLog } from "../util"; import { ClientBound, Stat, StatCount, PhysicsFlags, StyleFlags, Tank } from "./Enums"; import { getTankByName } from "./TankDefinitions"; -import { sendAchievements, achievementHashMap, getAchievementByName } from "./Achievements"; +import { sendAchievements, getAchievementByName } from "./Achievements"; const RELATIVE_POS_REGEX = new RegExp(/~(-?\d+)?/); @@ -337,9 +337,8 @@ export const commandCallbacks = { game_achievement: (client: Client, nameArg: string) => { const achievement = getAchievementByName(nameArg); if (!achievement) return; - - const hash = achievementHashMap.get(achievement); - sendAchievements(client, [hash]); + + sendAchievements(client, [achievement.hash]); }, admin_summon: (client: Client, entityArg: string, countArg?: string, xArg?: string, yArg?: string) => { const count = countArg ? parseInt(countArg) : 1; From 3f5db7b823c456745d3833376b2f35d94d15a6e0 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:01:29 +0300 Subject: [PATCH 07/32] rework --- src/Const/Achievements.ts | 88 ++++++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/src/Const/Achievements.ts b/src/Const/Achievements.ts index 5dcf1dea..302d04bc 100644 --- a/src/Const/Achievements.ts +++ b/src/Const/Achievements.ts @@ -19,7 +19,12 @@ import _Achievements from "./Achievements.json"; import Client from "../Client"; import Writer from "../Coder/Writer"; -import { ClientBound } from "./Enums"; +import { ClientBound, Tank } from "./Enums"; +import { DevTank } from "./DevTankDefinitions"; + +const OP_EQUALS = 0; +const OP_GTE = 1; +const OP_LTE = 2; /** The event types achievements can have */ export type eventId = "kill" | "score" | "levelUp" | "statUpgraded" | "classChange" | "latency"; @@ -37,28 +42,39 @@ export interface AchievementDefinition { desc: string; /** Conditions needed to unlock */ conds: AchievementCondition[]; - /** Achievement hash which is send to clients */ + /** Achievement hash which is sent to clients */ hash: string; } export interface AchievementTags { + /** Current value */ "value"?: string | number; + /** Total value */ "total"?: string | number; /** Value change */ "delta"?: string | number; /** Tank ID */ - "class"?: number; + "class"?: Tank | DevTank; /** Tank level */ "level"?: number; /** Stat ID */ "id"?: number; /** Is max stat level */ "isMaxLevel"?: boolean; + /** Used for ramming case */ "weapon.isTank"?: boolean; + /** Was the victim a tank? */ "victim.isTank"?: boolean; + /** Was the victim a boss? */ "victim.isBoss"?: boolean; + /** Was the victim a shiny shape? */ "victim.isShiny"?: boolean; + /** Victim tank ID */ + "victim.class"?: Tank | DevTank; + /** Victim mob ID */ "victim.arenaMobID"?: string | null; + /** Created during parsing */ + "op"?: number | null; } export interface AchievementCondition { @@ -68,7 +84,7 @@ export interface AchievementCondition { threshold?: number; } -// From https://github.com/jtpio/murmurhash2 +/** From https://github.com/jtpio/murmurhash2 */ export const MurMurHash2 = (str: string, seed: number): number => { const m = 0x5bd1e995; const encoder = new TextEncoder(); @@ -119,10 +135,41 @@ export const createAchievementHash = (a: AchievementDefinition) => { return `${MurMurHash2(a.name, nameSeed).toString(16)}${MurMurHash2(a.desc, descSeed).toString(16)}_1`; } +export const compileConds = (conds: AchievementCondition[]) => { + for (const c of conds) { + const tags = c.tags; + + for (const key in tags) { + const value = tags[key as keyof AchievementTags] as string; + + if (key === "total" || key === "value" || key === "delta") { + tags[key] = parseInt(value.slice(2)); + const op = value.slice(0, 2); + + switch (op) { + case "==": + tags.op = OP_EQUALS; + break; + case ">=": + tags.op = OP_GTE; + break; + case "<=": + tags.op = OP_LTE; + break; + default: throw new Error(`Invalid operation: ${op}`); + } + } + } + } + + return conds; +} + export const compileAchievement = (a: AchievementDefinition) => { return { ...a, - hash: createAchievementHash(a) + hash: createAchievementHash(a), + conds: compileConds(a.conds) } } @@ -146,7 +193,7 @@ export const achievementEventMap = Achievements.reduce((map, a) => { export const sendAchievementEvent = (client: Client, event: eventId, data: AchievementTags) => { const completed = []; - + const achievements = achievementEventMap.get(event); for (const a of achievements) { @@ -163,27 +210,29 @@ export const sendAchievementEvent = (client: Client, event: eventId, data: Achie export const checkCondition = (achievement: AchievementDefinition, data: AchievementTags) => { const conds = achievement.conds; - return conds.every(condition => parseTags(condition.tags ?? {}, data)); + return conds.every(condition => parseTags(condition.tags, data)); } -export const parseTags = (tags: AchievementTags, data: AchievementTags): boolean => { +export const parseTags = (tags: AchievementTags | null, data: AchievementTags): boolean => { + if (!tags) return true; + for (const key in tags) { - const value = tags[key as keyof AchievementTags] as string; + const value = tags[key as keyof AchievementTags]; + if (!value) continue; if (key === "total" || key === "value" || key === "delta") { - const op = value.charCodeAt(0); - const v = parseInt(value.slice(2)); - const dataValue = data[key as keyof AchievementTags]!; + const op = tags.op; + const givenValue = data[key as keyof AchievementTags]!; switch (op) { - case 61: // == - if (dataValue !== v) return false; + case OP_EQUALS: // == + if (givenValue === value) return true; break; - case 62: // >= - if (dataValue < v) return false; + case OP_GTE: // >= + if (givenValue > value) return true; break; - case 60: // <= - if (dataValue > v) return false; + case OP_LTE: // <= + if (givenValue <= value) return true; break; default: throw new Error(`Invalid operation: ${op}`); @@ -198,7 +247,6 @@ export const parseTags = (tags: AchievementTags, data: AchievementTags): boolean return true; } - export const sendAchievements = (client: Client, hashes: string[]) => { if (client.terminated) return; @@ -216,4 +264,4 @@ export const sendAchievements = (client: Client, hashes: string[]) => { export const getAchievementByName = (name: string): AchievementDefinition | null => { return Achievements.find(a => a.name === name) || null; -} \ No newline at end of file +} From dce180e5edc8cbd7e7f6f461b2123aaaed53d947 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:49:47 +0300 Subject: [PATCH 08/32] Update Achievements.ts --- src/Const/Achievements.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Const/Achievements.ts b/src/Const/Achievements.ts index 302d04bc..eb76a5bc 100644 --- a/src/Const/Achievements.ts +++ b/src/Const/Achievements.ts @@ -22,6 +22,9 @@ import Writer from "../Coder/Writer"; import { ClientBound, Tank } from "./Enums"; import { DevTank } from "./DevTankDefinitions"; +const NAME_SEED = 170; +const DESC_SEED = 221; + const OP_EQUALS = 0; const OP_GTE = 1; const OP_LTE = 2; @@ -129,10 +132,7 @@ export const MurMurHash2 = (str: string, seed: number): number => { } export const createAchievementHash = (a: AchievementDefinition) => { - const nameSeed = 170; - const descSeed = 221; - - return `${MurMurHash2(a.name, nameSeed).toString(16)}${MurMurHash2(a.desc, descSeed).toString(16)}_1`; + return `${MurMurHash2(a.name, NAME_SEED).toString(16)}${MurMurHash2(a.desc, DESC_SEED).toString(16)}_1`; } export const compileConds = (conds: AchievementCondition[]) => { @@ -217,8 +217,7 @@ export const parseTags = (tags: AchievementTags | null, data: AchievementTags): if (!tags) return true; for (const key in tags) { - const value = tags[key as keyof AchievementTags]; - if (!value) continue; + const value = tags[key as keyof AchievementTags]!; if (key === "total" || key === "value" || key === "delta") { const op = tags.op; @@ -232,10 +231,8 @@ export const parseTags = (tags: AchievementTags | null, data: AchievementTags): if (givenValue > value) return true; break; case OP_LTE: // <= - if (givenValue <= value) return true; + if (givenValue < value) return true; break; - default: - throw new Error(`Invalid operation: ${op}`); } } else { if (data[key as keyof AchievementTags] !== value) { From 1b752bce7641db15080bbb859b3775042608ae23 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:53:02 +0300 Subject: [PATCH 09/32] Comment --- src/Const/Achievements.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Const/Achievements.ts b/src/Const/Achievements.ts index eb76a5bc..29402a6e 100644 --- a/src/Const/Achievements.ts +++ b/src/Const/Achievements.ts @@ -36,7 +36,7 @@ export type eventId = "kill" | "score" | "levelUp" | "statUpgraded" | "classChan export type achievementType = "counter"; /** - * Format that the game stores bullet definitions in its memory. + * Format that the game stores achievements in its memory. */ export interface AchievementDefinition { /** Achievement name */ From b3e1ce19ab2f22e83a96a392fc17e17c5cce5d70 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:38:37 +0300 Subject: [PATCH 10/32] Update TankBody.ts --- src/Entity/Tank/TankBody.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Entity/Tank/TankBody.ts b/src/Entity/Tank/TankBody.ts index d6621076..7ba37173 100644 --- a/src/Entity/Tank/TankBody.ts +++ b/src/Entity/Tank/TankBody.ts @@ -194,8 +194,16 @@ export default class TankBody extends LivingEntity implements BarrelBase { } /** See LivingEntity.onKill */ public onKill(entity: LivingEntity, weapon?: LivingEntity) { + if (Entity.exists(this.cameraEntity.cameraData.values.player) && entity !== this) { + this.cameraEntity.addScore(entity.scoreReward); + } + const client = this.cameraEntity.getClient(); if (client) { + if (entity.nameData && !(entity.nameData.values.flags & NameFlags.hiddenName)) { + client.notify("You've killed " + (entity.nameData.values.name || "an unnamed tank")); + } + sendAchievementEvent(client, "kill", { "weapon.isTank": !weapon, "victim.arenaMobID": entity.arenaMobID, @@ -205,13 +213,6 @@ export default class TankBody extends LivingEntity implements BarrelBase { }); } - if (Entity.exists(this.cameraEntity.cameraData.values.player) && entity !== this) this.cameraEntity.addScore(entity.scoreReward); - - if ((entity.nameData && !(entity.nameData.values.flags & NameFlags.hiddenName))) { - const client = this.cameraEntity.getClient(); - if (client) client.notify("You've killed " + (entity.nameData.values.name || "an unnamed tank")); - } - // TODO(ABC): // This is actually not how necromancers claim squares. if (entity.arenaMobID === "square" && this.definition.flags.canClaimSquares && this.barrels.length) { From c9456dbb19415ed8aa523e22e86f834de96e4eba Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:47:27 +0300 Subject: [PATCH 11/32] Type fix --- src/Const/Achievements.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Const/Achievements.ts b/src/Const/Achievements.ts index 29402a6e..885370ca 100644 --- a/src/Const/Achievements.ts +++ b/src/Const/Achievements.ts @@ -250,7 +250,7 @@ export const sendAchievements = (client: Client, hashes: string[]) => { const w = client.write(); w.u8(ClientBound.Achievement); - w.u8(hashes.length); + w.vu(hashes.length); for (let i = 0; i < hashes.length; ++i) { w.stringNT(hashes[i]); From 022237fa73e9d3a57a179148700d37e8f57381d2 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:06:25 +0300 Subject: [PATCH 12/32] Fix ram achievements --- src/Entity/Live.ts | 4 ++-- src/Entity/Tank/Addons.ts | 4 ++-- src/Entity/Tank/AutoTurret.ts | 4 ++-- src/Entity/Tank/Projectile/Bullet.ts | 4 ++-- src/Entity/Tank/TankBody.ts | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Entity/Live.ts b/src/Entity/Live.ts index efa78bbf..bc536fab 100644 --- a/src/Entity/Live.ts +++ b/src/Entity/Live.ts @@ -119,12 +119,12 @@ export default class LivingEntity extends ObjectEntity { this.onDeath(killer); } - source.onKill(this); + source.onKill(this, source); } } /** Called when the entity kills another via collision. */ - public onKill(entity: LivingEntity, weapon?: LivingEntity) {} + public onKill(entity: LivingEntity, weapon: LivingEntity) {} /** Called when the entity is killed via collision */ public onDeath(killer: LivingEntity) {} diff --git a/src/Entity/Tank/Addons.ts b/src/Entity/Tank/Addons.ts index dc36ef25..618494fa 100644 --- a/src/Entity/Tank/Addons.ts +++ b/src/Entity/Tank/Addons.ts @@ -175,9 +175,9 @@ export class GuardObject extends ObjectEntity implements BarrelBase { * Called (if ever) similarly to LivingEntity.onKill * Spreads onKill to owner */ - public onKill(killedEntity: LivingEntity) { + public onKill(killedEntity: LivingEntity, weapon: LivingEntity) { if (!LivingEntity.isLive(this.owner)) return; - this.owner.onKill(killedEntity); + this.owner.onKill(killedEntity, weapon); } public tick(tick: number): void { diff --git a/src/Entity/Tank/AutoTurret.ts b/src/Entity/Tank/AutoTurret.ts index cc05fefb..bc251dc9 100644 --- a/src/Entity/Tank/AutoTurret.ts +++ b/src/Entity/Tank/AutoTurret.ts @@ -120,8 +120,8 @@ export default class AutoTurret extends ObjectEntity { * Called similarly to LivingEntity.onKill * Spreads onKill to owner */ - public onKill(killedEntity: LivingEntity) { - (this.owner as unknown as LivingEntity)?.onKill?.(killedEntity); + public onKill(killedEntity: LivingEntity, weapon: LivingEntity) { + (this.owner as unknown as LivingEntity)?.onKill?.(killedEntity, weapon); } public tick(tick: number) { diff --git a/src/Entity/Tank/Projectile/Bullet.ts b/src/Entity/Tank/Projectile/Bullet.ts index 79ce779a..64ee3b5f 100644 --- a/src/Entity/Tank/Projectile/Bullet.ts +++ b/src/Entity/Tank/Projectile/Bullet.ts @@ -103,8 +103,8 @@ export default class Bullet extends LivingEntity { } /** Extends LivingEntity.onKill - passes kill to the owner. */ - public onKill(killedEntity: LivingEntity) { - (this.tank as unknown as LivingEntity)?.onKill?.(killedEntity, this); + public onKill(killedEntity: LivingEntity, weapon: LivingEntity) { + (this.tank as unknown as LivingEntity)?.onKill?.(killedEntity, weapon); } public tick(tick: number) { diff --git a/src/Entity/Tank/TankBody.ts b/src/Entity/Tank/TankBody.ts index 7ba37173..53bbceb1 100644 --- a/src/Entity/Tank/TankBody.ts +++ b/src/Entity/Tank/TankBody.ts @@ -193,7 +193,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { } } /** See LivingEntity.onKill */ - public onKill(entity: LivingEntity, weapon?: LivingEntity) { + public onKill(entity: LivingEntity, weapon: LivingEntity) { if (Entity.exists(this.cameraEntity.cameraData.values.player) && entity !== this) { this.cameraEntity.addScore(entity.scoreReward); } @@ -205,7 +205,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { } sendAchievementEvent(client, "kill", { - "weapon.isTank": !weapon, + "weapon.isTank": TankBody.isTank(weapon), "victim.arenaMobID": entity.arenaMobID, "victim.isTank": TankBody.isTank(entity), "victim.isBoss": AbstractBoss.isBoss(entity), From 117881be384676b2ebd009fe3c64720aa07127c2 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:38:57 +0300 Subject: [PATCH 13/32] fix crash --- src/Client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.ts b/src/Client.ts index f78807bb..0fb8a1f0 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -329,7 +329,7 @@ export default class Client { player.destroy(); player.onDeath(player); - player.onKill(player); + player.onKill(player, player); } } From 65e96e64695de1e37752c5dced2afa5beadb57d6 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:35:01 +0300 Subject: [PATCH 14/32] More tag support --- src/Entity/Tank/TankBody.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Entity/Tank/TankBody.ts b/src/Entity/Tank/TankBody.ts index 53bbceb1..17253fea 100644 --- a/src/Entity/Tank/TankBody.ts +++ b/src/Entity/Tank/TankBody.ts @@ -204,12 +204,15 @@ export default class TankBody extends LivingEntity implements BarrelBase { client.notify("You've killed " + (entity.nameData.values.name || "an unnamed tank")); } + const victimIsTank = TankBody.isTank(entity); + sendAchievementEvent(client, "kill", { "weapon.isTank": TankBody.isTank(weapon), "victim.arenaMobID": entity.arenaMobID, - "victim.isTank": TankBody.isTank(entity), + "victim.isTank": victimIsTank, "victim.isBoss": AbstractBoss.isBoss(entity), "victim.isShiny": !!(entity.entityTags & EntityTags.isShiny), + "victim.class": victimIsTank ? entity.currentTank : -1 }); } From 7ffdefe7167cec50cd1ea78366e8a85425bd89a4 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:41:05 +0300 Subject: [PATCH 15/32] Achievements config var --- src/config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/config.ts b/src/config.ts index faffafdf..4112ed21 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,6 +52,9 @@ export const shinyChance: number = 1 / 1_000_000; /** Chance for a player to spawn out of an allied factory. */ export const factorySpawnChance: number = 0.05; +/** Enable achievement event system. */ +export const enableAchievements: boolean = true; + /** Is hosting a rest api */ export const enableApi: boolean = true; From ab494198534ec700f42454aa8e56b37f40f070a3 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:41:47 +0300 Subject: [PATCH 16/32] Achievements config var --- src/Const/Achievements.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Const/Achievements.ts b/src/Const/Achievements.ts index 885370ca..5ad76317 100644 --- a/src/Const/Achievements.ts +++ b/src/Const/Achievements.ts @@ -21,6 +21,7 @@ import Client from "../Client"; import Writer from "../Coder/Writer"; import { ClientBound, Tank } from "./Enums"; import { DevTank } from "./DevTankDefinitions"; +import { enableAchievements } from "../config"; const NAME_SEED = 170; const DESC_SEED = 221; @@ -192,6 +193,8 @@ export const achievementEventMap = Achievements.reduce((map, a) => { }, new Map()); export const sendAchievementEvent = (client: Client, event: eventId, data: AchievementTags) => { + if (!enableAchievements) return; + const completed = []; const achievements = achievementEventMap.get(event); @@ -207,13 +210,13 @@ export const sendAchievementEvent = (client: Client, event: eventId, data: Achie } } -export const checkCondition = (achievement: AchievementDefinition, data: AchievementTags) => { +const checkCondition = (achievement: AchievementDefinition, data: AchievementTags) => { const conds = achievement.conds; return conds.every(condition => parseTags(condition.tags, data)); } -export const parseTags = (tags: AchievementTags | null, data: AchievementTags): boolean => { +const parseTags = (tags: AchievementTags | null, data: AchievementTags): boolean => { if (!tags) return true; for (const key in tags) { From 446ab06d7d3b85dae172cf5f378f9a32c8a13391 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:42:13 +0300 Subject: [PATCH 17/32] Achievements config var --- src/Entity/Tank/TankBody.ts | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/Entity/Tank/TankBody.ts b/src/Entity/Tank/TankBody.ts index 17253fea..fe879e60 100644 --- a/src/Entity/Tank/TankBody.ts +++ b/src/Entity/Tank/TankBody.ts @@ -37,7 +37,7 @@ import { sendAchievementEvent } from "../../Const/Achievements"; import { DevTank } from "../../Const/DevTankDefinitions"; import { Inputs } from "../AI"; import { ArenaState } from "../../Native/Arena"; -import { AccessLevel, maxPlayerLevel } from "../../config"; +import { AccessLevel, maxPlayerLevel, enableAchievements } from "../../config"; /** * Abstract type of entity which barrels can connect to. @@ -185,11 +185,13 @@ export default class TankBody extends LivingEntity implements BarrelBase { this.calculateStatData(); // Re-calculate everything once this is done const client = camera.getClient(); if (client) { - sendAchievementEvent(client, "classChange", { - "class": id - }); - if (tank.upgradeMessage) client.notify(tank.upgradeMessage, 0x000000, 10000); + + if (enableAchievements) { + sendAchievementEvent(client, "classChange", { + "class": id + }); + } } } /** See LivingEntity.onKill */ @@ -203,17 +205,19 @@ export default class TankBody extends LivingEntity implements BarrelBase { if (entity.nameData && !(entity.nameData.values.flags & NameFlags.hiddenName)) { client.notify("You've killed " + (entity.nameData.values.name || "an unnamed tank")); } - - const victimIsTank = TankBody.isTank(entity); - - sendAchievementEvent(client, "kill", { - "weapon.isTank": TankBody.isTank(weapon), - "victim.arenaMobID": entity.arenaMobID, - "victim.isTank": victimIsTank, - "victim.isBoss": AbstractBoss.isBoss(entity), - "victim.isShiny": !!(entity.entityTags & EntityTags.isShiny), - "victim.class": victimIsTank ? entity.currentTank : -1 - }); + + if (enableAchievements) { + const victimIsTank = TankBody.isTank(entity); + + sendAchievementEvent(client, "kill", { + "weapon.isTank": TankBody.isTank(weapon), + "victim.arenaMobID": entity.arenaMobID, + "victim.isTank": victimIsTank, + "victim.isBoss": AbstractBoss.isBoss(entity), + "victim.isShiny": !!(entity.entityTags & EntityTags.isShiny), + "victim.class": victimIsTank ? entity.currentTank : -1 + }); + } } // TODO(ABC): From 52c780dc44815b68fd847df150e66076da4c6288 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:42:32 +0300 Subject: [PATCH 18/32] Achievements config var --- src/Native/Camera.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Native/Camera.ts b/src/Native/Camera.ts index 2d767a04..133373f4 100644 --- a/src/Native/Camera.ts +++ b/src/Native/Camera.ts @@ -30,7 +30,7 @@ import { sendAchievementEvent } from "../Const/Achievements"; import { removeFast } from "../util"; import { compileCreation, compileUpdate } from "./UpcreateCompiler"; -import { maxPlayerLevel } from "../config"; +import { maxPlayerLevel, enableAchievements } from "../config"; /** * Represents any entity with a camera field group. @@ -78,6 +78,8 @@ export class CameraEntity extends Entity { this.setFieldFactor(getTankById(this.cameraData.values.tank)?.fieldFactor ?? 1); this.calculateLevelData(); + if (!enableAchievements) return; + const client = this.getClient(); if (!client) return; @@ -104,6 +106,8 @@ export class CameraEntity extends Entity { this.calculateLevelData(); + if (!enableAchievements) return; + const client = this.getClient(); if (!client) return; @@ -120,7 +124,9 @@ export class CameraEntity extends Entity { if (player?.scoreData) player.scoreData.score = score; this.calculateLevelData(); - + + if (!enableAchievements) return; + const client = this.getClient(); if (!client) return; @@ -136,7 +142,9 @@ export class CameraEntity extends Entity { const player = this.cameraData.values.player; if (TankBody.isTank(player)) player.calculateStatData(); - + + if (!enableAchievements) return; + const client = this.getClient(); if (!client) return; @@ -152,7 +160,9 @@ export class CameraEntity extends Entity { const player = this.cameraData.values.player; if (TankBody.isTank(player)) player.calculateStatData(); - + + if (!enableAchievements) return; + const client = this.getClient(); if (!client) return; From 371c572302ac2f0d3656fc055b932bbdd27260b1 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:53:01 +0300 Subject: [PATCH 19/32] Disable in sbx --- src/Native/Arena.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Native/Arena.ts b/src/Native/Arena.ts index 6e704b4e..09a24cf2 100644 --- a/src/Native/Arena.ts +++ b/src/Native/Arena.ts @@ -70,6 +70,9 @@ export default class ArenaEntity extends Entity implements TeamGroupEntity { public state: ArenaState = ArenaState.COUNTDOWN; public shapeScoreRewardMultiplier: number = 1; + + /** If achievements cannot be obtained in this arena */ + public disableAchievements: boolean = false; /** The boss spawner. Set to null in gamemode file to disable boss spawning. */ public bossManager: BossManager | null = new BossManager(this); From 440162c9325d7d773f8ee688c441fd2ce87e40f9 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:53:21 +0300 Subject: [PATCH 20/32] Disable in sbx --- src/Gamemodes/Sandbox.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Gamemodes/Sandbox.ts b/src/Gamemodes/Sandbox.ts index f04770d4..772b21dd 100644 --- a/src/Gamemodes/Sandbox.ts +++ b/src/Gamemodes/Sandbox.ts @@ -49,6 +49,7 @@ export default class SandboxArena extends ArenaEntity { this.arenaData.values.flags |= ArenaFlags.canUseCheats; this.state = ArenaState.OPEN; // Sandbox should start instantly, no countdown + this.disableAchievements = true; this.setSandboxArenaSize(0); } From c8d6dca9a51dd4dea554193089d3e2647deee448 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:53:40 +0300 Subject: [PATCH 21/32] Disable in sbx --- src/Entity/Tank/TankBody.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Entity/Tank/TankBody.ts b/src/Entity/Tank/TankBody.ts index fe879e60..5cabc74d 100644 --- a/src/Entity/Tank/TankBody.ts +++ b/src/Entity/Tank/TankBody.ts @@ -187,7 +187,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { if (client) { if (tank.upgradeMessage) client.notify(tank.upgradeMessage, 0x000000, 10000); - if (enableAchievements) { + if (enableAchievements && !this.game.arena.disableAchievements) { sendAchievementEvent(client, "classChange", { "class": id }); @@ -206,7 +206,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { client.notify("You've killed " + (entity.nameData.values.name || "an unnamed tank")); } - if (enableAchievements) { + if (enableAchievements && !this.game.arena.disableAchievements) { const victimIsTank = TankBody.isTank(entity); sendAchievementEvent(client, "kill", { From a90ba184d79811d6f9cbc5e7fb01058d1bab3a72 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:54:01 +0300 Subject: [PATCH 22/32] Disable in sbx --- src/Native/Camera.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Native/Camera.ts b/src/Native/Camera.ts index 133373f4..6690c0f2 100644 --- a/src/Native/Camera.ts +++ b/src/Native/Camera.ts @@ -106,7 +106,7 @@ export class CameraEntity extends Entity { this.calculateLevelData(); - if (!enableAchievements) return; + if (!enableAchievements && !this.game.arena.disableAchievements) return; const client = this.getClient(); if (!client) return; @@ -125,7 +125,7 @@ export class CameraEntity extends Entity { this.calculateLevelData(); - if (!enableAchievements) return; + if (!enableAchievements && !this.game.arena.disableAchievements) return; const client = this.getClient(); if (!client) return; @@ -143,7 +143,7 @@ export class CameraEntity extends Entity { if (TankBody.isTank(player)) player.calculateStatData(); - if (!enableAchievements) return; + if (!enableAchievements && !this.game.arena.disableAchievements) return; const client = this.getClient(); if (!client) return; @@ -161,7 +161,7 @@ export class CameraEntity extends Entity { if (TankBody.isTank(player)) player.calculateStatData(); - if (!enableAchievements) return; + if (!enableAchievements && !this.game.arena.disableAchievements) return; const client = this.getClient(); if (!client) return; From e017dab5826d460bfd3552d08aa1d51bf8773e80 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:59:01 +0300 Subject: [PATCH 23/32] Format --- src/Entity/Tank/TankBody.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Entity/Tank/TankBody.ts b/src/Entity/Tank/TankBody.ts index 5cabc74d..e050fe48 100644 --- a/src/Entity/Tank/TankBody.ts +++ b/src/Entity/Tank/TankBody.ts @@ -104,7 +104,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { this.entityTags |= EntityTags.isTank; } - + public static isTank(entity: Entity | null | undefined): entity is TankBody { if (!ObjectEntity.isObject(entity)) return false; @@ -180,9 +180,10 @@ export default class TankBody extends LivingEntity implements BarrelBase { // Yeah, yeah why not this.cameraEntity.cameraData.tankOverride = tank.name; camera.setFieldFactor(tank.fieldFactor); - + this.scale(1); // Update addons and etc this.calculateStatData(); // Re-calculate everything once this is done + const client = camera.getClient(); if (client) { if (tank.upgradeMessage) client.notify(tank.upgradeMessage, 0x000000, 10000); @@ -194,6 +195,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { } } } + /** See LivingEntity.onKill */ public onKill(entity: LivingEntity, weapon: LivingEntity) { if (Entity.exists(this.cameraEntity.cameraData.values.player) && entity !== this) { @@ -203,9 +205,9 @@ export default class TankBody extends LivingEntity implements BarrelBase { const client = this.cameraEntity.getClient(); if (client) { if (entity.nameData && !(entity.nameData.values.flags & NameFlags.hiddenName)) { - client.notify("You've killed " + (entity.nameData.values.name || "an unnamed tank")); + client.notify(`You've killed ${(entity.nameData.values.name || "an unnamed tank")}`); } - + if (enableAchievements && !this.game.arena.disableAchievements) { const victimIsTank = TankBody.isTank(entity); @@ -248,7 +250,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { if (this.styleData.flags & StyleFlags.isFlashing) this.styleData.flags ^= StyleFlags.isFlashing; if (this.isInvulnerable === invulnerable) return; - + if (invulnerable) { this.damageReduction = 0.0; this.physicsData.absorbtionFactor = 0.0; @@ -256,7 +258,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { this.damageReduction = 1.0; this.physicsData.absorbtionFactor = this.definition.absorbtionFactor; } - + this.isInvulnerable = invulnerable; } @@ -273,7 +275,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { super.receiveDamage(source, amount); } - + public calculateStatData() { // Body damage this.damagePerTick = this.cameraEntity.cameraData.statLevels[Stat.BodyDamage] + 5 + (this.definition.bodyDamage ?? 0); @@ -295,7 +297,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { // Movement speed this.cameraEntity.cameraData.movementSpeed = this.definition.speed * 2.55 * Math.pow(1.07, this.cameraEntity.cameraData.values.statLevels.values[Stat.MovementSpeed]) / Math.pow(1.015, this.cameraEntity.cameraData.values.level - 1); - + for (const barrel of this.barrels) barrel.calculateStatData(); } @@ -353,6 +355,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { this.healthData.health -= 2 + this.healthData.values.maxHealth / 500; if (this.isInvulnerable) this.setInvulnerability(false); + if (this.styleData.values.flags & StyleFlags.isFlashing) { this.styleData.flags ^= StyleFlags.isFlashing; this.damageReduction = 1.0; @@ -371,10 +374,10 @@ export default class TankBody extends LivingEntity implements BarrelBase { } else if (this.cameraEntity.cameraData.values.flags & CameraFlags.usesCameraCoords) this.cameraEntity.cameraData.flags ^= CameraFlags.usesCameraCoords; if (this.definition.flags.invisibility) { - if (this.inputs.flags & InputFlags.leftclick) this.styleData.opacity += this.definition.visibilityRateShooting; + if (this.inputs.flags & (InputFlags.up | InputFlags.down | InputFlags.left | InputFlags.right) || this.inputs.movement.x || this.inputs.movement.y) this.styleData.opacity += this.definition.visibilityRateMoving; - + this.styleData.opacity -= this.definition.invisibilityRate; this.styleData.opacity = util.constrain(this.styleData.values.opacity, 0, 1); @@ -395,6 +398,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { x: this.inputs.movement.x * this.cameraEntity.cameraData.values.movementSpeed, y: this.inputs.movement.y * this.cameraEntity.cameraData.values.movementSpeed }); + this.inputs.movement.set({ x: 0, y: 0 From f49df0a1aac89f889a7738fed630c3400401853a Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:00:07 +0300 Subject: [PATCH 24/32] Format --- src/Entity/Tank/TankBody.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Entity/Tank/TankBody.ts b/src/Entity/Tank/TankBody.ts index e050fe48..cbe409a0 100644 --- a/src/Entity/Tank/TankBody.ts +++ b/src/Entity/Tank/TankBody.ts @@ -205,7 +205,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { const client = this.cameraEntity.getClient(); if (client) { if (entity.nameData && !(entity.nameData.values.flags & NameFlags.hiddenName)) { - client.notify(`You've killed ${(entity.nameData.values.name || "an unnamed tank")}`); + client.notify(`You've killed ${entity.nameData.values.name || "an unnamed tank"}`); } if (enableAchievements && !this.game.arena.disableAchievements) { From d3907201d982786b9dadca95ff297b91a5899b34 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:46:57 +0300 Subject: [PATCH 25/32] Fix multiple tags case --- src/Const/Achievements.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Const/Achievements.ts b/src/Const/Achievements.ts index 5ad76317..3ea5f1fa 100644 --- a/src/Const/Achievements.ts +++ b/src/Const/Achievements.ts @@ -77,8 +77,6 @@ export interface AchievementTags { "victim.class"?: Tank | DevTank; /** Victim mob ID */ "victim.arenaMobID"?: string | null; - /** Created during parsing */ - "op"?: number | null; } export interface AchievementCondition { @@ -86,6 +84,8 @@ export interface AchievementCondition { type?: achievementType; tags: AchievementTags; threshold?: number; + /** Created during parsing */ + op?: number | null; } /** From https://github.com/jtpio/murmurhash2 */ @@ -149,13 +149,13 @@ export const compileConds = (conds: AchievementCondition[]) => { switch (op) { case "==": - tags.op = OP_EQUALS; + c.op = OP_EQUALS; break; case ">=": - tags.op = OP_GTE; + c.op = OP_GTE; break; case "<=": - tags.op = OP_LTE; + c.op = OP_LTE; break; default: throw new Error(`Invalid operation: ${op}`); } @@ -213,28 +213,29 @@ export const sendAchievementEvent = (client: Client, event: eventId, data: Achie const checkCondition = (achievement: AchievementDefinition, data: AchievementTags) => { const conds = achievement.conds; - return conds.every(condition => parseTags(condition.tags, data)); + return conds.every(condition => parseCondition(condition, data)); } -const parseTags = (tags: AchievementTags | null, data: AchievementTags): boolean => { - if (!tags) return true; +const parseCondition = (conds: AchievementCondition | null, data: AchievementTags): boolean => { + if (!conds) return true; + const tags = conds.tags for (const key in tags) { const value = tags[key as keyof AchievementTags]!; if (key === "total" || key === "value" || key === "delta") { - const op = tags.op; + const op = conds.op; const givenValue = data[key as keyof AchievementTags]!; switch (op) { case OP_EQUALS: // == - if (givenValue === value) return true; + if (givenValue !== value) return false; break; case OP_GTE: // >= - if (givenValue > value) return true; + if (givenValue < value) return false; break; case OP_LTE: // <= - if (givenValue < value) return true; + if (givenValue > value) return false; break; } } else { From 0403bc8b2129a32858822874db4d1dc4c58014bf Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:14:08 +0300 Subject: [PATCH 26/32] Fix --- src/Const/Achievements.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Const/Achievements.ts b/src/Const/Achievements.ts index 3ea5f1fa..2e197909 100644 --- a/src/Const/Achievements.ts +++ b/src/Const/Achievements.ts @@ -222,10 +222,10 @@ const parseCondition = (conds: AchievementCondition | null, data: AchievementTag const tags = conds.tags for (const key in tags) { const value = tags[key as keyof AchievementTags]!; + const givenValue = data[key as keyof AchievementTags]!; if (key === "total" || key === "value" || key === "delta") { const op = conds.op; - const givenValue = data[key as keyof AchievementTags]!; switch (op) { case OP_EQUALS: // == @@ -239,7 +239,7 @@ const parseCondition = (conds: AchievementCondition | null, data: AchievementTag break; } } else { - if (data[key as keyof AchievementTags] !== value) { + if (givenValue !== value) { return false; } } From 3da884fcd49834da84207d2fa63a6f6a629fe884 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:34:02 +0300 Subject: [PATCH 27/32] Death anim tweaks --- src/Entity/Object.ts | 8 ++++++-- src/Entity/Tank/Projectile/Bullet.ts | 2 +- src/Entity/Tank/Projectile/Drone.ts | 3 +++ src/Entity/Tank/TankBody.ts | 1 + 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Entity/Object.ts b/src/Entity/Object.ts index 0f0e8448..e45f8a6b 100644 --- a/src/Entity/Object.ts +++ b/src/Entity/Object.ts @@ -48,6 +48,8 @@ class DeletionAnimation { return; } case 5: + // when being deleted, entities slow down half speed + this.entity.velocity.magnitude *= this.entity.deathAccelFactor; this.entity.styleData.opacity = 1 - (1 / 6); default: this.entity.scale(1.1); @@ -106,6 +108,9 @@ export default class ObjectEntity extends Entity { /** Velocity used for physics. */ public velocity = new Vector(); + /** Percent of accel applied when dying. */ + public deathAccelFactor = 0.5; + /** For internal spatial hash grid */ private _queryId: number = -1; @@ -273,8 +278,7 @@ export default class ObjectEntity extends Entity { /** Internal physics method used for calculating the current position of the object. */ public applyPhysics() { if (this.velocity.magnitude < 0.01) this.velocity.magnitude = 0; - // when being deleted, entities slow down half speed - else if (this.deletionAnimation) this.velocity.magnitude /= 2; + this.positionData.x += this.velocity.x; this.positionData.y += this.velocity.y; diff --git a/src/Entity/Tank/Projectile/Bullet.ts b/src/Entity/Tank/Projectile/Bullet.ts index 64ee3b5f..463ac306 100644 --- a/src/Entity/Tank/Projectile/Bullet.ts +++ b/src/Entity/Tank/Projectile/Bullet.ts @@ -37,7 +37,7 @@ export default class Bullet extends LivingEntity { /** Starting velocity of the bullet. */ protected baseSpeed = 0; /** Percent of accel applied when dying. */ - protected deathAccelFactor = 0.5; + public deathAccelFactor = 0.5; /** Life length in ticks before the bullet dies. */ protected lifeLength = 0; /** Angle the projectile is shot at. */ diff --git a/src/Entity/Tank/Projectile/Drone.ts b/src/Entity/Tank/Projectile/Drone.ts index 89d0827d..909fb5ed 100644 --- a/src/Entity/Tank/Projectile/Drone.ts +++ b/src/Entity/Tank/Projectile/Drone.ts @@ -40,6 +40,9 @@ export default class Drone extends Bullet { /** Cached prop of the definition. */ protected canControlDrones: boolean; + + /** Percent of accel applied when dying. */ + public deathAccelFactor = 1.0; public constructor(barrel: Barrel, tank: BarrelBase, tankDefinition: TankDefinition | null, shootAngle: number) { super(barrel, tank, tankDefinition, shootAngle); diff --git a/src/Entity/Tank/TankBody.ts b/src/Entity/Tank/TankBody.ts index cbe409a0..16209654 100644 --- a/src/Entity/Tank/TankBody.ts +++ b/src/Entity/Tank/TankBody.ts @@ -217,6 +217,7 @@ export default class TankBody extends LivingEntity implements BarrelBase { "victim.isTank": victimIsTank, "victim.isBoss": AbstractBoss.isBoss(entity), "victim.isShiny": !!(entity.entityTags & EntityTags.isShiny), + "class": this.currentTank, "victim.class": victimIsTank ? entity.currentTank : -1 }); } From be6348675b966eb992ac3c53e8cb9541c4266703 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:34:57 +0300 Subject: [PATCH 28/32] More tags --- src/Native/Camera.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Native/Camera.ts b/src/Native/Camera.ts index 6690c0f2..f6c3097a 100644 --- a/src/Native/Camera.ts +++ b/src/Native/Camera.ts @@ -114,6 +114,7 @@ export class CameraEntity extends Entity { sendAchievementEvent(client, "score", { "total": this.cameraData.values.score, "delta": score, + "class": this.cameraData.values.tank }); } @@ -133,6 +134,7 @@ export class CameraEntity extends Entity { sendAchievementEvent(client, "score", { "total": this.cameraData.values.score, "delta": score, + "class": this.cameraData.values.tank }); } From 5f170a15481d4790d94b89254c8241a7d138976c Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:39:35 +0300 Subject: [PATCH 29/32] Typo --- src/Native/Camera.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Native/Camera.ts b/src/Native/Camera.ts index f6c3097a..eac38c45 100644 --- a/src/Native/Camera.ts +++ b/src/Native/Camera.ts @@ -126,7 +126,7 @@ export class CameraEntity extends Entity { this.calculateLevelData(); - if (!enableAchievements && !this.game.arena.disableAchievements) return; + if (!enableAchievements || this.game.arena.disableAchievements) return; const client = this.getClient(); if (!client) return; @@ -145,7 +145,7 @@ export class CameraEntity extends Entity { if (TankBody.isTank(player)) player.calculateStatData(); - if (!enableAchievements && !this.game.arena.disableAchievements) return; + if (!enableAchievements || this.game.arena.disableAchievements) return; const client = this.getClient(); if (!client) return; From baddc6dba403af691e535465d5e912807581354a Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:40:10 +0300 Subject: [PATCH 30/32] Typo --- src/Native/Camera.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Native/Camera.ts b/src/Native/Camera.ts index eac38c45..ef628469 100644 --- a/src/Native/Camera.ts +++ b/src/Native/Camera.ts @@ -78,7 +78,7 @@ export class CameraEntity extends Entity { this.setFieldFactor(getTankById(this.cameraData.values.tank)?.fieldFactor ?? 1); this.calculateLevelData(); - if (!enableAchievements) return; + if (!enableAchievements || this.game.arena.disableAchievements) return; const client = this.getClient(); if (!client) return; @@ -88,6 +88,7 @@ export class CameraEntity extends Entity { "class": this.cameraData.values.tank }); } + /** Returns the camera's client if it exists */ public getClient(): Client | null { return null; From 6e9c682d903cde3127e03667d25a6c8f5225e070 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:06:23 +0300 Subject: [PATCH 31/32] Death anim tweaks --- src/Entity/Object.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Entity/Object.ts b/src/Entity/Object.ts index e45f8a6b..8ae78242 100644 --- a/src/Entity/Object.ts +++ b/src/Entity/Object.ts @@ -48,10 +48,10 @@ class DeletionAnimation { return; } case 5: - // when being deleted, entities slow down half speed - this.entity.velocity.magnitude *= this.entity.deathAccelFactor; this.entity.styleData.opacity = 1 - (1 / 6); default: + // when being deleted, entities slow down half speed + this.entity.velocity.magnitude *= this.entity.deathAccelFactor; this.entity.scale(1.1); this.entity.styleData.opacity -= 1 / 6; if (this.entity.styleData.values.opacity < 0) this.entity.styleData.opacity = 0; @@ -425,4 +425,4 @@ export default class ObjectEntity extends Entity { this.keepInArena(); } } -} +} \ No newline at end of file From eacf37b26b388591b4fe3adf6273073b29633785 Mon Sep 17 00:00:00 2001 From: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:27:22 +0300 Subject: [PATCH 32/32] Physics --- src/Entity/Object.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Entity/Object.ts b/src/Entity/Object.ts index 8ae78242..63b94a0f 100644 --- a/src/Entity/Object.ts +++ b/src/Entity/Object.ts @@ -109,7 +109,7 @@ export default class ObjectEntity extends Entity { public velocity = new Vector(); /** Percent of accel applied when dying. */ - public deathAccelFactor = 0.5; + public deathAccelFactor = 0.9; /** For internal spatial hash grid */ private _queryId: number = -1; @@ -425,4 +425,4 @@ export default class ObjectEntity extends Entity { this.keepInArena(); } } -} \ No newline at end of file +}