feat(rust): chat titles rank twenty-three conditions, and admins name the categories (PLAN_REDESIGNS §5, D172-D175, D209) #26
@@ -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 } }),
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
|
||||
{rules.map((r, i) => (
|
||||
{rules.map((r, i) => {
|
||||
const fallback = statOf(categories, r.stat).title
|
||||
return (
|
||||
<div key={i} className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', fontSize: '0.84rem' }}>
|
||||
<span className="dim" style={{ width: 18 }}>{i + 1}</span>
|
||||
<span>Top</span>
|
||||
<input type="number" min={1} max={10} value={r.topN} onChange={setRule(i, 'topN')} style={{ ...inputStyle, width: 60 }} aria-label="How many players" />
|
||||
<select value={r.stat} onChange={setRule(i, 'stat')} style={inputStyle} aria-label="Stat">
|
||||
{STATS.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||
{stats.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||
</select>
|
||||
<span>earn</span>
|
||||
<input value={r.text} onChange={setRule(i, 'text')} maxLength={24} required style={{ ...inputStyle, width: 160 }} aria-label="Title" />
|
||||
{/* 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). */}
|
||||
<input value={r.text} onChange={setRule(i, 'text')} maxLength={24} required={!fallback} placeholder={fallback || 'A title'} style={{ ...inputStyle, width: 160 }} aria-label="Title" />
|
||||
<input type="color" value={r.color} onChange={setRule(i, 'color')} aria-label="Colour" style={{ width: 36, height: 28, padding: 0, border: 'none', background: 'none' }} />
|
||||
<Chip text={r.text} color={r.color} />
|
||||
<Chip text={r.text || fallback} color={r.color} />
|
||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
||||
<button type="button" className="btn" disabled={i === 0} onClick={() => move(i, -1)} aria-label="Move up">↑</button>
|
||||
<button type="button" className="btn" disabled={i === rules.length - 1} onClick={() => move(i, 1)} aria-label="Move down">↓</button>
|
||||
<button type="button" className="btn" onClick={() => setRules((list) => list.filter((_, n) => n !== i))}>Remove</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{rules.length < 10 && (
|
||||
<div>
|
||||
<button type="button" className="btn" onClick={() => setRules((list) => [...list, { stat: 'kills', topN: 1, text: '', color: '#ffaa55' }])}>
|
||||
@@ -149,6 +163,84 @@ export function TitlesForm({ server, onSaved, onCancel }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The category titles (D175): one title per category for the whole site, which
|
||||
* every rule of that category without its own text shows. Each row saves on its
|
||||
* own, and Reset brings the module's default back.
|
||||
*/
|
||||
export function CategoryTitles({ categories, onSaved }) {
|
||||
const [drafts, setDrafts] = useState({})
|
||||
const [busy, setBusy] = useState('')
|
||||
const [errors, setErrors] = useState({})
|
||||
|
||||
if (!categories || !categories.length) return null
|
||||
|
||||
const save = async (stat, text) => {
|
||||
setBusy(stat)
|
||||
setErrors((e) => ({ ...e, [stat]: '' }))
|
||||
try {
|
||||
await api.admin.saveTitleCategory(stat, text)
|
||||
setDrafts((d) => {
|
||||
const next = { ...d }
|
||||
delete next[stat]
|
||||
return next
|
||||
})
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setErrors((e) => ({ ...e, [stat]: err.message || 'That did not save.' }))
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel sans" style={{ padding: '16px 18px', marginBottom: 18, fontSize: '0.84rem' }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: '0 0 6px', color: 'var(--head)' }}>Category titles</h2>
|
||||
<p className="dim" style={{ fontSize: '0.78rem', margin: '0 0 10px' }}>
|
||||
The title each category gives, on every server. A rule that has its own title keeps it; every other rule of the
|
||||
category shows this one from the next push. Reset brings back the default.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(140px, 1fr) minmax(110px, auto) minmax(160px, 1.2fr) auto', gap: '6px 10px', alignItems: 'center' }}>
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>Category</span>
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>Default</span>
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>This site’s title</span>
|
||||
<span />
|
||||
{categories.map((c) => {
|
||||
const draft = drafts[c.stat]
|
||||
const value = draft !== undefined ? draft : c.text || ''
|
||||
const changed = draft !== undefined && draft !== (c.text || '')
|
||||
return (
|
||||
<Row key={c.stat}>
|
||||
<span>{c.label}</span>
|
||||
<span className="dim">{c.default}</span>
|
||||
<span style={{ display: 'grid', gap: 2 }}>
|
||||
<input
|
||||
value={value}
|
||||
placeholder={c.default}
|
||||
maxLength={24}
|
||||
onChange={(e) => setDrafts((d) => ({ ...d, [c.stat]: e.target.value }))}
|
||||
style={inputStyle}
|
||||
aria-label={`${c.label} title`}
|
||||
/>
|
||||
{errors[c.stat] && <span style={{ color: '#d08a2a', fontSize: '0.74rem' }}>{errors[c.stat]}</span>}
|
||||
</span>
|
||||
<span style={{ display: 'flex', gap: 4 }}>
|
||||
<button type="button" className="btn" disabled={!changed || busy === c.stat} onClick={() => save(c.stat, value)}>Save</button>
|
||||
<button type="button" className="btn" disabled={!c.text || busy === c.stat} onClick={() => save(c.stat, '')}>Reset</button>
|
||||
</span>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
|
||||
@@ -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 <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
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.'}
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{titlesSummary(s.titles, s.titlePush)}</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{titlesSummary(s.titles, s.titlePush, categories)}</div>
|
||||
{zoneHelperNote(s.zoneHelper) && (
|
||||
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{zoneHelperNote(s.zoneHelper)}</div>
|
||||
)}
|
||||
@@ -286,11 +287,14 @@ export default function ServerSettings() {
|
||||
<TitlesForm
|
||||
key={titling.id}
|
||||
server={titling}
|
||||
categories={categories}
|
||||
onSaved={() => { setTitling(null); setReloads((n) => n + 1) }}
|
||||
onCancel={() => setTitling(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CategoryTitles categories={categories} onSaved={() => setReloads((n) => n + 1)} />
|
||||
|
||||
<VoiceCard />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
118
server/model/titles/lists.js
Normal file
118
server/model/titles/lists.js
Normal file
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
221
server/test/titleConditions.test.js
Normal file
221
server/test/titleConditions.test.js
Normal file
@@ -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' }])
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user