fix(feed): print weapons by name, not "ak47u entity" #60
@@ -21,7 +21,7 @@
|
|||||||
// here is presentation, and the honest presentation of a kind we have no words
|
// here is presentation, and the honest presentation of a kind we have no words
|
||||||
// for is its own name.
|
// for is its own name.
|
||||||
|
|
||||||
import { attacker, duration, prefab } from './format.js'
|
import { attacker, duration, weapon } from './format.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kinds this feed asks for.
|
* Kinds this feed asks for.
|
||||||
@@ -205,7 +205,7 @@ export function describe(row) {
|
|||||||
*/
|
*/
|
||||||
function death(frame, name) {
|
function death(frame, name) {
|
||||||
const where = [
|
const where = [
|
||||||
frame.weapon ? `with ${prefab(frame.weapon)}` : null,
|
frame.weapon ? `with ${weapon(frame)}` : null,
|
||||||
frame.distance ? `${Math.round(frame.distance)}m` : null,
|
frame.distance ? `${Math.round(frame.distance)}m` : null,
|
||||||
frame.grid || null,
|
frame.grid || null,
|
||||||
frame.sleeping ? 'while sleeping' : null,
|
frame.sleeping ? 'while sleeping' : null,
|
||||||
|
|||||||
@@ -130,6 +130,18 @@ export function prefab(name) {
|
|||||||
return String(name).replace(/[_.]+/g, ' ').trim()
|
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
|
* 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
|
* short names share. First match wins, so a longer prefix sits above a shorter
|
||||||
|
|||||||
@@ -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.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')
|
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/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
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')
|
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', () => {
|
test('a weapon keeps the light touch — attacker() is for what did the killing', () => {
|
||||||
assert.equal(prefab('rifle.ak'), 'rifle ak')
|
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), '')
|
||||||
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const raidableBases = require('../../raidableBases')
|
|||||||
const db = require('./events.db')
|
const db = require('./events.db')
|
||||||
const mapDb = require('../map/map.db')
|
const mapDb = require('../map/map.db')
|
||||||
const serversDb = require('../servers/servers.db')
|
const serversDb = require('../servers/servers.db')
|
||||||
|
const weaponNames = require('../titles/weaponNames')
|
||||||
|
|
||||||
/** Hard ceiling on a page, whatever a caller asks for. */
|
/** Hard ceiling on a page, whatever a caller asks for. */
|
||||||
const MAX_LIMIT = 200
|
const MAX_LIMIT = 200
|
||||||
@@ -155,6 +156,13 @@ function shape(row) {
|
|||||||
frame = {}
|
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 {
|
return {
|
||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
kind: row.kind,
|
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 }
|
||||||
|
|||||||
89
server/model/titles/weaponNames.js
Normal file
89
server/model/titles/weaponNames.js
Normal file
@@ -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 }
|
||||||
@@ -68,7 +68,7 @@ rustRouter.get(
|
|||||||
'/servers/:id/events',
|
'/servers/:id/events',
|
||||||
// #swagger.tags = ['Public · Rust']
|
// #swagger.tags = ['Public · Rust']
|
||||||
// #swagger.summary = 'Recent events on one Rust server'
|
// #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['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['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' } }
|
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||||
|
|||||||
49
server/test/weaponNames.test.js
Normal file
49
server/test/weaponNames.test.js
Normal file
@@ -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)
|
||||||
|
})
|
||||||
@@ -3203,7 +3203,7 @@
|
|||||||
"Public · Rust"
|
"Public · Rust"
|
||||||
],
|
],
|
||||||
"summary": "Recent events on one Rust server",
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
|
|||||||
Reference in New Issue
Block a user