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

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