fix(rust): protocol 13 step 2 — expiry, plugin loads, the loading hold, NPC names, the link fleet (F2 F5 F6 F7 F8 F13 F14)
Some checks failed
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Failing after 56s
PR Checks / server-tests (pull_request) Successful in 7m45s

The module's half of PLAN_FIXES §6 step 2 (decisions D181-D185, docs#288).

- F13/F14 (D170, D183): `world.expired`, recognisable from protocol 13 by its
  `what`, is handed to core as the resource the zone step ledgered
  (`world`, `<serverId>:<id>`) through ctx.events.expired, which records it
  `expired`. coreApi moves to ^1.11.0 (website#209).
- F8 (D184): `plugin.loaded` / `plugin.unloaded` mark the permission sync dirty
  when the plugin added or removed permissions, so an unresolved grant lands on
  the next tick instead of the fifteen-minute audit.
- Catalogue: plugin.loaded/unloaded, world.expired and lease.expired are staff
  kinds. The last two were never classified (default deny kept them off public
  pages); the test now covers every event kind through protocol 13.
- F7: permission and title pushes hold while the stored hello says
  `worldReady: false` (a human's "sync now" does not); a failed or refused
  permission sync now logs at warn.
- F2 (D185): the killfeed names an NPC attacker — a family (Scientist, Bandit
  guard, Bradley APC…) or the prefab without its variant digits (wolf2 → Wolf).
- F5/F6: a link code is asked of the servers that minted one in the last six
  minutes first, then of the rest, each group in parallel; "unsure" only when
  one of the minting servers is unreachable.
- D182: the admin server list carries the ZoneManager helper's state from the
  hello, and the servers page says what a missing or failed helper costs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-26 21:35:46 -05:00
parent 80c05a3c1e
commit b10f11b057
23 changed files with 611 additions and 44 deletions

View File

@@ -21,7 +21,7 @@
// here is presentation, and the honest presentation of a kind we have no words
// for is its own name.
import { duration, prefab } from './format.js'
import { attacker, duration, prefab } from './format.js'
/**
* Kinds this feed asks for.
@@ -161,7 +161,7 @@ function death(frame, name) {
case 'npc':
return {
tone: 'death',
actor: prefab(frame.attackerName) || 'Something',
actor: attacker(frame.attackerName) || 'Something',
verb: 'killed',
subject: name,
detail: where,

View File

@@ -119,6 +119,58 @@ export function prefab(name) {
return String(name).replace(/[_.]+/g, ' ').trim()
}
/**
* The NPC families whose prefab reads badly as words, by the prefix the game's
* short names share. First match wins, so a longer prefix sits above a shorter
* one it starts with.
*/
const NPC_FAMILIES = [
['scientistnpc_heavy', 'Heavy scientist'],
['scientistnpc', 'Scientist'],
['npc_bandit_guard', 'Bandit guard'],
['bandit_guard', 'Bandit guard'],
['npc_tunneldweller', 'Tunnel dweller'],
['npc_underwaterdweller', 'Underwater dweller'],
['bradleyapc', 'Bradley APC'],
['patrolhelicopter', 'Patrol helicopter'],
['ch47scientists', 'Chinook'],
['sentry.scientist', 'Outpost sentry'],
['sentry.bandit', 'Bandit Camp sentry'],
['autoturret', 'Auto turret'],
['flameturret', 'Flame turret'],
['guntrap', 'Shotgun trap'],
['sam_site', 'SAM site'],
['sam_static', 'SAM site'],
['polarbear', 'Polar bear'],
['simpleshark', 'Shark'],
]
/**
* What killed somebody, when it was not a player (PLAN_FIXES F2, D185) — the
* killfeed's `npc` attacker, which the plugin sends as a prefab short name.
*
* The first walk read "killed by wolf2". `prefab` keeps its light touch for a
* weapon, where the short name is what a Rust player reads on their own console;
* an ATTACKER is a creature or a machine, and a number stuck to its name is the
* game's variant, not something a reader needs. So a known family is named
* (above), and anything else has its variant digits and deploy suffixes trimmed
* and reads as capitalised words — `wolf2` is "Wolf", `boar` is "Boar". Still no
* table of every prefab: a new animal reads fine without one.
*/
export function attacker(name) {
if (!name) return ''
const text = String(name).toLowerCase()
const family = NPC_FAMILIES.find(([prefix]) => text.startsWith(prefix))
if (family) return family[1]
const words = text
.replace(/(\.deployed|_deployed|\.entity|\.prefab)$/, '')
.replace(/\d+$/, '')
.replace(/[_.]+/g, ' ')
.trim()
return words ? words[0].toUpperCase() + words.slice(1) : ''
}
/** A steam id, shortened for a table cell, without pretending it is a name. */
export function shortId(steamId) {
const id = String(steamId || '')

View File

@@ -61,6 +61,22 @@ const ZONES = (() => {
})()
/** An ISO instant as the value a `datetime-local` input takes, in the browser's clock. */
/**
* What the missing ZoneManager helper costs this server (PLAN_FIXES F12, D182), or
* null when there is nothing to say — the helper patched, or a plugin that
* reported no ZoneManager state at all. Scoring is never what is lost: the bridge
* falls back to the zone's shape. ZoneManager's own flags are.
*/
export function zoneHelperNote(helper) {
if (!helper || helper.state === 'patched' || helper.state === 'no-zonemanager') return null
const cost =
'Event zones still score everybody inside, but ZoneManager’s own flags and messages miss a player who was already standing in a zone when it opened or came back after a restart.'
if (helper.state === 'missing') {
return `The ZoneManager helper (RunicGatewayZones.cs) is not installed. ${cost} Reinstall or update the plugin to put it back.`
}
return `The ZoneManager helper could not patch this ZoneManager${helper.reason ? ` (${helper.reason})` : ''}. ${cost}`
}
function toLocalInput(iso) {
if (!iso) return ''
const d = new Date(iso)
@@ -235,6 +251,9 @@ export default function ServerSettings() {
: 'No wipe schedule set.'}
</div>
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{titlesSummary(s.titles, s.titlePush)}</div>
{zoneHelperNote(s.zoneHelper) && (
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{zoneHelperNote(s.zoneHelper)}</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn" onClick={() => { setError(''); setTitling(null); setForm(formFrom(s)) }}>Edit</button>
<button type="button" className="btn" onClick={() => test(s)}>Test the sidecar</button>

View File

@@ -43,7 +43,8 @@ test('the four attacker types are four different sentences', () => {
const victim = { name: 'Bob' }
const npc = describe(row('player.death', { ...victim, attackerType: 'npc', attackerName: 'scientistnpc_full_any' }))
assert.equal(npc.actor, 'scientistnpc full any')
// A family is named, not spelled out as a prefab (PLAN_FIXES F2, D185).
assert.equal(npc.actor, 'Scientist')
assert.equal(npc.subject, 'Bob')
const self = describe(row('player.death', { ...victim, attackerType: 'self' }))

View File

@@ -11,7 +11,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { ago, clock, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
import { ago, attacker, clock, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
const NOW = Date.parse('2026-09-16T12:00:00Z')
@@ -115,3 +115,28 @@ test('a title chip’s ink is whichever of black and white reads on its colour',
assert.equal(contrastInk('#ff0000'), '#000000')
assert.equal(contrastInk('red'), '#000000', 'not a hex colour: black, on the caller’s fallback')
})
test('an NPC attacker reads as a name: a family, or the prefab without its variant (F2, D185)', () => {
// The first walk's "killed by wolf2".
assert.equal(attacker('wolf2'), 'Wolf')
assert.equal(attacker('wolf'), 'Wolf')
assert.equal(attacker('boar'), 'Boar')
assert.equal(attacker('polarbear'), 'Polar bear')
assert.equal(attacker('scientistnpc_full_any'), 'Scientist')
assert.equal(attacker('scientistnpc_roam'), 'Scientist')
assert.equal(attacker('scientistnpc_heavy'), 'Heavy scientist')
assert.equal(attacker('npc_bandit_guard'), 'Bandit guard')
assert.equal(attacker('bradleyapc'), 'Bradley APC')
assert.equal(attacker('patrolhelicopter'), 'Patrol helicopter')
assert.equal(attacker('autoturret_deployed'), 'Auto turret')
assert.equal(attacker('sam_site_turret_deployed'), 'SAM site')
// Anything unknown still reads as words, capitalised, with no suffix or variant.
assert.equal(attacker('some_new_beast3'), 'Some new beast')
assert.equal(attacker('spikes.floor.deployed'), 'Spikes floor')
assert.equal(attacker(null), '')
assert.equal(attacker(''), '')
})
test('a weapon keeps the light touch — attacker() is for what did the killing', () => {
assert.equal(prefab('rifle.ak'), 'rifle ak')
})