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:
@@ -527,6 +527,198 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rust.npc.died",
|
||||
"label": "A RunicNPC NPC died",
|
||||
"description": "One of RunicNPC's NPCs was killed. A phase can wait for a number of them: \"8 guards died\".",
|
||||
"kind": "event",
|
||||
"subjectKey": "profile",
|
||||
"audience": "staff",
|
||||
"ceiling": "staff",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "serverId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "main",
|
||||
"description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Runic Gateway | Main",
|
||||
"description": "The server's display name."
|
||||
},
|
||||
{
|
||||
"name": "serverUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/rust/servers/main",
|
||||
"description": "Site-relative path to the server's page."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Main is back online",
|
||||
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
|
||||
},
|
||||
{
|
||||
"name": "intro",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Main is back up and taking players.",
|
||||
"description": "One sentence of detail. Core generic bodies use it as the body."
|
||||
},
|
||||
{
|
||||
"name": "profile",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "guard",
|
||||
"description": "The RunicNPC profile the NPC was made from. The cooldown subject, and what a phase gate usually names."
|
||||
},
|
||||
{
|
||||
"name": "npc",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Gate Guard",
|
||||
"description": "The NPC's own name, as the victim's death screen shows it."
|
||||
},
|
||||
{
|
||||
"name": "byEvent",
|
||||
"type": "boolean",
|
||||
"required": true,
|
||||
"example": true,
|
||||
"description": "Whether an event placed it (rather than an admin's placement or another plugin)."
|
||||
},
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "42",
|
||||
"description": "The event run that placed it, when one did."
|
||||
},
|
||||
{
|
||||
"name": "placement",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "guard-3",
|
||||
"description": "The placement it came from, when it came from one."
|
||||
},
|
||||
{
|
||||
"name": "killer",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Marisol",
|
||||
"description": "The player who landed the killing blow. Absent when no player did."
|
||||
},
|
||||
{
|
||||
"name": "killerSteamId",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "76561198000000002",
|
||||
"description": "Their Steam id."
|
||||
},
|
||||
{
|
||||
"name": "contributors",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 3,
|
||||
"description": "How many players took health from it, the killer included."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rust.npc.health",
|
||||
"label": "A RunicNPC NPC fell to a health threshold",
|
||||
"description": "One of RunicNPC's NPCs fell to a fraction of its health its profile names. A phase can wait for \"the boss below 50%\".",
|
||||
"kind": "event",
|
||||
"subjectKey": "profile",
|
||||
"audience": "staff",
|
||||
"ceiling": "staff",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "serverId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "main",
|
||||
"description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Runic Gateway | Main",
|
||||
"description": "The server's display name."
|
||||
},
|
||||
{
|
||||
"name": "serverUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/rust/servers/main",
|
||||
"description": "Site-relative path to the server's page."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Main is back online",
|
||||
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
|
||||
},
|
||||
{
|
||||
"name": "intro",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Main is back up and taking players.",
|
||||
"description": "One sentence of detail. Core generic bodies use it as the body."
|
||||
},
|
||||
{
|
||||
"name": "profile",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "guard",
|
||||
"description": "The RunicNPC profile the NPC was made from. The cooldown subject, and what a phase gate usually names."
|
||||
},
|
||||
{
|
||||
"name": "npc",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Gate Guard",
|
||||
"description": "The NPC's own name, as the victim's death screen shows it."
|
||||
},
|
||||
{
|
||||
"name": "byEvent",
|
||||
"type": "boolean",
|
||||
"required": true,
|
||||
"example": true,
|
||||
"description": "Whether an event placed it (rather than an admin's placement or another plugin)."
|
||||
},
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "42",
|
||||
"description": "The event run that placed it, when one did."
|
||||
},
|
||||
{
|
||||
"name": "placement",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "guard-3",
|
||||
"description": "The placement it came from, when it came from one."
|
||||
},
|
||||
{
|
||||
"name": "percent",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 50,
|
||||
"description": "The threshold it fell to, as a percentage of its health: 50 for half."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rust.player.banned",
|
||||
"label": "A player was banned",
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
{
|
||||
"$comment": "Generated inventory of the URLs module-rust serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see server/scripts/frozenManifest.js.",
|
||||
"routes": [
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/npcs/profiles/:pid",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/placements/:placement",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/permissions/exceptions/:id",
|
||||
@@ -56,6 +66,16 @@
|
||||
"path": "/api/v1/admin/rust/config/:serverId/writes/:writeId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/npcs",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/placements",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/permissions",
|
||||
@@ -121,6 +141,11 @@
|
||||
"path": "/api/v1/player/rust/links",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/npc-kills",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/permissions",
|
||||
@@ -176,11 +201,26 @@
|
||||
"path": "/api/v1/public/rust/servers/:id/map/live",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/npc-leaderboard",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/npc-profiles",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/online",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/players/:steamId/npc-kills",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/wipes",
|
||||
@@ -196,6 +236,36 @@
|
||||
"path": "/api/v1/admin/rust/config/:serverId/file",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/npcs/profiles",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/npcs/profiles/:pid/restore",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/placements",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/placements/:placement/rename",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/placements/:placement/respawn",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/push",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/drift/:id/accept",
|
||||
@@ -291,6 +361,16 @@
|
||||
"path": "/api/v1/player/rust/link",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/npcs/profiles/:pid",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/placements/:placement",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:id/permissions",
|
||||
|
||||
@@ -1337,3 +1337,8 @@ CREATE TABLE IF NOT EXISTS rust_npc_kills (
|
||||
KEY idx_rust_npc_kills_profile (profile, server_id, wipe_id),
|
||||
KEY idx_rust_npc_kills_site (site_profile_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- A title rule on a profile's kills names the site profile it ranks (runicnpc
|
||||
-- stage 4, D250). Null for every other stat. No foreign key: a deleted profile
|
||||
-- leaves a rule that ranks nobody, which the form shows and an admin removes.
|
||||
ALTER TABLE rust_title_rules ADD COLUMN IF NOT EXISTS profile_id INT UNSIGNED NULL;
|
||||
|
||||
@@ -159,6 +159,14 @@ const HEADLINES = Object.freeze({
|
||||
title: `${d.player || d.steamId} was unbanned on ${d.server}`,
|
||||
intro: `The ban on ${d.player || d.steamId} (${d.steamId}) was lifted.`,
|
||||
}),
|
||||
'rust.npc.died': (d) => ({
|
||||
title: `${d.npc || d.profile} was killed on ${d.server}`,
|
||||
intro: `${d.killer ? `${d.killer} killed` : 'Something killed'} ${d.npc || 'an NPC'} (${d.profile}) on ${d.server}.`,
|
||||
}),
|
||||
'rust.npc.health': (d) => ({
|
||||
title: `${d.npc || d.profile} is below ${d.percent}% on ${d.server}`,
|
||||
intro: `${d.npc || 'An NPC'} (${d.profile}) fell to ${d.percent}% of its health on ${d.server}.`,
|
||||
}),
|
||||
'rust.login.denied': (d) => ({
|
||||
title: `A login to ${d.server} was not approved`,
|
||||
intro: `${d.player || 'Someone'} (${d.steamId}) tried to join ${d.server} and was not let in within a minute.`,
|
||||
@@ -369,7 +377,49 @@ async function onBan(server, item, frame) {
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* RunicNPC's NPCs (runicnpc stage 4): a death, and a health threshold. What a
|
||||
* phase gate counts, so the window is short: a death an hour old is history, not
|
||||
* a wave falling. The dedupe key is the NPC's net id, which the game never
|
||||
* reuses within a boot.
|
||||
*/
|
||||
const NPC_MAX_AGE_MS = 15 * 60 * 1000
|
||||
|
||||
function npcVars(server, frame) {
|
||||
return {
|
||||
...serverVars(server),
|
||||
profile: str(frame.profile),
|
||||
npc: str(frame.name),
|
||||
byEvent: Boolean(frame.runId),
|
||||
runId: str(frame.runId),
|
||||
placement: str(frame.placement),
|
||||
}
|
||||
}
|
||||
|
||||
async function onNpc(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, NPC_MAX_AGE_MS) || !str(frame.profile)) return 0
|
||||
|
||||
if (frame.kind === 'npc.died') {
|
||||
const contributors = Array.isArray(frame.contributors) ? frame.contributors : []
|
||||
return fire(T['rust.npc.died'], {
|
||||
data: { ...npcVars(server, frame), killer: str(frame.killerName), killerSteamId: str(frame.killerId), contributors: contributors.length },
|
||||
dedupeKey: dedupeKey('npc.died', server.id, frame.netId, t),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
const percent = Math.round((Number(frame.threshold) || 0) * 100)
|
||||
return fire(T['rust.npc.health'], {
|
||||
data: { ...npcVars(server, frame), percent },
|
||||
dedupeKey: dedupeKey('npc.health', server.id, frame.netId, percent),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
const HANDLERS = Object.freeze({
|
||||
'npc.died': onNpc,
|
||||
'npc.health': onNpc,
|
||||
'entity.destroyed': onRaid,
|
||||
'server.wipe': onWipe,
|
||||
'clan.member.left': onClan,
|
||||
|
||||
@@ -407,7 +407,72 @@ const MODERATION = [
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION])
|
||||
// ── RunicNPC (docs/runicnpc/PLAN.md stage 4) ───────────────────────────────
|
||||
//
|
||||
// What an event's phase waits on: "8 guards died" (waves) and "the boss fell
|
||||
// below 50%". Core counts a gate's firings from phase entry and does not know
|
||||
// which run an NPC belongs to, so each firing says its profile, whether an event
|
||||
// placed it and which run did; a gate's `where` names what it waits for. A
|
||||
// profile only events place is the plain way to count only an event's NPCs.
|
||||
//
|
||||
// `staff`, both halves: a death names who killed it. A boss announced to
|
||||
// players is stage 6's, with its own trigger.
|
||||
|
||||
const NPC_VARS = [
|
||||
{ name: 'profile', type: 'string', required: true, example: 'guard',
|
||||
description: 'The RunicNPC profile the NPC was made from. The cooldown subject, and what a phase gate usually names.' },
|
||||
{ name: 'npc', type: 'string', required: false, example: 'Gate Guard',
|
||||
description: 'The NPC\'s own name, as the victim\'s death screen shows it.' },
|
||||
{ name: 'byEvent', type: 'boolean', required: true, example: true,
|
||||
description: 'Whether an event placed it (rather than an admin\'s placement or another plugin).' },
|
||||
{ name: 'runId', type: 'string', required: false, example: '42',
|
||||
description: 'The event run that placed it, when one did.' },
|
||||
{ name: 'placement', type: 'string', required: false, example: 'guard-3',
|
||||
description: 'The placement it came from, when it came from one.' },
|
||||
]
|
||||
|
||||
const NPCS = [
|
||||
{
|
||||
id: 'rust.npc.died',
|
||||
label: 'A RunicNPC NPC died',
|
||||
description: 'One of RunicNPC\'s NPCs was killed. A phase can wait for a number of them: "8 guards died".',
|
||||
kind: 'event',
|
||||
subjectKey: 'profile',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
...NPC_VARS,
|
||||
{ name: 'killer', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'The player who landed the killing blow. Absent when no player did.' },
|
||||
{ name: 'killerSteamId', type: 'string', required: false, example: '76561198000000002',
|
||||
description: 'Their Steam id.' },
|
||||
{ name: 'contributors', type: 'int', required: true, example: 3,
|
||||
description: 'How many players took health from it, the killer included.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.npc.health',
|
||||
label: 'A RunicNPC NPC fell to a health threshold',
|
||||
description: 'One of RunicNPC\'s NPCs fell to a fraction of its health its profile names. A phase can wait for "the boss below 50%".',
|
||||
kind: 'event',
|
||||
subjectKey: 'profile',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
...NPC_VARS,
|
||||
{ name: 'percent', type: 'int', required: true, example: 50,
|
||||
description: 'The threshold it fell to, as a percentage of its health: 50 for half.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS])
|
||||
|
||||
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ const servers = require('./model/servers/servers.model')
|
||||
const zones = require('./model/zones/zones.model')
|
||||
const zoneOptions = require('./model/zones/zoneOptions')
|
||||
const voice = require('./model/permissions/voice')
|
||||
const npcs = require('./model/npcs/npcs.model')
|
||||
const npcsDb = require('./model/npcs/npcs.db')
|
||||
const npcProfile = require('./model/npcs/npcProfile')
|
||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||
|
||||
const log = core.logger('world')
|
||||
@@ -110,6 +113,9 @@ const PERMANENT = new Set([
|
||||
// and a dome asked of a server without ZoneDomes or the domes helper.
|
||||
'bad-option',
|
||||
'dome-unavailable',
|
||||
// runicnpc stage 4 (D243): a profile the server does not have, or no RunicNPC.
|
||||
'unknown-profile',
|
||||
'runicnpc-missing',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
@@ -472,7 +478,10 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
|
||||
required: true,
|
||||
example,
|
||||
source,
|
||||
description: `Which of the server's own ${noun} to place.`,
|
||||
description:
|
||||
kind === 'npc'
|
||||
? "Which NPCs: one of this site's NPC profiles (Admin → Rust NPC profiles, on a server with RunicNPC), or one of the server's own scientists."
|
||||
: `Which of the server's own ${noun} to place.`,
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
@@ -492,8 +501,12 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
|
||||
],
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const known = PLACEABLE.find((x) => x.key === String(params.prefab || '').trim())
|
||||
if (!known || known.kind !== kind) {
|
||||
const picked = String(params.prefab || '').trim()
|
||||
|
||||
// D243: one of the site's RunicNPC profiles, beside Rust's own.
|
||||
const profile = kind === 'npc' && picked.startsWith(npcs.PROFILE_PREFIX) ? picked.slice(npcs.PROFILE_PREFIX.length) : null
|
||||
const known = profile === null ? PLACEABLE.find((x) => x.key === picked) : null
|
||||
if (profile !== null ? !npcProfile.NAME_RULE.test(profile) : !known || known.kind !== kind) {
|
||||
return { ok: false, retry: false, error: `"${params.prefab}" is not one of the ${noun} a Rust server places for events` }
|
||||
}
|
||||
|
||||
@@ -513,6 +526,11 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
|
||||
const found = await serverFor(where.serverId)
|
||||
if (!found.ok) return found
|
||||
|
||||
if (profile !== null) {
|
||||
const missing = await profileMissing(found.server, profile)
|
||||
if (missing) return { ok: false, retry: false, error: missing }
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
return place(
|
||||
@@ -521,17 +539,34 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
|
||||
{
|
||||
runId: String(runId),
|
||||
key: idempotencyKey,
|
||||
prefab: known.key,
|
||||
...(profile !== null ? { profile } : { prefab: known.key }),
|
||||
count,
|
||||
...(spread === undefined ? {} : { spread }),
|
||||
...where.wire,
|
||||
},
|
||||
known.label.toLowerCase(),
|
||||
profile !== null ? `NPCs of the profile "${profile}"` : known.label.toLowerCase(),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a profile cannot be placed on this server, from what the site knows, or
|
||||
* null (D243). A server without RunicNPC offers only Rust's own until stage 9;
|
||||
* a profile the site does not push there is not on it. What the server itself
|
||||
* holds is the plugin's to answer (`unknown-profile`).
|
||||
*/
|
||||
async function profileMissing(server, profile) {
|
||||
const [list, profiles] = await Promise.all([npcsDb.listNpcServers(), npcsDb.listProfiles()])
|
||||
const here = list.find((s) => s.id === server.id)
|
||||
const name = server.name || server.id
|
||||
if (!npcs.npcReady(here)) return `${name} cannot place the profile "${profile}": ${npcs.npcAbsence(here)}. Pick one of the server's own scientists.`
|
||||
if (!profiles.some((p) => !p.replaced && p.name === profile && npcs.covers(p, server.id))) {
|
||||
return `the site has no NPC profile "${profile}" for ${name} (Admin → Rust NPC profiles)`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{
|
||||
...WORLD_COMMON,
|
||||
@@ -688,7 +723,7 @@ const ACTIONS = [
|
||||
max: MAX_NPCS,
|
||||
label: 'Place NPCs',
|
||||
description:
|
||||
'Scientists or guards at a monument or a point. Taken away at teardown. The game does not save NPCs, so a restart ends them; the ledger then says so.',
|
||||
"NPCs of one of the site's profiles (RunicNPC), or the server's own scientists or guards, at a monument or a point. Taken away at teardown. NPCs are never saved, so a restart ends them; the ledger then says so.",
|
||||
source: 'rust.options.npcs',
|
||||
example: 'npc.scientist',
|
||||
}),
|
||||
@@ -720,12 +755,27 @@ const OPTION_SOURCES = [
|
||||
},
|
||||
// From the mirror, so both answer with every server off (the field they fill
|
||||
// must never be taken away by an outage, MODULE_API §2.4). One per verb (D97).
|
||||
//
|
||||
// D243: the NPC list puts the site's RunicNPC profiles first, then Rust's own,
|
||||
// each group named. A site without a server that has RunicNPC has no profile
|
||||
// rows, and its list reads as it always did.
|
||||
...['crate', 'npc'].map((kind) => ({
|
||||
id: kind === 'npc' ? 'rust.options.npcs' : 'rust.options.crates',
|
||||
label: kind === 'npc' ? 'NPCs' : 'Crates',
|
||||
description: `The ${kind === 'npc' ? 'NPCs' : 'crates'} a Rust server places for events.`,
|
||||
description:
|
||||
kind === 'npc'
|
||||
? "The site's NPC profiles, on servers with RunicNPC, then the NPCs a Rust server places of its own."
|
||||
: 'The crates a Rust server places for events.',
|
||||
async resolve() {
|
||||
return PLACEABLE.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label }))
|
||||
const own = PLACEABLE.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label }))
|
||||
if (kind !== 'npc') return own
|
||||
let profiles = []
|
||||
try {
|
||||
profiles = await npcs.optionRows()
|
||||
} catch (err) {
|
||||
log.warn('could not list the NPC profiles for the picker', { error: err.message })
|
||||
}
|
||||
return profiles.length ? [...profiles, ...own.map((row) => ({ ...row, group: "Rust's own" }))] : own
|
||||
},
|
||||
})),
|
||||
{
|
||||
|
||||
@@ -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 }))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
153
server/router/admin/npcs.controller.js
Normal file
153
server/router/admin/npcs.controller.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// ── Admin · Rust · NPCs: the handlers (docs/runicnpc/PLAN.md stage 4) ──────
|
||||
//
|
||||
// Thin: the model decides, and a refusal it throws carries its own status and
|
||||
// sentence. Every write is logged in core's activity log, a placement's too,
|
||||
// because a placement puts armed NPCs in the world.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const npcs = require('../../model/npcs/npcs.model')
|
||||
const npcSync = require('../../npcSync')
|
||||
|
||||
const log = core.logger('npcs')
|
||||
|
||||
const by = (req) => (req.user ? req.user.id : null)
|
||||
|
||||
/** A model refusal answers with its own status; anything else is ours to log. */
|
||||
function fail(res, err, what) {
|
||||
if (err instanceof npcs.NpcError) return res.status(err.status).json({ message: err.message })
|
||||
log.error(`failed to ${what}`, { error: err.message })
|
||||
return res.status(500).json({ message: `Failed to ${what}` })
|
||||
}
|
||||
|
||||
async function describe(req, res) {
|
||||
try {
|
||||
res.json(await npcs.describe())
|
||||
} catch (err) {
|
||||
fail(res, err, 'read the NPC profiles')
|
||||
}
|
||||
}
|
||||
|
||||
async function create(req, res) {
|
||||
try {
|
||||
const profile = await npcs.create(req.body || {}, by(req))
|
||||
await core.activity.log({ req, action: 'rust.npcs.profile.create', detail: { id: profile.id, name: profile.name } })
|
||||
res.status(201).json(profile)
|
||||
} catch (err) {
|
||||
fail(res, err, 'save the NPC profile')
|
||||
}
|
||||
}
|
||||
|
||||
async function update(req, res) {
|
||||
try {
|
||||
const profile = await npcs.update(Number(req.params.pid), req.body || {})
|
||||
await core.activity.log({ req, action: 'rust.npcs.profile.update', detail: { id: profile.id, name: profile.name } })
|
||||
res.json(profile)
|
||||
} catch (err) {
|
||||
fail(res, err, 'save the NPC profile')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(req, res) {
|
||||
try {
|
||||
await npcs.remove(Number(req.params.pid))
|
||||
await core.activity.log({ req, action: 'rust.npcs.profile.delete', detail: { id: Number(req.params.pid) } })
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
fail(res, err, 'delete the NPC profile')
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(req, res) {
|
||||
try {
|
||||
const profile = await npcs.restore(Number(req.params.pid))
|
||||
await core.activity.log({ req, action: 'rust.npcs.profile.restore', detail: { id: profile.id, name: profile.name } })
|
||||
res.json(profile)
|
||||
} catch (err) {
|
||||
fail(res, err, 'restore the NPC profile')
|
||||
}
|
||||
}
|
||||
|
||||
async function push(req, res) {
|
||||
try {
|
||||
const [result] = await npcSync.tick({ force: req.params.id })
|
||||
if (!result) return res.status(404).json({ message: 'No such server, or it is switched off' })
|
||||
await core.activity.log({ req, action: 'rust.npcs.push', detail: { server: req.params.id, outcome: result.outcome } })
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
fail(res, err, 'push the NPC profiles')
|
||||
}
|
||||
}
|
||||
|
||||
async function listPlacements(req, res) {
|
||||
try {
|
||||
res.json(await npcs.listPlacements(req.params.id))
|
||||
} catch (err) {
|
||||
fail(res, err, 'read the placements')
|
||||
}
|
||||
}
|
||||
|
||||
async function addPlacement(req, res) {
|
||||
try {
|
||||
const placed = await npcs.addPlacement(req.params.id, req.body || {})
|
||||
await core.activity.log({ req, action: 'rust.npcs.placement.add', detail: { server: req.params.id, id: placed.id, profile: req.body.profile, position: placed.position } })
|
||||
res.status(201).json(placed)
|
||||
} catch (err) {
|
||||
fail(res, err, 'place the NPCs')
|
||||
}
|
||||
}
|
||||
|
||||
async function setPlacement(req, res) {
|
||||
try {
|
||||
const out = await npcs.setPlacement(req.params.id, req.params.placement, req.body || {})
|
||||
await core.activity.log({ req, action: 'rust.npcs.placement.update', detail: { server: req.params.id, id: req.params.placement } })
|
||||
res.json(out)
|
||||
} catch (err) {
|
||||
fail(res, err, 'change the placement')
|
||||
}
|
||||
}
|
||||
|
||||
async function removePlacement(req, res) {
|
||||
try {
|
||||
await npcs.removePlacement(req.params.id, req.params.placement)
|
||||
await core.activity.log({ req, action: 'rust.npcs.placement.delete', detail: { server: req.params.id, id: req.params.placement } })
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
fail(res, err, 'remove the placement')
|
||||
}
|
||||
}
|
||||
|
||||
async function renamePlacement(req, res) {
|
||||
try {
|
||||
const out = await npcs.renamePlacement(req.params.id, req.params.placement, req.body.to)
|
||||
await core.activity.log({ req, action: 'rust.npcs.placement.rename', detail: { server: req.params.id, from: req.params.placement, to: req.body.to } })
|
||||
res.json({ id: out.id || req.body.to, previous: req.params.placement })
|
||||
} catch (err) {
|
||||
fail(res, err, 'rename the placement')
|
||||
}
|
||||
}
|
||||
|
||||
async function respawnPlacement(req, res) {
|
||||
try {
|
||||
const out = await npcs.respawnPlacement(req.params.id, req.params.placement)
|
||||
await core.activity.log({ req, action: 'rust.npcs.placement.respawn', detail: { server: req.params.id, id: req.params.placement } })
|
||||
res.json({ id: req.params.placement, respawned: Number(out.respawned) || 0 })
|
||||
} catch (err) {
|
||||
fail(res, err, 'respawn the placement')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
describe,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
restore,
|
||||
push,
|
||||
listPlacements,
|
||||
addPlacement,
|
||||
setPlacement,
|
||||
removePlacement,
|
||||
renamePlacement,
|
||||
respawnPlacement,
|
||||
}
|
||||
238
server/router/admin/npcs.router.js
Normal file
238
server/router/admin/npcs.router.js
Normal file
@@ -0,0 +1,238 @@
|
||||
// ── Admin · Rust · NPCs ───────────────────────────────────────────────────
|
||||
//
|
||||
// Mounted under the admin tier's `/rust` prefix, so every path here is
|
||||
// `/api/v1/admin/rust/npcs` (docs/runicnpc/PLAN.md stage 4). Two pages' worth:
|
||||
//
|
||||
// profiles the site's RunicNPC profiles, for one server, several or the
|
||||
// fleet, pushed to each server's RunicNPC (D221, D244, D247, D251)
|
||||
// placements one server's placements, which live on that server (D222): read,
|
||||
// edited, removed, renamed, respawned, and created by clicking the
|
||||
// live map (D245, D246)
|
||||
//
|
||||
// **Every route is `requireRole('admin')`,** like every other page under Admin →
|
||||
// Rust: a profile decides how hard an NPC hits the players it meets, and a
|
||||
// placement puts armed NPCs in the world.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const npcs = require('./npcs.controller')
|
||||
const { requireRole, validate } = core.middleware
|
||||
const { body, param } = core.validator
|
||||
|
||||
const npcsRouter = express.Router()
|
||||
|
||||
const NAME = /^[a-z0-9_-]{1,40}$/
|
||||
|
||||
const profileBody = [
|
||||
body('name').isString().trim().matches(NAME).withMessage('name is 1 to 40 of a-z, 0-9, _ and -'),
|
||||
body('body').isObject().withMessage('body is the profile, as RunicNPC reads it'),
|
||||
body('allServers').optional().isBoolean().withMessage('allServers is true or false'),
|
||||
body('servers').optional().isArray().withMessage('servers is a list of server ids'),
|
||||
body('killsScope').optional().isIn(['server', 'name', 'profile']).withMessage('killsScope is server, name or profile'),
|
||||
]
|
||||
|
||||
const serverParam = param('id').isString().isLength({ min: 1, max: 64 }).withMessage('id is a server id')
|
||||
const placementParam = param('placement').isString().matches(NAME).withMessage('placement is a placement name')
|
||||
|
||||
const placementBody = [
|
||||
body('profile').isString().trim().matches(NAME).withMessage('profile is a profile name'),
|
||||
body('count').optional().isInt({ min: 1, max: 50 }).withMessage('count is 1 to 50'),
|
||||
body('respawn').optional().isFloat({ min: 1, max: 86400 }).withMessage('respawn is 1 to 86400 seconds'),
|
||||
body('respawnMode').optional().isIn(['each', 'group']).withMessage('respawnMode is each or group'),
|
||||
body('movement').optional({ nullable: true }).isObject().withMessage('movement is { mode, radius }'),
|
||||
]
|
||||
|
||||
npcsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'NPC profiles, and what each server said about RunicNPC'
|
||||
// #swagger.description = 'Everything the NPC profiles page draws. Each server with what its last status said about RunicNPC (loaded, version, API), whether the site can manage it (`ready`, and `absence` saying why not), and what the last push did (`sync`: state, when its own profiles were adopted, when last pushed, the profiles RunicNPC refused and why). Every profile, a replaced one included (D251), with `label`, the first of its NPC names. The prefabs the form offers, the three ways a profile’s kills may be counted (D247), and RunicNPC’s defaults for a new profile. Read from the stored status, so it answers while a server is off.'
|
||||
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcs" } } } } */
|
||||
requireRole('admin'),
|
||||
npcs.describe,
|
||||
)
|
||||
|
||||
npcsRouter.post(
|
||||
'/profiles',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Save a new NPC profile'
|
||||
// #swagger.description = 'A RunicNPC profile (D238) for the servers it lists, or for every server (`allServers`). Checked as RunicNPC checks it, each kit against every covered server that answers (D217); a server that does not answer is left to RunicNPC, which refuses a missing kit when the profile is pushed. Two profiles of one name may not share a server. It reaches each server on the push loop’s next tick.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfileInput" } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Saved', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfile" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A value RunicNPC would refuse, or a kit a covered server does not have' } */
|
||||
/* #swagger.responses[404] = { description: 'A server that does not exist' } */
|
||||
/* #swagger.responses[409] = { description: 'A profile of that name is already on one of the servers' } */
|
||||
requireRole('admin'),
|
||||
...profileBody,
|
||||
validate,
|
||||
npcs.create,
|
||||
)
|
||||
|
||||
npcsRouter.put(
|
||||
'/profiles/:pid',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Change an NPC profile'
|
||||
// #swagger.description = 'Replaces the profile whole, under the same rules as saving a new one. Every server it was or is now on is pushed again; their placements of it respawn with the new values. A profile kept aside as replaced (D251) must be restored first.'
|
||||
// #swagger.parameters['pid'] = { in: 'path', required: true, description: 'The profile’s id', schema: { type: 'integer' } }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfileInput" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Saved', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfile" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A value RunicNPC would refuse, or a kit a covered server does not have' } */
|
||||
/* #swagger.responses[404] = { description: 'No such profile, or a server that does not exist' } */
|
||||
/* #swagger.responses[409] = { description: 'A profile of that name is already on one of the servers, or this one is kept aside as replaced' } */
|
||||
requireRole('admin'),
|
||||
param('pid').isInt({ min: 1 }).withMessage('pid is a profile id'),
|
||||
...profileBody,
|
||||
validate,
|
||||
npcs.update,
|
||||
)
|
||||
|
||||
npcsRouter.delete(
|
||||
'/profiles/:pid',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Delete an NPC profile'
|
||||
// #swagger.description = 'Removes it from every server it was on at the next push. Its placements there are kept and wait, showing “profile missing”, and spawn again if a profile of that name returns (D237). Its kills stay counted.'
|
||||
// #swagger.parameters['pid'] = { in: 'path', required: true, description: 'The profile’s id', schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'Deleted' } */
|
||||
/* #swagger.responses[404] = { description: 'No such profile' } */
|
||||
requireRole('admin'),
|
||||
param('pid').isInt({ min: 1 }).withMessage('pid is a profile id'),
|
||||
validate,
|
||||
npcs.remove,
|
||||
)
|
||||
|
||||
npcsRouter.post(
|
||||
'/profiles/:pid/restore',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Put a replaced NPC profile back into use'
|
||||
// #swagger.description = 'A server’s own profile, kept aside at adoption because a site profile of its name was already there (D251), is pushed to its server again. Refused while a site profile of that name still covers the server.'
|
||||
// #swagger.parameters['pid'] = { in: 'path', required: true, description: 'The profile’s id', schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'Restored', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfile" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'It is in use already' } */
|
||||
/* #swagger.responses[404] = { description: 'No such profile' } */
|
||||
/* #swagger.responses[409] = { description: 'A site profile of its name still covers its server' } */
|
||||
requireRole('admin'),
|
||||
param('pid').isInt({ min: 1 }).withMessage('pid is a profile id'),
|
||||
validate,
|
||||
npcs.restore,
|
||||
)
|
||||
|
||||
npcsRouter.post(
|
||||
'/servers/:id/push',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Push the NPC profiles to one server now'
|
||||
// #swagger.description = 'Rather than on the loop’s next tick. A server the site has never pushed to has its own profiles adopted first (D244). The answer says what happened: `pushed` (with the profiles and any RunicNPC refused), `absent` (no RunicNPC, or one older than API 3, and why) or `failed` (with the reason).'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'What the push did' } */
|
||||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||||
requireRole('admin'),
|
||||
serverParam,
|
||||
validate,
|
||||
npcs.push,
|
||||
)
|
||||
|
||||
npcsRouter.get(
|
||||
'/servers/:id/placements',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'One server’s NPC placements'
|
||||
// #swagger.description = 'Read live from the server, which holds them (D222): each placement’s name and values, how many of its NPCs are alive, what it waits for (a missing profile or route, D237) and its note (standing on the nearest navmesh because its ground went, D239). Also the routes a placement may walk and the cost warning for what the server plans now (D227).'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'The placements', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcPlacements" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||||
/* #swagger.responses[409] = { description: 'The server has no RunicNPC, or one older than API 3' } */
|
||||
/* #swagger.responses[503] = { description: 'The server’s game is not connected' } */
|
||||
requireRole('admin'),
|
||||
serverParam,
|
||||
validate,
|
||||
npcs.listPlacements,
|
||||
)
|
||||
|
||||
npcsRouter.post(
|
||||
'/servers/:id/placements',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Place NPCs at a point on the live map'
|
||||
// #swagger.description = 'A new placement from a clicked point, `position` being x and z only (D245). The server puts it on the ground there (terrain or rock, never a building: a roof is placed in game), checks it against the navmesh as `/rnpc place` does, and names it after its profile and a number (D246). The answer carries the name, where it landed, whether that is on something players built, and the cost warning.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcPlacementInput" } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Placed' } */
|
||||
/* #swagger.responses[400] = { description: 'RunicNPC refused it, with its reason (off the navmesh, under water, off the map, no such profile or route)' } */
|
||||
/* #swagger.responses[503] = { description: 'The server’s game is not connected' } */
|
||||
requireRole('admin'),
|
||||
serverParam,
|
||||
body('position').isObject().withMessage('position is { x, z }'),
|
||||
...placementBody,
|
||||
validate,
|
||||
npcs.addPlacement,
|
||||
)
|
||||
|
||||
npcsRouter.put(
|
||||
'/servers/:id/placements/:placement',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Change an NPC placement'
|
||||
// #swagger.description = 'New values for a placement: its profile, count, respawn delay and mode, and movement (D246). Its spot is kept unless a whole `position` (x, y, z) is sent. Its NPCs are removed and spawn again from the new values.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['placement'] = { in: 'path', required: true, description: 'The placement’s name', schema: { type: 'string' } }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcPlacementInput" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Changed' } */
|
||||
/* #swagger.responses[400] = { description: 'RunicNPC refused it, with its reason' } */
|
||||
/* #swagger.responses[404] = { description: 'No such placement' } */
|
||||
requireRole('admin'),
|
||||
serverParam,
|
||||
placementParam,
|
||||
...placementBody,
|
||||
validate,
|
||||
npcs.setPlacement,
|
||||
)
|
||||
|
||||
npcsRouter.delete(
|
||||
'/servers/:id/placements/:placement',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Remove an NPC placement'
|
||||
// #swagger.description = 'Removes the placement and its NPCs, as `/rnpc remove` does.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['placement'] = { in: 'path', required: true, description: 'The placement’s name', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'Removed' } */
|
||||
/* #swagger.responses[404] = { description: 'No such placement' } */
|
||||
requireRole('admin'),
|
||||
serverParam,
|
||||
placementParam,
|
||||
validate,
|
||||
npcs.removePlacement,
|
||||
)
|
||||
|
||||
npcsRouter.post(
|
||||
'/servers/:id/placements/:placement/rename',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Rename an NPC placement'
|
||||
// #swagger.description = 'As `/rnpc rename` (D241). Its live NPCs keep living under the new name.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['placement'] = { in: 'path', required: true, description: 'The placement’s name', schema: { type: 'string' } }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { to: { type: "string", example: "gate" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Renamed' } */
|
||||
/* #swagger.responses[400] = { description: 'The new name is taken or not a name' } */
|
||||
/* #swagger.responses[404] = { description: 'No such placement' } */
|
||||
requireRole('admin'),
|
||||
serverParam,
|
||||
placementParam,
|
||||
body('to').isString().matches(NAME).withMessage('to is 1 to 40 of a-z, 0-9, _ and -'),
|
||||
validate,
|
||||
npcs.renamePlacement,
|
||||
)
|
||||
|
||||
npcsRouter.post(
|
||||
'/servers/:id/placements/:placement/respawn',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Respawn an NPC placement now'
|
||||
// #swagger.description = 'As `/rnpc respawn`: its NPCs are removed and spawn again at once, whatever their respawn delay.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['placement'] = { in: 'path', required: true, description: 'The placement’s name', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'Respawning' } */
|
||||
/* #swagger.responses[404] = { description: 'No such placement' } */
|
||||
requireRole('admin'),
|
||||
serverParam,
|
||||
placementParam,
|
||||
validate,
|
||||
npcs.respawnPlacement,
|
||||
)
|
||||
|
||||
module.exports = npcsRouter
|
||||
@@ -19,6 +19,7 @@ const servers = require('../../model/servers/servers.model')
|
||||
const sidecar = require('../../sidecarClient')
|
||||
const titleSync = require('../../titleSync')
|
||||
const titles = require('../../model/titles/titles')
|
||||
const npcs = require('../../model/npcs/npcs.model')
|
||||
const titlesModel = require('../../model/titles/titles.model')
|
||||
const voice = require('../../model/permissions/voice')
|
||||
|
||||
@@ -255,6 +256,14 @@ async function putTitles(req, res) {
|
||||
const checked = titles.validateSettings(req.body)
|
||||
if (!checked.ok) return res.status(400).json({ message: checked.errors.join(' '), errors: checked.errors })
|
||||
|
||||
// D250: a profile's kills rank a site profile this server is pushed.
|
||||
const onServer = new Set((await npcs.boardProfiles(id)).map((p) => p.id))
|
||||
const stray = checked.value.rules.findIndex((r) => r.stat === 'profilekills' && !onServer.has(r.profile))
|
||||
if (stray >= 0) {
|
||||
const errors = [`rule ${stray + 1}: that NPC profile is not on this server`]
|
||||
return res.status(400).json({ message: errors[0], errors })
|
||||
}
|
||||
|
||||
await titlesModel.saveSettings(id, checked.value)
|
||||
await core.activity.log({
|
||||
req,
|
||||
|
||||
@@ -46,6 +46,10 @@ adminRustRouter.use('/visibility', require('./visibility.router'))
|
||||
// step copies, saved by name for one server, several, or the fleet.
|
||||
adminRustRouter.use('/zones', require('./zones.router'))
|
||||
|
||||
// RunicNPC (docs/runicnpc/PLAN.md stage 4): the site's NPC profiles, pushed to
|
||||
// each server, and each server's placements, which live on the server (D222).
|
||||
adminRustRouter.use('/npcs', require('./npcs.router'))
|
||||
|
||||
adminRustRouter.get(
|
||||
'/servers',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const links = require('../../model/links/links.model')
|
||||
const npcs = require('../../model/npcs/npcs.model')
|
||||
const npcsDb = require('../../model/npcs/npcs.db')
|
||||
const permissions = require('../../model/permissions/permissions.model')
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
|
||||
@@ -36,6 +38,22 @@ async function listServers(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /player/rust/npc-kills — the caller's kills of each NPC profile, current wipes (D252). */
|
||||
async function ownNpcKills(req, res) {
|
||||
try {
|
||||
const held = await links.listForUser(req.user.id)
|
||||
const rows = await npcsDb.ownKills(held.map((l) => String(l.steamId)).filter(Boolean))
|
||||
const labels = new Map()
|
||||
for (const serverId of new Set(rows.map((r) => r.serverId))) {
|
||||
for (const p of await npcs.boardProfiles(serverId)) labels.set(`${serverId} ${p.name}`, p.label)
|
||||
}
|
||||
res.json({ kills: rows.map((r) => ({ ...r, label: labels.get(`${r.serverId} ${r.profile}`) || r.profile })) })
|
||||
} catch (err) {
|
||||
log.error('failed to read a player’s NPC kills', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read your NPC kills' })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /player/rust/links — the Steam accounts the caller holds. */
|
||||
async function listLinks(req, res) {
|
||||
try {
|
||||
@@ -168,4 +186,4 @@ async function listPermissions(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions }
|
||||
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills }
|
||||
|
||||
@@ -58,6 +58,15 @@ const linkLimiter = rateLimit({
|
||||
message: 'Too many link attempts. Please try again later.',
|
||||
})
|
||||
|
||||
playerRustRouter.get(
|
||||
'/npc-kills',
|
||||
// #swagger.tags = ['Player · Rust']
|
||||
// #swagger.summary = 'Your kills of each NPC profile'
|
||||
// #swagger.description = 'The caller’s kills of RunicNPC’s NPCs, by server and profile, in each server’s current wipe, across every Steam account they have linked (docs/runicnpc/PLAN.md stage 4, D252). Empty with no linked account. Requires a session.'
|
||||
/* #swagger.responses[200] = { description: 'The kills', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcOwnKills" } } } } */
|
||||
rust.ownNpcKills,
|
||||
)
|
||||
|
||||
playerRustRouter.get(
|
||||
'/servers',
|
||||
// #swagger.tags = ['Player · Rust']
|
||||
|
||||
@@ -16,7 +16,9 @@ const events = require('../../model/events/events.model')
|
||||
const map = require('../../model/map/map.model')
|
||||
const mapDb = require('../../model/map/map.db')
|
||||
const mapLive = require('../../mapLive')
|
||||
const npcs = require('../../model/npcs/npcs.model')
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
const serversDb = require('../../model/servers/servers.db')
|
||||
const titles = require('../../model/titles/titles.model')
|
||||
const visibility = require('../../model/visibility/visibility.model')
|
||||
|
||||
@@ -130,6 +132,51 @@ async function listLeaderboard(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /servers/:id/npc-profiles — what the leaderboard's profile picker offers (D250). */
|
||||
async function listNpcProfiles(req, res) {
|
||||
try {
|
||||
res.json({ profiles: await npcs.boardProfiles(req.params.id) })
|
||||
} catch (err) {
|
||||
log.error('failed to read the NPC profiles', { server: req.params.id, error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read the NPC profiles' })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /servers/:id/npc-leaderboard?profile= — one profile's kills, counted as it says (D247, D250). */
|
||||
async function npcLeaderboard(req, res) {
|
||||
const profileId = Number(req.query.profile)
|
||||
if (!Number.isInteger(profileId) || profileId < 1) return res.status(400).json({ message: 'profile is a profile id' })
|
||||
|
||||
try {
|
||||
const state = await serversDb.getState(req.params.id)
|
||||
const wipeId = req.query.wipe ? String(req.query.wipe) : null
|
||||
const currentWipe = Boolean(wipeId && state && state.wipeId === wipeId)
|
||||
const [ranked, held] = await Promise.all([
|
||||
npcs.ranking({ serverId: req.params.id, profileId, wipeId, currentWipe, limit: req.query.limit }),
|
||||
titles.currentFor(req.params.id),
|
||||
])
|
||||
res.json({ profile: ranked.profile, leaderboard: ranked.rows.map((row) => ({ ...row, kills: row.value, titles: held.get(row.steamId) || [] })) })
|
||||
} catch (err) {
|
||||
if (err instanceof npcs.NpcError) return res.status(err.status).json({ message: err.message })
|
||||
log.error('failed to read an NPC leaderboard', { server: req.params.id, error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to read the leaderboard' })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /servers/:id/players/:steamId/npc-kills — one leaderboard row, opened (D252). */
|
||||
async function playerNpcKills(req, res) {
|
||||
const steamId = String(req.params.steamId || '')
|
||||
if (!/^\d{1,20}$/.test(steamId)) return res.status(400).json({ message: 'steamId is a Steam id' })
|
||||
|
||||
try {
|
||||
const wipeId = req.query.wipe ? String(req.query.wipe) : null
|
||||
res.json({ steamId, kills: await npcs.playerKills({ serverId: req.params.id, steamId, wipeId }) })
|
||||
} catch (err) {
|
||||
log.error('failed to read a player’s NPC kills', { server: req.params.id, error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read the kills' })
|
||||
}
|
||||
}
|
||||
|
||||
async function listWipes(req, res) {
|
||||
try {
|
||||
res.json({ wipes: await events.wipes(req.params.id) })
|
||||
@@ -353,6 +400,9 @@ module.exports = {
|
||||
getServer,
|
||||
listEvents,
|
||||
listLeaderboard,
|
||||
listNpcProfiles,
|
||||
npcLeaderboard,
|
||||
playerNpcKills,
|
||||
listWipes,
|
||||
listOnline,
|
||||
listClans,
|
||||
|
||||
@@ -92,6 +92,45 @@ rustRouter.get(
|
||||
servers.listLeaderboard,
|
||||
)
|
||||
|
||||
rustRouter.get(
|
||||
'/servers/:id/npc-profiles',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'The NPC profiles a server’s leaderboard can rank by'
|
||||
// #swagger.description = 'The site’s RunicNPC profiles pushed to this server (docs/runicnpc/PLAN.md stage 4, D250): each one’s id, its name, a label (the first of its NPC names), and how its kills are counted (D247) — `server` (this server’s kills of that name), `name` (every server’s kills of that name) or `profile` (this site profile’s, wherever it was pushed). Empty on a site without RunicNPC.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'The profiles', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcBoardProfiles" } } } } */
|
||||
siteMode,
|
||||
servers.listNpcProfiles,
|
||||
)
|
||||
|
||||
rustRouter.get(
|
||||
'/servers/:id/npc-leaderboard',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'Who has killed the most NPCs of one profile'
|
||||
// #swagger.description = 'One RunicNPC profile’s kills, most first, counted as the profile says (D247, D250). Per wipe when `wipe` is given, all time otherwise. A profile counted across servers reaches every server’s current wipe when `wipe` is this server’s current one, and only this server’s rows for an older wipe. Each row carries the chat `titles` the player holds now, as the main leaderboard’s do.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['profile'] = { in: 'query', required: true, description: 'The profile’s id, from npc-profiles', schema: { type: 'integer' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'The ranking', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcLeaderboard" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such profile on this server' } */
|
||||
siteMode,
|
||||
servers.npcLeaderboard,
|
||||
)
|
||||
|
||||
rustRouter.get(
|
||||
'/servers/:id/players/:steamId/npc-kills',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'One player’s kills of each NPC profile on a server'
|
||||
// #swagger.description = 'What opening a leaderboard row shows (D252): the player’s kills of RunicNPC’s NPCs by profile on this server, for the wipe the page shows (`wipe`), or all time without it. Counted by profile name on this server, whatever a profile’s leaderboard counts; labelled with the site’s name for the profile where it has one. The same numbers the leaderboard already makes public, split by profile.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['steamId'] = { in: 'path', required: true, description: 'The player’s Steam id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'The kills', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcPlayerKills" } } } } */
|
||||
siteMode,
|
||||
servers.playerNpcKills,
|
||||
)
|
||||
|
||||
rustRouter.get(
|
||||
'/servers/:id/wipes',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
|
||||
@@ -1114,6 +1114,140 @@ module.exports = {
|
||||
presets: { type: 'array', items: { $ref: '#/components/schemas/RustZonePreset' } },
|
||||
},
|
||||
},
|
||||
RustNpcProfileBody: {
|
||||
type: 'object',
|
||||
description: 'A RunicNPC profile as RunicNPC reads it (docs/runicnpc/API.md, D238). Anything left out takes RunicNPC’s default.',
|
||||
properties: {
|
||||
names: { type: 'array', items: { type: 'string' }, example: ['Warden', 'Old Warden'] },
|
||||
kits: { type: 'array', items: { type: 'string' }, example: ['warden_rifle'] },
|
||||
prefab: { type: 'string', example: 'scientistnpc_roam' },
|
||||
role: { type: 'string', enum: ['roamer', 'sentry'], example: 'roamer' },
|
||||
movement: { type: 'object', example: { mode: 'wander', radius: 20 } },
|
||||
health: { type: 'number', example: 250 },
|
||||
damageDealt: { type: 'number', example: 1 },
|
||||
damageTaken: { type: 'object', example: { head: 1, body: 1, legs: 1 } },
|
||||
aimCone: { type: 'number', example: 2 },
|
||||
ranges: { type: 'object', example: { sense: 30, loseTarget: 40, chase: 40, attack: 30 } },
|
||||
visionCone: { type: 'number', example: -0.8 },
|
||||
sleepDistance: { type: 'number', example: 160 },
|
||||
healthThresholds: { type: 'array', items: { type: 'number' }, example: [0.5] },
|
||||
},
|
||||
},
|
||||
RustNpcProfile: {
|
||||
type: 'object',
|
||||
description: 'A site NPC profile (docs/runicnpc/PLAN.md stage 4) for one server, several, or every server. `adoptedFrom` names the server it was read from on the site’s first push there (D244); `replaced` marks one kept aside because a site profile of its name was already on that server (D251). `killsScope` is how its kills are counted (D247).',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 4 },
|
||||
name: { type: 'string', example: 'warden' },
|
||||
label: { type: 'string', example: 'Warden' },
|
||||
body: { $ref: '#/components/schemas/RustNpcProfileBody' },
|
||||
allServers: { type: 'boolean', example: false },
|
||||
servers: { type: 'array', items: { type: 'string' }, example: ['main'] },
|
||||
killsScope: { type: 'string', enum: ['server', 'name', 'profile'], example: 'server' },
|
||||
adoptedFrom: { type: 'string', nullable: true, example: null },
|
||||
replaced: { type: 'boolean', example: false },
|
||||
updatedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
},
|
||||
},
|
||||
RustNpcProfileInput: {
|
||||
type: 'object',
|
||||
required: ['name', 'body'],
|
||||
description: 'A profile to save. `servers` is required unless `allServers` is true. The name is RunicNPC’s: 1 to 40 of a-z, 0-9, _ and -.',
|
||||
properties: {
|
||||
name: { type: 'string', example: 'warden' },
|
||||
body: { $ref: '#/components/schemas/RustNpcProfileBody' },
|
||||
allServers: { type: 'boolean', example: false },
|
||||
servers: { type: 'array', items: { type: 'string' }, example: ['main'] },
|
||||
killsScope: { type: 'string', enum: ['server', 'name', 'profile'], example: 'server' },
|
||||
},
|
||||
},
|
||||
RustNpcs: {
|
||||
type: 'object',
|
||||
description: 'The NPC profiles page: each server with what it said about RunicNPC and what the last push did, every profile, the prefabs the form offers, the kill-counting scopes, and RunicNPC’s defaults.',
|
||||
properties: {
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'main' },
|
||||
name: { type: 'string', example: 'Main · Vanilla' },
|
||||
enabled: { type: 'boolean', example: true },
|
||||
online: { type: 'boolean', nullable: true, example: true },
|
||||
runicNpc: { type: 'object', nullable: true, example: { loaded: true, version: '0.2.0', api: 3 } },
|
||||
ready: { type: 'boolean', example: true },
|
||||
absence: { type: 'string', nullable: true, example: null },
|
||||
sync: { type: 'object', nullable: true, example: { state: 'ok', adoptedAt: '2026-09-30T10:00:00.000Z', syncedAt: '2026-09-30T10:00:01.000Z', refused: {}, error: null } },
|
||||
},
|
||||
},
|
||||
},
|
||||
profiles: { type: 'array', items: { $ref: '#/components/schemas/RustNpcProfile' } },
|
||||
prefabs: { type: 'array', items: { type: 'string' }, example: ['scientistnpc_roam'] },
|
||||
killsScopes: { type: 'array', items: { type: 'string' }, example: ['server', 'name', 'profile'] },
|
||||
defaults: { $ref: '#/components/schemas/RustNpcProfileBody' },
|
||||
},
|
||||
},
|
||||
RustNpcPlacements: {
|
||||
type: 'object',
|
||||
description: 'One server’s placements, read live from it (D222), with the routes one may walk and the cost warning (D227).',
|
||||
properties: {
|
||||
placements: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'warden-2' },
|
||||
placement: { type: 'object', example: { profile: 'warden', position: { x: 100, y: 12.5, z: -340 }, yaw: 90, count: 3, respawn: 300, respawnMode: 'each' } },
|
||||
alive: { type: 'integer', example: 3 },
|
||||
waiting: { type: 'string', nullable: true, example: null },
|
||||
note: { type: 'string', nullable: true, example: null },
|
||||
lastError: { type: 'string', nullable: true, example: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
routes: { type: 'array', items: { type: 'string' }, example: ['gate'] },
|
||||
cost: { type: 'string', nullable: true, example: 'RunicNPC: 12 NPC(s) on this server…' },
|
||||
},
|
||||
},
|
||||
RustNpcPlacementInput: {
|
||||
type: 'object',
|
||||
required: ['profile'],
|
||||
description: '`/rnpc place`’s options (D246). To create, `position` is the clicked point, x and z only; to change, a whole x, y, z moves it, and none keeps its spot.',
|
||||
properties: {
|
||||
profile: { type: 'string', example: 'warden' },
|
||||
position: { type: 'object', example: { x: 100, z: -340 } },
|
||||
count: { type: 'integer', example: 3 },
|
||||
respawn: { type: 'number', example: 300 },
|
||||
respawnMode: { type: 'string', enum: ['each', 'group'], example: 'each' },
|
||||
movement: { type: 'object', nullable: true, example: { mode: 'route:gate', radius: 0 } },
|
||||
},
|
||||
},
|
||||
RustNpcBoardProfiles: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
profiles: { type: 'array', items: { type: 'object' }, example: [{ id: 4, name: 'warden', label: 'Warden', killsScope: 'server' }] },
|
||||
},
|
||||
},
|
||||
RustNpcLeaderboard: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
profile: { type: 'object', example: { id: 4, name: 'warden', label: 'Warden', killsScope: 'server' } },
|
||||
leaderboard: { type: 'array', items: { type: 'object' }, example: [{ steamId: '76561198000000002', name: 'Marisol', kills: 12, titles: [] }] },
|
||||
},
|
||||
},
|
||||
RustNpcPlayerKills: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
steamId: { type: 'string', example: '76561198000000002' },
|
||||
kills: { type: 'array', items: { type: 'object' }, example: [{ profile: 'warden', label: 'Warden', kills: 3 }] },
|
||||
},
|
||||
},
|
||||
RustNpcOwnKills: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kills: { type: 'array', items: { type: 'object' }, example: [{ serverId: 'main', profile: 'warden', label: 'Warden', kills: 3 }] },
|
||||
},
|
||||
},
|
||||
RustVisibilityUpdate: {
|
||||
type: 'object',
|
||||
description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',
|
||||
|
||||
@@ -48,6 +48,8 @@ test('the player tier serves the identity routes, the entitlement read, and noth
|
||||
'GET /links',
|
||||
// Phase 8: what the site has given the caller in game. Read-only on this
|
||||
// tier by construction — the authoring routes are all admin.
|
||||
// RunicNPC stage 4 (D252): the caller's own kills of each NPC profile. Read-only.
|
||||
'GET /npc-kills',
|
||||
'GET /permissions',
|
||||
'GET /servers',
|
||||
'POST /link',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user