+ Faction table
+
+ How factions treat each other. One row is both ways: bandits ↔ guards: hostile makes each hunt the other.
+ A pair that is not here is neutral, and a faction is always allied to itself. scientists and{' '}
+ animals are Rust’s own; a profile hostile to scientists fights them, and they only take cover.
+
+
+ {shown.length === 0 && Empty: every pair is neutral.
}
+ {shown.map((r, i) => (
+
+ change(i, 'a', e.target.value)} list="rnpc-table-factions" maxLength={40} style={{ ...inputStyle, maxWidth: 180 }} />
+ ↔
+ change(i, 'b', e.target.value)} list="rnpc-table-factions" maxLength={40} style={{ ...inputStyle, maxWidth: 180 }} />
+
+
+
+ ))}
+
+
+
+ {rows !== null && }
+
+
+ )
+}
diff --git a/routes.manifest.json b/routes.manifest.json
index 7edb5b7..78cccf4 100644
--- a/routes.manifest.json
+++ b/routes.manifest.json
@@ -361,6 +361,11 @@
"path": "/api/v1/player/rust/link",
"tier": "public"
},
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/rust/npcs/factions",
+ "tier": "public"
+ },
{
"method": "PUT",
"path": "/api/v1/admin/rust/npcs/profiles/:pid",
diff --git a/server/db/purge.sql b/server/db/purge.sql
index 251b7f7..0738218 100644
--- a/server/db/purge.sql
+++ b/server/db/purge.sql
@@ -20,6 +20,7 @@
-- registrant owned what.
-- RunicNPC (runicnpc PLAN.md stage 4). Children before `rust_npc_profiles`.
+DROP TABLE IF EXISTS rust_npc_factions;
DROP TABLE IF EXISTS rust_npc_kills;
DROP TABLE IF EXISTS rust_npc_sync;
DROP TABLE IF EXISTS rust_npc_profile_servers;
diff --git a/server/db/schema.sql b/server/db/schema.sql
index d89a860..ad5d433 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -1305,7 +1305,7 @@ CREATE TABLE IF NOT EXISTS rust_npc_profile_servers (
-- server's own profiles were read and imported (D244); until then nothing is
-- pushed there. `pushed` is the name → site profile id map of the last push,
-- which is how a kill is credited to "this profile only" (D247). `state` is
--- `ok`, `failed`, or `absent` (no RunicNPC, or one older than API 3).
+-- `ok`, `failed`, or `absent` (no RunicNPC, or one older than API 4).
CREATE TABLE IF NOT EXISTS rust_npc_sync (
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
adopted_at DATETIME NULL,
@@ -1338,6 +1338,17 @@ CREATE TABLE IF NOT EXISTS rust_npc_kills (
KEY idx_rust_npc_kills_site (site_profile_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+-- The faction table (runicnpc stage 5, D254, D268): how factions treat each
+-- other, one row per pair and both ways, stored with `a` before `b`. One table
+-- for the whole site, pushed to every server with its profiles. `scientists`
+-- and `animals` are Rust's own two; a pair that is not here is neutral.
+CREATE TABLE IF NOT EXISTS rust_npc_factions (
+ a VARCHAR(40) NOT NULL,
+ b VARCHAR(40) NOT NULL,
+ relation VARCHAR(8) NOT NULL,
+ PRIMARY KEY (a, b)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
-- A title rule on a profile's kills names the site profile it ranks (runicnpc
-- stage 4, D250). Null for every other stat. No foreign key: a deleted profile
-- leaves a rule that ranks nobody, which the form shows and an admin removes.
diff --git a/server/eventWorld.js b/server/eventWorld.js
index e3d7465..b4810f2 100644
--- a/server/eventWorld.js
+++ b/server/eventWorld.js
@@ -33,6 +33,7 @@ const voice = require('./model/permissions/voice')
const npcs = require('./model/npcs/npcs.model')
const npcsDb = require('./model/npcs/npcs.db')
const npcProfile = require('./model/npcs/npcProfile')
+const clansDb = require('./model/clans/clans.db')
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const log = core.logger('world')
@@ -116,6 +117,11 @@ const PERMANENT = new Set([
// runicnpc stage 4 (D243): a profile the server does not have, or no RunicNPC.
'unknown-profile',
'runicnpc-missing',
+ // runicnpc stage 5 (D272): no zone of the run holds the point; a RunicNPC
+ // older than the API escort, ally and tether need. An escort who is not on
+ // the server (`escort-offline`) is left to retry: they may join.
+ 'no-zone',
+ 'runicnpc-old',
])
const BUDGETS = [
@@ -498,6 +504,7 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
description: `How widely to scatter a group, up to ${MAX_SPREAD} m. Left blank, 10.`,
},
...LOCATION_PARAMS,
+ ...(kind === 'npc' ? NPC_ORDER_PARAMS : []),
],
async perform({ runId, idempotencyKey, params, verify }) {
@@ -531,6 +538,13 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
if (missing) return { ok: false, retry: false, error: missing }
}
+ // Stage 5: escort, ally and tether are RunicNPC's, so only a profile takes them.
+ const orders = kind === 'npc' ? npcOrders(params, found.server) : { ok: true, wire: {} }
+ if (!orders.ok) return { ok: false, retry: false, error: orders.error }
+ if (profile === null && Object.keys(orders.wire).length) {
+ return { ok: false, retry: false, error: "escort, ally and tether are for one of the site's NPC profiles (RunicNPC), not Rust's own scientists" }
+ }
+
if (verify) return { ok: true }
return place(
@@ -543,6 +557,7 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
count,
...(spread === undefined ? {} : { spread }),
...where.wire,
+ ...orders.wire,
},
profile !== null ? `NPCs of the profile "${profile}"` : known.label.toLowerCase(),
)
@@ -550,6 +565,76 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
}
}
+/** A Steam id: seventeen digits, as Rust's are. */
+const STEAM_ID = /^\d{17}$/
+
+/**
+ * Stage 5 (D269, D270, D272): what a RunicNPC profile's NPCs are told besides
+ * their profile. Each is optional; the server refuses a step whose order it
+ * cannot honour (an escort who is not on, a clan it does not have, no zone of
+ * the run around the point), so nothing half-placed is left behind.
+ */
+const NPC_ORDER_PARAMS = [
+ {
+ name: 'escort',
+ type: 'string',
+ required: false,
+ example: '76561198000000001',
+ description:
+ "A player's Steam id, or a {placeholder} from the run's start params: the NPCs keep close to that player and fight whoever attacks them, and walk back to their spot if the player dies or leaves (D269). The player must be on the server. RunicNPC profiles only.",
+ },
+ {
+ name: 'allyClan',
+ type: 'string',
+ required: false,
+ source: 'rust.options.clans',
+ example: 'main/1234567',
+ description:
+ "A clan on the same server: the NPCs never target its members and defend them and what they own (D257). RunicNPC profiles only; give this or allyTeamOf, not both.",
+ },
+ {
+ name: 'allyTeamOf',
+ type: 'string',
+ required: false,
+ example: '76561198000000001',
+ description:
+ "A player's Steam id, or a {placeholder}: the NPCs are allied to that player and their team (D257, D270). RunicNPC profiles only.",
+ },
+ {
+ name: 'tether',
+ type: 'boolean',
+ required: false,
+ example: true,
+ description:
+ 'Keep the NPCs inside the zone this event made around the point (D272): a Make a zone step must come first. RunicNPC profiles only.',
+ },
+]
+
+/** The orders, checked, as the bridge reads them: `{ ok, wire }` or `{ ok: false, error }`. */
+function npcOrders(params, server) {
+ const wire = {}
+ const escort = String(params.escort === undefined || params.escort === null ? '' : params.escort).trim()
+ if (escort) {
+ if (!STEAM_ID.test(escort)) return { ok: false, error: `an escort is a player's Steam id, and "${escort}" is not one` }
+ wire.escort = escort
+ }
+ const clan = String(params.allyClan === undefined || params.allyClan === null ? '' : params.allyClan).trim()
+ const teamOf = String(params.allyTeamOf === undefined || params.allyTeamOf === null ? '' : params.allyTeamOf).trim()
+ if (clan && teamOf) return { ok: false, error: 'an ally is a clan or a player and their team, not both' }
+ if (clan) {
+ const m = /^([^/]+)\/(-?\d{1,20})$/.exec(clan)
+ if (!m) return { ok: false, error: `"${clan}" is not a clan from the list` }
+ if (m[1] !== server.id) return { ok: false, error: `that clan is on ${m[1]}, and these NPCs are placed on ${server.id}` }
+ wire.ally = { kind: 'clan', id: m[2] }
+ }
+ if (teamOf) {
+ if (!STEAM_ID.test(teamOf)) return { ok: false, error: `an ally's team is named by a player's Steam id, and "${teamOf}" is not one` }
+ wire.ally = { kind: 'player', id: teamOf }
+ }
+ if (params.tether === true || params.tether === 'true') wire.tether = true
+ return { ok: true, wire }
+}
+
/**
* Why a profile cannot be placed on this server, from what the site knows, or
* null (D243). A server without RunicNPC offers only Rust's own until stage 9;
@@ -778,6 +863,23 @@ const OPTION_SOURCES = [
return profiles.length ? [...profiles, ...own.map((row) => ({ ...row, group: "Rust's own" }))] : own
},
})),
+ {
+ // Stage 5 (D270): an ally for a Place NPCs step. From the site's own clan
+ // mirror, so it answers with every server off. A row's value carries its
+ // server, because a clan id means something only on its own server.
+ id: 'rust.options.clans',
+ label: 'Clans',
+ description: "Each server's clans, as the site last read them.",
+ searchable: true,
+ async resolve({ q } = {}) {
+ const term = String(q || '').trim().toLowerCase()
+ const rows = (await clansDb.listActiveClans())
+ .filter((c) => c.clanId !== null && c.clanId !== undefined)
+ .map((c) => ({ value: `${c.serverId}/${c.clanId}`, label: `${c.name} (${c.memberCount})`, group: c.serverName || c.serverId }))
+ .filter((r) => !term || r.label.toLowerCase().includes(term))
+ return bounded(rows, 'rust.options.clans')
+ },
+ },
{
// D210: the admins' own presets. A row's VALUE is the options line itself,
// so picking one writes that line into the step, and the step keeps its own
diff --git a/server/model/clans/clans.db.js b/server/model/clans/clans.db.js
index a3adf9e..0992d38 100644
--- a/server/model/clans/clans.db.js
+++ b/server/model/clans/clans.db.js
@@ -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
diff --git a/server/model/npcs/npcProfile.js b/server/model/npcs/npcProfile.js
index 357e91e..e4d7c77 100644
--- a/server/model/npcs/npcProfile.js
+++ b/server/model/npcs/npcProfile.js
@@ -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: