Merge pull request 'feat(rust): event NPCs named on the map, and a Place NPCs step's progress line (runicnpc stage 8)' (#33) from feat/runicnpc-stage8 into edge
Reviewed-on: #33
This commit is contained in:
@@ -35,6 +35,7 @@
|
|||||||
"db",
|
"db",
|
||||||
"engagement",
|
"engagement",
|
||||||
"eventLeases.js",
|
"eventLeases.js",
|
||||||
|
"eventProgress.js",
|
||||||
"eventRewards.js",
|
"eventRewards.js",
|
||||||
"eventWorld.js",
|
"eventWorld.js",
|
||||||
"index.js",
|
"index.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 `<img src>`, a
|
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
||||||
// download link, an EventSource. Reach for `request` first.
|
// download link, an EventSource. Reach for `request` first.
|
||||||
export { BASE, query }
|
export { BASE, query }
|
||||||
@@ -342,5 +351,6 @@ export default {
|
|||||||
playerNpcKills,
|
playerNpcKills,
|
||||||
adminUserLinks,
|
adminUserLinks,
|
||||||
adminUserPermissions,
|
adminUserPermissions,
|
||||||
|
coreEvents,
|
||||||
BASE,
|
BASE,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import Empty from './Empty.jsx'
|
|||||||
import usePolled from '../hooks/usePolled.js'
|
import usePolled from '../hooks/usePolled.js'
|
||||||
import { ago } from '../lib/format.js'
|
import { ago } from '../lib/format.js'
|
||||||
import { boundsOf, countdown, fromLatLng, grid, gridLabel, toLatLng } from '../lib/mapGeometry.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'
|
import api, { BASE } from '../api.js'
|
||||||
|
|
||||||
const STYLE_ID = 'rust-leaflet-css'
|
const STYLE_ID = 'rust-leaflet-css'
|
||||||
@@ -45,6 +46,7 @@ const COLOURS = {
|
|||||||
supply: '#66bb6a',
|
supply: '#66bb6a',
|
||||||
crate: '#ffee58',
|
crate: '#ffee58',
|
||||||
event: '#ce93d8',
|
event: '#ce93d8',
|
||||||
|
boss: '#d500f9',
|
||||||
online: '#ffffff',
|
online: '#ffffff',
|
||||||
sleeping: '#9e9e9e',
|
sleeping: '#9e9e9e',
|
||||||
tc: '#ff7043',
|
tc: '#ff7043',
|
||||||
@@ -60,6 +62,7 @@ const WORLD_NAMES = {
|
|||||||
bradley: 'Bradley APC',
|
bradley: 'Bradley APC',
|
||||||
supply: 'Supply drop',
|
supply: 'Supply drop',
|
||||||
crate: 'Locked crate',
|
crate: 'Locked crate',
|
||||||
|
boss: 'Boss',
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUDIENCE_WORDS = { public: 'everyone', signed_in: 'signed-in players', staff: 'staff only' }
|
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,
|
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 container = useRef(null)
|
||||||
const mapRef = useRef(null)
|
const mapRef = useRef(null)
|
||||||
const groups = 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
|
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.hackLeftSec != null) tip += ` — ${countdown(w.hackLeftSec)} left on the hack`
|
||||||
if (w.kind === 'crate' && w.hacked) tip += ' — hacked'
|
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)
|
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'))
|
.bindTooltip(escape(e.name ? `Event zone · ${e.name}` : 'Event zone'))
|
||||||
.addTo(made.events)
|
.addTo(made.events)
|
||||||
} else {
|
} 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'))
|
.bindTooltip(escape(m.self ? `You${m.online ? '' : ' (asleep, offline)'}` : m.name || 'Clan mate'))
|
||||||
.addTo(made.mates)
|
.addTo(made.mates)
|
||||||
}
|
}
|
||||||
}, [leaflet, geometry, live.data])
|
}, [leaflet, geometry, live.data, titles])
|
||||||
|
|
||||||
// An admin page's own pins, above everything else.
|
// An admin page's own pins, above everything else.
|
||||||
useEffect(() => {
|
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) ───────────────────────────────────────────────
|
// ── The live map (phase 14) ───────────────────────────────────────────────
|
||||||
|
|
||||||
const MAP_LAYERS = [
|
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: '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: '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.' },
|
{ 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'])
|
||||||
|
})
|
||||||
90
server/eventProgress.js
Normal file
90
server/eventProgress.js
Normal file
@@ -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 }
|
||||||
@@ -35,6 +35,7 @@ const npcsDb = require('./model/npcs/npcs.db')
|
|||||||
const npcProfile = require('./model/npcs/npcProfile')
|
const npcProfile = require('./model/npcs/npcProfile')
|
||||||
const clansDb = require('./model/clans/clans.db')
|
const clansDb = require('./model/clans/clans.db')
|
||||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||||
|
const eventProgress = require('./eventProgress')
|
||||||
|
|
||||||
const log = core.logger('world')
|
const log = core.logger('world')
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@ const PLACEABLE = [
|
|||||||
{ key: 'npc.scientist.tethered', kind: 'npc', label: 'Scientist (stays put)' },
|
{ key: 'npc.scientist.tethered', kind: 'npc', label: 'Scientist (stays put)' },
|
||||||
{ key: 'npc.bandit.guard', kind: 'npc', label: 'Bandit guard' },
|
{ 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. */
|
/** The plugin's refusals a second attempt would repeat. Anything else is left to core's default. */
|
||||||
const PERMANENT = new Set([
|
const PERMANENT = new Set([
|
||||||
@@ -477,6 +479,9 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
|
|||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
cost: (p) => ({ [budget]: Math.max(0, Math.round(Number(p.count) || 0)) }),
|
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: [
|
params: [
|
||||||
{
|
{
|
||||||
name: 'prefab',
|
name: 'prefab',
|
||||||
|
|||||||
@@ -301,6 +301,13 @@ async function mateIdsFor(serverId, acc) {
|
|||||||
return { own, mates: new Set(clan.filter((id) => !own.has(id))) }
|
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
|
* 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
|
* 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 }
|
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.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) {
|
if (acc.layers.players.visible) {
|
||||||
out.players = Array.isArray(live.players) ? live.players : []
|
out.players = Array.isArray(live.players) ? live.players : []
|
||||||
if (live.playersTruncated) out.playersTruncated = true
|
if (live.playersTruncated) out.playersTruncated = true
|
||||||
|
|||||||
@@ -455,6 +455,17 @@ async function siteProfileFor(serverId, name) {
|
|||||||
return sync && sync.pushed && sync.pushed[name] ? Number(sync.pushed[name]) : 0
|
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. */
|
/** The profiles a server's leaderboard may rank by: those pushed to it, with their labels. */
|
||||||
async function boardProfiles(serverId) {
|
async function boardProfiles(serverId) {
|
||||||
return active(await db.listProfiles())
|
return active(await db.listProfiles())
|
||||||
@@ -517,6 +528,7 @@ module.exports = {
|
|||||||
renamePlacement,
|
renamePlacement,
|
||||||
respawnPlacement,
|
respawnPlacement,
|
||||||
optionRows,
|
optionRows,
|
||||||
|
profileOn,
|
||||||
siteProfileFor,
|
siteProfileFor,
|
||||||
boardProfiles,
|
boardProfiles,
|
||||||
ranking,
|
ranking,
|
||||||
|
|||||||
@@ -918,11 +918,12 @@ module.exports = {
|
|||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
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 },
|
x: { type: 'number', example: 812.4 },
|
||||||
z: { type: 'number', example: -1320.6 },
|
z: { type: 'number', example: -1320.6 },
|
||||||
hackLeftSec: { type: 'integer', description: 'A locked crate being hacked: seconds left.', example: 540 },
|
hackLeftSec: { type: 'integer', description: 'A locked crate being hacked: seconds left.', example: 540 },
|
||||||
hacked: { type: 'boolean', example: false },
|
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' },
|
x: { type: 'number' },
|
||||||
z: { type: 'number' },
|
z: { type: 'number' },
|
||||||
radius: { type: 'number', description: 'A zone’s.', example: 60 },
|
radius: { type: 'number', description: 'A zone’s.', example: 60 },
|
||||||
name: { type: 'string', description: 'A zone’s.', example: 'Harbor brawl' },
|
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.', example: 'crate.elite' },
|
prefab: { type: 'string', description: 'A crate’s or NPC’s allowlist key, or `profile:<name>` 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.' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
137
server/test/eventProgress.test.js
Normal file
137
server/test/eventProgress.test.js
Normal file
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
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) => {
|
test('a hidden layer is absent from the answer, not an empty array', async (t) => {
|
||||||
settings(t)
|
settings(t)
|
||||||
const out = map.project(LIVE, await map.access({}, 'main'))
|
const out = map.project(LIVE, await map.access({}, 'main'))
|
||||||
|
|||||||
@@ -9232,7 +9232,8 @@
|
|||||||
"chinook",
|
"chinook",
|
||||||
"bradley",
|
"bradley",
|
||||||
"supply",
|
"supply",
|
||||||
"crate"
|
"crate",
|
||||||
|
"boss"
|
||||||
],
|
],
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -9299,6 +9300,23 @@
|
|||||||
"example": false
|
"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": {
|
"description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "A zone’s."
|
"example": "A zone’s, or a RunicNPC NPC’s."
|
||||||
},
|
},
|
||||||
"example": {
|
"example": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -9421,13 +9439,52 @@
|
|||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "A crate’s or NPC’s allowlist key."
|
"example": "A crate’s or NPC’s allowlist key, or `profile:<name>` for a RunicNPC profile."
|
||||||
},
|
},
|
||||||
"example": {
|
"example": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "crate.elite"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user