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:
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 }
|
||||
Reference in New Issue
Block a user