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",