Files
Module-Rust/server/model/npcs/npcProfile.js
wtclaude 564a234890 feat(rust): RunicNPC profiles, their push and adoption, per-profile kills (runicnpc stage 4, WIP)
Schema for site NPC profiles (per server, shared or fleet), the per-server
push record, and kills by profile. The push adopts a server's own profiles
before its first push (D244), keeping one whose name a site profile already
has as replaced (D251). The tally's npcProfileKills are stored per profile
and credited to the site profile pushed under that name (D247).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:21:46 -05:00

177 lines
7.4 KiB
JavaScript

// ── A RunicNPC profile, checked here as RunicNPC checks it (D238) ───────────
//
// The site authors profiles and pushes them to each server's RunicNPC, which
// checks them again and refuses what it cannot use (docs/runicnpc/API.md). The
// site checks first so an admin reads the reason on the form, not in a push
// report minutes later. The rules below are RunicNPC's `ValidateProfile`, in
// the same order and with the same sentences, less the one only the server can
// answer: whether it has each kit. That one is checked against the kit list
// each server last reported (`npcs.model.js`).
//
// Pure functions: no database, no sidecar.
/** RunicNPC's rule for a profile, placement or route name. */
const NAME_RULE = /^[a-z0-9_-]{1,40}$/
/** A plain scientist prefab, as RunicNPC resolves one (`scientistnpc_*`). */
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']
/** D247: how a profile's kills are counted. `server` is the default. */
const KILLS_SCOPES = ['server', 'name', 'profile']
const NAMES_MAX = 20
const DISPLAY_NAME_MAX = 32
const KITS_MAX = 20
const THRESHOLDS_MAX = 10
/** RunicNPC's defaults (API.md, D238), so a form may leave a value out. */
function defaults() {
return {
names: [],
kits: [],
prefab: 'scientistnpc_roam',
role: 'roamer',
movement: { mode: 'wander', radius: 20 },
health: 150,
damageDealt: 1,
damageTaken: { head: 1, body: 1, legs: 1 },
aimCone: 2,
ranges: { sense: 30, loseTarget: 40, chase: 40, attack: 30 },
visionCone: -0.8,
sleepDistance: 160,
healthThresholds: [],
}
}
function num(value) {
if (value === null || value === undefined || value === '') return NaN
const n = Number(value)
return Number.isFinite(n) ? n : NaN
}
/** `wander`, `monument` or `route:<name>` (D233). */
function parseMode(mode) {
const text = String(mode === undefined || mode === null ? '' : mode).trim()
if (text === 'wander' || text === 'monument') return { kind: text }
const m = /^route:([a-z0-9_-]{1,40})$/.exec(text)
return m ? { kind: 'route', route: m[1] } : null
}
/** A movement, checked: `{ ok, value }` or `{ ok: false, error }`. */
function checkMovement(input) {
const m = input || {}
const mode = String(m.mode === undefined || m.mode === null ? '' : m.mode).trim()
const parsed = parseMode(mode)
if (!parsed) return { ok: false, error: `movement.mode: '${mode}' is not wander, monument or route:<name>` }
const radius = m.radius === undefined || m.radius === null || m.radius === '' ? (parsed.kind === 'wander' ? 20 : 0) : num(m.radius)
if (Number.isNaN(radius) || radius < 0) return { ok: false, error: 'movement.radius: a number of metres' }
if (parsed.kind === 'wander' && !(radius > 0)) return { ok: false, error: "movement.radius: a wanderer's radius must be above 0" }
return { ok: true, value: { mode, radius } }
}
/**
* Checks a profile's body as the admin form sends it, merged over RunicNPC's
* defaults. Resolves `{ ok: true, value }` with the body RunicNPC will read, or
* `{ ok: false, error }` with RunicNPC's own sentence for the first problem.
*/
function checkBody(input) {
const b = { ...defaults(), ...(input || {}) }
const names = Array.isArray(b.names) ? b.names.map((n) => String(n === null || n === undefined ? '' : n).trim()) : null
if (!names || names.length === 0 || names.some((n) => !n)) return { ok: false, error: 'names: give at least one, and no blank ones' }
if (names.length > NAMES_MAX) return { ok: false, error: `names: at most ${NAMES_MAX}` }
if (names.some((n) => n.length > DISPLAY_NAME_MAX)) return { ok: false, error: `names: each at most ${DISPLAY_NAME_MAX} characters` }
const kits = Array.isArray(b.kits) ? [...new Set(b.kits.map((k) => String(k === null || k === undefined ? '' : k).trim()).filter(Boolean))] : null
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped (D217)' }
if (kits.length > KITS_MAX) return { ok: false, error: `kits: at most ${KITS_MAX}` }
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` }
const movement = checkMovement(b.movement)
if (!movement.ok) return movement
const health = num(b.health)
if (!(health > 0)) return { ok: false, error: 'health: must be above 0' }
const damageDealt = num(b.damageDealt)
if (Number.isNaN(damageDealt) || damageDealt < 0) return { ok: false, error: 'damageDealt: must not be negative' }
const taken = b.damageTaken || {}
const damageTaken = { head: num(taken.head), body: num(taken.body), legs: num(taken.legs) }
if (Object.values(damageTaken).some((v) => Number.isNaN(v) || v < 0)) return { ok: false, error: 'damageTaken: head, body and legs must not be negative' }
const aimCone = num(b.aimCone)
if (Number.isNaN(aimCone) || aimCone < 0) return { ok: false, error: 'aimCone: must not be negative' }
const r = b.ranges || {}
const ranges = { sense: num(r.sense), loseTarget: num(r.loseTarget), chase: num(r.chase), attack: num(r.attack) }
if (!(ranges.sense > 0) || !(ranges.attack > 0) || !(ranges.loseTarget >= ranges.sense) || !(ranges.chase >= 0)) {
return { ok: false, error: 'ranges: sense and attack above 0, loseTarget at least sense, chase not negative' }
}
const visionCone = num(b.visionCone)
if (!(visionCone >= -1 && visionCone <= 1)) return { ok: false, error: 'visionCone: between -1 and 1' }
const sleepDistance = num(b.sleepDistance)
if (!(sleepDistance >= 0)) return { ok: false, error: 'sleepDistance: 0 (never sleeps) or more' }
const thresholds = Array.isArray(b.healthThresholds) ? b.healthThresholds.map(num) : null
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}` }
return {
ok: true,
value: {
names,
kits,
prefab,
role: b.role,
movement: movement.value,
health,
damageDealt,
damageTaken,
aimCone,
ranges,
visionCone,
sleepDistance,
healthThresholds: [...new Set(thresholds)].sort((x, y) => y - x),
},
}
}
/**
* A profile read from a server's own RunicNPC (D244), made into a body this
* module will save. Whatever RunicNPC holds is kept as it is; only a shape this
* module could not push back is refused, with the reason.
*/
function fromServer(name, body) {
if (!NAME_RULE.test(String(name || ''))) return { ok: false, error: `'${name}' is not a profile name RunicNPC could hold` }
return checkBody(body || {})
}
/** A player-facing label for a profile: its first NPC name, else its own name. */
function labelOf(profile) {
const names = profile && profile.body && Array.isArray(profile.body.names) ? profile.body.names : []
return names[0] || (profile && profile.name) || ''
}
module.exports = {
NAME_RULE,
PREFAB_RULE,
PREFABS,
ROLES,
KILLS_SCOPES,
defaults,
parseMode,
checkMovement,
checkBody,
fromServer,
labelOf,
}