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

View File

@@ -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 `<img src>`, a
// download link, an EventSource. Reach for `request` first.
export { BASE, query }
@@ -342,5 +351,6 @@ export default {
playerNpcKills,
adminUserLinks,
adminUserPermissions,
coreEvents,
BASE,
}

View File

@@ -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(() => {

View File

@@ -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'))
}

View File

@@ -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.' },

View File

@@ -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'])
})