diff --git a/client/src/api.js b/client/src/api.js index c0e5762..c76eae6 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -134,6 +134,9 @@ export const admin = { renderMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/render`, { method: 'POST' }), // Phase 17: a server's chat titles, what optional mods it has, and the voice. saveTitles: (id, body) => req(`/admin/rust/servers/${encodeURIComponent(id)}/titles`, { method: 'PUT', body }), + // PLAN_REDESIGNS §5.5 (D175): a category's title for the whole site; '' resets it. + saveTitleCategory: (stat, text) => + req(`/admin/rust/title-categories/${encodeURIComponent(stat)}`, { method: 'PUT', body: { text } }), integrations: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/integrations`), voice: () => req('/admin/rust/voice'), saveVoice: (group) => req('/admin/rust/voice', { method: 'PUT', body: { group } }), diff --git a/client/src/routes/admin/ChatTitles.jsx b/client/src/routes/admin/ChatTitles.jsx index 3c1deb8..3dd9f4b 100644 --- a/client/src/routes/admin/ChatTitles.jsx +++ b/client/src/routes/admin/ChatTitles.jsx @@ -20,25 +20,33 @@ import { ErrorState, Loading, useAsync } from '../../core.js' import api from '../../api.js' import { contrastInk } from '../../lib/format.js' -const STATS = [ - { id: 'kills', label: 'Kills' }, - { id: 'npckills', label: 'NPC kills' }, - { id: 'playtime', label: 'Playtime' }, -] - const MODES = [ { id: 'first', label: 'The first title they earn', hint: 'The rule highest in the list wins.' }, { id: 'all', label: 'Every title they earn' }, { id: 'upto', label: 'Up to a number of titles', hint: 'In list order.' }, ] -const statLabel = (id) => (STATS.find((s) => s.id === id) || { label: id }).label +/** + * What a rule can rank (PLAN_REDESIGNS §5): the server's categories, each with + * the title it shows now (D175), then playtime, which has no title of its own. + */ +export function statOptions(categories) { + return [ + ...(categories || []).map((c) => ({ id: c.stat, label: c.label, title: c.current })), + { id: 'playtime', label: 'Playtime', title: null }, + ] +} + +const statOf = (categories, id) => statOptions(categories).find((s) => s.id === id) || { id, label: id, title: null } + +/** A rule's title as players see it: its own text, else its category's. */ +export const ruleTitle = (rule, categories) => rule.text || statOf(categories, rule.stat).title || '' /** One line summarising a server's titles, for its row on the list. */ -export function titlesSummary(titles, push) { +export function titlesSummary(titles, push, categories) { const rules = (titles && titles.rules) || [] if (!rules.length) return 'No chat titles.' - const list = rules.map((r) => `${r.text} (top ${r.topN} ${statLabel(r.stat).toLowerCase()})`).join(', ') + 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'}.` : '' return `Chat titles: ${list}.${shown}` } @@ -55,8 +63,9 @@ function Chip({ text, color }) { ) } -export function TitlesForm({ server, onSaved, onCancel }) { +export function TitlesForm({ server, categories, onSaved, onCancel }) { const start = server.titles || { mode: 'first', max: 2, rules: [] } + const stats = statOptions(categories) const [mode, setMode] = useState(start.mode) const [max, setMax] = useState(start.max) const [rules, setRules] = useState(start.rules.map((r) => ({ ...r }))) @@ -95,27 +104,32 @@ export function TitlesForm({ server, onSaved, onCancel }) { Each rule gives a title to the top players on this wipe for one stat. A stat of zero earns nothing, so a fresh wipe has no titles until somebody plays. Titles show in game chat when BetterChat is installed, and beside the name on the leaderboard here and in the app. Up to ten rules; a title is at most 24 characters and cannot carry markup. + Leave a title blank to use the category’s title, which is set once for every server below the list.

- {rules.map((r, i) => ( + {rules.map((r, i) => { + const fallback = statOf(categories, r.stat).title + return (
{i + 1} Top earn - + {/* The category's title is a PLACEHOLDER, never a value: saved as the rule's own text it would stop a rename reaching this rule (§5.5). */} + - +
- ))} + ) + })} {rules.length < 10 && (
+ + + + ) + })} +
+ + ) +} + +/** A grid row's cells, without a wrapper element the grid would lay out as one cell. */ +function Row({ children }) { + return <>{children} +} + /** What a server says it has loaded, as one sentence (§33.2 `integrations`). */ export function integrationsLine(r) { if (!r || !r.integrations) { diff --git a/client/src/routes/admin/ServerSettings.jsx b/client/src/routes/admin/ServerSettings.jsx index 810bddc..adaf4aa 100644 --- a/client/src/routes/admin/ServerSettings.jsx +++ b/client/src/routes/admin/ServerSettings.jsx @@ -29,7 +29,7 @@ import { useState } from 'react' import { ErrorState, Loading, useAsync } from '../../core.js' import api from '../../api.js' import { ago, nextWipe } from '../../lib/format.js' -import { TitlesForm, VoiceCard, integrationsLine, titlesSummary } from './ChatTitles.jsx' +import { CategoryTitles, TitlesForm, VoiceCard, integrationsLine, titlesSummary } from './ChatTitles.jsx' const RULES = [ { id: 'none', label: 'No schedule', hint: 'Nothing is forecast, not even the monthly forced wipe.' }, @@ -164,6 +164,7 @@ export default function ServerSettings() { if (loadError) return if (!data) return const servers = data.servers || [] + const categories = data.titleCategories || [] const set = (key) => (e) => { const value = e && e.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e @@ -250,7 +251,7 @@ export default function ServerSettings() { ? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.` : 'No wipe schedule set.'} -
{titlesSummary(s.titles, s.titlePush)}
+
{titlesSummary(s.titles, s.titlePush, categories)}
{zoneHelperNote(s.zoneHelper) && (
{zoneHelperNote(s.zoneHelper)}
)} @@ -286,11 +287,14 @@ export default function ServerSettings() { { setTitling(null); setReloads((n) => n + 1) }} onCancel={() => setTitling(null)} /> )} + setReloads((n) => n + 1)} /> + ) diff --git a/routes.manifest.json b/routes.manifest.json index e767c76..0425e74 100644 --- a/routes.manifest.json +++ b/routes.manifest.json @@ -86,6 +86,11 @@ "path": "/api/v1/admin/rust/servers/:id/integrations", "tier": "public" }, + { + "method": "GET", + "path": "/api/v1/admin/rust/title-categories", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/admin/rust/visibility", @@ -311,6 +316,11 @@ "path": "/api/v1/admin/rust/servers/:id/titles", "tier": "public" }, + { + "method": "PUT", + "path": "/api/v1/admin/rust/title-categories/:stat", + "tier": "public" + }, { "method": "PUT", "path": "/api/v1/admin/rust/visibility", diff --git a/server/db/purge.sql b/server/db/purge.sql index 747c46e..97c2703 100644 --- a/server/db/purge.sql +++ b/server/db/purge.sql @@ -23,6 +23,11 @@ DROP TABLE IF EXISTS rust_zone_preset_servers; DROP TABLE IF EXISTS rust_zone_presets; +-- The chat-title conditions (PLAN_REDESIGNS §5). No foreign keys; the stats +-- columns go with their table below. +DROP TABLE IF EXISTS rust_title_categories; +DROP TABLE IF EXISTS rust_weapon_kills; + -- The permission manager, rebuilt (PLAN_REDESIGNS §1). Children before -- `rust_permgroups`, which they reference. DROP TABLE IF EXISTS rust_perm_exceptions; diff --git a/server/db/schema.sql b/server/db/schema.sql index 529d28e..92d4066 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -958,10 +958,13 @@ ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS wipe_once_at DATETIME NULL; -- time they are pushed or shown, so nothing rolls them over at a wipe and -- nothing can go stale (`model/titles`). -- --- stat `kills` · `npckills` · `playtime` +-- stat one of the category ids in `model/titles/titles.js` (`kills`, +-- `playtime` and, since PLAN_REDESIGNS §5, twenty-two more) -- top_n 1–10 -- text at most 24 characters, with `[ ] < > { }` stripped on save so an --- operator's title can carry no markup of its own (§33.4) +-- operator's title can carry no markup of its own (§33.4). Empty +-- means "the category's title" (D175), except for `playtime`, +-- which has none and always carries its own -- color `#rrggbb` -- position the operator's order, which is also precedence (D136) CREATE TABLE IF NOT EXISTS rust_title_rules ( @@ -1164,6 +1167,61 @@ ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS perm_policy VARCHAR(16) NOT NU -- case the change was meant for every server). ALTER TABLE rust_perm_drift ADD COLUMN IF NOT EXISTS direction VARCHAR(16) NOT NULL DEFAULT 'added'; + +-- ── The chat-title conditions (PLAN_REDESIGNS §5, D172–D175, D209) ──────── +-- +-- Twenty-three things a title can rank, counted by the plugin into +-- `player.tally` and kept here as more of the permanent record. They feed +-- titles only: the public leaderboard keeps its columns (§5.5). +-- +-- `npc_kills` keeps counting every NPC AND animal, as it always has, so the +-- public NPC column does not change meaning mid-wipe (§5.2). `animal_kills` +-- counts the animals again, and a title's "NPC kills" is the difference (D209). +-- +-- Every column is a sum except the two `best_*`, which hold the longest single +-- kill in metres and move by GREATEST at ingest: a tally carries its interval's +-- maximum, and adding two maxima is not a distance. +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS animal_kills INT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS headshots INT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS best_pvp_m DECIMAL(7,1) NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS best_pve_m DECIMAL(7,1) NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS apc_kills INT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS heli_kills INT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS crafted_attire INT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS crafted_weapons INT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS built INT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS repaired INT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS healed INT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS rockets INT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS explosives INT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS missions INT UNSIGNED NOT NULL DEFAULT 0; + +-- Every credited kill by the weapon's short prefab name, the name the killfeed +-- already shows (§5.3). Shaped like `rust_gather_totals`, and for the same +-- reason: the bow, melee, blade and revolver lists are applied when a title is +-- READ, so editing a list corrects the whole wipe and a new Rust weapon needs no +-- plugin release. +CREATE TABLE IF NOT EXISTS rust_weapon_kills ( + server_id VARCHAR(64) NOT NULL, + wipe_id VARCHAR(48) NOT NULL, + steam_id VARCHAR(32) NOT NULL, + weapon VARCHAR(64) NOT NULL, + kills INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (server_id, wipe_id, steam_id, weapon), + KEY idx_rust_weapon_kills_top (server_id, wipe_id, weapon) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- The admin's title for a category, for the whole site (D175). A row exists +-- only while the admin has one; deleting it brings the module's default back. +-- A rule's own text still wins over both (§5.5), and a rule saved with no text +-- means "the category's title", which is how a rename reaches existing rules. +CREATE TABLE IF NOT EXISTS rust_title_categories ( + stat VARCHAR(16) NOT NULL PRIMARY KEY, + text VARCHAR(24) NOT NULL, + updated_by INT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- ── Zone presets (PLAN_REDESIGNS §3.1, D195, D210) ───────────────────────── -- -- A set of ZoneManager flags and settings an admin saved under a name, for one diff --git a/server/ingest.js b/server/ingest.js index cd4c143..48b9b6e 100644 --- a/server/ingest.js +++ b/server/ingest.js @@ -149,6 +149,15 @@ async function apply(serverId, item, server = null) { for (const [resource, amount] of Object.entries(gathered)) { await db.addGathered(at, resource, Number(amount) || 0) } + + // Protocol 13, the chat-title conditions (PLAN_REDESIGNS §5.1). Deltas + // too, except the two distances, which are this interval's best. + await db.addTitleStats(at, frame) + + const weapons = frame.weaponKills && typeof frame.weaponKills === 'object' ? frame.weaponKills : {} + for (const [weapon, kills] of Object.entries(weapons)) { + await db.addWeaponKills(at, weapon, Math.floor(Number(kills) || 0)) + } break } diff --git a/server/model/events/events.db.js b/server/model/events/events.db.js index 08f0692..4befed6 100644 --- a/server/model/events/events.db.js +++ b/server/model/events/events.db.js @@ -13,6 +13,7 @@ const core = require('../../core') const EVENTS = 'rust_events' const STATS = 'rust_player_wipe_stats' const GATHER = 'rust_gather_totals' +const WEAPONS = 'rust_weapon_kills' const PLAYERS = 'rust_players' const WIPES = 'rust_wipes' const PRESENCE = 'rust_presence' @@ -132,6 +133,78 @@ async function addStats({ serverId, wipeId, steamId }, deltas = {}) { ) } +/** + * The tally's counts for the chat-title conditions (PLAN_REDESIGNS §5.1): the + * wire field each is, and the column it adds to. All sums. + */ +const TITLE_SUMS = { + animalKills: 'animal_kills', + headshots: 'headshots', + apcKills: 'apc_kills', + heliKills: 'heli_kills', + built: 'built', + repaired: 'repaired', + healed: 'healed', + rockets: 'rockets', + explosives: 'explosives', + missions: 'missions', +} + +/** The two *best* fields and their columns: the longest single kill, never a sum. */ +const TITLE_BESTS = { bestPvpM: 'best_pvp_m', bestPveM: 'best_pve_m' } + +/** Item categories the plugin sends under `crafted`, and the column each counts in. */ +const CRAFTED = { attire: 'crafted_attire', weapon: 'crafted_weapons' } + +/** + * Adds one tally's title counts to the player's row for the wipe. A sum adds; + * a best is GREATEST of the stored and the sent, because a tally carries its + * interval's maximum and two maxima added are not a distance. Nothing is + * written when the tally carried none of them, so an older plugin's frame + * costs no statement. + */ +async function addTitleStats({ serverId, wipeId, steamId }, frame = {}) { + if (!serverId || !steamId) return + + const sums = {} + for (const [field, col] of Object.entries(TITLE_SUMS)) sums[col] = Math.max(0, Math.floor(Number(frame[field]) || 0)) + + const crafted = frame.crafted && typeof frame.crafted === 'object' ? frame.crafted : {} + for (const [category, col] of Object.entries(CRAFTED)) sums[col] = Math.max(0, Math.floor(Number(crafted[category]) || 0)) + + const bests = {} + for (const [field, col] of Object.entries(TITLE_BESTS)) { + const m = Number(frame[field]) + bests[col] = Number.isFinite(m) && m > 0 ? Math.min(Math.round(m * 10) / 10, 999999.9) : 0 + } + + if (![...Object.values(sums), ...Object.values(bests)].some((v) => v > 0)) return + + const cols = [...Object.keys(sums), ...Object.keys(bests)] + + await core.query( + `INSERT INTO ${STATS} (server_id, wipe_id, steam_id, ${cols.join(', ')}, last_seen) + VALUES (?, ?, ?, ${cols.map(() => '?').join(', ')}, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE + ${Object.keys(sums).map((c) => `${c} = ${c} + VALUES(${c})`).join(',\n ')}, + ${Object.keys(bests).map((c) => `${c} = GREATEST(${c}, VALUES(${c}))`).join(',\n ')}, + last_seen = CURRENT_TIMESTAMP`, + [serverId, wipeId || '', steamId, ...Object.values(sums), ...Object.values(bests)], + ) +} + +/** One weapon's credited kills (§5.3), added as a delta like a gathered resource. */ +async function addWeaponKills({ serverId, wipeId, steamId }, weapon, kills) { + if (!serverId || !steamId || !weapon || !(kills > 0)) return + + await core.query( + `INSERT INTO ${WEAPONS} (server_id, wipe_id, steam_id, weapon, kills) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE kills = kills + VALUES(kills)`, + [serverId, wipeId || '', steamId, String(weapon).slice(0, 64), kills], + ) +} + async function addGathered({ serverId, wipeId, steamId }, resource, amount) { if (!serverId || !steamId || !resource || !(amount > 0)) return @@ -312,6 +385,8 @@ module.exports = { touchPlayer, addStats, addGathered, + addTitleStats, + addWeaponKills, replacePresence, pruneEvents, recentEvents, diff --git a/server/model/titles/lists.js b/server/model/titles/lists.js new file mode 100644 index 0000000..40285a4 --- /dev/null +++ b/server/model/titles/lists.js @@ -0,0 +1,118 @@ +// ── The named lists behind seven title categories (PLAN_REDESIGNS §5.3, D174) ── +// +// "In one place, so a Rust update adds a weapon by editing a list." The plugin +// sends every credited kill by weapon SHORT PREFAB NAME — the name the killfeed +// already shows (`bow_hunting.entity`) — and every gathered resource by item +// shortname (`sulfur.ore`). These lists are applied when a title is READ, so +// editing one corrects the whole wipe and no plugin release is needed. +// +// Read off a live server's own item list on 2026-09-29 (a read-only probe that +// walked `ItemManager.itemList` and named each held entity's prefab and class), +// not written from memory. To refresh after a Rust update, do the same. +// +// bow every `BowWeapon`, `CrossbowWeapon` and `MiniCrossbow` — except the +// speargun, which is a crossbow underneath and not a bow to a player +// melee every `BaseMelee`, except the pies (`Food` is a `BaseMelee` too, +// because eating is a swing) +// blade the machete, the swords, the knives and the cleaver, as §4.6 names +// them; every blade is in melee as well +// revolver the revolver, the python and the high-caliber revolver + +const BLADES = [ + 'machete.weapon', + 'salvaged_sword.entity', + 'longsword.entity', + 'krieg_chainsword.entity', + 'salvaged_cleaver.entity', + 'knife_bone.entity', + 'bone.knife.obsidian.entity', + 'knife.combat.entity', + 'sunken.knife.combat.entity', + 'butcherknife.entity', + 'skinningknife.entity', +] + +const lists = { + bow: [ + 'bow_hunting.entity', + 'compound_bow.entity', + 'legacybow.entity', + 'crossbow.entity', + 'crossbow_bowless.entity', + 'mini_crossbow.entity', + ], + blade: BLADES, + melee: [ + ...BLADES, + 'axe_salvaged.entity', + 'bone_club.entity', + 'boomerang.entity', + 'cake.entity', + 'candy_cane.entity', + 'chainsaw.entity', + 'cny_spear.entity', + 'concrete_hatchet.entity', + 'concrete_pickaxe.entity', + 'diver_hatchet.entity', + 'diver_pickaxe.entity', + 'diver_torch.entity', + 'flashlight.entity', + 'frontier_hatchet.entity', + 'hammer.entity', + 'hammer_salvaged.entity', + 'handcuffs.entity', + 'hatchet.entity', + 'icepick_salvaged.entity', + 'industrial_torch.entity', + 'jackhammer.entity', + 'lumberjack_axe.entity', + 'lumberjack_pick.entity', + 'mace.baseballbat', + 'mace.entity', + 'paddle.entity', + 'pickaxe.entity', + 'pitchfork.entity', + 'rock.a.jungle.entity', + 'rock.entity', + 'shovel.entity', + 'sickle.entity', + 'skull.entity', + 'skulltorch.entity', + 'snowball.entity', + 'spear_stone.entity', + 'spear_wooden.entity', + 'stone_pickaxe.entity', + 'stonehatchet.entity', + 'toolgun.entity', + 'torch.entity', + 'vampirestake.entity', + ], + revolver: ['pistol_revolver.entity', 'python.entity', 'hc_revolver.entity'], + + // Gathering (D159: all three sources, already in `rust_gather_totals`). + wood: ['wood'], + ore: ['metal.ore', 'sulfur.ore', 'hq.metal.ore'], + // Hemp picks up as cloth. A seed or a clone that comes with a plant is not + // the plant, and neither is anything cooked from one. + plants: [ + 'cloth', + 'corn', + 'wheat', + 'pumpkin', + 'potato', + 'mushroom', + 'red.berry', + 'blue.berry', + 'green.berry', + 'yellow.berry', + 'white.berry', + 'black.berry', + 'blueberries', + 'black.raspberries', + 'rose', + 'orchid', + 'sunflower', + ], +} + +module.exports = lists diff --git a/server/model/titles/titles.db.js b/server/model/titles/titles.db.js index 1ab45e7..74c7281 100644 --- a/server/model/titles/titles.db.js +++ b/server/model/titles/titles.db.js @@ -58,4 +58,105 @@ async function saveSettings(serverId, { mode, max, rules }) { ) } -module.exports = { RULES, listRules, listAllRules, getMode, listModes, saveSettings } +// ── The standings a category ranks (PLAN_REDESIGNS §5) ──────────────────── + +const STATS = 'rust_player_wipe_stats' +const WEAPONS = 'rust_weapon_kills' +const GATHER = 'rust_gather_totals' +const CATEGORIES = 'rust_title_categories' + +/** Only these ever reach a column position in SQL: the names `titles.js` declares. */ +const COLUMN = /^[a-z_]+$/ + +/** + * One category's standings for one wipe: `{ steamId, value }` rows, highest + * first, ties broken by who was seen last — the leaderboard's own order, so a + * rule's top N is what the web would show (§33.4 reading 2). + * + * @param {object} source a category's `source` from `titles.js` + * @param {object} lists list name → the short names it holds (`lists.js`) + */ +async function standings({ serverId, wipeId, source, lists = {}, limit }) { + const params = [serverId, wipeId] + + if (source.sum || source.diff || source.best) { + let value + if (source.sum) value = `SUM(s.${source.sum})` + else if (source.best) value = `MAX(s.${source.best})` + // Signed: `npc_kills` is unsigned, and a row counted before `animal_kills` + // existed must not wrap round to four billion. + else value = `SUM(CAST(s.${source.diff[0]} AS SIGNED) - CAST(s.${source.diff[1]} AS SIGNED))` + + const cols = [source.sum, source.best, ...(source.diff || [])].filter(Boolean) + if (!cols.every((c) => COLUMN.test(c))) throw new Error('not a stats column') + + return core.query( + `SELECT s.steam_id AS steamId, ${value} AS value + FROM ${STATS} s + WHERE s.server_id = ? AND s.wipe_id = ? + GROUP BY s.steam_id + ORDER BY value DESC, MAX(s.last_seen) DESC + LIMIT ?`, + [...params, limit], + ) + } + + const table = source.weapons ? WEAPONS : GATHER + const key = source.weapons ? 'weapon' : 'resource' + const amount = source.weapons ? 'kills' : 'amount' + const names = lists[source.weapons || source.gathered] || [] + + if (!names.length) return [] + + // The stats row is joined only for the tie-break; its key is the table's, + // so one row joins at most one and the sum is not multiplied. + return core.query( + `SELECT t.steam_id AS steamId, SUM(t.${amount}) AS value + FROM ${table} t + LEFT JOIN ${STATS} s + ON s.server_id = t.server_id AND s.wipe_id = t.wipe_id AND s.steam_id = t.steam_id + WHERE t.server_id = ? AND t.wipe_id = ? AND t.${key} IN (${names.map(() => '?').join(', ')}) + GROUP BY t.steam_id + ORDER BY value DESC, MAX(s.last_seen) DESC + LIMIT ?`, + [...params, ...names, limit], + ) +} + +/** The admin's category titles (D175), as stat → text. */ +async function categoryTitles() { + const rows = await core.query(`SELECT stat, text FROM ${CATEGORIES}`) + return Object.fromEntries(rows.map((r) => [r.stat, r.text])) +} + +/** The same, with who changed each and when, for the admin screen. */ +async function listCategories() { + return core.query(`SELECT stat, text, updated_by AS updatedBy, updated_at AS updatedAt FROM ${CATEGORIES}`) +} + +async function setCategory(stat, text, userId = null) { + await core.query( + `INSERT INTO ${CATEGORIES} (stat, text, updated_by, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE text = VALUES(text), updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP`, + [stat, text, userId], + ) +} + +/** Reset: the row goes, and the module's default is back. */ +async function clearCategory(stat) { + await core.query(`DELETE FROM ${CATEGORIES} WHERE stat = ?`, [stat]) +} + +module.exports = { + RULES, + listRules, + listAllRules, + getMode, + listModes, + saveSettings, + standings, + categoryTitles, + listCategories, + setCategory, + clearCategory, +} diff --git a/server/model/titles/titles.js b/server/model/titles/titles.js index fe08886..3e5c1e6 100644 --- a/server/model/titles/titles.js +++ b/server/model/titles/titles.js @@ -18,13 +18,52 @@ const crypto = require('node:crypto') -/** The stats a rule may rank, and the leaderboard sort and column each means. */ +/** + * The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three + * conditions and their shipped titles, plus `playtime`, which predates them and + * has no default title, so a playtime rule always carries its own text. + * + * `source` says where the standing is read from (`titles.db.js`): + * `sum` a column of `rust_player_wipe_stats`, summed + * `diff` one column less another — human NPCs are every NPC kill less the + * animals (§5.2, D209), so the public NPC column keeps its meaning + * `best` a column's MAX: the longest single kill (D174) + * `weapons` a named list's kills in `rust_weapon_kills` (§5.3) + * `gathered` a named list's amount in `rust_gather_totals` (D159) + * + * An admin may rename any category that has a default (D175); the ids are what + * a rule stores, so they never change. + */ const STATS = { - kills: { sort: 'kills', value: (row) => row.kills, label: 'kills' }, - npckills: { sort: 'npcKills', value: (row) => row.npcKills, label: 'NPC kills' }, - playtime: { sort: 'playtime', value: (row) => row.playtimeSec, label: 'playtime' }, + kills: { label: 'Players killed', title: 'Reaper', source: { sum: 'kills' } }, + npckills: { label: 'NPC kills', title: 'Exterminator', source: { diff: ['npc_kills', 'animal_kills'] } }, + animalkills: { label: 'Animal kills', title: 'Beastbane', source: { sum: 'animal_kills' } }, + bowkills: { label: 'Bow kills', title: 'Fletcher', source: { weapons: 'bow' } }, + meleekills: { label: 'Melee kills', title: 'Brawler', source: { weapons: 'melee' } }, + bladekills: { label: 'Blade kills', title: 'Duelist', source: { weapons: 'blade' } }, + revolverkills: { label: 'Revolver kills', title: 'Outlaw', source: { weapons: 'revolver' } }, + headshots: { label: 'Headshots', title: 'Crownbreaker', source: { sum: 'headshots' } }, + pvpdistance: { label: 'Longest player kill', title: 'Longshot', source: { best: 'best_pvp_m' } }, + pvedistance: { label: 'Longest NPC or animal kill', title: 'Marksman', source: { best: 'best_pve_m' } }, + apckills: { label: 'APC kills', title: 'Tankbreaker', source: { sum: 'apc_kills' } }, + helikills: { label: 'Helicopter kills', title: 'Rotorbane', source: { sum: 'heli_kills' } }, + wood: { label: 'Wood gathered', title: 'Timberwright', source: { gathered: 'wood' } }, + ore: { label: 'Ore gathered', title: 'Prospector', source: { gathered: 'ore' } }, + plants: { label: 'Plants gathered', title: 'Greenhand', source: { gathered: 'plants' } }, + attire: { label: 'Clothes crafted', title: 'Seamster', source: { sum: 'crafted_attire' } }, + weapons: { label: 'Weapons crafted', title: 'Armorer', source: { sum: 'crafted_weapons' } }, + built: { label: 'Structures built', title: 'Mason', source: { sum: 'built' } }, + repaired: { label: 'Structures repaired', title: 'Tinkerer', source: { sum: 'repaired' } }, + healed: { label: 'Players healed', title: 'Mender', source: { sum: 'healed' } }, + rockets: { label: 'Rockets fired', title: 'Artillerist', source: { sum: 'rockets' } }, + explosives: { label: 'Explosives thrown', title: 'Demolitionist', source: { sum: 'explosives' } }, + missions: { label: 'Missions completed', title: 'Wayfarer', source: { sum: 'missions' } }, + playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } }, } +/** The categories an admin can rename: every one with a shipped title (D175). */ +const CATEGORY_IDS = Object.keys(STATS).filter((id) => STATS[id].title) + const MODES = ['first', 'all', 'upto'] const MAX_RULES = 10 @@ -83,8 +122,13 @@ function validateSettings(body) { const topN = Number(r.topN) if (!Number.isInteger(topN) || topN < 1 || topN > MAX_TOP_N) errors.push(`rule ${n}: top must be from 1 to ${MAX_TOP_N}`) + // Empty means "the category's title" (D175) — for a category that has one. + // A text that was typed and cleaned down to nothing is still refused: a + // title made entirely of brackets is a mistake, not a request for the default. + const typed = String(r.text === undefined || r.text === null ? '' : r.text).trim() const text = cleanText(r.text) - if (!text) errors.push(`rule ${n}: the title needs some text`) + const stat = STATS[r.stat] + if (!text && (typed || !stat || !stat.title)) errors.push(`rule ${n}: the title needs some text`) else if (text.length > TEXT_MAX) errors.push(`rule ${n}: a title is at most ${TEXT_MAX} characters`) const color = String(r.color || '').trim().toLowerCase() @@ -96,12 +140,49 @@ function validateSettings(body) { return errors.length ? { ok: false, errors } : { ok: true, value: { mode, max, rules: clean } } } +/** + * The title a category shows (D175): the admin's, if they wrote one, else the + * shipped default. `overrides` is stat → the admin's text. + */ +function categoryTitle(stat, overrides = {}) { + const own = overrides[stat] + if (own) return own + return STATS[stat] ? STATS[stat].title : null +} + +/** + * Checks an admin's title for a category. Resolves `{ ok, text }` or + * `{ ok: false, error }`. An empty text is not an error here — it is the Reset, + * and the caller deletes the row. + */ +function validateCategory(stat, raw) { + if (!CATEGORY_IDS.includes(stat)) return { ok: false, error: `no category ${stat}` } + const typed = String(raw === undefined || raw === null ? '' : raw).trim() + const text = cleanText(raw) + if (typed && !text) return { ok: false, error: 'the title needs some text' } + if (text.length > TEXT_MAX) return { ok: false, error: `a title is at most ${TEXT_MAX} characters` } + return { ok: true, text } +} + +/** + * Rules with their text settled, first that is set winning (§4.6): the rule's + * own, the admin's for the category, the module's default. A rule that ends up + * with none — only possible for a rule saved before its category lost a title — + * is dropped rather than shown as an empty chip. + */ +function resolveRules(rules, overrides = {}) { + return (rules || []) + .map((r) => ({ ...r, text: r.text || categoryTitle(r.stat, overrides) || '' })) + .filter((r) => r.text) +} + /** * Who holds which title. * - * @param {Array} rules in precedence order: `{ stat, topN, text, color }` - * @param {object} standings stat → leaderboard rows for the current wipe, - * in the leaderboard's order, at least `topN` long + * @param {Array} rules in precedence order: `{ stat, topN, text, color }`, + * with the text already settled (`resolveRules`) + * @param {object} standings stat → `{ steamId, value }` rows for the current + * wipe, in the leaderboard's order, at least `topN` long * @param {object} options * @param {string} options.mode `first` · `all` · `upto` * @param {number} options.max how many `upto` shows @@ -111,10 +192,9 @@ function evaluate(rules, standings, { mode = 'first', max = 2 } = {}) { const held = new Map() for (const rule of rules || []) { - const stat = STATS[rule.stat] - if (!stat) continue + if (!STATS[rule.stat]) continue - const rows = (standings[rule.stat] || []).filter((row) => Number(stat.value(row)) > 0).slice(0, rule.topN) + const rows = (standings[rule.stat] || []).filter((row) => Number(row.value) > 0).slice(0, rule.topN) for (const row of rows) { if (!held.has(row.steamId)) held.set(row.steamId, []) @@ -150,6 +230,10 @@ function digest(set) { module.exports = { STATS, + CATEGORY_IDS, + categoryTitle, + validateCategory, + resolveRules, MODES, MAX_RULES, MAX_TOP_N, diff --git a/server/model/titles/titles.model.js b/server/model/titles/titles.model.js index 0628805..8cf342b 100644 --- a/server/model/titles/titles.model.js +++ b/server/model/titles/titles.model.js @@ -5,9 +5,9 @@ // the app's — so it is remembered briefly: the leaderboard is polled by every // open page, and each rule is a leaderboard query of its own. -const eventsDb = require('../events/events.db') const serversDb = require('../servers/servers.db') const db = require('./titles.db') +const lists = require('./lists') const titles = require('./titles') /** How long one server's answer is reused. The same as the push loop's tick. */ @@ -47,8 +47,40 @@ async function saveSettings(serverId, value) { forget(serverId) } -function forget(serverId) { - for (const key of memo.keys()) if (key.startsWith(`${serverId} `)) memo.delete(key) +/** Forgets one server's answer, or every server's when a category was renamed. */ +function forget(serverId = null) { + for (const key of memo.keys()) if (serverId === null || key.startsWith(`${serverId} `)) memo.delete(key) +} + +/** + * The categories as the admin screen shows them (D175): each one's label, the + * shipped default, the admin's own title if any, and what rules show now. + */ +async function categories() { + const own = new Map((await db.listCategories()).map((r) => [r.stat, r])) + return titles.CATEGORY_IDS.map((stat) => { + const row = own.get(stat) + const def = titles.STATS[stat] + return { + stat, + label: def.label, + default: def.title, + text: row ? row.text : null, + current: row ? row.text : def.title, + updatedAt: row ? row.updatedAt : null, + } + }) +} + +/** + * Sets or resets one category's title. An empty text is the Reset. Every + * server's answer is forgotten, because every server's rules may use it, and + * the next push carries the new name (§4.6: "from the next push"). + */ +async function setCategory(stat, text, userId = null) { + if (text) await db.setCategory(stat, text, userId) + else await db.clearCategory(stat) + forget() } /** @@ -64,43 +96,42 @@ async function heldFor(serverId, { wipeId, now = Date.now() } = {}) { const hit = memo.get(key) if (hit && now - hit.at < MEMO_MS) return hit.held - const settings = await settingsFor(serverId) + const [settings, overrides] = await Promise.all([settingsFor(serverId), db.categoryTitles()]) + const rules = titles.resolveRules(settings.rules, overrides) const standings = {} // One query per STAT, at the deepest top N any rule on it asks for, rather // than one per rule: two rules on kills read the same rows. const depth = new Map() - for (const r of settings.rules) depth.set(r.stat, Math.max(depth.get(r.stat) || 0, r.topN)) + for (const r of rules) if (titles.STATS[r.stat]) depth.set(r.stat, Math.max(depth.get(r.stat) || 0, r.topN)) await Promise.all( [...depth.entries()].map(async ([stat, limit]) => { - standings[stat] = await eventsDb.leaderboard({ serverId, wipeId, sort: titles.STATS[stat].sort, limit }) + const rows = await db.standings({ serverId, wipeId, source: titles.STATS[stat].source, lists, limit }) + // SUM() and MAX() come back as strings; the rules compare numbers. + standings[stat] = rows.map((r) => ({ steamId: r.steamId, value: Number(r.value) || 0 })) }), ) - const held = titles.evaluate(settings.rules, normalise(standings), settings) + const held = titles.evaluate(rules, standings, settings) memo.set(key, { at: now, held }) return held } -/** The leaderboard's rows carry numbers as strings from SUM(); the rules compare numbers. */ -function normalise(standings) { - const out = {} - for (const [stat, rows] of Object.entries(standings)) { - out[stat] = rows.map((r) => ({ - steamId: r.steamId, - kills: Number(r.kills) || 0, - npcKills: Number(r.npcKills) || 0, - playtimeSec: Number(r.playtimeSec) || 0, - })) - } - return out -} - /** The same, for a server by id, reading its current wipe from its last report. */ async function currentFor(serverId) { const state = await serversDb.getState(serverId) return heldFor(serverId, { wipeId: state && state.wipeId ? state.wipeId : null }) } -module.exports = { MEMO_MS, settingsFor, settingsByServer, saveSettings, heldFor, currentFor, forget } +module.exports = { + MEMO_MS, + settingsFor, + settingsByServer, + saveSettings, + heldFor, + currentFor, + forget, + categories, + setCategory, +} diff --git a/server/router/admin/rust.controller.js b/server/router/admin/rust.controller.js index cedcf26..cbe3022 100644 --- a/server/router/admin/rust.controller.js +++ b/server/router/admin/rust.controller.js @@ -26,15 +26,22 @@ const log = core.logger('admin') async function listServers(req, res) { try { - const [rows, settings] = await Promise.all([servers.listForAdmin(), titlesModel.settingsByServer()]) + const [rows, settings, categories] = await Promise.all([ + servers.listForAdmin(), + titlesModel.settingsByServer(), + titlesModel.categories(), + ]) // Phase 17: each server's chat titles, and what the last push of them found. + // PLAN_REDESIGNS §5.5: the category titles too, since a rule with no text of + // its own shows its category's, and the list says which that is. res.json({ servers: rows.map((row) => ({ ...row, titles: settings.get(row.id) || { mode: 'first', max: 2, rules: [] }, titlePush: titleSync.lastPush(row.id), })), + titleCategories: categories, }) } catch (err) { log.error('failed to read the server list', { error: err.message }) @@ -262,6 +269,45 @@ async function putTitles(req, res) { } } +/** The title categories (D175): each one's default, the admin's own, and what shows. */ +async function getTitleCategories(req, res) { + try { + return res.json({ categories: await titlesModel.categories() }) + } catch (err) { + log.error('failed to read the title categories', { error: err.message }) + return res.status(500).json({ message: 'Failed to read the title categories' }) + } +} + +/** + * Rename one category for the whole site (D175), or reset it with an empty + * text. Every rule of that category without its own text shows the new title + * on every server from the next push. + */ +async function putTitleCategory(req, res) { + const { stat } = req.params + + try { + const checked = titles.validateCategory(stat, req.body.text) + if (!checked.ok) { + const missing = !titles.CATEGORY_IDS.includes(stat) + return res.status(missing ? 404 : 400).json({ message: checked.error, errors: [checked.error] }) + } + + await titlesModel.setCategory(stat, checked.text, req.user ? req.user.id : null) + await core.activity.log({ + req, + action: checked.text ? 'rust.titles.category.set' : 'rust.titles.category.reset', + detail: { stat, text: checked.text || null }, + }) + + return res.json({ categories: await titlesModel.categories() }) + } catch (err) { + log.error('failed to save a title category', { stat, error: err.message }) + return res.status(500).json({ message: 'Failed to save the category title' }) + } +} + /** * Which optional mods one server has loaded right now, read live from the game * (`integrations` on `server.status`). Live, because an operator installs @@ -320,6 +366,8 @@ module.exports = { fetchMap, renderMap, putTitles, + getTitleCategories, + putTitleCategory, integrations, getVoice, putVoice, diff --git a/server/router/admin/rust.router.js b/server/router/admin/rust.router.js index 3b17936..80d647f 100644 --- a/server/router/admin/rust.router.js +++ b/server/router/admin/rust.router.js @@ -155,7 +155,7 @@ adminRustRouter.put( '/servers/:id/titles', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Set a server’s chat titles' - // #swagger.description = 'Replaces the server’s title rules and how many titles a player shows. A rule ranks the CURRENT wipe by `kills`, `npckills` or `playtime` and gives its top N (1–10) a title; only a stat above zero counts, so a fresh wipe gives no titles. Rules are in precedence order, at most ten. `mode` is `first` (the first rule a player meets), `all`, or `upto` `max` (1–5). The titles are shown in game by BetterChat when it is loaded — they are pushed whether or not it is, and it shows them as soon as it is — and on the web and app leaderboards. A 400 carries one sentence per problem in `errors`.' + // #swagger.description = 'Replaces the server’s title rules and how many titles a player shows. A rule ranks the CURRENT wipe by one of the title categories (`RustTitleSettings`) and gives its top N (1–10) a title; only a stat above zero counts, so a fresh wipe gives no titles. A rule’s `text` may be empty, meaning the category’s title (the admin’s, else the module’s default); `playtime` has no default and needs its own. A text that was typed but is only markup is refused. Rules are in precedence order, at most ten. `mode` is `first` (the first rule a player meets), `all`, or `upto` `max` (1–5). The titles are shown in game by BetterChat when it is loaded — they are pushed whether or not it is, and it shows them as soon as it is — and on the web and app leaderboards. A 400 carries one sentence per problem in `errors`.' // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } } /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustTitleSettings" } } } } */ /* #swagger.responses[200] = { description: 'Saved; answers the server’s settings as stored' } */ @@ -171,6 +171,33 @@ adminRustRouter.put( admin.putTitles, ) +adminRustRouter.get( + '/title-categories', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'The chat-title categories and their titles' + // #swagger.description = 'Every category a title rule can rank that has a shipped title (PLAN_REDESIGNS §5, D172), with its label, the module’s default, the admin’s own title if they set one, and `current`, what a rule with no text of its own shows. The admin’s title is for the whole site (D175).' + /* #swagger.responses[200] = { description: 'The categories', content: { "application/json": { schema: { type: 'object', properties: { categories: { type: 'array', items: { $ref: "#/components/schemas/RustTitleCategory" } } } } } } } */ + requireRole('admin'), + admin.getTitleCategories, +) + +adminRustRouter.put( + '/title-categories/:stat', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Rename a chat-title category, or reset it' + // #swagger.description = 'Sets the site’s own title for one category (D175): at most 24 characters, markup characters taken out as a rule’s are. An empty `text` resets the category to the module’s default. Every rule of this category with no text of its own shows the new title on every server from the next push; a rule that carries its own text keeps it. Answers the whole list.' + // #swagger.parameters['stat'] = { in: 'path', required: true, description: 'The category id, for example animalkills', schema: { type: 'string' } } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', properties: { text: { type: 'string', maxLength: 24, example: 'Hunter' } } } } } } */ + /* #swagger.responses[200] = { description: 'Saved; answers every category' } */ + /* #swagger.responses[400] = { description: 'The title is too long, or only markup' } */ + /* #swagger.responses[404] = { description: 'No such category' } */ + requireRole('admin'), + param('stat').isString().isLength({ min: 1, max: 16 }), + body('text').optional({ nullable: true }).isString().withMessage('text is a string'), + validate, + admin.putTitleCategory, +) + adminRustRouter.get( '/servers/:id/integrations', // #swagger.tags = ['Admin · Rust'] diff --git a/server/swagger/doc.js b/server/swagger/doc.js index 6f17731..d61b63a 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -19,6 +19,10 @@ // raw OpenAPI.** `{ type: 'object' }` comes back as a meta-description of itself. // That is uniform across core's committed spec and is the house shape. +// The title categories' ids, so the enum below cannot drift from what the +// validator accepts. `titles.js` is pure and needs nothing from core. +const titles = require('../model/titles/titles') + module.exports = { tags: [ { @@ -180,12 +184,17 @@ module.exports = { items: { type: 'object', properties: { - stat: { type: 'string', enum: ['kills', 'npckills', 'playtime'], example: 'kills' }, + stat: { + type: 'string', + enum: Object.keys(titles.STATS), + description: 'The category the rule ranks (PLAN_REDESIGNS §5). `npckills` is human NPCs only (D209); `pvpdistance` and `pvedistance` rank the longest single kill; `playtime` has no default title.', + example: 'kills', + }, topN: { type: 'integer', minimum: 1, maximum: 10, example: 1 }, text: { type: 'string', maxLength: 24, - description: 'The title. `[`, `]`, `<`, `>`, `{` and `}` are taken out on save, so a title carries no markup of its own.', + description: 'The title. `[`, `]`, `<`, `>`, `{` and `}` are taken out on save, so a title carries no markup of its own. Empty means the category’s title (D175), except for `playtime`.', example: 'Top Killer', }, color: { type: 'string', example: '#ff8800' }, @@ -194,6 +203,18 @@ module.exports = { }, }, }, + RustTitleCategory: { + type: 'object', + description: 'One chat-title category (PLAN_REDESIGNS §5.5, D175) and the title a rule of it shows when the rule has none of its own.', + properties: { + stat: { type: 'string', example: 'animalkills' }, + label: { type: 'string', example: 'Animal kills' }, + default: { type: 'string', description: 'The module’s shipped title.', example: 'Beastbane' }, + text: { type: 'string', nullable: true, description: 'The admin’s own title for the whole site, or null.', example: 'Hunter' }, + current: { type: 'string', description: 'What shows: `text` when set, else `default`.', example: 'Hunter' }, + updatedAt: { type: 'string', format: 'date-time', nullable: true }, + }, + }, RustIntegrations: { type: 'object', description: 'Which optional mods a server has loaded right now (GET /admin/rust/servers/{id}/integrations), read live from the game.', diff --git a/server/test/optionalMods.test.js b/server/test/optionalMods.test.js index 58e1658..1bb9458 100644 --- a/server/test/optionalMods.test.js +++ b/server/test/optionalMods.test.js @@ -44,14 +44,14 @@ withCore() const titles = require('../model/titles/titles') const chatStyle = require('../model/permissions/chatStyle') -const row = (steamId, stats) => ({ steamId, kills: 0, npcKills: 0, playtimeSec: 0, ...stats }) +const row = (steamId, value = 0) => ({ steamId, value }) // ── Titles ───────────────────────────────────────────────────────────────── test('a title rule counts only a stat above zero, so a fresh wipe gives no titles (§33.4 reading 1)', () => { const held = titles.evaluate( [{ stat: 'kills', topN: 3, text: 'Killer', color: '#ff0000' }], - { kills: [row('1', { kills: 5 }), row('2', { kills: 0 }), row('3', { kills: 0 })] }, + { kills: [row('1', 5), row('2', 0), row('3', 0)] }, ) assert.deepStrictEqual([...held.keys()], ['1']) @@ -62,7 +62,7 @@ test('a title rule counts only a stat above zero, so a fresh wipe gives no title test('ties follow the leaderboard order: a rule’s top N is the first N rows it was given (reading 2)', () => { const held = titles.evaluate( [{ stat: 'playtime', topN: 2, text: 'Regular', color: '#00ff00' }], - { playtime: [row('9', { playtimeSec: 60 }), row('4', { playtimeSec: 60 }), row('7', { playtimeSec: 60 })] }, + { playtime: [row('9', 60), row('4', 60), row('7', 60)] }, ) assert.deepStrictEqual([...held.keys()], ['9', '4']) }) @@ -74,9 +74,9 @@ test('the mode keeps the first rule met, every rule, or up to N — in the opera { stat: 'playtime', topN: 1, text: 'Regular', color: '#0000ff' }, ] const standings = { - kills: [row('1', { kills: 3 })], - npckills: [row('1', { npcKills: 2 })], - playtime: [row('1', { playtimeSec: 9 })], + kills: [row('1', 3)], + npckills: [row('1', 2)], + playtime: [row('1', 9)], } const texts = (mode, max) => titles.evaluate(rules, standings, { mode, max }).get('1').map((t) => t.text) diff --git a/server/test/titleConditions.test.js b/server/test/titleConditions.test.js new file mode 100644 index 0000000..bc4f628 --- /dev/null +++ b/server/test/titleConditions.test.js @@ -0,0 +1,221 @@ +// ── The chat-title conditions (PLAN_REDESIGNS §5, D172–D175, D209) ───────── +// +// Four things here, each a mistake that would look right in review: +// +// • **a best is not a sum.** The two distance columns take GREATEST at ingest; +// adding two intervals' maxima would give a kill nobody made. +// • **"NPC kills" subtracts in SIGNED arithmetic.** `npc_kills` is unsigned, +// and a row counted before `animal_kills` existed must not wrap to 4 billion. +// • **a rename reaches a rule with no text.** The rule's own text wins, then +// the admin's category title, then the default (§4.6's three layers). +// • **a category's title is never a rule's text by accident.** An empty text is +// "use the category's", and a typed title made of markup is still refused. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +/** Installs a core whose `db.query` records every statement and answers `rows`. */ +function withRecorder(rows = []) { + const statements = [] + const ctx = fakeCtx({ + db: { + query: (sql, params = []) => { + statements.push({ sql, params }) + return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows) + }, + pool: {}, + }, + }) + require('../core')._reset() + require('../core').init(ctx) + return { + statements, + touching: (table) => statements.filter((s) => s.sql.includes(table)), + ctx, + } +} + +withRecorder() + +const titles = require('../model/titles/titles') +const lists = require('../model/titles/lists') + +const frame = (over = {}) => ({ type: 'event', t: 1, serverId: 'main', wipeId: 'w1', ...over }) +const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...over }) }) + +// ── The categories ──────────────────────────────────────────────────────── + +test('twenty-three categories carry a default title, and playtime carries none (D172)', () => { + assert.strictEqual(titles.CATEGORY_IDS.length, 23) + assert.ok(!titles.CATEGORY_IDS.includes('playtime')) + assert.strictEqual(titles.STATS.playtime.title, null) + assert.strictEqual(titles.STATS.animalkills.title, 'Beastbane') + assert.strictEqual(titles.STATS.missions.title, 'Wayfarer') + + // Every id fits the column a rule stores it in. + for (const id of Object.keys(titles.STATS)) assert.ok(id.length <= 16, id) + + // Every list a category names exists, and is not empty. + for (const { source } of Object.values(titles.STATS)) { + const name = source.weapons || source.gathered + if (name) assert.ok(Array.isArray(lists[name]) && lists[name].length, name) + } +}) + +test('"NPC kills" is human NPCs: every NPC kill less the animals (D209)', () => { + assert.deepStrictEqual(titles.STATS.npckills.source, { diff: ['npc_kills', 'animal_kills'] }) +}) + +test('every blade is also a melee weapon (§5.3)', () => { + for (const blade of lists.blade) assert.ok(lists.melee.includes(blade), blade) +}) + +// ── Rules and their text ────────────────────────────────────────────────── + +test('a rule may leave its text empty when its category has a title, and not for playtime (D175)', () => { + const ok = titles.validateSettings({ rules: [{ stat: 'animalkills', topN: 1, text: '', color: '#00ff00' }] }) + assert.strictEqual(ok.ok, true) + assert.strictEqual(ok.value.rules[0].text, '') + + const playtime = titles.validateSettings({ rules: [{ stat: 'playtime', topN: 1, text: '', color: '#00ff00' }] }) + assert.strictEqual(playtime.ok, false) + assert.ok(playtime.errors.some((e) => /rule 1: the title needs some text/.test(e))) +}) + +test('a typed title made only of markup is refused, even where empty would be allowed (reading 3)', () => { + const bad = titles.validateSettings({ rules: [{ stat: 'kills', topN: 1, text: '[]<>', color: '#ff0000' }] }) + assert.strictEqual(bad.ok, false) + assert.ok(bad.errors.some((e) => /the title needs some text/.test(e))) +}) + +test('a rule’s text wins, then the admin’s category title, then the default (§4.6)', () => { + const rules = [ + { stat: 'kills', topN: 1, text: 'Top Gun', color: '#ff0000' }, + { stat: 'animalkills', topN: 1, text: '', color: '#00ff00' }, + { stat: 'headshots', topN: 1, text: '', color: '#0000ff' }, + ] + const resolved = titles.resolveRules(rules, { animalkills: 'Hunter', kills: 'Ignored' }) + assert.deepStrictEqual(resolved.map((r) => r.text), ['Top Gun', 'Hunter', 'Crownbreaker']) +}) + +test('a playtime rule with no text shows nothing rather than an empty chip', () => { + const resolved = titles.resolveRules([{ stat: 'playtime', topN: 1, text: '', color: '#ffffff' }], {}) + assert.deepStrictEqual(resolved, []) +}) + +test('a category title is checked like a rule’s, and empty is the Reset', () => { + assert.deepStrictEqual(titles.validateCategory('animalkills', ' [Hunter] '), { ok: true, text: 'Hunter' }) + assert.deepStrictEqual(titles.validateCategory('animalkills', ''), { ok: true, text: '' }) + assert.strictEqual(titles.validateCategory('animalkills', '<>').ok, false) + assert.strictEqual(titles.validateCategory('animalkills', 'x'.repeat(25)).ok, false) + assert.strictEqual(titles.validateCategory('playtime', 'Regular').ok, false, 'playtime has no category title') + assert.strictEqual(titles.validateCategory('deaths', 'X').ok, false) +}) + +// ── The ingest ──────────────────────────────────────────────────────────── + +test('a tally’s title counts are added, and its two distances kept as a best', async () => { + const rec = withRecorder() + const { apply } = require('../ingest') + + await apply( + 'main', + item('player.tally', { + steamId: 'p1', + animalKills: 2, + headshots: 1, + bestPvpM: 187.46, + bestPveM: 42.1, + crafted: { attire: 3, weapon: 1, tool: 9 }, + built: 12, + missions: 1, + weaponKills: { 'bow_hunting.entity': 2, 'machete.weapon': 1 }, + }), + ) + + const titled = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('animal_kills')) + assert.ok(titled, 'one statement for the title counts') + assert.match(titled.sql, /animal_kills = animal_kills \+ VALUES\(animal_kills\)/) + assert.match(titled.sql, /best_pvp_m = GREATEST\(best_pvp_m, VALUES\(best_pvp_m\)\)/) + assert.match(titled.sql, /best_pve_m = GREATEST\(best_pve_m, VALUES\(best_pve_m\)\)/) + assert.doesNotMatch(titled.sql, /best_pvp_m = best_pvp_m \+/) + + const cols = titled.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+), last_seen\)/)[1].split(', ') + const value = (col) => titled.params[3 + cols.indexOf(col)] + assert.strictEqual(value('animal_kills'), 2) + assert.strictEqual(value('crafted_attire'), 3) + assert.strictEqual(value('crafted_weapons'), 1) + assert.strictEqual(value('built'), 12) + assert.strictEqual(value('missions'), 1) + assert.strictEqual(value('best_pvp_m'), 187.5) + assert.strictEqual(value('rockets'), 0) + + const weapons = rec.touching('rust_weapon_kills') + assert.deepStrictEqual( + weapons.map((w) => [w.params[3], w.params[4]]), + [['bow_hunting.entity', 2], ['machete.weapon', 1]], + ) + assert.match(weapons[0].sql, /kills = kills \+ VALUES\(kills\)/) +}) + +test('an older plugin’s tally writes no title statement at all', async () => { + const rec = withRecorder() + const { apply } = require('../ingest') + + await apply('main', item('player.tally', { steamId: 'p1', npcKills: 1 })) + + assert.strictEqual(rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('animal_kills')).length, 0) + assert.strictEqual(rec.touching('rust_weapon_kills').length, 0) +}) + +// ── The standings ───────────────────────────────────────────────────────── + +test('a category’s standings read from the right place, in the leaderboard’s order', async () => { + const rec = withRecorder([]) + const db = require('../model/titles/titles.db') + const at = { serverId: 'main', wipeId: 'w1', lists, limit: 3 } + + await db.standings({ ...at, source: titles.STATS.npckills.source }) + const human = rec.statements.at(-1) + assert.match(human.sql, /CAST\(s\.npc_kills AS SIGNED\) - CAST\(s\.animal_kills AS SIGNED\)/) + assert.match(human.sql, /ORDER BY value DESC, MAX\(s\.last_seen\) DESC/) + + await db.standings({ ...at, source: titles.STATS.pvpdistance.source }) + assert.match(rec.statements.at(-1).sql, /MAX\(s\.best_pvp_m\) AS value/) + + await db.standings({ ...at, source: titles.STATS.bowkills.source }) + const bow = rec.statements.at(-1) + assert.match(bow.sql, /FROM rust_weapon_kills t/) + assert.deepStrictEqual(bow.params.slice(2, 2 + lists.bow.length), lists.bow) + assert.strictEqual(bow.params.at(-1), 3) + + await db.standings({ ...at, source: titles.STATS.ore.source }) + assert.match(rec.statements.at(-1).sql, /FROM rust_gather_totals t/) + + const before = rec.statements.length + assert.deepStrictEqual(await db.standings({ ...at, source: { weapons: 'nothing' } }), []) + assert.strictEqual(rec.statements.length, before, 'an empty list asks nothing') +}) + +test('a rename reaches every server’s next answer: the memo is forgotten fleet-wide', async () => { + let override = null + withRecorder((sql) => { + if (sql.includes('FROM rust_title_categories')) return override ? [{ stat: 'animalkills', text: override }] : [] + if (sql.includes('FROM rust_title_rules')) return [{ stat: 'animalkills', topN: 1, text: '', color: '#00ff00' }] + if (sql.includes('title_mode')) return [{ mode: 'first', max: 2 }] + if (sql.includes('SUM(s.animal_kills)')) return [{ steamId: '7', value: '4' }] + return [] + }) + const model = require('../model/titles/titles.model') + model.forget() + + const first = await model.heldFor('main', { wipeId: 'w1', now: 1000 }) + assert.deepStrictEqual(first.get('7'), [{ text: 'Beastbane', color: '#00ff00' }]) + + override = 'Hunter' + await model.setCategory('animalkills', 'Hunter', 1) + const after = await model.heldFor('main', { wipeId: 'w1', now: 1001 }) + assert.deepStrictEqual(after.get('7'), [{ text: 'Hunter', color: '#00ff00' }]) +}) diff --git a/swagger-fragment.json b/swagger-fragment.json index a6ba74b..118e91f 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -1303,7 +1303,7 @@ "Admin · Rust" ], "summary": "Set a server’s chat titles", - "description": "Replaces the server’s title rules and how many titles a player shows. A rule ranks the CURRENT wipe by `kills`, `npckills` or `playtime` and gives its top N (1–10) a title; only a stat above zero counts, so a fresh wipe gives no titles. Rules are in precedence order, at most ten. `mode` is `first` (the first rule a player meets), `all`, or `upto` `max` (1–5). The titles are shown in game by BetterChat when it is loaded — they are pushed whether or not it is, and it shows them as soon as it is — and on the web and app leaderboards. A 400 carries one sentence per problem in `errors`.", + "description": "Replaces the server’s title rules and how many titles a player shows. A rule ranks the CURRENT wipe by one of the title categories (`RustTitleSettings`) and gives its top N (1–10) a title; only a stat above zero counts, so a fresh wipe gives no titles. A rule’s `text` may be empty, meaning the category’s title (the admin’s, else the module’s default); `playtime` has no default and needs its own. A text that was typed but is only markup is refused. Rules are in precedence order, at most ten. `mode` is `first` (the first rule a player meets), `all`, or `upto` `max` (1–5). The titles are shown in game by BetterChat when it is loaded — they are pushed whether or not it is, and it shows them as soon as it is — and on the web and app leaderboards. A 400 carries one sentence per problem in `errors`.", "parameters": [ { "name": "id", @@ -1341,6 +1341,89 @@ } } }, + "/api/v1/admin/rust/title-categories": { + "get": { + "tags": [ + "Admin · Rust" + ], + "summary": "The chat-title categories and their titles", + "description": "Every category a title rule can rank that has a shipped title (PLAN_REDESIGNS §5, D172), with its label, the module’s default, the admin’s own title if they set one, and `current`, what a rule with no text of its own shows. The admin’s title is for the whole site (D175).", + "responses": { + "200": { + "description": "The categories", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RustTitleCategory" + } + } + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/title-categories/{stat}": { + "put": { + "tags": [ + "Admin · Rust" + ], + "summary": "Rename a chat-title category, or reset it", + "description": "Sets the site’s own title for one category (D175): at most 24 characters, markup characters taken out as a rule’s are. An empty `text` resets the category to the module’s default. Every rule of this category with no text of its own shows the new title on every server from the next push; a rule that carries its own text keeps it. Answers the whole list.", + "parameters": [ + { + "name": "stat", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The category id, for example animalkills" + } + ], + "responses": { + "200": { + "description": "Saved; answers every category" + }, + "400": { + "description": "The title is too long, or only markup" + }, + "404": { + "description": "No such category" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "maxLength": 24, + "example": "Hunter" + } + } + } + } + } + } + } + }, "/api/v1/admin/rust/visibility": { "get": { "tags": [ @@ -3642,12 +3725,37 @@ "example": [ "kills", "npckills", + "animalkills", + "bowkills", + "meleekills", + "bladekills", + "revolverkills", + "headshots", + "pvpdistance", + "pvedistance", + "apckills", + "helikills", + "wood", + "ore", + "plants", + "attire", + "weapons", + "built", + "repaired", + "healed", + "rockets", + "explosives", + "missions", "playtime" ], "items": { "type": "string" } }, + "description": { + "type": "string", + "example": "The category the rule ranks (PLAN_REDESIGNS §5). `npckills` is human NPCs only (D209); `pvpdistance` and `pvedistance` rank the longest single kill; `playtime` has no default title." + }, "example": { "type": "string", "example": "kills" @@ -3688,7 +3796,7 @@ }, "description": { "type": "string", - "example": "The title. `[`, `]`, `<`, `>`, `{` and `}` are taken out on save, so a title carries no markup of its own." + "example": "The title. `[`, `]`, `<`, `>`, `{` and `}` are taken out on save, so a title carries no markup of its own. Empty means the category’s title (D175), except for `playtime`." }, "example": { "type": "string", @@ -3719,6 +3827,122 @@ } } }, + "RustTitleCategory": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One chat-title category (PLAN_REDESIGNS §5.5, D175) and the title a rule of it shows when the rule has none of its own." + }, + "properties": { + "type": "object", + "properties": { + "stat": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "animalkills" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Animal kills" + } + } + }, + "default": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The module’s shipped title." + }, + "example": { + "type": "string", + "example": "Beastbane" + } + } + }, + "text": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The admin’s own title for the whole site, or null." + }, + "example": { + "type": "string", + "example": "Hunter" + } + } + }, + "current": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "What shows: `text` when set, else `default`." + }, + "example": { + "type": "string", + "example": "Hunter" + } + } + }, + "updatedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, "RustIntegrations": { "type": "object", "properties": {