feat(rust): NPC profile and placement routes, the event picker, triggers and titles (runicnpc stage 4)
Admin: /admin/rust/npcs for profiles (create, change, delete, restore a replaced one, push now) and each server's placements (list, add from a map point, change, remove, rename, respawn). Public: the profiles a leaderboard ranks by, one profile's ranking counted as the profile says (D247, D250), and one player's kills by profile (D252). Player: your own kills by profile. The Place NPCs step offers the site's profiles first, then Rust's own (D243). rust.npc.died and rust.npc.health are triggers a phase can wait on. A title rule can rank a profile's kills. Swagger fragment, engagement and route manifests regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -12,7 +12,7 @@ const SERVERS = 'rust_servers'
|
||||
/** One server's rules, in precedence order. */
|
||||
async function listRules(serverId) {
|
||||
return core.query(
|
||||
`SELECT id, stat, top_n AS topN, text, color
|
||||
`SELECT id, stat, top_n AS topN, text, color, profile_id AS profile
|
||||
FROM ${RULES}
|
||||
WHERE server_id = ?
|
||||
ORDER BY position ASC, id ASC`,
|
||||
@@ -23,7 +23,7 @@ async function listRules(serverId) {
|
||||
/** Every server's rules, for the admin list — one read rather than one per server. */
|
||||
async function listAllRules() {
|
||||
return core.query(
|
||||
`SELECT server_id AS serverId, stat, top_n AS topN, text, color
|
||||
`SELECT server_id AS serverId, stat, top_n AS topN, text, color, profile_id AS profile
|
||||
FROM ${RULES}
|
||||
ORDER BY server_id ASC, position ASC, id ASC`,
|
||||
)
|
||||
@@ -52,9 +52,9 @@ async function saveSettings(serverId, { mode, max, rules }) {
|
||||
if (!rules.length) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${RULES} (server_id, position, stat, top_n, text, color)
|
||||
VALUES ${rules.map(() => '(?, ?, ?, ?, ?, ?)').join(',')}`,
|
||||
rules.flatMap((r, i) => [serverId, i, r.stat, r.topN, r.text, r.color]),
|
||||
`INSERT INTO ${RULES} (server_id, position, stat, top_n, text, color, profile_id)
|
||||
VALUES ${rules.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(',')}`,
|
||||
rules.flatMap((r, i) => [serverId, i, r.stat, r.topN, r.text, r.color, r.profile || null]),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ const crypto = require('node:crypto')
|
||||
* `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)
|
||||
* `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.
|
||||
@@ -59,6 +62,15 @@ const STATS = {
|
||||
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' } },
|
||||
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). */
|
||||
@@ -134,7 +146,11 @@ function validateSettings(body) {
|
||||
const color = String(r.color || '').trim().toLowerCase()
|
||||
if (!/^#[0-9a-f]{6}$/.test(color)) errors.push(`rule ${n}: colour must look like #ffaa55`)
|
||||
|
||||
clean.push({ stat: r.stat, topN, text, color })
|
||||
// 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 } }
|
||||
@@ -194,7 +210,7 @@ function evaluate(rules, standings, { mode = 'first', max = 2 } = {}) {
|
||||
for (const rule of rules || []) {
|
||||
if (!STATS[rule.stat]) continue
|
||||
|
||||
const rows = (standings[rule.stat] || []).filter((row) => Number(row.value) > 0).slice(0, rule.topN)
|
||||
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, [])
|
||||
@@ -230,6 +246,7 @@ function digest(set) {
|
||||
|
||||
module.exports = {
|
||||
STATS,
|
||||
standingKey,
|
||||
CATEGORY_IDS,
|
||||
categoryTitle,
|
||||
validateCategory,
|
||||
|
||||
@@ -9,6 +9,7 @@ const serversDb = require('../servers/servers.db')
|
||||
const db = require('./titles.db')
|
||||
const lists = require('./lists')
|
||||
const titles = require('./titles')
|
||||
const npcs = require('../npcs/npcs.model')
|
||||
|
||||
/** How long one server's answer is reused. The same as the push loop's tick. */
|
||||
const MEMO_MS = 30 * 1000
|
||||
@@ -25,10 +26,15 @@ function shapeSettings(mode, rules) {
|
||||
return {
|
||||
mode: titles.normaliseMode(mode && mode.mode),
|
||||
max: mode && Number(mode.max) ? Number(mode.max) : 2,
|
||||
rules: rules.map((r) => ({ stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color })),
|
||||
rules: rules.map(rule),
|
||||
}
|
||||
}
|
||||
|
||||
/** One stored rule as the form and the evaluator read it; a profile's kills name the profile (D250). */
|
||||
function rule(r) {
|
||||
return { stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color, ...(r.stat === 'profilekills' ? { profile: Number(r.profile) || null } : {}) }
|
||||
}
|
||||
|
||||
/** Every server's settings, keyed by id, for the admin server list. */
|
||||
async function settingsByServer() {
|
||||
const [modes, rules] = await Promise.all([db.listModes(), db.listAllRules()])
|
||||
@@ -36,7 +42,7 @@ async function settingsByServer() {
|
||||
|
||||
for (const r of rules) {
|
||||
const s = out.get(r.serverId)
|
||||
if (s) s.rules.push({ stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color })
|
||||
if (s) s.rules.push(rule(r))
|
||||
}
|
||||
|
||||
return out
|
||||
@@ -102,14 +108,24 @@ async function heldFor(serverId, { wipeId, now = Date.now() } = {}) {
|
||||
|
||||
// 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.
|
||||
// A profile's kills are one standing per profile (`titles.standingKey`).
|
||||
const depth = new Map()
|
||||
for (const r of rules) if (titles.STATS[r.stat]) depth.set(r.stat, Math.max(depth.get(r.stat) || 0, r.topN))
|
||||
const ruleBy = new Map()
|
||||
for (const r of rules) {
|
||||
if (!titles.STATS[r.stat]) continue
|
||||
const key = titles.standingKey(r)
|
||||
depth.set(key, Math.max(depth.get(key) || 0, r.topN))
|
||||
ruleBy.set(key, r)
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
[...depth.entries()].map(async ([stat, limit]) => {
|
||||
const rows = await db.standings({ serverId, wipeId, source: titles.STATS[stat].source, lists, limit })
|
||||
[...depth.entries()].map(async ([key, limit]) => {
|
||||
const r = ruleBy.get(key)
|
||||
const rows = r.stat === 'profilekills'
|
||||
? await npcs.standing(serverId, r.profile, wipeId, limit)
|
||||
: await db.standings({ serverId, wipeId, source: titles.STATS[r.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 }))
|
||||
standings[key] = rows.map((row) => ({ steamId: row.steamId, value: Number(row.value) || 0 }))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user