Files
wtclaude 481837e937
All checks were successful
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 49s
feat(rust): RaidableBases — feed, raids map layer, Warlord, Spawn a raid base, profiles (PLAN_REDESIGNS §11.4)
PLAN_REDESIGNS §11.4 (D202-D208, D386-D391), protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.12.

- raid.started, raid.completed, raid.purchased and raid.locked public,
  raid.ended staff's; raidableBases.js reads each row per viewer (raiders'
  and a buyer's names only for the presence audience, D376/D390; Steam ids,
  position and a run's key only for staff). Feed lines in the World filter.
- The step rust.raidbase.spawn, "Spawn a raid base" (ledgered, budget
  rust.raids): difficulty, profile and base from option sources listing each
  server's own names (D386), a spot as a world call's location with a height
  adjustment or an exact height, and PvP/NPCs/lock as on/off/blank. A base
  the raids helper made is a world resource, so eventWorld's revert and
  reconcile serve it and world.expired marks it expired; one it did not make
  records nothing and the run log says it despawns on its own timer. Refused
  at verify for a server that said it cannot; a dry run with a spot returns
  the spot check as detail.warnings (MODULE_API 1.16.0, D391). rb-not-ready
  retries in ten minutes.
- Warlord: raids_completed, title category raidscompleted, the leaderboard's
  Raid bases column (sort=raidsCompleted).
- The map's raids layer (D114), public by default with its own audience; the
  run's key is dropped before any viewer gets it.
- Server settings' line for RaidableBases; its profiles in Mod configuration
  under a virtual path (D205).
- Swagger fragment, bundle and README.

Tests: server 684/684 (raidableBases.test.js new, every name in it invented);
client 99/99, build and externals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 15:18:52 -05:00

284 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Chat titles, as rules and as a set (phase 17, D135–D137) ───────────────
//
// An operator writes rules per server — "#1 kills", "top 3 playtime" — and the
// site works out who holds each title from the CURRENT wipe's standings. The
// same answer goes to three places: BetterChat in the game (`titles.set`), the
// web leaderboard and the app's. So it is worked out here, once, as pure
// functions of the rules and the standings, and every consumer reads it.
//
// Four readings shape it (§33.4):
//
// 1. a rule counts only a stat above zero — a fresh wipe gives no titles,
// rather than #1 kills to somebody with none
// 2. ties follow the leaderboard's own order, so a rule's top N is the first
// N rows the web shows for that stat
// 3. a title's text is at most 24 characters, with markup characters taken
// out, and its colour is `#rrggbb`
// 4. the rule order is precedence: `first` shows the first rule a player meets
const crypto = require('node:crypto')
/**
* The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three
* conditions and their shipped titles, the richest (§11.2.2, Magnate), the
* two world-event counts (§14.3.1, Safecracker and Scavenger), Dangerous
* Treasures won (§14.3.4, Treasure Hunter), guarded crates cleared (§14.3.5,
* Warden), RaidableBases bases completed (§11.4, Warlord), 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)
* `balance` the Economics balance, a value per wipe (PLAN_REDESIGNS §11.2.2):
* ranked only above the server's starting balance (D336), and
* only on a server that reports Economics
* `npcProfile` one site NPC profile's kills, counted as the profile says
* (runicnpc stage 4, D247, D250). A rule on it names the profile,
* and carries its own text, like playtime.
*
* 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: { 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' } },
// Protocol 14, Rust's own world events (D356): a hack counts for its starter, once per crate.
crateshacked: { label: 'Locked crates hacked', title: 'Safecracker', source: { sum: 'crates_hacked' } },
dropslooted: { label: 'Airdrops looted first', title: 'Scavenger', source: { sum: 'drops_looted' } },
// Protocol 14, Dangerous Treasures (D375): a chest counts for the player who emptied it.
treasureswon: { label: 'Dangerous Treasures won', title: 'Treasure Hunter', source: { sum: 'treasures_won' } },
// Protocol 14, Guarded Crate (D375): a crate counts for the player who killed its last guard.
guardedwon: { label: 'Guarded crates cleared', title: 'Warden', source: { sum: 'guarded_won' } },
// Protocol 14, RaidableBases (§11.4, D202): a base counts for each of the plugin's own raiders when it is completed.
raidscompleted: { label: 'Raid bases completed', title: 'Warlord', source: { sum: 'raids_completed' } },
richest: { label: 'Richest (Economics)', title: 'Magnate', source: { balance: 'balance' } },
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
profilekills: { label: 'Kills of an NPC profile', title: null, source: { npcProfile: true } },
}
/**
* The standing a rule ranks by. One per stat, except a profile's kills, which
* are one per profile: two rules on two profiles rank two different boards.
*/
function standingKey(rule) {
return rule && rule.stat === 'profilekills' ? `profilekills:${rule.profile}` : rule && rule.stat
}
/** 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
const MAX_TOP_N = 10
const TEXT_MAX = 24
const MAX_UPTO = 5
/**
* An operator's title text, made safe to put in a chat line: the characters
* BetterChat's markup and its placeholders are built from are taken out, then
* the whitespace is collapsed. `{` and `}` go too, beyond reading 3's four: a
* title is substituted into `{Title}` before `{Message}` is, so a title reading
* `{Message}` would print the player's words twice.
*/
function cleanText(raw) {
return String(raw === undefined || raw === null ? '' : raw)
.replace(/[[\]<>{}]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
/** A mode word, or `first` — the fewest — for one this build does not know. */
function normaliseMode(mode) {
return MODES.includes(mode) ? mode : 'first'
}
/**
* A server's title settings as the admin form sends them, checked whole.
*
* Resolves `{ ok: true, value: { mode, max, rules } }` with every rule cleaned,
* or `{ ok: false, errors }`, one sentence per problem. A text that is empty
* AFTER cleaning is refused rather than saved blank, so a title made entirely of
* brackets is a sentence on the form and not an empty chip in the game.
*/
function validateSettings(body) {
const errors = []
const input = body || {}
const mode = input.mode === undefined ? 'first' : input.mode
if (!MODES.includes(mode)) errors.push(`mode must be one of ${MODES.join(', ')}`)
const max = input.max === undefined || input.max === null || input.max === '' ? 2 : Number(input.max)
if (!Number.isInteger(max) || max < 1 || max > MAX_UPTO) errors.push(`max must be a whole number from 1 to ${MAX_UPTO}`)
const rules = Array.isArray(input.rules) ? input.rules : null
if (!rules) errors.push('rules must be a list')
else if (rules.length > MAX_RULES) errors.push(`a server has at most ${MAX_RULES} title rules`)
const clean = []
for (const [i, rule] of (rules || []).entries()) {
const n = i + 1
const r = rule || {}
if (!STATS[r.stat]) errors.push(`rule ${n}: stat must be one of ${Object.keys(STATS).join(', ')}`)
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)
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()
if (!/^#[0-9a-f]{6}$/.test(color)) errors.push(`rule ${n}: colour must look like #ffaa55`)
// D250: a profile's kills name the site profile ranked.
const profile = r.stat === 'profilekills' ? Number(r.profile) : null
if (r.stat === 'profilekills' && !(Number.isInteger(profile) && profile > 0)) errors.push(`rule ${n}: pick the NPC profile whose kills it ranks`)
clean.push({ stat: r.stat, topN, text, color, ...(r.stat === 'profilekills' ? { profile } : {}) })
}
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 }`,
* 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
* @returns {Map<string, Array<{text, color}>>} Steam id → titles, in rule order
*/
function evaluate(rules, standings, { mode = 'first', max = 2 } = {}) {
const held = new Map()
for (const rule of rules || []) {
if (!STATS[rule.stat]) continue
const rows = (standings[standingKey(rule)] || []).filter((row) => Number(row.value) > 0).slice(0, rule.topN)
for (const row of rows) {
if (!held.has(row.steamId)) held.set(row.steamId, [])
held.get(row.steamId).push({ text: rule.text, color: rule.color })
}
}
const keep = { first: 1, upto: max, all: Infinity }[normaliseMode(mode)]
for (const [steamId, list] of held) held.set(steamId, list.slice(0, keep))
return held
}
/** One player's titles as BetterChat markup: `[#hex]text[/#]`, space-separated. */
function markup(list) {
return list.map((t) => `[#${t.color.replace(/^#/, '')}]${t.text}[/#]`).join(' ')
}
/** The wire set, sorted so an unchanged set digests the same on every tick. */
function wireSet(held) {
return [...held.entries()]
.map(([steamId, list]) => ({ steamId, text: markup(list) }))
.sort((a, b) => a.steamId.localeCompare(b.steamId))
}
function digest(set) {
return crypto
.createHash('sha256')
.update(set.map((t) => `${t.steamId} ${t.text}`).join('\n'))
.digest('hex')
}
module.exports = {
STATS,
standingKey,
CATEGORY_IDS,
categoryTitle,
validateCategory,
resolveRules,
MODES,
MAX_RULES,
MAX_TOP_N,
MAX_UPTO,
TEXT_MAX,
cleanText,
normaliseMode,
validateSettings,
evaluate,
markup,
wireSet,
digest,
}