From 55a9ea464912513173864780b430def3a81de4b1 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 23 Oct 2026 01:22:37 -0500 Subject: [PATCH] fix(feed): print weapons by name, not "ak47u entity" The bridge names a kill's weapon by the held entity's short prefab name (ak47u.entity), and the feed printed it with the dots turned to spaces: "with ak47u entity". Every event read now gives the frame a weaponName (server/model/titles/weaponNames.js): the game's own English name for the 53 ranged weapons (guns, bows, crossbows, launchers, flamethrowers), read off the Oxide rig's item list on Rust 26139947, and for anything else the prefab name as words with its .entity/.weapon ending dropped. The site prints weaponName and falls back to the same stripping for an older server. The prefab name stays in the frame: the title lists count by it. Added at read, so stored rows are renamed too. No protocol change; the events route's Swagger description names the new field. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/lib/feed.js | 4 +- client/src/lib/format.js | 12 ++++ client/test/feed.test.js | 15 +++++ client/test/format.test.js | 14 ++++- server/model/events/events.model.js | 10 +++- server/model/titles/weaponNames.js | 89 +++++++++++++++++++++++++++++ server/router/public/rust.router.js | 2 +- server/test/weaponNames.test.js | 49 ++++++++++++++++ swagger-fragment.json | 2 +- 9 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 server/model/titles/weaponNames.js create mode 100644 server/test/weaponNames.test.js diff --git a/client/src/lib/feed.js b/client/src/lib/feed.js index da09b8a..d4ec3a8 100644 --- a/client/src/lib/feed.js +++ b/client/src/lib/feed.js @@ -21,7 +21,7 @@ // here is presentation, and the honest presentation of a kind we have no words // for is its own name. -import { attacker, duration, prefab } from './format.js' +import { attacker, duration, weapon } from './format.js' /** * Kinds this feed asks for. @@ -205,7 +205,7 @@ export function describe(row) { */ function death(frame, name) { const where = [ - frame.weapon ? `with ${prefab(frame.weapon)}` : null, + frame.weapon ? `with ${weapon(frame)}` : null, frame.distance ? `${Math.round(frame.distance)}m` : null, frame.grid || null, frame.sleeping ? 'while sleeping' : null, diff --git a/client/src/lib/format.js b/client/src/lib/format.js index 39e0d64..8c850f4 100644 --- a/client/src/lib/format.js +++ b/client/src/lib/format.js @@ -130,6 +130,18 @@ export function prefab(name) { return String(name).replace(/[_.]+/g, ' ').trim() } +/** + * The weapon in a kill. The server sends `weaponName` — the game's own name for + * a gun, or the prefab name as words (`server/model/titles/weaponNames.js`) — + * and that is what prints. A server too old to send it gets the same fallback + * here: the `.entity` / `.weapon` ending dropped, so never "ak47u entity". + */ +export function weapon(frame) { + if (!frame || !frame.weapon) return '' + if (frame.weaponName) return String(frame.weaponName) + return prefab(String(frame.weapon).trim().replace(/[._](entity|weapon|deployed)$/, '')) +} + /** * The NPC families whose prefab reads badly as words, by the prefix the game's * short names share. First match wins, so a longer prefix sits above a shorter diff --git a/client/test/feed.test.js b/client/test/feed.test.js index b17233e..1ea4958 100644 --- a/client/test/feed.test.js +++ b/client/test/feed.test.js @@ -338,3 +338,18 @@ test('a purchase and an admin lock name the player only when the server sent the assert.equal(describe(raid('raid.locked', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base was locked to a player') assert.equal(describe(raid('raid.ended', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base despawned') }) + +test('a kill line says the weapon by name', () => { + const named = describe(row('player.death', { + name: 'Bob', attackerType: 'player', attackerName: 'Alice', + weapon: 'ak47u.entity', weaponName: 'Assault Rifle', distance: 22, + })) + assert.match(named.detail, /with Assault Rifle/) + assert.doesNotMatch(named.detail, /entity/) + + const older = describe(row('player.death', { + name: 'Bob', attackerType: 'player', attackerName: 'Alice', weapon: 'bolt_rifle.entity', + })) + assert.match(older.detail, /with bolt rifle/) + assert.doesNotMatch(older.detail, /entity/) +}) diff --git a/client/test/format.test.js b/client/test/format.test.js index 1abdc86..c7bd24d 100644 --- a/client/test/format.test.js +++ b/client/test/format.test.js @@ -11,7 +11,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { ago, attacker, clock, coins, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js' +import { ago, attacker, clock, coins, contrastInk, count, day, duration, nextWipe, prefab, shortId, weapon } from '../src/lib/format.js' const NOW = Date.parse('2026-09-16T12:00:00Z') @@ -147,3 +147,15 @@ test('an NPC attacker reads as a name: a family, or the prefab without its varia test('a weapon keeps the light touch — attacker() is for what did the killing', () => { assert.equal(prefab('rifle.ak'), 'rifle ak') }) + +test('a kill prints the weapon the server named, and never an entity ending', () => { + // weaponName is the server's (model/titles/weaponNames.js): the game's own name for a gun. + assert.equal(weapon({ weapon: 'ak47u.entity', weaponName: 'Assault Rifle' }), 'Assault Rifle') + // A server too old to send it: the ending dropped, the rest as words. + assert.equal(weapon({ weapon: 'ak47u.entity' }), 'ak47u') + assert.equal(weapon({ weapon: 'bolt_rifle.entity' }), 'bolt rifle') + assert.equal(weapon({ weapon: 'machete.weapon' }), 'machete') + assert.equal(weapon({ weapon: 'rifle.ak' }), 'rifle ak') + assert.equal(weapon({}), '') + assert.equal(weapon(null), '') +}) diff --git a/server/model/events/events.model.js b/server/model/events/events.model.js index a394638..cd682c3 100644 --- a/server/model/events/events.model.js +++ b/server/model/events/events.model.js @@ -21,6 +21,7 @@ const raidableBases = require('../../raidableBases') const db = require('./events.db') const mapDb = require('../map/map.db') const serversDb = require('../servers/servers.db') +const weaponNames = require('../titles/weaponNames') /** Hard ceiling on a page, whatever a caller asks for. */ const MAX_LIMIT = 200 @@ -155,6 +156,13 @@ function shape(row) { frame = {} } + // The weapon as a page should print it (`model/titles/weaponNames.js`). Added + // at read, so rows stored before this existed read the same as new ones, and + // a later edit to the table renames every kill the feed still shows. + if (typeof frame.weapon === 'string' && frame.weaponName === undefined) { + frame.weaponName = weaponNames.nameOf(frame.weapon) + } + return { id: Number(row.id), kind: row.kind, @@ -247,4 +255,4 @@ async function online(serverId) { })) } -module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, MAX_LIMIT } +module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, shape, MAX_LIMIT } diff --git a/server/model/titles/weaponNames.js b/server/model/titles/weaponNames.js new file mode 100644 index 0000000..8dddbec --- /dev/null +++ b/server/model/titles/weaponNames.js @@ -0,0 +1,89 @@ +// ── What a weapon is called on a page ────────────────────────────────────── +// +// The plugin names the weapon in a kill by the held entity's SHORT PREFAB NAME +// (`ak47u.entity`) — the same name the title lists beside this file count by, +// and the right key for counting. It is the wrong thing to print: the feed said +// "with ak47u entity". So every event read gives the frame a `weaponName` from +// here, and the site and the app print that, falling back to the prefab name +// themselves only for a module too old to send one. +// +// The table is the ranged weapons — every gun, bow, crossbow, launcher and +// flamethrower — named as the game names them. Read off the Oxide rig's own item +// list on 2026-10-23 (Rust 26139947) by the same read-only walk the title lists +// came from: each item whose held entity is an `AttackEntity`, its short prefab +// name and its English display name. Melee, throwables and anything a later Rust +// update adds fall back to `stripped()`, which drops the `.entity` / `.weapon` +// ending and turns the rest into words ("salvaged sword"), so a new weapon reads +// acceptably before anyone edits this list. To refresh, walk the list again. + +const NAMES = new Map([ + ['50cal.left.entity', 'Dual 50 Cal Turret'], + ['50cal.right.entity', 'Dual 50 Cal Turret'], + ['50cal.single.entity', '50 Cal Turret'], + ['ak47u.entity', 'Assault Rifle'], + ['ak47u.glass.blue.entity', 'Crystal Assault Rifle Sapphire'], + ['ak47u.glass.entity', 'Crystal Assault Rifle Diamond'], + ['ak47u.glass.green.entity', 'Crystal Assault Rifle Emerald'], + ['ak47u.glass.pink.entity', 'Crystal Assault Rifle Pink Diamond'], + ['ak47u.glass.red.entity', 'Crystal Assault Rifle Ruby'], + ['ak47u_diver.entity', 'Abyss Assault Rifle'], + ['ak47u_ice.entity', 'Ice Assault Rifle'], + ['ak47u_jungle.entity', 'Jungle Relic Assault Rifle'], + ['ak47u_med.entity', 'Medieval Assault Rifle'], + ['blunderbuss.entity', 'Blunderbuss'], + ['bolt_rifle.entity', 'Bolt Action Rifle'], + ['bow_hunting.entity', 'Hunting Bow'], + ['compound_bow.entity', 'Compound Bow'], + ['crossbow.entity', 'Crossbow'], + ['crossbow_bowless.entity', 'Bowless Crossbow'], + ['double_shotgun.entity', 'Double Barrel Shotgun'], + ['flamethrower.entity', 'Flame Thrower'], + ['glock.entity', 'Prototype 17'], + ['hc_revolver.entity', 'High Caliber Revolver'], + ['hmlmg.entity', 'HMLMG'], + ['krieg_shotgun.entity', 'Krieg Shotgun'], + ['l96.entity', 'L96 Rifle'], + ['legacybow.entity', 'Legacy Bow'], + ['lr300.entity', 'LR-300 Assault Rifle'], + ['lr300_space.entity', 'Space LR-300 Assault Rifle'], + ['m16a2.entity', 'M16A2'], + ['m249.entity', 'M249'], + ['m39.entity', 'M39 Rifle'], + ['m4_shotgun.entity', 'M4 Shotgun'], + ['m92.entity', 'M92 Pistol'], + ['mgl.entity', 'Multiple Grenade Launcher'], + ['militaryflamethrower.entity', 'Military Flame Thrower'], + ['mini_crossbow.entity', 'Mini Crossbow'], + ['mp5.entity', 'MP5A4'], + ['nailgun.entity', 'Nailgun'], + ['pistol_revolver.entity', 'Revolver'], + ['pistol_semiauto.a.m15.entity', 'M15 Semi-Automatic Pistol'], + ['pistol_semiauto.entity', 'Semi-Automatic Pistol'], + ['python.entity', 'Python Revolver'], + ['rocket_launcher.entity', 'Rocket Launcher'], + ['rocket_launcher_dragon.entity', 'Dragon Rocket Launcher'], + ['semi_auto_rifle.entity', 'Semi-Automatic Rifle'], + ['shotgun_pump.entity', 'Pump Shotgun'], + ['shotgun_waterpipe.entity', 'Waterpipe Shotgun'], + ['sks.entity', 'SKS'], + ['smg.entity', 'Custom SMG'], + ['spas12.entity', 'Spas-12 Shotgun'], + ['t1_smg.entity', 'Handmade SMG'], + ['thompson.entity', 'Thompson'], +]) + +/** The endings a held entity's prefab name carries that no player says. */ +const ENDING = /[._](entity|weapon|deployed)$/ + +/** A prefab name as words: its ending dropped, its dots and underscores spaces. */ +function stripped(prefab) { + return String(prefab).trim().replace(ENDING, '').replace(/[_.]+/g, ' ').trim() +} + +/** What to print for a weapon's prefab name: the game's own name, or the stripped one. */ +function nameOf(prefab) { + if (typeof prefab !== 'string' || !prefab.trim()) return null + return NAMES.get(prefab.trim()) || stripped(prefab) +} + +module.exports = { nameOf, stripped, NAMES } diff --git a/server/router/public/rust.router.js b/server/router/public/rust.router.js index 7fcb284..6464768 100644 --- a/server/router/public/rust.router.js +++ b/server/router/public/rust.router.js @@ -68,7 +68,7 @@ rustRouter.get( '/servers/:id/events', // #swagger.tags = ['Public · Rust'] // #swagger.summary = 'Recent events on one Rust server' - // #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes.' + // #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped.' // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } } // #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } } // #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } } diff --git a/server/test/weaponNames.test.js b/server/test/weaponNames.test.js new file mode 100644 index 0000000..413e140 --- /dev/null +++ b/server/test/weaponNames.test.js @@ -0,0 +1,49 @@ +// ── What a weapon is called on a page ───────────────────────────────────── +// +// The feed printed "with ak47u entity". Every event read now carries a +// `weaponName`: the game's own name for a ranged weapon, or the prefab name as +// words with its ending dropped. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') +const weaponNames = require('../model/titles/weaponNames') + +test('a gun is called what the game calls it', () => { + assert.equal(weaponNames.nameOf('ak47u.entity'), 'Assault Rifle') + assert.equal(weaponNames.nameOf('bolt_rifle.entity'), 'Bolt Action Rifle') + assert.equal(weaponNames.nameOf('lr300.entity'), 'LR-300 Assault Rifle') + assert.equal(weaponNames.nameOf('semi_auto_rifle.entity'), 'Semi-Automatic Rifle') + assert.equal(weaponNames.nameOf('bow_hunting.entity'), 'Hunting Bow') +}) + +test('anything the table does not hold reads as words, without its ending', () => { + assert.equal(weaponNames.nameOf('machete.weapon'), 'machete') + assert.equal(weaponNames.nameOf('salvaged_sword.entity'), 'salvaged sword') + assert.equal(weaponNames.nameOf('bone.knife.obsidian.entity'), 'bone knife obsidian') + assert.equal(weaponNames.nameOf('some_new_gun.entity'), 'some new gun') + assert.equal(weaponNames.nameOf('rifle.ak'), 'rifle ak') + assert.equal(weaponNames.nameOf(''), null) + assert.equal(weaponNames.nameOf(null), null) +}) + +test('no name in the table still carries a prefab ending', () => { + for (const [prefab, name] of weaponNames.NAMES) { + assert.ok(prefab.endsWith('.entity'), `${prefab} is a held entity's short prefab name`) + assert.doesNotMatch(name, /entity|weapon|_/, `${prefab} -> ${name}`) + } +}) + +test('every event read gives a weapon its name, and leaves a frame without one alone', () => { + require('../core')._reset() + require('../core').init(fakeCtx()) + const { shape } = require('../model/events/events.model') + + const kill = shape({ id: 1, kind: 'player.death', t: 1, raw: JSON.stringify({ weapon: 'ak47u.entity', distance: 22 }) }) + assert.equal(kill.frame.weaponName, 'Assault Rifle') + assert.equal(kill.frame.weapon, 'ak47u.entity', 'the prefab name stays: the title lists count by it') + + const fall = shape({ id: 2, kind: 'player.death', t: 1, raw: JSON.stringify({ attackerType: 'environment' }) }) + assert.equal('weaponName' in fall.frame, false) +}) diff --git a/swagger-fragment.json b/swagger-fragment.json index ab27e91..4ae6c82 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -3203,7 +3203,7 @@ "Public · Rust" ], "summary": "Recent events on one Rust server", - "description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes.", + "description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped.", "parameters": [ { "name": "id",