feat(rust): event NPCs named on the map, and a Place NPCs step's progress line (runicnpc stage 8)
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

- 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:
2026-10-06 08:23:13 -05:00
parent 3c3d154b36
commit 5162496d74
14 changed files with 484 additions and 11 deletions

View 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)
})