// ── Admin · Rust · NPCs ─────────────────────────────────────────────────── // // Mounted under the admin tier's `/rust` prefix, so every path here is // `/api/v1/admin/rust/npcs` (docs/runicnpc/PLAN.md stage 4). Two pages' worth: // // profiles the site's RunicNPC profiles, for one server, several or the // fleet, pushed to each server's RunicNPC (D221, D244, D247, D251) // placements one server's placements, which live on that server (D222): read, // edited, removed, renamed, respawned, and created by clicking the // live map (D245, D246) // // **Every route is `requireRole('admin')`,** like every other page under Admin → // Rust: a profile decides how hard an NPC hits the players it meets, and a // placement puts armed NPCs in the world. const core = require('../../core') const express = core.express const npcs = require('./npcs.controller') const { requireRole, validate } = core.middleware const { body, param } = core.validator const npcsRouter = express.Router() const NAME = /^[a-z0-9_-]{1,40}$/ const profileBody = [ body('name').isString().trim().matches(NAME).withMessage('name is 1 to 40 of a-z, 0-9, _ and -'), body('body').isObject().withMessage('body is the profile, as RunicNPC reads it'), body('allServers').optional().isBoolean().withMessage('allServers is true or false'), body('servers').optional().isArray().withMessage('servers is a list of server ids'), body('killsScope').optional().isIn(['server', 'name', 'profile']).withMessage('killsScope is server, name or profile'), ] const serverParam = param('id').isString().isLength({ min: 1, max: 64 }).withMessage('id is a server id') const placementParam = param('placement').isString().matches(NAME).withMessage('placement is a placement name') const placementBody = [ body('profile').isString().trim().matches(NAME).withMessage('profile is a profile name'), body('count').optional().isInt({ min: 1, max: 50 }).withMessage('count is 1 to 50'), body('respawn').optional().isFloat({ min: 1, max: 86400 }).withMessage('respawn is 1 to 86400 seconds'), body('respawnMode').optional().isIn(['each', 'group']).withMessage('respawnMode is each or group'), body('movement').optional({ nullable: true }).isObject().withMessage('movement is { mode, radius }'), body('tether').optional({ nullable: true }).isString().matches(/^[A-Za-z0-9_.:-]{0,64}$/).withMessage('tether is a ZoneManager zone id'), ] npcsRouter.get( '/', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'NPC profiles, and what each server said about RunicNPC' // #swagger.description = 'Everything the NPC profiles page draws. Each server with what its last status said about RunicNPC (loaded, version, API), whether the site can manage it (`ready`, and `absence` saying why not), and what the last push did (`sync`: state, when its own profiles were adopted, when last pushed, the profiles RunicNPC refused and why). Every profile, a replaced one included (D251), with `label`, the first of its NPC names. The prefabs the form offers, the three ways a profile’s kills may be counted (D247), and RunicNPC’s defaults for a new profile. Read from the stored status, so it answers while a server is off.' /* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcs" } } } } */ requireRole('admin'), npcs.describe, ) npcsRouter.put( '/factions', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Replace the NPC faction table' // #swagger.description = 'How factions treat each other (runicnpc stage 5, D254): one row per pair, both ways (D268), each `hostile`, `neutral` or `allied`. A pair that is not listed is neutral, and a faction is always allied to itself. `scientists` and `animals` are Rust’s own two; a pair of those two is refused, since RunicNPC does not change how Rust’s NPCs treat each other. Checked as RunicNPC checks it, stored as one table for the whole site, and pushed to every server with its profiles on the loop’s next tick. A profile with no faction settings still fights players only (D255).' /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["factions"], properties: { factions: { type: "array", items: { $ref: "#/components/schemas/RustNpcFactionPair" } } } } } } } */ /* #swagger.responses[200] = { description: 'Saved', content: { "application/json": { schema: { type: "object", properties: { factions: { type: "array", items: { $ref: "#/components/schemas/RustNpcFactionPair" } } } } } } } */ /* #swagger.responses[400] = { description: 'A pair RunicNPC would refuse, in its own words' } */ requireRole('admin'), body('factions').isArray({ max: 400 }).withMessage('factions is a list of { a, b, relation }, at most 400'), validate, npcs.setFactions, ) npcsRouter.post( '/profiles', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Save a new NPC profile' // #swagger.description = 'A RunicNPC profile (D238) for the servers it lists, or for every server (`allServers`). Checked as RunicNPC checks it, each kit against every covered server that answers (D217); a server that does not answer is left to RunicNPC, which refuses a missing kit when the profile is pushed. Two profiles of one name may not share a server. It reaches each server on the push loop’s next tick.' /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfileInput" } } } } */ /* #swagger.responses[201] = { description: 'Saved', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfile" } } } } */ /* #swagger.responses[400] = { description: 'A value RunicNPC would refuse, or a kit a covered server does not have' } */ /* #swagger.responses[404] = { description: 'A server that does not exist' } */ /* #swagger.responses[409] = { description: 'A profile of that name is already on one of the servers' } */ requireRole('admin'), ...profileBody, validate, npcs.create, ) npcsRouter.put( '/profiles/:pid', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Change an NPC profile' // #swagger.description = 'Replaces the profile whole, under the same rules as saving a new one. Every server it was or is now on is pushed again; their placements of it respawn with the new values. A profile kept aside as replaced (D251) must be restored first.' // #swagger.parameters['pid'] = { in: 'path', required: true, description: 'The profile’s id', schema: { type: 'integer' } } /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfileInput" } } } } */ /* #swagger.responses[200] = { description: 'Saved', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfile" } } } } */ /* #swagger.responses[400] = { description: 'A value RunicNPC would refuse, or a kit a covered server does not have' } */ /* #swagger.responses[404] = { description: 'No such profile, or a server that does not exist' } */ /* #swagger.responses[409] = { description: 'A profile of that name is already on one of the servers, or this one is kept aside as replaced' } */ requireRole('admin'), param('pid').isInt({ min: 1 }).withMessage('pid is a profile id'), ...profileBody, validate, npcs.update, ) npcsRouter.delete( '/profiles/:pid', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Delete an NPC profile' // #swagger.description = 'Removes it from every server it was on at the next push. Its placements there are kept and wait, showing “profile missing”, and spawn again if a profile of that name returns (D237). Its kills stay counted.' // #swagger.parameters['pid'] = { in: 'path', required: true, description: 'The profile’s id', schema: { type: 'integer' } } /* #swagger.responses[200] = { description: 'Deleted' } */ /* #swagger.responses[404] = { description: 'No such profile' } */ requireRole('admin'), param('pid').isInt({ min: 1 }).withMessage('pid is a profile id'), validate, npcs.remove, ) npcsRouter.post( '/profiles/:pid/restore', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Put a replaced NPC profile back into use' // #swagger.description = 'A server’s own profile, kept aside at adoption because a site profile of its name was already there (D251), is pushed to its server again. Refused while a site profile of that name still covers the server.' // #swagger.parameters['pid'] = { in: 'path', required: true, description: 'The profile’s id', schema: { type: 'integer' } } /* #swagger.responses[200] = { description: 'Restored', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfile" } } } } */ /* #swagger.responses[400] = { description: 'It is in use already' } */ /* #swagger.responses[404] = { description: 'No such profile' } */ /* #swagger.responses[409] = { description: 'A site profile of its name still covers its server' } */ requireRole('admin'), param('pid').isInt({ min: 1 }).withMessage('pid is a profile id'), validate, npcs.restore, ) npcsRouter.post( '/servers/:id/push', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Push the NPC profiles to one server now' // #swagger.description = 'Rather than on the loop’s next tick. A server the site has never pushed to has its own profiles adopted first (D244). The answer says what happened: `pushed` (with the profiles and any RunicNPC refused), `absent` (no RunicNPC, or one older than API 4, and why) or `failed` (with the reason).' // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } } /* #swagger.responses[200] = { description: 'What the push did' } */ /* #swagger.responses[404] = { description: 'No such server' } */ requireRole('admin'), serverParam, validate, npcs.push, ) npcsRouter.get( '/servers/:id/placements', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'One server’s NPC placements' // #swagger.description = 'Read live from the server, which holds them (D222): each placement’s name and values, how many of its NPCs are alive, what it waits for (a missing profile or route, D237) and its note (standing on the nearest navmesh because its ground went, D239). Also the routes a placement may walk and the cost warning for what the server plans now (D227).' // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } } /* #swagger.responses[200] = { description: 'The placements', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcPlacements" } } } } */ /* #swagger.responses[404] = { description: 'No such server' } */ /* #swagger.responses[409] = { description: 'The server has no RunicNPC, or one older than API 3' } */ /* #swagger.responses[503] = { description: 'The server’s game is not connected' } */ requireRole('admin'), serverParam, validate, npcs.listPlacements, ) npcsRouter.post( '/servers/:id/placements', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Place NPCs at a point on the live map' // #swagger.description = 'A new placement from a clicked point, `position` being x and z only (D245). The server puts it on the ground there (terrain or rock, never a building: a roof is placed in game), checks it against the navmesh as `/rnpc place` does, and names it after its profile and a number (D246). The answer carries the name, where it landed, whether that is on something players built, and the cost warning.' // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } } /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcPlacementInput" } } } } */ /* #swagger.responses[201] = { description: 'Placed' } */ /* #swagger.responses[400] = { description: 'RunicNPC refused it, with its reason (off the navmesh, under water, off the map, no such profile or route)' } */ /* #swagger.responses[503] = { description: 'The server’s game is not connected' } */ requireRole('admin'), serverParam, body('position').isObject().withMessage('position is { x, z }'), ...placementBody, validate, npcs.addPlacement, ) npcsRouter.put( '/servers/:id/placements/:placement', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Change an NPC placement' // #swagger.description = 'New values for a placement: its profile, count, respawn delay and mode, and movement (D246). Its spot is kept unless a whole `position` (x, y, z) is sent. Its NPCs are removed and spawn again from the new values.' // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } } // #swagger.parameters['placement'] = { in: 'path', required: true, description: 'The placement’s name', schema: { type: 'string' } } /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcPlacementInput" } } } } */ /* #swagger.responses[200] = { description: 'Changed' } */ /* #swagger.responses[400] = { description: 'RunicNPC refused it, with its reason' } */ /* #swagger.responses[404] = { description: 'No such placement' } */ requireRole('admin'), serverParam, placementParam, ...placementBody, validate, npcs.setPlacement, ) npcsRouter.delete( '/servers/:id/placements/:placement', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Remove an NPC placement' // #swagger.description = 'Removes the placement and its NPCs, as `/rnpc remove` does.' // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } } // #swagger.parameters['placement'] = { in: 'path', required: true, description: 'The placement’s name', schema: { type: 'string' } } /* #swagger.responses[200] = { description: 'Removed' } */ /* #swagger.responses[404] = { description: 'No such placement' } */ requireRole('admin'), serverParam, placementParam, validate, npcs.removePlacement, ) npcsRouter.post( '/servers/:id/placements/:placement/rename', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Rename an NPC placement' // #swagger.description = 'As `/rnpc rename` (D241). Its live NPCs keep living under the new name.' // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } } // #swagger.parameters['placement'] = { in: 'path', required: true, description: 'The placement’s name', schema: { type: 'string' } } /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { to: { type: "string", example: "gate" } } } } } } */ /* #swagger.responses[200] = { description: 'Renamed' } */ /* #swagger.responses[400] = { description: 'The new name is taken or not a name' } */ /* #swagger.responses[404] = { description: 'No such placement' } */ requireRole('admin'), serverParam, placementParam, body('to').isString().matches(NAME).withMessage('to is 1 to 40 of a-z, 0-9, _ and -'), validate, npcs.renamePlacement, ) npcsRouter.post( '/servers/:id/placements/:placement/respawn', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Respawn an NPC placement now' // #swagger.description = 'As `/rnpc respawn`: its NPCs are removed and spawn again at once, whatever their respawn delay.' // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } } // #swagger.parameters['placement'] = { in: 'path', required: true, description: 'The placement’s name', schema: { type: 'string' } } /* #swagger.responses[200] = { description: 'Respawning' } */ /* #swagger.responses[404] = { description: 'No such placement' } */ requireRole('admin'), serverParam, placementParam, validate, npcs.respawnPlacement, ) module.exports = npcsRouter