Files
Module-Rust/server/eventProgress.js
wtclaude 5162496d74
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
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
2026-10-06 08:23:13 -05:00

91 lines
3.7 KiB
JavaScript

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