// ── 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 }