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:
@@ -182,7 +182,7 @@ async function findByGameId(serverId, clanId) {
|
||||
/** Every clan still on a board, for core's `getTeams`. */
|
||||
async function listActiveClans() {
|
||||
return core.query(
|
||||
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
|
||||
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName, c.clan_id AS clanId,
|
||||
c.name, c.color, c.score, c.member_count AS memberCount
|
||||
FROM ${CLANS} c
|
||||
JOIN ${SERVERS} s ON s.id = c.server_id
|
||||
|
||||
@@ -19,7 +19,21 @@ const PREFAB_RULE = /^scientistnpc_[a-z0-9_]{1,40}$/
|
||||
/** The prefabs the form offers; RunicNPC accepts any plain `scientistnpc_*`. */
|
||||
const PREFABS = ['scientistnpc_roam', 'scientistnpc_heavy', 'scientistnpc_patrol', 'scientistnpc_roamtethered', 'scientistnpc_full_any']
|
||||
|
||||
const ROLES = ['roamer', 'sentry']
|
||||
const ROLES = ['roamer', 'sentry', 'guard']
|
||||
|
||||
/** Rust's own two factions (D254). Neither can be a profile's faction. */
|
||||
const BUILT_IN_FACTIONS = ['scientists', 'animals']
|
||||
|
||||
const RELATIONS = ['hostile', 'neutral', 'allied']
|
||||
|
||||
/** D259, D264, D267. */
|
||||
const TURRETS = ['default', 'ignore', 'always']
|
||||
|
||||
/** D260: the kit's extras a profile may opt into, each used up (D265). */
|
||||
const KIT_USES = ['heal', 'grenades', 'melee', 'rockets', 'flamethrower']
|
||||
|
||||
const RELATIONS_MAX = 40
|
||||
const FACTION_PAIRS_MAX = 400
|
||||
|
||||
/** D247: how a profile's kills are counted. `server` is the default. */
|
||||
const KILLS_SCOPES = ['server', 'name', 'profile']
|
||||
@@ -45,9 +59,60 @@ function defaults() {
|
||||
visionCone: -0.8,
|
||||
sleepDistance: 160,
|
||||
healthThresholds: [],
|
||||
// Stage 5 (D254–D261, D271). None of them makes a profile fight anything but players (D255).
|
||||
faction: null,
|
||||
relations: {},
|
||||
alertRadius: 40,
|
||||
turrets: 'default',
|
||||
hurtByPlayers: true,
|
||||
hurtsPlayers: true,
|
||||
kitUse: { heal: false, grenades: false, melee: false, rockets: false, flamethrower: false },
|
||||
}
|
||||
}
|
||||
|
||||
/** A faction name, one of Rust's two, or (where a profile names it) `profile:<name>`. */
|
||||
function isFactionKey(key, profileAllowed) {
|
||||
const k = String(key === undefined || key === null ? '' : key)
|
||||
if (BUILT_IN_FACTIONS.includes(k) || NAME_RULE.test(k)) return true
|
||||
return profileAllowed && k.startsWith('profile:') && NAME_RULE.test(k.slice(8))
|
||||
}
|
||||
|
||||
function pairKey(a, b) {
|
||||
return a < b ? `${a}|${b}` : `${b}|${a}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The faction table (D254), one row per pair, both ways (D268), checked as
|
||||
* RunicNPC's `ValidateFactions` checks it, with its sentences. `{ ok, value }`
|
||||
* with the rows sorted, or `{ ok: false, error }`.
|
||||
*/
|
||||
function checkFactions(input) {
|
||||
if (!Array.isArray(input)) return { ok: false, error: 'factions: a list of { a, b, relation }' }
|
||||
if (input.length > FACTION_PAIRS_MAX) return { ok: false, error: `factions: at most ${FACTION_PAIRS_MAX} pairs` }
|
||||
const seen = new Set()
|
||||
const rows = []
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const f = input[i] || {}
|
||||
const a = String(f.a === undefined || f.a === null ? '' : f.a).trim()
|
||||
const b = String(f.b === undefined || f.b === null ? '' : f.b).trim()
|
||||
const relation = String(f.relation === undefined || f.relation === null ? 'neutral' : f.relation)
|
||||
if (!isFactionKey(a, false) || !isFactionKey(b, false)) {
|
||||
return { ok: false, error: `factions[${i}]: a and b are faction names (1–40 of a-z, 0-9, _ and -), scientists or animals` }
|
||||
}
|
||||
if (a === b) return { ok: false, error: `factions[${i}]: a faction is always allied to itself` }
|
||||
if (BUILT_IN_FACTIONS.includes(a) && BUILT_IN_FACTIONS.includes(b)) {
|
||||
return { ok: false, error: `factions[${i}]: both are Rust's own, and RunicNPC does not change how Rust's NPCs treat each other` }
|
||||
}
|
||||
if (!RELATIONS.includes(relation)) return { ok: false, error: `factions[${i}]: '${relation}' is not hostile, neutral or allied` }
|
||||
const key = pairKey(a, b)
|
||||
if (seen.has(key)) return { ok: false, error: `factions[${i}]: ${a} and ${b} are given twice (one row is both ways, D268)` }
|
||||
seen.add(key)
|
||||
rows.push(a < b ? { a, b, relation } : { a: b, b: a, relation })
|
||||
}
|
||||
rows.sort((x, y) => x.a.localeCompare(y.a) || x.b.localeCompare(y.b))
|
||||
return { ok: true, value: rows }
|
||||
}
|
||||
|
||||
function num(value) {
|
||||
if (value === null || value === undefined || value === '') return NaN
|
||||
const n = Number(value)
|
||||
@@ -93,7 +158,7 @@ function checkBody(input) {
|
||||
const prefab = String(b.prefab || '').trim()
|
||||
if (!PREFAB_RULE.test(prefab)) return { ok: false, error: `prefab: '${prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)` }
|
||||
|
||||
if (!ROLES.includes(b.role)) return { ok: false, error: `role: '${b.role}' is not roamer or sentry` }
|
||||
if (!ROLES.includes(b.role)) return { ok: false, error: `role: '${b.role}' is not roamer, sentry or guard` }
|
||||
|
||||
const movement = checkMovement(b.movement)
|
||||
if (!movement.ok) return movement
|
||||
@@ -125,6 +190,29 @@ function checkBody(input) {
|
||||
if (!thresholds || thresholds.some((t) => !(t > 0 && t < 1))) return { ok: false, error: 'healthThresholds: fractions between 0 and 1' }
|
||||
if (thresholds.length > THRESHOLDS_MAX) return { ok: false, error: `healthThresholds: at most ${THRESHOLDS_MAX}` }
|
||||
|
||||
// ---- stage 5, in RunicNPC's order and words ----
|
||||
const faction = b.faction === undefined || b.faction === null || String(b.faction).trim() === '' ? null : String(b.faction).trim()
|
||||
if (faction !== null && (!NAME_RULE.test(faction) || BUILT_IN_FACTIONS.includes(faction))) {
|
||||
return { ok: false, error: `faction: '${faction}' must be 1–40 of a-z, 0-9, _ and -, and not scientists or animals (Rust's own)` }
|
||||
}
|
||||
const rel = b.relations === undefined || b.relations === null ? {} : b.relations
|
||||
if (typeof rel !== 'object' || Array.isArray(rel)) return { ok: false, error: 'relations: an object, even an empty one' }
|
||||
const relations = {}
|
||||
for (const [key, value] of Object.entries(rel)) {
|
||||
if (!isFactionKey(key, true)) return { ok: false, error: `relations: '${key}' is not a faction, scientists, animals or profile:<name>` }
|
||||
if (!RELATIONS.includes(value)) return { ok: false, error: `relations.${key}: '${value}' is not hostile, neutral or allied` }
|
||||
relations[key] = value
|
||||
}
|
||||
if (Object.keys(relations).length > RELATIONS_MAX) return { ok: false, error: `relations: at most ${RELATIONS_MAX}` }
|
||||
const alertRadius = num(b.alertRadius)
|
||||
if (!(alertRadius >= 0)) return { ok: false, error: 'alertRadius: 0 (off) or more' }
|
||||
if (!TURRETS.includes(b.turrets)) return { ok: false, error: `turrets: '${b.turrets}' is not default, ignore or always` }
|
||||
if (typeof b.hurtByPlayers !== 'boolean' || typeof b.hurtsPlayers !== 'boolean') return { ok: false, error: 'hurtByPlayers and hurtsPlayers: true or false' }
|
||||
const kit = b.kitUse === undefined || b.kitUse === null ? {} : b.kitUse
|
||||
if (typeof kit !== 'object' || Array.isArray(kit)) return { ok: false, error: 'kitUse: an object, even an empty one' }
|
||||
const kitUse = {}
|
||||
for (const k of KIT_USES) kitUse[k] = kit[k] === true
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -141,6 +229,13 @@ function checkBody(input) {
|
||||
visionCone,
|
||||
sleepDistance,
|
||||
healthThresholds: [...new Set(thresholds)].sort((x, y) => y - x),
|
||||
faction,
|
||||
relations,
|
||||
alertRadius,
|
||||
turrets: b.turrets,
|
||||
hurtByPlayers: b.hurtByPlayers,
|
||||
hurtsPlayers: b.hurtsPlayers,
|
||||
kitUse,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -166,6 +261,12 @@ module.exports = {
|
||||
PREFAB_RULE,
|
||||
PREFABS,
|
||||
ROLES,
|
||||
BUILT_IN_FACTIONS,
|
||||
RELATIONS,
|
||||
TURRETS,
|
||||
KIT_USES,
|
||||
isFactionKey,
|
||||
checkFactions,
|
||||
KILLS_SCOPES,
|
||||
defaults,
|
||||
parseMode,
|
||||
|
||||
@@ -12,6 +12,7 @@ const PROFILES = 'rust_npc_profiles'
|
||||
const PROFILE_SERVERS = 'rust_npc_profile_servers'
|
||||
const SYNC = 'rust_npc_sync'
|
||||
const KILLS = 'rust_npc_kills'
|
||||
const FACTIONS = 'rust_npc_factions'
|
||||
const SERVERS = 'rust_servers'
|
||||
const STATE = 'rust_server_state'
|
||||
const PLAYERS = 'rust_players'
|
||||
@@ -194,6 +195,29 @@ async function markDirty(serverIds = null) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── The faction table (stage 5, D254) ─────────────────────────────────────
|
||||
|
||||
/** Every pair, `a` before `b`, sorted. */
|
||||
async function listFactions() {
|
||||
return (await core.query(`SELECT a, b, relation FROM ${FACTIONS} ORDER BY a ASC, b ASC`)).map((r) => ({ a: r.a, b: r.b, relation: r.relation }))
|
||||
}
|
||||
|
||||
/** Replaces the whole table, in one transaction; the caller has checked it (`checkFactions`). */
|
||||
async function replaceFactions(rows) {
|
||||
const conn = await core.pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
await conn.query(`DELETE FROM ${FACTIONS}`)
|
||||
for (const r of rows) await conn.query(`INSERT INTO ${FACTIONS} (a, b, relation) VALUES (?, ?, ?)`, [r.a, r.b, r.relation])
|
||||
await conn.commit()
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kills (D247) ────────────────────────────────────────────────────────────
|
||||
|
||||
async function addKills({ serverId, wipeId, steamId }, profile, siteProfileId, kills) {
|
||||
@@ -300,6 +324,8 @@ module.exports = {
|
||||
markAdopted,
|
||||
putSync,
|
||||
markDirty,
|
||||
listFactions,
|
||||
replaceFactions,
|
||||
addKills,
|
||||
scopeWhere,
|
||||
ranking,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user