feat(rust): NPC sides, the faction table, an event's escort, ally and tether (runicnpc stage 5)
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 39s
PR Checks / frozen-manifest (pull_request) Successful in 58s

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

@@ -26,7 +26,7 @@ const inputStyle = {
font: 'inherit',
}
const blank = (profile = '') => ({ profile, count: '1', respawn: '300', respawnMode: 'each', move: '', radius: '' })
const blank = (profile = '') => ({ profile, count: '1', respawn: '300', respawnMode: 'each', move: '', radius: '', tether: '' })
function formOf(p) {
const v = p.placement || {}
@@ -37,6 +37,7 @@ function formOf(p) {
respawnMode: v.respawnMode || 'each',
move: (v.movement && v.movement.mode) || '',
radius: v.movement && v.movement.radius !== undefined ? String(v.movement.radius) : '',
tether: v.tether || '',
}
}
@@ -47,6 +48,8 @@ function bodyOf(f) {
respawn: Number(f.respawn),
respawnMode: f.respawnMode,
...(f.move ? { movement: { mode: f.move, radius: f.radius === '' ? undefined : Number(f.radius) } } : { movement: null }),
// Stage 5 (D272): a ZoneManager zone its NPCs never leave; empty for none.
tether: f.tether.trim() || null,
}
}
@@ -188,6 +191,7 @@ function ServerPlacements({ server, profiles }) {
<strong style={{ color: 'var(--head)' }}>{p.id}</strong>
<span className="dim">
{v.count} × {v.profile} · {p.alive} alive · respawn {v.respawn} s, {v.respawnMode} · {move}
{v.tether ? ` · inside zone ${v.tether}` : ''}
{v.position ? ` · ${Math.round(v.position.x)}, ${Math.round(v.position.z)}` : ''}
</span>
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
@@ -282,6 +286,10 @@ function PlacementFields({ form, setForm, profiles, routes }) {
<input value={form.radius} onChange={set('radius')} inputMode="decimal" style={{ ...inputStyle, maxWidth: 80 }} />
</label>
)}
<label style={label} title="A ZoneManager zone on this server its NPCs never leave. The server refuses a zone it does not have, or one that does not hold the spot.">
<span style={{ color: 'var(--head)' }}>Keep inside zone</span>
<input value={form.tether} onChange={set('tether')} placeholder="none" maxLength={64} style={{ ...inputStyle, maxWidth: 160 }} />
</label>
</>
)}
</div>