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

@@ -12,7 +12,7 @@
//
// A tick asks each server whether it needs a push — its first, a change to its
// set, a restart, a failure worth retrying, or the quarter-hourly audit — and
// pushes only then. A server without RunicNPC (or with one older than API 3) is
// pushes only then. A server without RunicNPC (or with one older than API 4) is
// recorded `absent` and left alone; its events keep Rust's own scientists
// (D243).
@@ -94,8 +94,8 @@ async function pushOne(server, { npc, sync, force = false }) {
return { server: server.id, outcome: 'absent', reason: model.npcAbsence(npc) }
}
const profiles = await db.listProfiles()
const first = model.desiredFor(server.id, profiles)
const [profiles, factions] = await Promise.all([db.listProfiles(), db.listFactions()])
const first = model.desiredFor(server.id, profiles, factions)
const reason = reasonToPush({ server: npc, sync, hash: first.hash, force })
if (!reason) return { server: server.id, outcome: 'current' }
@@ -112,12 +112,17 @@ async function pushOne(server, { npc, sync, force = false }) {
// A server a site already manages has nothing of its own left to adopt: its
// file is some site's last push. Only a standalone server's are its own.
adopted = data.managed ? [] : await model.adopt(server.id, data.profiles || {})
// Stage 5 (D254): its own faction table too, pair by pair, the site's winning.
const adoptedFactions = data.managed ? [] : await model.adoptFactions(data.factions)
if (data.managed) await db.markAdopted(server.id)
if (adopted.length) log.info('adopted a server\'s own NPC profiles', { server: server.id, adopted })
if (adoptedFactions.length) log.info('adopted a server\'s own faction pairs', { server: server.id, pairs: adoptedFactions.length })
if (adoptedFactions.length) adopted.push(...adoptedFactions.map((f) => ({ name: `${f.a}↔${f.b}`, outcome: 'faction' })))
}
const desired = adopted && adopted.length ? model.desiredFor(server.id, await db.listProfiles()) : first
const pushed = await sidecar.npcProfilesSet(server, desired.profiles)
const desired =
adopted && adopted.length ? model.desiredFor(server.id, await db.listProfiles(), await db.listFactions()) : first
const pushed = await sidecar.npcProfilesSet(server, desired.profiles, desired.factions)
const data = pushed.ok ? pushed.data || {} : null
if (!data || data.kind !== 'npc.ok') {
const error = data ? data.message || data.reason || `answered ${data.kind}` : `the push did not arrive (${pushed.status})`
@@ -126,7 +131,9 @@ async function pushOne(server, { npc, sync, force = false }) {
return { server: server.id, outcome: 'failed', error, ...(adopted ? { adopted } : {}) }
}
const refused = data.refused && typeof data.refused === 'object' ? data.refused : {}
const refused = data.refused && typeof data.refused === 'object' ? { ...data.refused } : {}
// A faction table RunicNPC refused is kept aside there, and the old one kept; it says why.
if (data.factionsRefused) refused['(factions)'] = String(data.factionsRefused)
await db.putSync(server.id, { state: 'ok', syncedHash: desired.hash, bootId: npc.bootId, pushed: desired.map, refused })
log.info('pushed NPC profiles', { server: server.id, reason, profiles: Object.keys(desired.profiles).length, refused: Object.keys(refused).length })
return { server: server.id, outcome: 'pushed', reason, profiles: Object.keys(desired.profiles), refused, ...(adopted ? { adopted } : {}) }