feat(rust): chat titles rank twenty-three conditions, and admins name the categories (PLAN_REDESIGNS §5, D172-D175, D209)

- Schema: fourteen title columns on rust_player_wipe_stats (the two
  best_* distances move by GREATEST, the rest are sums), rust_weapon_kills
  (kills by weapon prefab name) and rust_title_categories (the admin's
  title per category). purge.sql drops the two tables.
- Ingest: player.tally's new fields, in one statement per frame, none
  for an older plugin's frame.
- Titles: 23 categories plus playtime, each naming where it is read
  from (a sum, a MAX, npc_kills - animal_kills in signed arithmetic, or
  a named list). The bow/melee/blade/revolver/wood/ore/plants lists are
  one file, applied when a title is read. "NPC kills" ranks human NPCs
  only (D209).
- A rule's text may be empty, meaning the category's title: the rule's
  own, then the admin's, then the default. Typed-only-markup is still
  refused. A rename forgets every server's cached answer.
- Admin API: GET /admin/rust/title-categories and
  PUT /admin/rust/title-categories/:stat (empty text resets); the server
  list returns them too. Swagger fragment and routes.manifest.json
  regenerated (63 routes, against the pinned core).
- Screen: every category in the rule form, the category's title as a
  placeholder, and a Category titles section with Save and Reset.

The weapon lists are unverified until the rig probe runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-28 23:32:59 -05:00
parent afb16112b0
commit aea94b5d7a
18 changed files with 1150 additions and 65 deletions

View File

@@ -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,