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." + } + } } } }