feat(rust): event NPCs named on the map, and a Place NPCs step's progress line (runicnpc stage 8) #33
@@ -35,6 +35,7 @@
|
||||
"db",
|
||||
"engagement",
|
||||
"eventLeases.js",
|
||||
"eventProgress.js",
|
||||
"eventRewards.js",
|
||||
"eventWorld.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
|
||||
// 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'])
|
||||
})
|
||||
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 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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:<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)
|
||||
})
|
||||
|
||||
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'))
|
||||
|
||||
@@ -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:<name>` 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user