diff --git a/ci/bundle.json b/ci/bundle.json
index 2680b9e..80783ea 100644
--- a/ci/bundle.json
+++ b/ci/bundle.json
@@ -35,6 +35,7 @@
"db",
"engagement",
"eventLeases.js",
+ "eventProgress.js",
"eventRewards.js",
"eventWorld.js",
"index.js",
diff --git a/client/src/api.js b/client/src/api.js
index 1a26de7..ee68435 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -323,6 +323,15 @@ export const adminUserPermissions = {
),
}
+// ── core's public event calendar ───────────────────────────────────────────
+// The one CORE route this module reads, and core published `runId` on it for
+// exactly this: a map marker holds a run id and nothing else, and the calendar is
+// how a client finds the event it belongs to (website EVENTS.md §I, Phase 14a).
+// It lists only announced events, so an unannounced one stays unnamed here too.
+export const coreEvents = {
+ calendar: () => req('/public/events'),
+}
+
// Exported for the rare caller that needs the base itself — an `
`, a
// download link, an EventSource. Reach for `request` first.
export { BASE, query }
@@ -342,5 +351,6 @@ export default {
playerNpcKills,
adminUserLinks,
adminUserPermissions,
+ coreEvents,
BASE,
}
diff --git a/client/src/components/MapView.jsx b/client/src/components/MapView.jsx
index ada424a..63d9489 100644
--- a/client/src/components/MapView.jsx
+++ b/client/src/components/MapView.jsx
@@ -21,6 +21,7 @@ import Empty from './Empty.jsx'
import usePolled from '../hooks/usePolled.js'
import { ago } from '../lib/format.js'
import { boundsOf, countdown, fromLatLng, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
+import { eventTip, isBoss, titlesFrom, unknownRuns } from '../lib/mapEvents.js'
import api, { BASE } from '../api.js'
const STYLE_ID = 'rust-leaflet-css'
@@ -45,6 +46,7 @@ const COLOURS = {
supply: '#66bb6a',
crate: '#ffee58',
event: '#ce93d8',
+ boss: '#d500f9',
online: '#ffffff',
sleeping: '#9e9e9e',
tc: '#ff7043',
@@ -60,6 +62,7 @@ const WORLD_NAMES = {
bradley: 'Bradley APC',
supply: 'Supply drop',
crate: 'Locked crate',
+ boss: 'Boss',
}
const AUDIENCE_WORDS = { public: 'everyone', signed_in: 'signed-in players', staff: 'staff only' }
@@ -104,6 +107,34 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
enabled: Boolean(geometry) && anyLive,
})
+ // Event titles by run id, from core's public calendar (runicnpc D303): asked
+ // when the events layer names a run this page does not know, and at most once
+ // a minute, so a run that is not announced is not asked about on every poll.
+ const [titles, setTitles] = useState({})
+ const askedAt = useRef(0)
+ const liveEvents = live.data && live.data.events
+ useEffect(() => {
+ if (!unknownRuns(liveEvents, titles).length || Date.now() - askedAt.current < 60_000) return undefined
+ askedAt.current = Date.now()
+ let active = true
+ api.coreEvents
+ .calendar()
+ .then((calendar) => {
+ if (!active) return
+ // Every run asked about is filed, named or not, so it is not asked again.
+ const known = titlesFrom(calendar)
+ setTitles((prev) => {
+ const next = { ...prev, ...known }
+ for (const id of unknownRuns(liveEvents, prev)) if (!(id in next)) next[id] = null
+ return next
+ })
+ })
+ .catch(() => {})
+ return () => {
+ active = false
+ }
+ }, [liveEvents, titles])
+
const container = useRef(null)
const mapRef = useRef(null)
const groups = useRef(null)
@@ -213,7 +244,10 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
let tip = WORLD_NAMES[w.kind] || w.kind
if (w.kind === 'crate' && w.hackLeftSec != null) tip += ` — ${countdown(w.hackLeftSec)} left on the hack`
if (w.kind === 'crate' && w.hacked) tip += ' — hacked'
- dot(L, at(w), COLOURS[w.kind] || COLOURS.crate, w.kind === 'cargo' ? 7 : 5).bindTooltip(escape(tip)).addTo(made.world)
+ // A RunicNPC boss outside any event (D302): its name, drawn as big as a ship.
+ if (w.kind === 'boss' && w.name) tip = `${w.name} (boss)`
+ const size = w.kind === 'cargo' || isBoss(w) ? 7 : 5
+ dot(L, at(w), COLOURS[w.kind] || COLOURS.crate, size, isBoss(w) ? 2 : 1).bindTooltip(escape(tip)).addTo(made.world)
}
const px = (metres) => metres * ((geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize)
@@ -223,7 +257,10 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
.bindTooltip(escape(e.name ? `Event zone · ${e.name}` : 'Event zone'))
.addTo(made.events)
} else {
- dot(L, at(e), COLOURS.event, 5).bindTooltip(e.kind === 'npc' ? 'Event NPC' : 'Event crate').addTo(made.events)
+ // A name and its event; a boss bigger, never its health (D303).
+ dot(L, at(e), isBoss(e) ? COLOURS.boss : COLOURS.event, isBoss(e) ? 8 : 5, isBoss(e) ? 2 : 1)
+ .bindTooltip(escape(eventTip(e, titles)))
+ .addTo(made.events)
}
}
@@ -242,7 +279,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
.bindTooltip(escape(m.self ? `You${m.online ? '' : ' (asleep, offline)'}` : m.name || 'Clan mate'))
.addTo(made.mates)
}
- }, [leaflet, geometry, live.data])
+ }, [leaflet, geometry, live.data, titles])
// An admin page's own pins, above everything else.
useEffect(() => {
diff --git a/client/src/lib/mapEvents.js b/client/src/lib/mapEvents.js
new file mode 100644
index 0000000..abdb05c
--- /dev/null
+++ b/client/src/lib/mapEvents.js
@@ -0,0 +1,44 @@
+// ── What an event marker says (runicnpc stage 8, D302, D303) ──────────────
+//
+// An NPC's marker says its name and its event; a boss is drawn bigger, and no
+// health is shown (D303 — the server does not even send it). A boss placed
+// outside any event is in the world layer as `kind: 'boss'` (D302).
+//
+// The event's title comes from core's public calendar, which publishes each
+// run's id for this: a map row holds a run id and nothing else. The calendar
+// lists announced events only, so an unannounced event is "an event" here and
+// nothing more.
+//
+// Pure, and free of React, so it is tested in Node.
+
+/** `{ [runId]: title }` from core's public calendar answer. */
+export function titlesFrom(calendar) {
+ const out = {}
+ for (const e of (calendar && calendar.entries) || []) {
+ if (e && e.kind === 'run' && e.runId != null && e.title) out[String(e.runId)] = e.title
+ }
+ return out
+}
+
+/** The run ids an answer's events layer names that `titles` does not know yet. */
+export function unknownRuns(events, titles) {
+ const out = new Set()
+ for (const e of events || []) {
+ if (e && e.runId != null && !Object.prototype.hasOwnProperty.call(titles || {}, String(e.runId))) out.add(String(e.runId))
+ }
+ return [...out]
+}
+
+/** The tooltip for one events-layer row that is not a zone. */
+export function eventTip(row, titles) {
+ const event = (titles && row && row.runId != null && titles[String(row.runId)]) || 'an event'
+ if (!row || row.kind !== 'npc') return `Event crate · ${event}`
+ const who = row.name || 'Event NPC'
+ const role = row.boss ? ' (boss)' : row.add ? " (a boss's add)" : ''
+ return `${who}${role} · ${event}`
+}
+
+/** Whether a row is drawn as a boss: bigger (D303). */
+export function isBoss(row) {
+ return Boolean(row && (row.boss || row.kind === 'boss'))
+}
diff --git a/client/src/routes/admin/Visibility.jsx b/client/src/routes/admin/Visibility.jsx
index 0a15edc..dbdd74f 100644
--- a/client/src/routes/admin/Visibility.jsx
+++ b/client/src/routes/admin/Visibility.jsx
@@ -380,7 +380,7 @@ const selectStyle = {
// ── The live map (phase 14) ───────────────────────────────────────────────
const MAP_LAYERS = [
- { id: 'world', label: 'Monuments & world events', hint: 'Cargo, helicopters, Bradley, supply drops, locked crates.' },
+ { id: 'world', label: 'Monuments & world events', hint: 'Cargo, helicopters, Bradley, supply drops, locked crates, and RunicNPC bosses placed outside an event.' },
{ id: 'events', label: 'Site events', hint: 'The zones, crates and NPCs this site’s events placed, while they run.' },
{ id: 'players', label: 'Players', hint: 'Where everybody is, with names, and the sleepers.' },
{ id: 'bases', label: 'Bases', hint: 'Tool cupboards and vending machines, as positions only.' },
diff --git a/client/test/mapEvents.test.js b/client/test/mapEvents.test.js
new file mode 100644
index 0000000..1a06fbb
--- /dev/null
+++ b/client/test/mapEvents.test.js
@@ -0,0 +1,49 @@
+// ── What an event marker says (runicnpc stage 8, D302, D303) ──────────────
+//
+// The decisions: a name and an event title, never health; a boss marked and
+// drawn bigger; an unannounced event named by nothing but "an event".
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { eventTip, isBoss, titlesFrom, unknownRuns } from '../src/lib/mapEvents.js'
+
+const CALENDAR = {
+ entries: [
+ { kind: 'run', runId: 41, title: 'Harbour Raid' },
+ { kind: 'projected', title: 'Next week' },
+ { kind: 'run', runId: 42, title: '' },
+ ],
+}
+
+test("the calendar's runs become titles by run id; a projection has none", () => {
+ assert.deepEqual(titlesFrom(CALENDAR), { 41: 'Harbour Raid' })
+ assert.deepEqual(titlesFrom(null), {})
+})
+
+test('an NPC says its name and its event; a boss and an add say which they are', () => {
+ const titles = titlesFrom(CALENDAR)
+ assert.equal(eventTip({ kind: 'npc', runId: '41', name: 'Bandit' }, titles), 'Bandit · Harbour Raid')
+ assert.equal(eventTip({ kind: 'npc', runId: '41', name: 'The Juggernaut', boss: true }, titles), 'The Juggernaut (boss) · Harbour Raid')
+ assert.equal(eventTip({ kind: 'npc', runId: '41', name: 'Bandit', add: true }, titles), "Bandit (a boss's add) · Harbour Raid")
+})
+
+test('an unannounced event, a Rust scientist and a crate', () => {
+ assert.equal(eventTip({ kind: 'npc', runId: '7', name: 'Bandit' }, {}), 'Bandit · an event')
+ assert.equal(eventTip({ kind: 'npc', runId: '41' }, { 41: 'Harbour Raid' }), 'Event NPC · Harbour Raid')
+ assert.equal(eventTip({ kind: 'crate', runId: '41' }, { 41: 'Harbour Raid' }), 'Event crate · Harbour Raid')
+})
+
+test('health never reaches a tooltip, even if a row carried it', () => {
+ assert.equal(eventTip({ kind: 'npc', runId: '41', name: 'J', boss: true, health: 0.5 }, {}).includes('50'), false)
+})
+
+test('a boss is an event boss or a world one', () => {
+ assert.equal(isBoss({ kind: 'npc', boss: true }), true)
+ assert.equal(isBoss({ kind: 'boss' }), true)
+ assert.equal(isBoss({ kind: 'npc' }), false)
+})
+
+test('the run ids still to look up', () => {
+ assert.deepEqual(unknownRuns([{ runId: '41' }, { runId: '7' }, { runId: '7' }, {}], { 41: 'x' }), ['7'])
+})
diff --git a/server/eventProgress.js b/server/eventProgress.js
new file mode 100644
index 0000000..f7c3044
--- /dev/null
+++ b/server/eventProgress.js
@@ -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 }
diff --git a/server/eventWorld.js b/server/eventWorld.js
index d980f9b..a2439d4 100644
--- a/server/eventWorld.js
+++ b/server/eventWorld.js
@@ -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',
diff --git a/server/model/map/map.model.js b/server/model/map/map.model.js
index 6217927..517c289 100644
--- a/server/model/map/map.model.js
+++ b/server/model/map/map.model.js
@@ -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
diff --git a/server/model/npcs/npcs.model.js b/server/model/npcs/npcs.model.js
index 7b7a7c9..673c5b1 100644
--- a/server/model/npcs/npcs.model.js
+++ b/server/model/npcs/npcs.model.js
@@ -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,
diff --git a/server/swagger/doc.js b/server/swagger/doc.js
index 6bb1311..1f1ffc1 100644
--- a/server/swagger/doc.js
+++ b/server/swagger/doc.js
@@ -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:` 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.' },
},
},
},
diff --git a/server/test/eventProgress.test.js b/server/test/eventProgress.test.js
new file mode 100644
index 0000000..7f30de5
--- /dev/null
+++ b/server/test/eventProgress.test.js
@@ -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)
+})
diff --git a/server/test/map.test.js b/server/test/map.test.js
index 274c098..3ac5609 100644
--- a/server/test/map.test.js
+++ b/server/test/map.test.js
@@ -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'))
diff --git a/swagger-fragment.json b/swagger-fragment.json
index 0cb2297..e83e46b 100644
--- a/swagger-fragment.json
+++ b/swagger-fragment.json
@@ -9232,7 +9232,8 @@
"chinook",
"bradley",
"supply",
- "crate"
+ "crate",
+ "boss"
],
"items": {
"type": "string"
@@ -9299,6 +9300,23 @@
"example": false
}
}
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "example": "A boss’s: one of RunicNPC’s, placed outside any event (runicnpc D302)."
+ },
+ "example": {
+ "type": "string",
+ "example": "The Juggernaut"
+ }
+ }
}
}
}
@@ -9404,7 +9422,7 @@
},
"description": {
"type": "string",
- "example": "A zone’s."
+ "example": "A zone’s, or a RunicNPC NPC’s."
},
"example": {
"type": "string",
@@ -9421,13 +9439,52 @@
},
"description": {
"type": "string",
- "example": "A crate’s or NPC’s allowlist key."
+ "example": "A crate’s or NPC’s allowlist key, or `profile:` for a RunicNPC profile."
},
"example": {
"type": "string",
"example": "crate.elite"
}
}
+ },
+ "key": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "example": "The step that placed it, so a step’s NPCs still standing can be counted."
+ }
+ }
+ },
+ "boss": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "A RunicNPC boss; the map draws it bigger (runicnpc D303). Its health is not sent."
+ }
+ }
+ },
+ "add": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "One of a boss’s adds: placed by the boss, not by a step, and owned by its run."
+ }
+ }
}
}
}