feat(rust): event NPCs named on the map, and a Place NPCs step's progress line (runicnpc stage 8)
- 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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
44
client/src/lib/mapEvents.js
Normal file
44
client/src/lib/mapEvents.js
Normal 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'))
|
||||
}
|
||||
@@ -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.' },
|
||||
|
||||
49
client/test/mapEvents.test.js
Normal file
49
client/test/mapEvents.test.js
Normal 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'])
|
||||
})
|
||||
Reference in New Issue
Block a user