feat(rust): NPC sides, the faction table, an event's escort, ally and tether (runicnpc stage 5)

RunicNPC stage 5 on the site (docs runicnpc/PLAN.md, D253-D272):

- profiles gain the guard role, faction, relations (its own exceptions),
  alertRadius, turrets, hurtByPlayers, hurtsPlayers and kitUse, checked in
  RunicNPC's order and words, and defaulting to "players only" (D255);
- the faction table: one site-wide table, one row per pair and both ways
  (D254, D268), stored in rust_npc_factions, edited on the NPC profiles page
  (PUT /admin/rust/npcs/factions), pushed to every server with its
  profiles and hashed with them, and a standalone server's own pairs
  adopted at its first push with the site's winning (D244, D251);
- the Place NPCs step takes escort (a Steam id or a {placeholder}), an ally
  (a clan from the new rust.options.clans source, or the team of a player)
  and tether (the zone this event made), for a RunicNPC profile only
  (D269, D270, D272);
- a placement may be held inside a zone (tether, D272);
- the bridge's RunicNPC API floor is 4.

Tests: 488 server, 66 client. routes.manifest.json regenerated against the
pinned core (one route added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-01 07:58:13 -05:00
parent b83fa8174c
commit 11619a4dc6
18 changed files with 1171 additions and 42 deletions

View File

@@ -18,8 +18,8 @@ const serversDb = require('../servers/servers.db')
const db = require('./npcs.db')
const shape = require('./npcProfile')
/** The RunicNPC API the bridge's `npc.*` commands need (D249). */
const API_NEEDED = 3
/** The RunicNPC API the bridge's `npc.*` commands need: 3 for placements (D249), 4 for the faction table and an event's orders (stage 5). */
const API_NEEDED = 4
class NpcError extends Error {
constructor(message, status = 400) {
@@ -56,9 +56,11 @@ function npcAbsence(server) {
/**
* The profiles one server is pushed, as RunicNPC reads them, and the name → site
* profile id map a kill is credited through (D247). Sorted, so an unchanged set
* hashes the same on every tick.
* hashes the same on every tick. The faction table (stage 5, D254) is the
* site's one table, sent to every server with its profiles and hashed with them,
* so an edit to it is pushed like an edit to a profile.
*/
function desiredFor(serverId, profiles) {
function desiredFor(serverId, profiles, factions = []) {
const set = {}
const map = {}
for (const p of active(profiles).filter((x) => covers(x, serverId)).sort((a, b) => a.name.localeCompare(b.name) || a.id - b.id)) {
@@ -66,8 +68,12 @@ function desiredFor(serverId, profiles) {
set[p.name] = p.body
map[p.name] = p.id
}
const hash = crypto.createHash('sha256').update(JSON.stringify(Object.keys(set).sort().map((n) => [n, set[n]]))).digest('hex')
return { profiles: set, map, hash }
const rows = (factions || []).map((f) => ({ a: f.a, b: f.b, relation: f.relation }))
const hash = crypto
.createHash('sha256')
.update(JSON.stringify([Object.keys(set).sort().map((n) => [n, set[n]]), rows]))
.digest('hex')
return { profiles: set, factions: rows, map, hash }
}
// ── Adoption (D244, D251) ───────────────────────────────────────────────────
@@ -101,10 +107,40 @@ async function adopt(serverId, theirs, userId = null) {
return done
}
/**
* A standalone server's own faction table, adopted with its profiles (D244):
* each pair the site's table does not already set is added, and where the site
* sets a pair the site wins (D251). Returns the pairs added.
*/
async function adoptFactions(theirs) {
const checked = shape.checkFactions(Array.isArray(theirs) ? theirs : [])
if (!checked.ok || checked.value.length === 0) return []
const mine = await db.listFactions()
const have = new Set(mine.map((f) => `${f.a}|${f.b}`))
const added = checked.value.filter((f) => !have.has(`${f.a}|${f.b}`))
if (added.length) await db.replaceFactions(shape.checkFactions([...mine, ...added]).value)
return added
}
// ── The faction table (stage 5, D254, D268) ─────────────────────────────────
async function getFactions() {
return db.listFactions()
}
/** Replaces the site's faction table, checked as RunicNPC checks it, and pushes it to every server. */
async function setFactions(input) {
const checked = shape.checkFactions(input)
if (!checked.ok) throw new NpcError(checked.error)
await db.replaceFactions(checked.value)
await db.markDirty(null)
return db.listFactions()
}
// ── The admin page ──────────────────────────────────────────────────────────
async function describe() {
const [list, profiles, sync] = await Promise.all([db.listNpcServers(), db.listProfiles(), db.listSync()])
const [list, profiles, sync, factions] = await Promise.all([db.listNpcServers(), db.listProfiles(), db.listSync(), db.listFactions()])
const syncBy = new Map(sync.map((s) => [s.serverId, s]))
return {
servers: list.map((s) => {
@@ -120,6 +156,13 @@ async function describe() {
prefabs: shape.PREFABS,
killsScopes: shape.KILLS_SCOPES,
defaults: shape.defaults(),
// Stage 5: the faction table, the factions the profiles name, and what the form offers.
factions,
factionNames: [...new Set(profiles.map((p) => p.body && p.body.faction).filter(Boolean))].sort(),
builtInFactions: shape.BUILT_IN_FACTIONS,
roles: shape.ROLES,
turrets: shape.TURRETS,
kitUses: shape.KIT_USES,
}
}
@@ -305,9 +348,19 @@ function placementBody(input, { position }) {
if (!m.ok) throw new NpcError(m.error)
body.movement = m.value
}
// D272: a ZoneManager zone its NPCs never leave. The server checks that the
// zone exists and holds the spot, and refuses the placement otherwise.
const tether = p.tether === undefined || p.tether === null ? '' : String(p.tether).trim()
if (tether) {
if (!TETHER_RULE.test(tether)) throw new NpcError('tether: a ZoneManager zone id')
body.tether = tether
}
return body
}
/** RunicNPC's rule for a zone id: one plain word. */
const TETHER_RULE = /^[A-Za-z0-9_.:-]{1,64}$/
function point(raw, { withY }) {
const r = raw || {}
const x = Number(r.x)
@@ -424,6 +477,10 @@ async function playerKills({ serverId, steamId, wipeId = null }) {
}
module.exports = {
adoptFactions,
getFactions,
setFactions,
TETHER_RULE,
API_NEEDED,
PROFILE_PREFIX,
NpcError,