// ── 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 }