feat(rust): event NPCs named on the map, and a Place NPCs step's progress line (runicnpc stage 8)
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 25s
PR Checks / frozen-manifest (pull_request) Successful in -1m18s

- rust.npc.place's progress() (MODULE_API 1.12.0, D304). It reports the
  step's NPCs still standing, by its key, of how many it placed, with
  the profile's label: "Bandit: 2 of 3 left". A boss step is its
  health, "Walk Juggernaut: 59%", and a boss's adds are not counted.
  The data comes from the map's live answer, so it costs the game no
  extra ask. A reader who may not see the map's events layer gets no
  line.
- The map (D302, D303):
  - an NPC's tooltip is its name and its event's title, read from
    core's public calendar, which lists only announced events;
  - a boss is bigger and in its own colour, and an add says so;
  - a boss outside any event is in the world layer;
  - a boss's health is removed from what a viewer is sent.
- The world layer's visibility hint mentions those bosses.

coreApi stays ^1.11.0: an older core ignores the optional member, and
the action works without a progress line.

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-06 08:23:13 -05:00
parent 3c3d154b36
commit 5162496d74
14 changed files with 484 additions and 11 deletions

90
server/eventProgress.js Normal file
View File

@@ -0,0 +1,90 @@
// ── How a Place NPCs step stands, for its event's page (runicnpc stage 8) ──
//
// MODULE_API 1.12.0: core asks an action's `progress()` while a run is live and
// renders the answer as text it does not interpret. D304 settles what this one
// says: a line per Place NPCs step — "Bandits: 3 of 8 left" — and a boss as its
// health, "The Juggernaut: 62%". A boss's adds are not counted.
//
// **Where it comes from.** The step's ledger rows say which server and how many
// were placed; the map's live answer (`mapLive`, one `map.live` per server per
// five seconds, whoever asks) says which of them still stand, by the step's key,
// and a boss's health. So a busy event page costs the game nothing the map does
// not already cost it.
//
// **The map's events layer decides who may read it.** A page must never tell a
// reader what the map would hide from them, so a reader who may not see the
// events layer on that server gets no line at all.
//
// Never throws: core drops a throw, but a quiet `null` is the honest answer when
// the game or a setting cannot be read.
const servers = require('./model/servers/servers.model')
const serversDb = require('./model/servers/servers.db')
const npcs = require('./model/npcs/npcs.model')
const mapModel = require('./model/map/map.model')
const mapLive = require('./mapLive')
const core = require('./core')
const log = core.logger('progress')
/** The label for Rust's own NPCs, by their allowlist key, set by `eventWorld`. */
let ownLabels = new Map()
/** Called once by eventWorld with its PLACEABLE list. */
function setOwnLabels(placeable) {
ownLabels = new Map(placeable.filter((p) => p.kind === 'npc').map((p) => [p.key, p.label]))
}
/** The server a step's NPCs are on, from its ledger rows. */
function serverOf(resources) {
for (const r of resources || []) {
if (r && r.payload && r.payload.serverId) return String(r.payload.serverId)
}
return null
}
/**
* `progress()` for `rust.npc.place`: `{ label, left, of }`, `{ label, percent }`
* or null.
*/
async function npcProgress({ runId, idempotencyKey, params, resources, viewer }) {
try {
const of = (resources || []).length
if (of === 0) return null
const serverId = serverOf(resources)
if (!serverId) return null
const access = await mapModel.access({ user: viewer && viewer.userId != null ? { id: viewer.userId } : null }, serverId)
if (!access.layers.events.visible) return null
const row = await serversDb.getServer(serverId)
if (!row || !row.enabled) return null
const picked = String((params && params.prefab) || '')
const profileName = picked.startsWith(npcs.PROFILE_PREFIX) ? picked.slice(npcs.PROFILE_PREFIX.length) : null
const profile = profileName ? await npcs.profileOn(serverId, profileName) : null
const label = profile ? profile.label : ownLabels.get(picked) || picked
if (!label) return null
const live = await mapLive.live(servers.withToken(row))
if (!live.ok || !live.data) return null
const standing = (live.data.events || []).filter(
(e) => e && e.kind === 'npc' && String(e.runId) === String(runId) && e.key === idempotencyKey && !e.add,
)
// A boss is its health; a dead one, nothing left.
if (profile && profile.boss) {
const boss = standing.find((e) => e.boss)
const health = boss && typeof boss.health === 'number' ? boss.health : 0
return { label, percent: Math.round(Math.max(0, Math.min(1, health)) * 100) }
}
return { label, left: Math.min(standing.length, of), of }
} catch (err) {
log.warn('could not say how a Place NPCs step stands', { run: runId, error: err.message })
return null
}
}
module.exports = { npcProgress, setOwnLabels, serverOf }

View File

@@ -35,6 +35,7 @@ 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 eventProgress = require('./eventProgress')
const log = core.logger('world')
@@ -100,6 +101,7 @@ const PLACEABLE = [
{ key: 'npc.scientist.tethered', kind: 'npc', label: 'Scientist (stays put)' },
{ key: 'npc.bandit.guard', kind: 'npc', label: 'Bandit guard' },
]
eventProgress.setOwnLabels(PLACEABLE)
/** The plugin's refusals a second attempt would repeat. Anything else is left to core's default. */
const PERMANENT = new Set([
@@ -477,6 +479,9 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
label,
description,
cost: (p) => ({ [budget]: Math.max(0, Math.round(Number(p.count) || 0)) }),
// Runicnpc stage 8 (D304, MODULE_API 1.12.0): how a live run's NPCs stand, for
// its event page. Crates say nothing: one looted is simply gone.
...(kind === 'npc' ? { progress: eventProgress.npcProgress } : {}),
params: [
{
name: 'prefab',

View File

@@ -301,6 +301,13 @@ async function mateIdsFor(serverId, acc) {
return { own, mates: new Set(clan.filter((id) => !own.has(id))) }
}
/** An events row without the health a boss's carries. */
function withoutHealth(row) {
if (!row || typeof row !== 'object' || !('health' in row)) return row
const { health, ...rest } = row
return rest
}
/**
* One live answer, cut down to what one viewer may see. **Pure**, and the
* security boundary in one function: a layer the viewer may not see is not in
@@ -315,7 +322,9 @@ function project(live, acc, ids = { own: new Set(), mates: new Set() }) {
const out = { mapKey: live.mapKey || null, t: live.t || null }
if (acc.layers.world.visible) out.world = Array.isArray(live.world) ? live.world : []
if (acc.layers.events.visible) out.events = Array.isArray(live.events) ? live.events : []
// A boss's health rides on the wire for the event page's progress line, and the
// map does not show it (runicnpc D303), so it is not in what a viewer is sent.
if (acc.layers.events.visible) out.events = Array.isArray(live.events) ? live.events.map(withoutHealth) : []
if (acc.layers.players.visible) {
out.players = Array.isArray(live.players) ? live.players : []
if (live.playersTruncated) out.playersTruncated = true

View File

@@ -455,6 +455,17 @@ async function siteProfileFor(serverId, name) {
return sync && sync.pushed && sync.pushed[name] ? Number(sync.pushed[name]) : 0
}
/**
* One profile as a server has it, for an event page's progress line (runicnpc
* stage 8, D304): its label and whether it is a boss. Null when no active profile
* of that name is for the server.
*/
async function profileOn(serverId, name) {
const found = active(await db.listProfiles()).find((p) => p.name === name && covers(p, serverId))
if (!found) return null
return { label: shape.labelOf(found), boss: Boolean(found.body && found.body.boss) }
}
/** The profiles a server's leaderboard may rank by: those pushed to it, with their labels. */
async function boardProfiles(serverId) {
return active(await db.listProfiles())
@@ -517,6 +528,7 @@ module.exports = {
renamePlacement,
respawnPlacement,
optionRows,
profileOn,
siteProfileFor,
boardProfiles,
ranking,

View File

@@ -918,11 +918,12 @@ module.exports = {
items: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['cargo', 'heli', 'chinook', 'bradley', 'supply', 'crate'], example: 'cargo' },
kind: { type: 'string', enum: ['cargo', 'heli', 'chinook', 'bradley', 'supply', 'crate', 'boss'], example: 'cargo' },
x: { type: 'number', example: 812.4 },
z: { type: 'number', example: -1320.6 },
hackLeftSec: { type: 'integer', description: 'A locked crate being hacked: seconds left.', example: 540 },
hacked: { type: 'boolean', example: false },
name: { type: 'string', description: 'A boss’s: one of RunicNPC’s, placed outside any event (runicnpc D302).', example: 'The Juggernaut' },
},
},
},
@@ -936,8 +937,11 @@ module.exports = {
x: { type: 'number' },
z: { type: 'number' },
radius: { type: 'number', description: 'A zone’s.', example: 60 },
name: { type: 'string', description: 'A zone’s.', example: 'Harbor brawl' },
prefab: { type: 'string', description: 'A crate’s or NPC’s allowlist key.', example: 'crate.elite' },
name: { type: 'string', description: 'A zone’s, or a RunicNPC NPC’s.', example: 'Harbor brawl' },
prefab: { type: 'string', description: 'A crate’s or NPC’s allowlist key, or `profile:<name>` for a RunicNPC profile.', example: 'crate.elite' },
key: { type: 'string', description: 'The step that placed it, so a step’s NPCs still standing can be counted.' },
boss: { type: 'boolean', description: 'A RunicNPC boss; the map draws it bigger (runicnpc D303). Its health is not sent.' },
add: { type: 'boolean', description: 'One of a boss’s adds: placed by the boss, not by a step, and owned by its run.' },
},
},
},

View File

@@ -0,0 +1,137 @@
// ── A Place NPCs step's progress line (runicnpc stage 8, D304) ─────────────
//
// `rust.npc.place`'s `progress()` (MODULE_API 1.12.0). The decisions it carries:
//
// • a line per step: the profile's label, how many of its NPCs still stand by
// the step's key, of how many it placed; a boss's adds are not counted
// • a boss step is its boss's health as a percent, 0 once it is dead
// • a reader who may not see the map's events layer gets nothing (the page
// never says what the map would hide)
// • anything that cannot be read is null, never a throw
//
// Stubbed at the model seams the function reads.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const progress = require('../eventProgress')
const eventWorld = require('../eventWorld')
const mapModel = require('../model/map/map.model')
const mapLive = require('../mapLive')
const serversDb = require('../model/servers/servers.db')
const npcs = require('../model/npcs/npcs.model')
const { fakeCtx } = require('./_fakes')
const saved = {
access: mapModel.access,
live: mapLive.live,
getServer: serversDb.getServer,
profileOn: npcs.profileOn,
}
let events
let visible
let profile
beforeEach(() => {
require('../core')._reset()
require('../core').init(fakeCtx())
events = []
visible = true
profile = { label: 'Bandit', boss: false }
mapModel.access = async () => ({ layers: { events: { visible } } })
mapLive.live = async () => ({ ok: true, data: { events } })
serversDb.getServer = async (id) => ({ id, name: id, enabled: true })
npcs.profileOn = async () => profile
})
afterEach(() => {
mapModel.access = saved.access
mapLive.live = saved.live
serversDb.getServer = saved.getServer
npcs.profileOn = saved.profileOn
})
const placed = (n, serverId = 'main') =>
Array.from({ length: n }, (_, i) => ({ kind: 'world', ref: `${serverId}/${i}`, payload: { serverId, what: 'npc' } }))
const envelope = (over = {}) => ({
runId: 41,
idempotencyKey: 'k1',
params: { prefab: 'profile:bandit', count: 8 },
resources: placed(8),
viewer: { userId: null },
...over,
})
const npc = (over = {}) => ({ kind: 'npc', runId: '41', key: 'k1', x: 0, z: 0, ...over })
test('rust.npc.place declares progress, and the crate verb does not', () => {
const byId = new Map(eventWorld.ACTIONS.map((a) => [a.id, a]))
assert.equal(typeof byId.get('rust.npc.place').progress, 'function')
assert.equal(byId.get('rust.crate.place').progress, undefined)
})
test("a step's NPCs still standing, by its key, of how many it placed; adds and other steps are not counted", async () => {
events = [
npc(),
npc(),
npc(),
npc({ add: true }),
npc({ key: 'k2' }),
npc({ runId: '99' }),
{ kind: 'zone', runId: '41', key: 'k1', x: 0, z: 0 },
]
assert.deepStrictEqual(await progress.npcProgress(envelope()), { label: 'Bandit', left: 3, of: 8 })
})
test('all dead is 0 left, not nothing', async () => {
assert.deepStrictEqual(await progress.npcProgress(envelope()), { label: 'Bandit', left: 0, of: 8 })
})
test("a boss step is its boss's health, and 0% once it is dead", async () => {
profile = { label: 'The Juggernaut', boss: true }
events = [npc({ boss: true, health: 0.616 }), npc({ add: true })]
const env = envelope({ params: { prefab: 'profile:jugg', count: 1 }, resources: placed(1) })
assert.deepStrictEqual(await progress.npcProgress(env), { label: 'The Juggernaut', percent: 62 })
events = []
assert.deepStrictEqual(await progress.npcProgress(env), { label: 'The Juggernaut', percent: 0 })
})
test("Rust's own scientists are labelled as the step picker labels them", async () => {
events = [npc()]
const env = envelope({ params: { prefab: 'npc.scientist.heavy', count: 2 }, resources: placed(2) })
assert.deepStrictEqual(await progress.npcProgress(env), { label: 'Heavy scientist', left: 1, of: 2 })
})
test('a reader who may not see the events layer gets no line', async () => {
events = [npc()]
visible = false
assert.equal(await progress.npcProgress(envelope()), null)
})
test('the reader is passed to the map visibility check as the request it reads', async () => {
let seen
mapModel.access = async (req, serverId) => {
seen = { req, serverId }
return { layers: { events: { visible: true } } }
}
await progress.npcProgress(envelope({ viewer: { userId: 12 }, resources: placed(2, 'pve') }))
assert.deepStrictEqual(seen, { req: { user: { id: 12 } }, serverId: 'pve' })
})
test('nothing placed, a game that does not answer, a server switched off, or a throw is null', async () => {
assert.equal(await progress.npcProgress(envelope({ resources: [] })), null)
mapLive.live = async () => ({ ok: false, status: 'http-503', data: null })
assert.equal(await progress.npcProgress(envelope()), null)
mapLive.live = async () => ({ ok: true, data: { events: [] } })
serversDb.getServer = async (id) => ({ id, enabled: false })
assert.equal(await progress.npcProgress(envelope()), null)
serversDb.getServer = async () => {
throw new Error('pool closed')
}
assert.equal(await progress.npcProgress(envelope()), null)
})

View File

@@ -130,6 +130,24 @@ test('D113: widening both the layer and presence does show them', async (t) => {
assert.equal(map.project(LIVE, acc).players.length, LIVE.players.length)
})
test("runicnpc D303: a boss's health is not sent to the map, and its name, key and flags are", async (t) => {
settings(t)
const live = {
...LIVE,
events: [
{ kind: 'npc', runId: '4', x: 1, z: 1, prefab: 'profile:jugg', key: 'k1', name: 'The Juggernaut', boss: true, health: 0.62 },
{ kind: 'npc', runId: '4', x: 2, z: 2, prefab: 'profile:bandit', key: 'k2', name: 'Bandit', add: true },
],
}
const out = map.project(live, await map.access({}, 'main'))
assert.equal(Object.prototype.hasOwnProperty.call(out.events[0], 'health'), false)
assert.equal(out.events[0].name, 'The Juggernaut')
assert.equal(out.events[0].boss, true)
assert.equal(out.events[0].key, 'k1')
assert.deepStrictEqual(out.events[1], live.events[1], 'a row without health passes untouched')
assert.equal(live.events[0].health, 0.62, "the cache's own row is not changed")
})
test('a hidden layer is absent from the answer, not an empty array', async (t) => {
settings(t)
const out = map.project(LIVE, await map.access({}, 'main'))