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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
255 lines
9.5 KiB
JavaScript
255 lines
9.5 KiB
JavaScript
// ── Formatting, with no dependencies and no React ─────────────────────────
|
|
//
|
|
// Every function here is pure and takes what the API answered, so the suite next
|
|
// door can ask all of it without a DOM. That is deliberate: the client half's
|
|
// real failures are timing and resolution (see `test/build.test.js`), which a
|
|
// DOM-less runner cannot see — so the way to have any test coverage at all on
|
|
// this side is to keep the parts that CAN be tested free of React.
|
|
//
|
|
// `Intl` does the work. It is in every browser core supports, it knows the
|
|
// viewer's locale and their clock, and it is one fewer thing in a chunk an
|
|
// operator ships.
|
|
|
|
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
|
|
|
const UNITS = [
|
|
['year', 31536000],
|
|
['month', 2592000],
|
|
['week', 604800],
|
|
['day', 86400],
|
|
['hour', 3600],
|
|
['minute', 60],
|
|
['second', 1],
|
|
]
|
|
|
|
/**
|
|
* "3 minutes ago", from an ISO string or an epoch-millisecond number.
|
|
*
|
|
* Both shapes arrive from this module's own API: `updatedAt` is an ISO string
|
|
* the model produced, and an event's `t` is the millisecond stamp the plugin put
|
|
* on the frame. Accepting both here is what stops every caller remembering which
|
|
* is which.
|
|
*/
|
|
export function ago(value, now = Date.now()) {
|
|
const at = toMillis(value)
|
|
if (at === null) return 'never'
|
|
|
|
const seconds = Math.round((at - now) / 1000)
|
|
const magnitude = Math.abs(seconds)
|
|
|
|
// Under a minute, "in 0 seconds" is what `numeric: 'auto'` produces and it is
|
|
// not what anybody means. Say the thing.
|
|
if (magnitude < 45) return 'just now'
|
|
|
|
const [unit, size] = UNITS.find(([, s]) => magnitude >= s) || ['second', 1]
|
|
return RELATIVE.format(Math.round(seconds / size), unit)
|
|
}
|
|
|
|
/**
|
|
* The stamp on a feed row.
|
|
*
|
|
* **Today's rows get a time; everything older gets a date as well.** The feed can
|
|
* be filtered to a past wipe, and a row from six weeks ago rendered as `02:03 PM`
|
|
* reads as this afternoon — which the page walk found the moment it looked at the
|
|
* previous wipe: three events from August, all apparently a few minutes old.
|
|
*
|
|
* `now` is a parameter so the boundary is testable rather than a property of the
|
|
* machine the test runs on.
|
|
*/
|
|
export function clock(value, now = Date.now()) {
|
|
const at = toMillis(value)
|
|
if (at === null) return ''
|
|
|
|
const when = new Date(at)
|
|
const time = when.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
|
|
|
const today = new Date(now)
|
|
const sameDay =
|
|
when.getFullYear() === today.getFullYear() &&
|
|
when.getMonth() === today.getMonth() &&
|
|
when.getDate() === today.getDate()
|
|
|
|
if (sameDay) return time
|
|
return `${when.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`
|
|
}
|
|
|
|
/** A date, for a wipe: the thing people actually compare wipes by. */
|
|
export function day(value) {
|
|
const at = toMillis(value)
|
|
if (at === null) return 'unknown'
|
|
return new Date(at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
/**
|
|
* A session or a playtime, as `4h 12m`.
|
|
*
|
|
* Seconds are dropped above a minute and kept below it, because a two-hour
|
|
* session reported to the second is noise and a forty-second one reported as
|
|
* "0m" is wrong.
|
|
*/
|
|
export function duration(seconds) {
|
|
const total = Number(seconds)
|
|
if (!Number.isFinite(total) || total <= 0) return '—'
|
|
if (total < 60) return `${Math.round(total)}s`
|
|
|
|
const hours = Math.floor(total / 3600)
|
|
const minutes = Math.round((total % 3600) / 60)
|
|
|
|
if (hours === 0) return `${minutes}m`
|
|
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
|
|
}
|
|
|
|
/** Thousands separators, in the viewer's locale. */
|
|
export function count(value) {
|
|
const n = Number(value)
|
|
return Number.isFinite(n) ? n.toLocaleString() : '0'
|
|
}
|
|
|
|
/**
|
|
* An Economics balance (PLAN_REDESIGNS §11.2.2): up to two places, and a dash
|
|
* for one the server never reported. Not `count`'s zero — "0 coins" is a claim
|
|
* about somebody's balance, and an unknown one is not it.
|
|
*/
|
|
export function coins(value) {
|
|
if (value === null || value === undefined || value === '') return '—'
|
|
const n = Number(value)
|
|
return Number.isFinite(n) ? n.toLocaleString(undefined, { maximumFractionDigits: 2 }) : '—'
|
|
}
|
|
|
|
/**
|
|
* A prefab short name as something readable — `patrolhelicopter` stays itself,
|
|
* `rifle.ak` becomes `rifle ak`.
|
|
*
|
|
* Deliberately a light touch rather than a lookup table. A table mapping every
|
|
* Rust prefab to a pretty name is a second copy of the game's item list that
|
|
* goes stale every wipe, and the short name is what a Rust player reads on their
|
|
* own server console anyway.
|
|
*/
|
|
export function prefab(name) {
|
|
if (!name) return ''
|
|
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
|
|
* one it starts with.
|
|
*/
|
|
const NPC_FAMILIES = [
|
|
['scientistnpc_heavy', 'Heavy scientist'],
|
|
['scientistnpc', 'Scientist'],
|
|
['npc_bandit_guard', 'Bandit guard'],
|
|
['bandit_guard', 'Bandit guard'],
|
|
['npc_tunneldweller', 'Tunnel dweller'],
|
|
['npc_underwaterdweller', 'Underwater dweller'],
|
|
['bradleyapc', 'Bradley APC'],
|
|
['patrolhelicopter', 'Patrol helicopter'],
|
|
['ch47scientists', 'Chinook'],
|
|
['sentry.scientist', 'Outpost sentry'],
|
|
['sentry.bandit', 'Bandit Camp sentry'],
|
|
['autoturret', 'Auto turret'],
|
|
['flameturret', 'Flame turret'],
|
|
['guntrap', 'Shotgun trap'],
|
|
['sam_site', 'SAM site'],
|
|
['sam_static', 'SAM site'],
|
|
['polarbear', 'Polar bear'],
|
|
['simpleshark', 'Shark'],
|
|
]
|
|
|
|
/**
|
|
* What killed somebody, when it was not a player (PLAN_FIXES F2, D185) — the
|
|
* killfeed's `npc` attacker, which the plugin sends as a prefab short name.
|
|
*
|
|
* The first walk read "killed by wolf2". `prefab` keeps its light touch for a
|
|
* weapon, where the short name is what a Rust player reads on their own console;
|
|
* an ATTACKER is a creature or a machine, and a number stuck to its name is the
|
|
* game's variant, not something a reader needs. So a known family is named
|
|
* (above), and anything else has its variant digits and deploy suffixes trimmed
|
|
* and reads as capitalised words — `wolf2` is "Wolf", `boar` is "Boar". Still no
|
|
* table of every prefab: a new animal reads fine without one.
|
|
*/
|
|
export function attacker(name) {
|
|
if (!name) return ''
|
|
const text = String(name).toLowerCase()
|
|
const family = NPC_FAMILIES.find(([prefix]) => text.startsWith(prefix))
|
|
if (family) return family[1]
|
|
|
|
const words = text
|
|
.replace(/(\.deployed|_deployed|\.entity|\.prefab)$/, '')
|
|
.replace(/\d+$/, '')
|
|
.replace(/[_.]+/g, ' ')
|
|
.trim()
|
|
return words ? words[0].toUpperCase() + words.slice(1) : ''
|
|
}
|
|
|
|
/** A steam id, shortened for a table cell, without pretending it is a name. */
|
|
export function shortId(steamId) {
|
|
const id = String(steamId || '')
|
|
return id.length > 10 ? `…${id.slice(-6)}` : id
|
|
}
|
|
|
|
/**
|
|
* The next wipe (phase 16, D130), as `Thu, Oct 1, 7:00 PM (in 6 days)` in the
|
|
* VIEWER's locale and clock — or `null` when the server has no schedule.
|
|
*
|
|
* The server computes the instant from the operator's rule in the operator's
|
|
* zone; the page only says it the reader's way. A `once` source is an operator
|
|
* moving a wipe, and says so, because "the wipe is not when it usually is" is
|
|
* the thing a regular needs to notice.
|
|
*/
|
|
export function nextWipe(value, now = Date.now()) {
|
|
if (!value || !value.at) return null
|
|
const at = toMillis(value.at)
|
|
if (at === null) return null
|
|
const when = new Date(at).toLocaleString(undefined, {
|
|
weekday: 'short',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
})
|
|
return `${when} (${ago(at, now)})${value.source === 'once' ? ' — rescheduled' : ''}`
|
|
}
|
|
|
|
function toMillis(value) {
|
|
if (value === null || value === undefined || value === '') return null
|
|
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
|
|
|
const parsed = Date.parse(value)
|
|
return Number.isNaN(parsed) ? null : parsed
|
|
}
|
|
|
|
/**
|
|
* The ink that reads on a background of `hex` — black or white, whichever has
|
|
* the higher WCAG contrast. A chat title's colour is the operator's, chosen for
|
|
* a dark game chat, and this page is drawn in the reader's theme: yellow text on
|
|
* a white page is unreadable, so the colour becomes the chip and the text is
|
|
* picked for it (phase 17, D137). Anything that is not `#rrggbb` gets black on
|
|
* the caller's fallback.
|
|
*/
|
|
export function contrastInk(hex) {
|
|
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex || ''))
|
|
if (!m) return '#000000'
|
|
const linear = (c) => {
|
|
const v = parseInt(c, 16) / 255
|
|
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
|
|
}
|
|
const L = 0.2126 * linear(m[1]) + 0.7152 * linear(m[2]) + 0.0722 * linear(m[3])
|
|
// Contrast against white is 1.05 / (L + 0.05); against black (L + 0.05) / 0.05.
|
|
return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? '#000000' : '#ffffff'
|
|
}
|
|
|
|
export default { ago, clock, day, duration, count, prefab, shortId, nextWipe, contrastInk }
|