release: weapon names and the admin copy fixes (v0.5.1) #61

Merged
whitlocktech merged 4 commits from edge into main 2026-10-23 02:07:23 -05:00
19 changed files with 220 additions and 35 deletions

View File

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

View File

@@ -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

View File

@@ -49,7 +49,7 @@ export function titlesSummary(titles, push, categories) {
const rules = (titles && titles.rules) || []
if (!rules.length) return 'No chat titles.'
const list = rules.map((r) => `${ruleTitle(r, categories)} (top ${r.topN} ${statOf(categories, r.stat).label.toLowerCase()})`).join(', ')
const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? '' : 's'} hold one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? ' holds' : 's hold'} one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
return `Chat titles: ${list}.${shown}`
}

View File

@@ -542,7 +542,7 @@ function GroupPanel({ view, group, act, busy, chatFields }) {
{tab === 'servers' && (
<div className="sans" style={{ fontSize: '0.82rem' }}>
<p className="dim" style={{ margin: '0 0 8px' }}>
A group belongs to one server unless it is shared (D189). A shared group carries the same permissions and players on every server
A group belongs to one server unless it is shared. A shared group carries the same permissions and players on every server
it is on; a change made in one game gives that server its own copy.
</p>
<label style={{ display: 'block', marginBottom: 6 }}>
@@ -791,7 +791,7 @@ export default function Permissions() {
: sync.state === 'failed' ? <Chip tone="bad" title={sync.error || ''}>last sync failed</Chip>
: <Chip tone="warn">waiting for a sync</Chip>
)}
{sync && !sync.importedAt && <Chip tone="warn" title="The first sync imports everything on the server (D198)">not imported yet</Chip>}
{sync && !sync.importedAt && <Chip tone="warn" title="The first sync imports everything on the server">not imported yet</Chip>}
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync(server.id))}>Sync now</button>
</>
)}

View File

@@ -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/)
})

View File

@@ -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), '')
})

View File

@@ -169,7 +169,7 @@ function replyDetail(data) {
...(replies.length
? { replies: replies.map((r) => ({ command: String(r.command || ''), reply: String(r.reply || ''), ...(r.steamId ? { steamId: String(r.steamId) } : {}) })) }
: {}),
...(skippedCount ? { skipped, skippedCount, skippedNote: 'skipped: not on the server when the reward ran (D383)' } : {}),
...(skippedCount ? { skipped, skippedCount, skippedNote: 'skipped: not on the server when the reward ran' } : {}),
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing was run twice' } : {}),
}
}

View File

@@ -452,7 +452,7 @@ const OPTION_SOURCES = [
{
id: 'rust.options.servers',
label: 'Rust servers',
description: 'Every enabled server on this site. A lease holds a value on one of them (D73).',
description: 'Every enabled server on this site. A lease holds a value on one of them.',
async resolve() {
const list = await servers.listForPolling()
return list.map((s) => ({ value: s.id, label: s.name || s.id }))

View File

@@ -356,7 +356,7 @@ const participationOpen = {
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'Which server counts.' },
{ name: 'zone', type: 'string', required: false, example: 'Airfield brawl', source: 'rust.options.runzones',
description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts (D100).' },
description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts.' },
{ name: 'score', type: 'string', required: true, example: 'both', source: 'rust.options.scoremodes',
description: 'What earns a place: seconds present, kills, or both.' },
{ name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsof',
@@ -538,7 +538,7 @@ const kitEntitle = {
budgetMs: BUDGET_MS,
params: [
{ name: 'kit', type: 'string', required: true, example: 'main/vip-starter', source: 'rust.options.kits',
description: 'The kit, as server/kit. The reward reaches only that server (D102).' },
description: 'The kit, as server/kit. The reward reaches only that server.' },
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all (a kit weekend).' },
{ name: 'count', type: 'float', required: false, example: 3,
@@ -1172,13 +1172,13 @@ const announce = {
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.chatservers',
description: 'Which server, or * for every server (D105).' },
description: 'Which server, or * for every server.' },
{ name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!',
description: `The line, up to ${MAX_CHAT} characters.` },
// Optional, and the action stays version 1: a bump would stop every step
// already written from dispatching until somebody re-saved it (§33.2).
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
description: 'chat (the default), or popup — which needs PopupNotifications on the server, and is refused with a reason where it is missing (D141).' },
description: 'chat (the default), or popup — which needs PopupNotifications on the server, and is refused with a reason where it is missing.' },
],
// One per server reached. `*` is priced at the enabled servers when core asks,
@@ -1361,7 +1361,7 @@ const OPTION_SOURCES = [
{ value: 'npcs', label: 'NPCs, animals included' },
{ value: 'both', label: 'Players and NPCs' },
]),
fixed('rust.options.recipientmodes', 'Recipients', 'Who a reward goes to (D101).', [
fixed('rust.options.recipientmodes', 'Recipients', 'Who a reward goes to.', [
{ value: 'everyone', label: 'Everyone who scored' },
{ value: 'top', label: 'The top N (ties in)' },
{ value: 'minScore', label: 'A score of at least X' },
@@ -1369,7 +1369,7 @@ const OPTION_SOURCES = [
{ value: 'topPercent', label: 'The top X per cent (ties in)' },
{ value: 'linked', label: 'Every linked player of the server (no tally)' },
]),
fixed('rust.options.delivery', 'Delivery', 'Where a line goes (D141).', [
fixed('rust.options.delivery', 'Delivery', 'Where a line goes.', [
{ value: 'chat', label: 'Chat' },
{ value: 'popup', label: 'A popup — needs PopupNotifications on the server' },
]),

View File

@@ -146,13 +146,13 @@ const BUDGETS = [
id: 'rust.npcs',
label: 'NPCs placed',
unit: 'NPCs',
description: 'Scientists and guards an event puts in the world — its own dial, so fights can be capped apart from loot (D89).',
description: 'Scientists and guards an event puts in the world — its own dial, so fights can be capped apart from loot.',
},
{
id: 'rust.zone.minutes',
label: 'Zone time',
unit: 'minutes',
description: 'How long the zones an event opens stand, added up. Every zone declares its minutes, and the game erases it when they run out (D96).',
description: 'How long the zones an event opens stand, added up. Every zone declares its minutes, and the game erases it when they run out.',
},
]
@@ -626,7 +626,7 @@ const NPC_ORDER_PARAMS = [
required: false,
example: '76561198000000001',
description:
"A player's Steam id, or a {placeholder} from the run's start params: the NPCs keep close to that player and fight whoever attacks them, and walk back to their spot if the player dies or leaves (D269). The player must be on the server. RunicNPC profiles only.",
"A player's Steam id, or a {placeholder} from the run's start params: the NPCs keep close to that player and fight whoever attacks them, and walk back to their spot if the player dies or leaves. The player must be on the server. RunicNPC profiles only.",
},
{
name: 'allyClan',
@@ -635,7 +635,7 @@ const NPC_ORDER_PARAMS = [
source: 'rust.options.clans',
example: 'main/1234567',
description:
"A clan on the same server: the NPCs never target its members and defend them and what they own (D257). RunicNPC profiles only; give this or allyTeamOf, not both.",
"A clan on the same server: the NPCs never target its members and defend them and what they own. RunicNPC profiles only; give this or allyTeamOf, not both.",
},
{
name: 'allyTeamOf',
@@ -643,7 +643,7 @@ const NPC_ORDER_PARAMS = [
required: false,
example: '76561198000000001',
description:
"A player's Steam id, or a {placeholder}: the NPCs are allied to that player and their team (D257, D270). RunicNPC profiles only.",
"A player's Steam id, or a {placeholder}: the NPCs are allied to that player and their team. RunicNPC profiles only.",
},
{
name: 'tether',
@@ -651,7 +651,7 @@ const NPC_ORDER_PARAMS = [
required: false,
example: true,
description:
'Keep the NPCs inside the zone this event made around the point (D272): a Make a zone step must come first. RunicNPC profiles only.',
'Keep the NPCs inside the zone this event made around the point: a Make a zone step must come first. RunicNPC profiles only.',
},
{
// Runicnpc stage 7 (D296): on by default; a step older than the switch drops loot as before.
@@ -660,7 +660,7 @@ const NPC_ORDER_PARAMS = [
required: false,
example: true,
description:
"Whether the NPCs drop their profile's loot table when killed (D296). On unless switched off; off, their corpse still holds what the profile starts it with. An event's own rewards are separate. RunicNPC profiles only.",
"Whether the NPCs drop their profile's loot table when killed. On unless switched off; off, their corpse still holds what the profile starts it with. An event's own rewards are separate. RunicNPC profiles only.",
},
]
@@ -908,7 +908,7 @@ const OPTION_SOURCES = [
// cached list would offer monuments that are not there any more.
id: 'rust.options.monuments',
label: 'Monuments',
description: "Each server's monuments on its current map. A kind that repeats is numbered, #1 first (D93).",
description: "Each server's monuments on its current map. A kind that repeats is numbered, #1 first.",
searchable: true,
async resolve({ q } = {}) {
const term = String(q || '').trim().toLowerCase()
@@ -983,7 +983,7 @@ const OPTION_SOURCES = [
{
id: 'rust.options.domes',
label: 'Domes',
description: "ZoneDomes' dome colours (D194). A coloured dome shows only where it meets terrain or a structure.",
description: "ZoneDomes' dome colours. A coloured dome shows only where it meets terrain or a structure.",
async resolve() {
return DOMES.map((d) => ({ value: d.value, label: d.label }))
},

View File

@@ -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 }

View File

@@ -124,7 +124,7 @@ function checkFactions(input) {
}
if (!RELATIONS.includes(relation)) return { ok: false, error: `factions[${i}]: '${relation}' is not hostile, neutral or allied` }
const key = pairKey(a, b)
if (seen.has(key)) return { ok: false, error: `factions[${i}]: ${a} and ${b} are given twice (one row is both ways, D268)` }
if (seen.has(key)) return { ok: false, error: `factions[${i}]: ${a} and ${b} are given twice (one row is both ways)` }
seen.add(key)
rows.push(a < b ? { a, b, relation } : { a: b, b: a, relation })
}
@@ -173,7 +173,7 @@ function checkBody(input) {
if (names.some((n) => n.length > DISPLAY_NAME_MAX)) return { ok: false, error: `names: each at most ${DISPLAY_NAME_MAX} characters` }
const kits = Array.isArray(b.kits) ? [...new Set(b.kits.map((k) => String(k === null || k === undefined ? '' : k).trim()).filter(Boolean))] : null
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped (D217)' }
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped' }
if (kits.length > KITS_MAX) return { ok: false, error: `kits: at most ${KITS_MAX}` }
const prefab = String(b.prefab || '').trim()
@@ -242,8 +242,8 @@ function checkBody(input) {
const hasBoss = b.boss !== undefined && b.boss !== null
const hasUse = b.use !== undefined && b.use !== null
if (passive && hasBoss) return { ok: false, error: 'boss: a passive NPC never fights, so it cannot be a boss' }
if (passive && !hasUse) return { ok: false, error: 'use: a passive NPC says what pressing E on it does (D278)' }
if (hasUse && !passive) return { ok: false, error: 'use: only a passive NPC answers E (D278)' }
if (passive && !hasUse) return { ok: false, error: 'use: a passive NPC says what pressing E on it does' }
if (hasUse && !passive) return { ok: false, error: 'use: only a passive NPC answers E' }
let use = null
if (hasUse) {
const checked = checkUse(b.use)

View File

@@ -272,7 +272,7 @@ async function create(input, userId = null) {
async function update(id, input) {
const existing = await db.getProfile(id)
if (!existing) throw new NpcError('no such profile', 404)
if (existing.replaced) throw new NpcError('this profile is kept aside as replaced (D251): restore it before editing it', 409)
if (existing.replaced) throw new NpcError('this profile is kept aside as replaced: restore it before editing it', 409)
const clean = await validate(input, existing.id)
await db.saveProfile({ ...clean, id: existing.id })
const before = reach(existing)

View 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 }

View File

@@ -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' } }

View File

@@ -262,7 +262,7 @@ test('a reward sends Steam ids and names to the bridge, which fills the placehol
assert.strictEqual(result.ok, true)
assert.deepStrictEqual([result.detail.people, result.detail.rewarded], [2, 1])
assert.deepStrictEqual([result.detail.skipped, result.detail.skippedCount], [['p3'], 1])
assert.match(result.detail.skippedNote, /D383/)
assert.match(result.detail.skippedNote, /not on the server when the reward ran/)
assert.strictEqual(result.resources, undefined)
})

View File

@@ -169,8 +169,8 @@ const BOSS = {
test('stage 6: a boss or passive profile is refused for what RunicNPC would refuse, in its own words', () => {
const cases = [
[{ ...PASSIVE, ...BOSS }, 'boss: a passive NPC never fights, so it cannot be a boss'],
[{ role: 'passive' }, 'use: a passive NPC says what pressing E on it does (D278)'],
[{ use: { mode: 'chat', lines: ['hi'] } }, 'use: only a passive NPC answers E (D278)'],
[{ role: 'passive' }, 'use: a passive NPC says what pressing E on it does'],
[{ use: { mode: 'chat', lines: ['hi'] } }, 'use: only a passive NPC answers E'],
[{ movement: { mode: 'stand' } }, "movement.mode: stand is a passive NPC's"],
[{ ...PASSIVE, use: { mode: 'shout' } }, "use.mode: 'shout' is not chat or window"],
[{ ...PASSIVE, use: { mode: 'chat', lines: [] } }, 'use.lines: a chat answer gives at least one line'],
@@ -542,7 +542,7 @@ test('the faction table is one row per pair, both ways, refused in RunicNPC\'s w
assert.match(shape.checkFactions([{ a: 'red', b: 'red', relation: 'hostile' }]).error, /always allied to itself/)
assert.match(shape.checkFactions([{ a: 'scientists', b: 'animals', relation: 'hostile' }]).error, /both are Rust's own/)
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'angry' }]).error, /'angry' is not hostile, neutral or allied/)
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'hostile' }, { a: 'blue', b: 'red', relation: 'allied' }]).error, /given twice \(one row is both ways, D268\)/)
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'hostile' }, { a: 'blue', b: 'red', relation: 'allied' }]).error, /given twice \(one row is both ways\)/)
assert.match(shape.checkFactions('red').error, /a list of/)
})

View 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)
})

View File

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