chore(release): cut edge over to main — protocol 14 and Steam sign-in (MODULE_API 1.17.0) #58

Merged
whitlocktech merged 42 commits from edge into main 2026-10-22 06:16:14 -05:00
31 changed files with 3073 additions and 28 deletions
Showing only changes of commit e7d5c8ed1a - Show all commits

View File

@@ -84,6 +84,17 @@ forced wipe (first Thursday, 19:00 UK time) — plus an optional one-off date th
computed wipe. It is computed on every read and never stored, so it cannot go stale after a wipe.
The server list, the server page, the Android app and `/wipe` all show it.
**Rust's own world events need no plugin** (`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.1). The patrol
helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the
satellite and the Deep Sea reach the feed (a **World events** filter) and each is an engagement trigger
(`rust.world.<event>`), so a notice or an event's phase can follow one. The event and its grid are public;
a player's name in it only for the presence audience, a Steam id only for staff. **Who downs a helicopter
or a Bradley** is the operator's rule per server — killing blow (the default), last hit, most damage, or
everyone who hurt it — and the same rule writes the line and counts toward **Rotorbane** and
**Tankbreaker**. **Safecracker** (locked crates hacked) and **Scavenger** (airdrops looted first) are
title categories with leaderboard columns, and the **Call a world event** step (`rust.world.call`) calls
one now, kept when the run ends: an airdrop or a patrol helicopter can be sent to a point.
**Four uMod plugins are optional, and the module works without any of them** (`docs/modules/rust/PLAN.md`
§33). The bridge plugin's hard requirements are **Kits** and **ZoneManager**.

View File

@@ -40,6 +40,7 @@
"eventRewards.js",
"eventTemplates.js",
"eventWorld.js",
"eventWorldCall.js",
"index.js",
"ingest.js",
"mapImages.js",
@@ -50,7 +51,8 @@
"permSync.js",
"router",
"sidecarClient.js",
"titleSync.js"
"titleSync.js",
"worldEvents.js"
],
"root": [
"engagement-triggers.json",

View File

@@ -30,6 +30,10 @@ const COLUMNS = [
{ key: 'npcKills', label: 'NPC kills', sort: 'npcKills', value: (r) => count(r.npcKills) },
{ key: 'structures', label: 'Structures', sort: null, value: (r) => count(r.structures) },
{ key: 'playtimeSec', label: 'Played', sort: 'playtime', value: (r) => duration(r.playtimeSec) },
// Rust's own world events (protocol 14, D356, D377): the counts Safecracker
// and Scavenger rank, on every server, since the game itself raises them.
{ key: 'cratesHacked', label: 'Crates hacked', sort: 'cratesHacked', value: (r) => count(r.cratesHacked) },
{ key: 'dropsLooted', label: 'Drops looted', sort: 'dropsLooted', value: (r) => count(r.dropsLooted) },
]
// The richest (PLAN_REDESIGNS §11.2.2): the last known Economics balance. The

View File

@@ -44,6 +44,9 @@ export const FEED_KINDS = Object.freeze([
// The death names its killer, so the server shows it only where presence is on.
'npc.boss.spawned',
'npc.boss.died',
// Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1): the server
// reads each one for the viewer, so a name is here only where it may be.
'world.event',
])
/** The filters the feed offers, and the kinds each one asks the API for. */
@@ -58,6 +61,7 @@ export const FILTERS = Object.freeze([
},
{ id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] },
{ id: 'bosses', label: 'Bosses', kinds: ['npc.boss.spawned', 'npc.boss.died'] },
{ id: 'world', label: 'World events', kinds: ['world.event'] },
])
export function kindsFor(filterId) {
@@ -144,6 +148,9 @@ export function describe(row) {
: { tone: 'death', actor: name, verb: 'was killed', subject: null, detail: fought }
}
case 'world.event':
return worldLine(frame)
default:
return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' }
}
@@ -193,4 +200,107 @@ function death(frame, name) {
}
}
/** Where a locked crate is, as the end of a sentence. */
const CRATE_PLACES = Object.freeze({
oilrig_large: 'on Large Oil Rig',
oilrig_small: 'on Small Oil Rig',
cargo: 'on the cargo ship',
ghostship: 'on a ghost ship',
chinook: 'dropped by a Chinook',
})
/**
* Who a downed helicopter, Chinook or Bradley credits, as a phrase: "Alice",
* "Alice and Bob", "Alice, Bob and 2 others". Null when the line names nobody
* — below the presence audience the server sends `creditedCount` and no names.
*/
export function creditedPhrase(frame) {
const names = (Array.isArray(frame.credited) ? frame.credited : []).map((c) => c && c.name).filter(Boolean)
if (names.length === 0) return null
const total = Math.max(names.length, Number(frame.creditedCount) || 0)
const shown = names.slice(0, 2)
const more = total - shown.length
if (more > 0) return `${shown.join(', ')} and ${more} other${more === 1 ? '' : 's'}`
return shown.length === 2 ? `${shown[0]} and ${shown[1]}` : shown[0]
}
/**
* Rust's own world events (protocol 14), one sentence per type. The server has
* already read the frame for this viewer: it carries `grid`, a `name` only for
* the presence audience, and `credited` by the server's rule (D372, D376). A
* line therefore never decides who may read a name; it only says what it has.
*/
function worldLine(frame) {
const grid = frame.grid || ''
const who = frame.name || null
const place = CRATE_PLACES[frame.where] || 'somewhere on the map'
const by = creditedPhrase(frame)
const downed = (what) => (by
? { tone: 'kill', actor: null, verb: `${what} by`, subject: by, detail: grid }
: { tone: 'kill', actor: null, verb: what, subject: null, detail: grid })
const say = (verb, detail = grid) => ({ tone: 'server', actor: null, verb, subject: null, detail })
const harbour = frame.harbourName || 'the harbour'
switch (frame.event) {
case 'heli.arrived':
return say('A patrol helicopter has arrived')
case 'heli.left':
return say('The patrol helicopter has left', '')
case 'heli.downed':
return downed('Patrol helicopter downed')
case 'chinook.downed':
return downed('Chinook shot down')
case 'bradley.destroyed':
return downed(frame.where === 'launch_site' ? "Launch Site's Bradley destroyed" : frame.where === 'road' ? 'Road Bradley destroyed' : 'Bradley destroyed')
case 'bradley.spawned': {
const n = Number(frame.count) || 1
if (frame.where === 'road') return say(n > 1 ? `${n} road Bradleys are out` : 'A road Bradley is out', '')
return say('Bradley is patrolling Launch Site', '')
}
case 'cargo.arrived':
return say('The cargo ship has arrived')
case 'cargo.docking':
return say(`The cargo ship is docking at ${harbour}`)
case 'cargo.docked':
return say(`The cargo ship has docked at ${harbour}`)
case 'cargo.undocked':
return say(`The cargo ship has cast off from ${harbour}`)
case 'cargo.left':
return say('The cargo ship is leaving', '')
case 'chinook.arrived':
return say('A Chinook is inbound with a locked crate')
case 'chinook.dropped':
return say(frame.monumentName ? `A Chinook dropped a locked crate at ${frame.monumentName}` : 'A Chinook dropped a locked crate')
case 'airdrop.called':
return who ? { tone: 'server', actor: who, verb: 'called an airdrop', subject: null, detail: grid } : say('An airdrop is on its way')
case 'airdrop.landed':
return say('An airdrop has landed', [who ? `called by ${who}` : null, grid].filter(Boolean).join(' · '))
case 'airdrop.looted':
return who ? { tone: 'server', actor: who, verb: 'looted an airdrop', subject: null, detail: grid } : say('An airdrop was looted')
case 'crate.hack.started':
return who
? { tone: 'server', actor: who, verb: `started hacking the locked crate ${place}`, subject: null, detail: grid }
: say(`A locked crate is being hacked ${place}`)
case 'crate.unlocked':
return say(`The locked crate ${place} is open`)
case 'crate.looted':
return who
? { tone: 'server', actor: who, verb: `looted the locked crate ${place}`, subject: null, detail: grid }
: say(`The locked crate ${place} was looted`)
case 'vendor.arrived':
return say('The Travelling Vendor has arrived')
case 'vendor.left':
return say('The Travelling Vendor has left', '')
case 'satellite.crashed':
return say('A satellite has crashed')
case 'deepsea.opened':
return say('The Deep Sea is open', '')
case 'deepsea.closed':
return say('The Deep Sea has closed', '')
default:
// A type this build has no words for, which only staff are sent: its own name.
return { tone: 'other', actor: who, verb: String(frame.event || 'world event'), subject: null, detail: grid }
}
}
export default { describe, FEED_KINDS, FILTERS, kindsFor }

View File

@@ -38,6 +38,16 @@ const RULES = [
{ id: 'biweekly', label: 'Every other week', hint: 'Every second week, on the weeks the date below falls in, and the forced wipe.' },
]
// Who a downed patrol helicopter, Chinook or Bradley credits (protocol 14, D372).
// The same rule writes the feed line and the Rotorbane / Tankbreaker counts; the
// server's own list, mirrored here so the form needs no round trip.
const CREDIT_RULES = [
{ id: 'blow', label: 'Killing blow', hint: 'The player whose hit destroyed it — for the patrol helicopter, the crippling hit, else the last hit. The default.' },
{ id: 'last', label: 'Last hit', hint: 'The last player to hurt it.' },
{ id: 'most', label: 'Most damage', hint: 'The player who dealt it the most damage.' },
{ id: 'all', label: 'Everyone who hurt it', hint: 'Every player who damaged it, one each.' },
]
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const SOURCE = { forced: 'the forced wipe', rule: 'its own schedule', once: 'the one-off date' }
@@ -110,6 +120,7 @@ function blankForm() {
wipeTz: localZone(),
wipeAnchor: '',
wipeOnce: '',
creditRule: 'blow',
}
}
@@ -131,6 +142,7 @@ function formFrom(server) {
wipeAnchor: s.anchor || '',
wipeOnce: toLocalInput(s.onceAt),
storedOnceAt: s.onceAt || null,
creditRule: server.creditRule || 'blow',
}
}
@@ -155,6 +167,7 @@ function bodyFrom(form) {
? null
: new Date(form.wipeOnce).toISOString(),
}
if (form.creditRule) body.creditRule = form.creditRule
if (form.sidecarToken) body.sidecarToken = form.sidecarToken
if (form.protocol !== undefined) body.protocol = form.protocol
return body
@@ -409,6 +422,17 @@ function ServerForm({ form, set, busy, error, onSave, onCancel }) {
</p>
)}
</fieldset>
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '12px 14px', display: 'grid', gap: 12 }}>
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>World events</legend>
<Field
label="Who downs a helicopter or a Bradley"
hint={`${(CREDIT_RULES.find((r) => r.id === form.creditRule) || CREDIT_RULES[0]).hint} It names them in the feed and counts toward Rotorbane and Tankbreaker, from now on.`}
>
<select value={form.creditRule} onChange={set('creditRule')} style={inputStyle}>
{CREDIT_RULES.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
</select>
</Field>
</fieldset>
<datalist id="rust-zones">{ZONES.map((z) => <option key={z} value={z} />)}</datalist>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>

View File

@@ -155,3 +155,55 @@ test('an unknown filter falls back to everything rather than to nothing', () =>
assert.deepEqual(kindsFor('nonsense'), FEED_KINDS)
assert.deepEqual(kindsFor(undefined), FEED_KINDS)
})
// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ─────────
//
// The frames here are what the SERVER sends a viewer (`worldEvents.shapeFor`),
// not what the plugin emitted: a grid instead of a position, names only for the
// presence audience, `credited` by the server's rule.
const world = (frame) => row('world.event', { kind: 'world.event', ...frame })
test('a downed helicopter names whom the rule credits, and only them', () => {
const one = describe(world({ event: 'heli.downed', grid: 'G7', creditRule: 'blow', creditedCount: 1, credited: [{ name: 'Alice' }] }))
assert.deepEqual([one.tone, one.verb, one.subject, one.detail], ['kill', 'Patrol helicopter downed by', 'Alice', 'G7'])
const many = describe(world({ event: 'heli.downed', grid: 'G7', creditRule: 'all', creditedCount: 4, credited: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Cleo' }, { name: 'Dov' }] }))
assert.equal(many.subject, 'Alice, Bob and 2 others')
})
test('below the presence audience the same line names nobody (D376)', () => {
const line = describe(world({ event: 'heli.downed', grid: 'G8', creditRule: 'blow', creditedCount: 1 }))
assert.deepEqual([line.verb, line.subject, line.detail], ['Patrol helicopter downed', null, 'G8'])
const hack = describe(world({ event: 'crate.hack.started', where: 'oilrig_large', grid: 'C3' }))
assert.equal(hack.actor, null)
assert.equal(hack.verb, 'A locked crate is being hacked on Large Oil Rig')
})
test('a named line reads as the plan writes it', () => {
const hack = describe(world({ event: 'crate.hack.started', where: 'oilrig_large', name: 'Bob' }))
assert.equal(`${hack.actor} ${hack.verb}`, 'Bob started hacking the locked crate on Large Oil Rig')
const road = describe(world({ event: 'bradley.spawned', where: 'road', count: 12 }))
assert.equal(road.verb, '12 road Bradleys are out')
assert.equal(describe(world({ event: 'deepsea.opened' })).verb, 'The Deep Sea is open')
assert.equal(describe(world({ event: 'cargo.docked', harbourName: 'Harbor', grid: 'Q9' })).verb, 'The cargo ship has docked at Harbor')
assert.equal(describe(world({ event: 'chinook.dropped', monumentName: 'Water Treatment Plant' })).verb, 'A Chinook dropped a locked crate at Water Treatment Plant')
})
test('every type the server knows has its own words, and an unknown one says its name', () => {
const { TYPE_IDS } = createRequire(import.meta.url)('../../server/worldEvents.js')
for (const event of TYPE_IDS) {
const line = describe(world({ event }))
assert.notEqual(line.tone, 'other', `${event} has no words of its own`)
assert.ok(line.verb && line.verb !== event, event)
}
assert.equal(describe(world({ event: 'meteor.landed' })).verb, 'meteor.landed')
})
test('the world filter asks for world events, which the server serves publicly', () => {
assert.deepEqual(kindsFor('world'), ['world.event'])
assert.ok(FEED_KINDS.includes('world.event'))
})

File diff suppressed because it is too large Load Diff

View File

@@ -59,6 +59,13 @@ const PUBLIC_KINDS = Object.freeze([
// the players near it (D286), so `npc.boss.phase` is staff's, below.
'npc.boss.spawned',
'npc.boss.died',
// Protocol 14: Rust's own world events (PLAN_REDESIGNS §14.3.1, D353, D376).
// Public as a KIND, and filtered per `event` inside it by `worldEvents.js`:
// an event type this build does not know is staff's alone, a player's name
// shows only to the presence audience, a Steam id only to staff, and a
// position to nobody. So it is not a presence kind — the event names nobody
// once `worldEvents.shapeFor` has read it for a viewer below that audience.
'world.event',
])
/**
@@ -152,7 +159,7 @@ const PRESENCE_KINDS = Object.freeze([
'npc.boss.died',
])
/** Every event kind the protocol defines, through protocol 13. */
/** Every event kind the protocol defines, through protocol 14. */
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
const PUBLIC = new Set(PUBLIC_KINDS)

View File

@@ -1371,3 +1371,18 @@ ALTER TABLE rust_title_rules ADD COLUMN IF NOT EXISTS profile_id INT UNSIGNED NU
-- richest column and the Magnate title rank only balances above the server's
-- starting balance, so the untouched accounts of a fresh wipe rank nobody.
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS balance DECIMAL(14,2) NULL;
-- Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1). Two title
-- counts (D356): locked crates a player started hacking, once per crate
-- (Safecracker), and airdrops they looted first (Scavenger). Sums, from the
-- tally like the others. APC and helicopter kills keep their columns; under
-- protocol 14 the site writes them from `world.event`'s credit block instead.
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS crates_hacked INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS drops_looted INT UNSIGNED NOT NULL DEFAULT 0;
-- Who a downed helicopter, Chinook or Bradley credits on this server (D372,
-- D379): `blow` (the killing blow; for the patrol helicopter the crippling hit,
-- else the last hit), `last`, `most` (damage) or `all` (everyone who hurt it,
-- one each). The same rule writes the feed line and the count. A change applies
-- from then on; counts already written are not recounted.
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS credit_rule VARCHAR(8) NOT NULL DEFAULT 'blow';

View File

@@ -42,6 +42,8 @@ const clansDb = require('../model/clans/clans.db')
const eventsDb = require('../model/events/events.db')
const linksDb = require('../model/links/links.db')
const serversDb = require('../model/servers/servers.db')
const mapDb = require('../model/map/map.db')
const worldEvents = require('../worldEvents')
const { TRIGGER_IDS: T, PATHS, serverPath, leaderboardPath, clanPath } = require('./triggers')
const log = core.logger('engagement')
@@ -186,9 +188,65 @@ const HEADLINES = Object.freeze({
}),
})
/** Where a crate is, as a sentence ends: "on Large Oil Rig", "dropped by a Chinook". */
const CRATE_PLACES = Object.freeze({
oilrig_large: 'on Large Oil Rig',
oilrig_small: 'on Small Oil Rig',
cargo: 'on the cargo ship',
ghostship: 'on a ghost ship',
chinook: 'dropped by a Chinook',
})
/**
* The headline of a world event (protocol 14), one sentence per type. Plain
* register like the rest; a part that is missing drops out rather than leaving
* a hole, so "at G7" disappears when the server has no map geometry yet.
*/
function worldHeadline(event, d) {
const at = d.grid ? ` at ${d.grid}` : ''
const on = ` on ${d.server}`
const by = d.player ? ` by ${d.player}` : ''
const place = CRATE_PLACES[d.where] || 'somewhere on the map'
const bradley = d.where === 'launch_site' ? "Launch Site's Bradley" : d.where === 'road' ? 'A road Bradley' : 'A Bradley'
const lines = {
'heli.arrived': ['A patrol helicopter is overhead', `A patrol helicopter flew onto the map${at}${on}.`],
'heli.left': ['The patrol helicopter has left', `The patrol helicopter left the map${on}.`],
'heli.downed': ['Patrol helicopter downed', `The patrol helicopter was downed${by}${at}${on}.`],
'bradley.spawned': [
d.where === 'road' ? (d.count > 1 ? `${d.count} road Bradleys are out` : 'A road Bradley is out') : 'Bradley is patrolling Launch Site',
d.where === 'road' ? `Road Bradleys took to the roads${on}.` : `Launch Site's Bradley is back${on}.`,
],
'bradley.destroyed': [`${bradley} was destroyed`, `${bradley} was destroyed${by}${at}${on}.`],
'cargo.arrived': ['The cargo ship has arrived', `The cargo ship sailed onto the map${at}${on}.`],
'cargo.docking': ['The cargo ship is docking', `The cargo ship is coming in to dock${d.harbour ? ` at ${d.harbour}` : ''}${on}.`],
'cargo.docked': ['The cargo ship has docked', `The cargo ship docked${d.harbour ? ` at ${d.harbour}` : ''}${on}, with fresh crates.`],
'cargo.undocked': ['The cargo ship cast off', `The cargo ship left${d.harbour ? ` ${d.harbour}` : ' the harbour'}${on}.`],
'cargo.left': ['The cargo ship is leaving', `The cargo ship turned for the edge of the map${on}.`],
'chinook.arrived': ['A Chinook is inbound', `A Chinook flew onto the map${at}${on} with a locked crate.`],
'chinook.dropped': ['A Chinook dropped a locked crate', `A Chinook dropped a locked crate${d.monument ? ` at ${d.monument}` : at}${on}.`],
'chinook.downed': ['A Chinook was shot down', `A Chinook was shot down${by}${at}${on}.`],
'airdrop.called': ['An airdrop is on its way', `An airdrop is on its way${at}${on}${d.player ? `, called by ${d.player}` : ''}.`],
'airdrop.landed': ['An airdrop has landed', `An airdrop landed${at}${on}.`],
'airdrop.looted': ['An airdrop was looted', `${d.player || 'Somebody'} looted an airdrop${at}${on}.`],
'crate.hack.started': ['A locked crate is being hacked', `${d.player || 'Somebody'} started hacking the locked crate ${place}${on}.`],
'crate.unlocked': ['A locked crate is open', `The locked crate ${place} finished unlocking${on}.`],
'crate.looted': ['A locked crate was looted', `${d.player || 'Somebody'} looted the locked crate ${place}${on}.`],
'vendor.arrived': ['The Travelling Vendor is here', `The Travelling Vendor drove onto the map${at}${on}.`],
'vendor.left': ['The Travelling Vendor has left', `The Travelling Vendor left the map${on}.`],
'satellite.crashed': ['A satellite has crashed', `A satellite came down${at}${on}. Its crates are burning.`],
'deepsea.opened': ['The Deep Sea is open', `The Deep Sea opened${on}.`],
'deepsea.closed': ['The Deep Sea has closed', `The Deep Sea closed${on}.`],
}
const line = lines[event]
return line ? { title: `${line[0]}${on}`, intro: line[1] } : {}
}
const WORLD_PREFIX = 'rust.world.'
function headline(triggerId, data) {
const make = HEADLINES[triggerId]
return make ? make(data || {}) : {}
if (make) return make(data || {})
return String(triggerId).startsWith(WORLD_PREFIX) ? worldHeadline(triggerId.slice(WORLD_PREFIX.length), data || {}) : {}
}
/**
@@ -460,7 +518,51 @@ async function onBoss(server, item, frame) {
}) ? 1 : 0
}
/**
* Rust's own world events (protocol 14, D355): one trigger per type. Public
* news, so a replay older than a broadcast's window tells nobody (D63). The
* grid comes from the map's geometry, as the feed's does; a downed helicopter,
* Chinook or Bradley names whom the server's credit rule names, as its feed
* line and its count do (D372). Names only, never a Steam id.
*/
async function onWorld(server, item, frame) {
const t = frameTime(item, frame)
const event = str(frame.event)
if (!event || !worldEvents.isKnownType(event) || !stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
const type = worldEvents.TYPES[event]
const crate = event.startsWith('crate.')
const data = {
...serverVars(server),
subject: crate ? `${server.id}:${event}:${frame.where || ''}` : `${server.id}:${event}`,
...(grid ? { grid } : {}),
...(frame.where !== undefined ? { where: str(frame.where) } : {}),
...(event === 'bradley.spawned' ? { count: Math.max(1, Math.floor(Number(frame.count) || 1)) } : {}),
...(frame.harbourName || frame.harbour ? { harbour: str(frame.harbourName) || str(frame.harbour) } : {}),
...(frame.monumentName || frame.monument ? { monument: str(frame.monumentName) || str(frame.monument) } : {}),
}
if (type.player && str(frame.name)) data.player = str(frame.name)
if (type.credit) {
const ids = worldEvents.credited(frame, worldEvents.creditRuleOf(server.creditRule))
const phrase = worldEvents.namesPhrase(ids.map((id) => worldEvents.nameOf(frame, id)), ids.length)
data.credited = ids.length
if (phrase) data.player = phrase
}
return fire(`${WORLD_PREFIX}${event}`, {
data,
dedupeKey: dedupeKey('world', server.id, event, t, frame.x, frame.z),
occurredAt: t,
}) ? 1 : 0
}
const HANDLERS = Object.freeze({
'world.event': onWorld,
'npc.boss.spawned': onBoss,
'npc.boss.phase': onBoss,
'npc.boss.died': onBoss,

View File

@@ -548,7 +548,81 @@ const BOSSES = [
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES])
// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1, D355) ─────
//
// One trigger per `world.event` type, `rust.world.<type>`. Public news, as a
// boss is: `everyone` is the ceiling and `subscribers` the default. A phase
// gate reads the same catalogue, so "advance when Bradley is destroyed" is
// `{ on: 'rust.world.bradley.destroyed' }` with no further code.
//
// The cooldown subject is the server and the type, and the crate types add
// where the crate is — one line per oil rig per cooldown, not one per hack on
// the map. `player` is the in-game name only, as a boss's killer is; no Steam
// id is declared.
const WORLD_SUBJECT = { name: 'subject', type: 'string', required: true, example: 'main:heli.downed',
description: 'The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display.' }
const WORLD_GRID = { name: 'grid', type: 'string', required: false, example: 'G7',
description: 'The map grid square. Absent when the server has no map geometry yet, or the event is off the map.' }
const WORLD_PLAYER = { name: 'player', type: 'string', required: false, example: 'Marisol',
description: 'The player it names, by in-game name. Absent when nobody is named.' }
const WORLD_CREDITED = [
{ name: 'player', type: 'string', required: false, example: 'Marisol and Darrow',
description: 'Who is credited, by in-game name and by the server\'s credit rule: "Alice", "Alice and Bob", "Alice, Bob and 2 others". Absent when nobody is.' },
{ name: 'credited', type: 'int', required: true, example: 2,
description: 'How many players the credit rule names.' },
]
const WORLD_WHERE = (example, list) => ({ name: 'where', type: 'string', required: true, example,
description: `Where it is: ${list}.` })
const CRATE_WHERE = WORLD_WHERE('oilrig_large', 'oilrig_large, oilrig_small, cargo, ghostship or chinook (a crate a Chinook dropped)')
const WORLD_HARBOUR = { name: 'harbour', type: 'string', required: false, example: 'Harbor',
description: 'The harbour, by the game\'s own name for it.' }
const WORLD_MONUMENT = { name: 'monument', type: 'string', required: false, example: 'Water Treatment Plant',
description: 'The monument it dropped at, by the game\'s own name for it.' }
const WORLD_COUNT = { name: 'count', type: 'int', required: true, example: 12,
description: 'How many: road Bradleys come several at once.' }
/** Each world event's label, description and the variables it carries beyond the server's. */
const WORLD_EVENTS = [
['heli.arrived', 'A patrol helicopter arrived', 'A patrol helicopter flew onto the map.', [WORLD_GRID]],
['heli.left', 'The patrol helicopter left', 'A patrol helicopter left the map without being downed.', [WORLD_GRID]],
['heli.downed', 'A patrol helicopter was downed', 'Players brought down a patrol helicopter.', [WORLD_GRID, ...WORLD_CREDITED]],
['bradley.spawned', 'Bradley is out', 'Launch Site\'s Bradley spawned, or road Bradleys took to the roads.', [WORLD_WHERE('road', 'launch_site or road'), WORLD_COUNT]],
['bradley.destroyed', 'A Bradley was destroyed', 'Players destroyed a Bradley APC: Launch Site\'s, a road one, or another.', [WORLD_WHERE('launch_site', 'launch_site, road or other'), WORLD_GRID, ...WORLD_CREDITED]],
['cargo.arrived', 'The cargo ship arrived', 'The cargo ship sailed onto the map.', [WORLD_GRID]],
['cargo.docking', 'The cargo ship is docking', 'The cargo ship turned in to dock at a harbour.', [WORLD_GRID, WORLD_HARBOUR]],
['cargo.docked', 'The cargo ship docked', 'The cargo ship docked at a harbour, with fresh crates.', [WORLD_GRID, WORLD_HARBOUR]],
['cargo.undocked', 'The cargo ship cast off', 'The cargo ship left the harbour it was docked at.', [WORLD_GRID, WORLD_HARBOUR]],
['cargo.left', 'The cargo ship is leaving', 'The cargo ship turned for the edge of the map.', [WORLD_GRID]],
['chinook.arrived', 'A Chinook arrived', 'A Chinook flew onto the map with a locked crate.', [WORLD_GRID]],
['chinook.dropped', 'A Chinook dropped its crate', 'A Chinook dropped a locked crate at a monument.', [WORLD_GRID, WORLD_MONUMENT]],
['chinook.downed', 'A Chinook was shot down', 'Players shot down a Chinook.', [WORLD_GRID, ...WORLD_CREDITED]],
['airdrop.called', 'An airdrop is on its way', 'A cargo plane is bringing an airdrop: called by a supply signal, or by the schedule.', [WORLD_GRID, WORLD_PLAYER]],
['airdrop.landed', 'An airdrop landed', 'An airdrop reached the ground.', [WORLD_GRID, WORLD_PLAYER]],
['airdrop.looted', 'An airdrop was looted', 'Somebody opened an airdrop first.', [WORLD_GRID, WORLD_PLAYER]],
['crate.hack.started', 'A locked crate is being hacked', 'Somebody started hacking a locked crate. On an oil rig this also calls its scientists.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
['crate.unlocked', 'A locked crate unlocked', 'A hacked locked crate finished unlocking.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
['crate.looted', 'A locked crate was looted', 'Somebody opened an unlocked locked crate first.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
['vendor.arrived', 'The Travelling Vendor arrived', 'The Travelling Vendor drove onto the map.', [WORLD_GRID]],
['vendor.left', 'The Travelling Vendor left', 'The Travelling Vendor left the map.', [WORLD_GRID]],
['satellite.crashed', 'A satellite crashed', 'A satellite came down, and its crates are burning.', [WORLD_GRID]],
['deepsea.opened', 'The Deep Sea opened', 'The Deep Sea is open: its ghost ships and islands can be reached.', []],
['deepsea.closed', 'The Deep Sea closed', 'The Deep Sea closed.', []],
]
const WORLD = WORLD_EVENTS.map(([event, label, description, vars]) => ({
id: `rust.world.${event}`,
label,
description,
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE, WORLD_SUBJECT, ...vars],
}))
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES, ...WORLD])
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))

180
server/eventWorldCall.js Normal file
View File

@@ -0,0 +1,180 @@
// ── Calling one of Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ──
//
// `rust.world.call`, "Call a world event": a patrol helicopter, a cargo ship, a
// Chinook, an airdrop, the Travelling Vendor, road Bradleys, the F-15 or a
// satellite crash, now, the way the game's own schedule runs them (D354, D367).
// The plugin runs the schedule's `TriggeredEvent` — no console command — so it
// works the same on Carbon.
//
// **Kept (`reversible: 'none'`).** Nothing a call starts is removed when the run
// ends: a cargo ship or a drop may already hold players or their loot (D354).
// `risk: 'change'` all the same, so an operator switches it on deliberately.
//
// **A position only where the game has a route to one** (stage 1's spike): the
// airdrop's drop point and the patrol helicopter's first destination. The
// Chinook drops only at the map's drop zones, and the one route to a chosen
// zone borrows the Airfield terminal's shared state, so it takes no position
// (D378); the rest go where the game puts them.
const client = require('./sidecarClient')
const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS, location } = require('./eventWorld')
/** The plugin's ceiling on calls per run (`WorldCallsHardMax`); an operator may set it lower. */
const MAX_CALLS_PER_RUN = 10
/**
* The events a call may run, mirroring the plugin's list. `max` is how many one
* step may call at once: the game runs the cargo ship, the vendor and road
* Bradleys (twelve at a time) one at a time.
*/
const EVENTS = Object.freeze([
{ value: 'patrol_heli', label: 'Patrol helicopter', max: 5, placeable: true },
{ value: 'airdrop', label: 'Airdrop', max: 5, placeable: true },
{ value: 'chinook', label: 'Chinook (drops a locked crate where the game chooses)', max: 5 },
{ value: 'cargo', label: 'Cargo ship', max: 1 },
{ value: 'bradley_road', label: 'Road Bradleys (several at once)', max: 1 },
{ value: 'travelling_vendor', label: 'Travelling Vendor', max: 1 },
{ value: 'satellite', label: 'Satellite crash', max: 5 },
{ value: 'f15', label: 'F-15 flyover (decoration only)', max: 5 },
])
/** The plugin's refusals a second attempt would repeat. */
const PERMANENT = new Set([
'events-disabled',
'malformed',
'bad-event',
'bad-count',
'not-placeable',
'too-many',
'out-of-range',
'no-monument',
'off-map',
])
const BUDGETS = [
{
id: 'rust.world.calls',
label: 'World events called',
unit: 'events',
description: "Rust's own world events an event calls — patrol helicopters, cargo ships, airdrops and the rest — each one counted. What a call starts is kept when the run ends.",
},
]
const OPTION_SOURCES = [
{
// The plugin's own list, mirrored, so the form fills with every server off.
id: 'rust.options.world_events',
label: 'World events',
description: "Rust's own world events a step can call. Only the airdrop and the patrol helicopter take a position; the rest go where the game puts them.",
async resolve() {
return EVENTS.map((e) => ({ value: e.value, label: e.label }))
},
},
]
/** Whether a step gives a position at all: a monument, or x or z. */
function hasPosition(params) {
const blank = (v) => v === undefined || v === null || v === ''
return !blank(params.monument) || !blank(params.x) || !blank(params.z)
}
const worldCall = {
id: 'rust.world.call',
label: 'Call a world event',
description:
"Calls one of Rust's own world events now — a patrol helicopter, an airdrop, a Chinook, the cargo ship, road Bradleys, the Travelling Vendor, a satellite crash or an F-15 flyover — as the game's own schedule would. An airdrop or a patrol helicopter can be sent to a monument or a point; the rest go where the game puts them. What it starts is kept when the run ends.",
risk: 'change',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'event', type: 'string', required: true, example: 'airdrop', source: 'rust.options.world_events',
description: 'Which world event to call.' },
{ name: 'count', type: 'int', required: false, example: 1,
description: 'How many at once: 1 to 5, and 1 for the cargo ship, road Bradleys and the vendor. Left blank, 1.' },
{ name: 'server', type: 'string', required: false, example: 'main', source: 'rust.options.servers',
description: 'Which server. A monument already says; otherwise required.' },
{ name: 'monument', type: 'string', required: false, example: 'main/powerplant_1', source: 'rust.options.monuments',
description: 'Airdrop and patrol helicopter only: where to send it, by monument. Give this OR x and z, or neither for where the game chooses.' },
{ name: 'offsetX', type: 'float', required: false, example: 0, description: "Metres east of the monument's centre (negative is west)." },
{ name: 'offsetZ', type: 'float', required: false, example: 0, description: "Metres north of the monument's centre (negative is south)." },
{ name: 'x', type: 'float', required: false, example: -604, description: 'Airdrop and patrol helicopter only: world x, instead of a monument.' },
{ name: 'z', type: 'float', required: false, example: -342, description: 'Airdrop and patrol helicopter only: world z, instead of a monument.' },
],
cost: (p) => ({ 'rust.world.calls': Math.max(1, Math.round(Number(p.count) || 1)) }),
async perform({ runId, idempotencyKey, params, verify }) {
const event = EVENTS.find((e) => e.value === String(params.event || '').trim())
if (!event) {
return { ok: false, retry: false, error: `"${params.event}" is not one of the world events a Rust server calls` }
}
const blank = params.count === undefined || params.count === null || params.count === ''
const count = blank ? 1 : Number(params.count)
if (!Number.isInteger(count) || count < 1 || count > event.max) {
return { ok: false, retry: false, error: `call 1 to ${event.max} of the ${event.label.toLowerCase()} at a time, and "${params.count}" is not that` }
}
let serverId = String(params.server || '').trim()
let wire = {}
if (hasPosition(params)) {
if (!event.placeable) {
return { ok: false, retry: false, error: `the ${event.label.toLowerCase()} goes where the game puts it; only an airdrop and a patrol helicopter take a position` }
}
const where = location(params)
if (!where.ok) return { ok: false, retry: false, error: where.error }
serverId = where.serverId
// A height means nothing to a call: the drop falls to the ground and the
// helicopter flies at its own.
const { y, ...flat } = where.wire
wire = flat
} else if (!serverId) {
return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
}
const found = await serverFor(serverId)
if (!found.ok) return found
if (verify) return { ok: true }
const result = await client.worldCall(found.server, {
runId: String(runId),
key: idempotencyKey,
event: event.value,
count,
...wire,
})
if (!result.ok) {
const permanent = result.status === 'not-configured' || result.status === 'no-token'
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'world event call') }
}
const data = result.data || {}
if (data.kind !== 'world.called') {
return {
ok: false,
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
error: pluginError(data, `${found.server.name || found.server.id} refused the ${event.label.toLowerCase()}`),
}
}
const called = Number(data.called) || 0
return {
ok: true,
detail: {
event: event.value,
called,
...(called < count ? { note: `the game started ${called} of the ${count} asked for` } : {}),
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing new was called' } : {}),
},
}
},
}
const ACTIONS = [worldCall]
module.exports = { EVENTS, MAX_CALLS_PER_RUN, PERMANENT, BUDGETS, ACTIONS, OPTION_SOURCES, hasPosition }

View File

@@ -59,6 +59,7 @@ module.exports = function register(ctx, api) {
const eventLeases = require('./eventLeases')
const eventWorld = require('./eventWorld')
const eventRewards = require('./eventRewards')
const eventWorldCall = require('./eventWorldCall')
const eventPermRewards = require('./eventPermRewards')
const eventTemplates = require('./eventTemplates')
const boot = require('./boot')
@@ -173,8 +174,11 @@ module.exports = function register(ctx, api) {
//
// Permissions as rewards (PLAN_REDESIGNS.md §12) beside them: any registered
// permission, lent until the run ends or given for good, each on its own budget.
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS])
//
// Calling one of Rust's own world events (PLAN_REDESIGNS.md §14.3.1, protocol
// 14) beside them: kept when the run ends, on a budget of its own.
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS])
// The templates (PLAN_REDESIGNS.md §2.5, MODULE_API 1.13.0): a kit weekend
// as a starting point on core's New event screen. Core keeps nothing about a
@@ -193,6 +197,7 @@ module.exports = function register(ctx, api) {
...eventWorld.OPTION_SOURCES,
...eventRewards.OPTION_SOURCES,
...eventPermRewards.OPTION_SOURCES,
...eventWorldCall.OPTION_SOURCES,
])
// The slash commands (phase 16, R11). The handlers run HERE, in the website
@@ -211,10 +216,10 @@ module.exports = function register(ctx, api) {
streams: STREAMS.length,
audiences: AUDIENCES.length,
leases: eventLeases.LEASES.length,
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length,
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length,
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length,
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length,
announceLeg: eventRewards.LEG.leg,
commands: commands.map((cmd) => cmd.name).join(' '),
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length,
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length,
})
}

View File

@@ -44,6 +44,7 @@ const npcs = require('./model/npcs/npcs.model')
const npcsDb = require('./model/npcs/npcs.db')
const permissionsDb = require('./model/permissions/permissions.db')
const sidecar = require('./sidecarClient')
const worldEvents = require('./worldEvents')
const log = core.logger('ingest')
@@ -268,6 +269,23 @@ async function apply(serverId, item, server = null) {
break
}
// ── Protocol 14: Rust's own world events (§14.3.1) ──────────────────────
//
// Stored like everything else, and read per viewer by the feed. The one
// thing a world event COUNTS is a downed patrol helicopter, Chinook or
// Bradley: the plugin sends every candidate and the server's rule picks
// (D372, D379), so Rotorbane and Tankbreaker always agree with the line.
// A protocol-13 plugin still sends `heliKills` / `apcKills` on its tally
// instead, and the tally path above adds those as it always has.
case 'world.event': {
const rule = worldEvents.creditRuleOf(server && server.creditRule)
for (const kill of worldEvents.killsOf(frame, rule)) {
await db.touchPlayer(kill.steamId, kill.name || null)
await db.addTitleStats({ serverId, wipeId, steamId: kill.steamId }, { [kill.field]: 1 })
}
break
}
// ── Protocol 14: Economics wiped every balance (§11.2.2) ────────────────
//
// `ecowipe` puts everyone back at the start in the wipe in play, so none of

View File

@@ -148,6 +148,9 @@ const TITLE_SUMS = {
rockets: 'rockets',
explosives: 'explosives',
missions: 'missions',
// Protocol 14 (D356): Safecracker and Scavenger.
cratesHacked: 'crates_hacked',
dropsLooted: 'drops_looted',
}
/** The two *best* fields and their columns: the longest single kill, never a sum. */
@@ -339,7 +342,15 @@ async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
*/
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25, balanceFloor = null }) {
const byBalance = sort === 'balance' && wipeId && Number.isFinite(balanceFloor)
const column = { kills: 'kills', deaths: 'deaths', npcKills: 'npc_kills', playtime: 'playtime_sec' }[sort] || 'kills'
const column = {
kills: 'kills',
deaths: 'deaths',
npcKills: 'npc_kills',
playtime: 'playtime_sec',
// Protocol 14 (D356, D377): the columns Safecracker and Scavenger bring.
cratesHacked: 'crates_hacked',
dropsLooted: 'drops_looted',
}[sort] || 'kills'
const params = [serverId]
let where = 's.server_id = ?'
@@ -363,6 +374,8 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
SUM(s.npc_kills) AS npcKills,
SUM(s.structures) AS structures,
SUM(s.playtime_sec) AS playtimeSec,
SUM(s.crates_hacked) AS cratesHacked,
SUM(s.drops_looted) AS dropsLooted,
MAX(s.balance) AS balance,
MAX(s.last_seen) AS lastSeen
FROM ${STATS} s

View File

@@ -13,7 +13,10 @@
// type.
const catalogue = require('../../catalogue')
const worldEvents = require('../../worldEvents')
const db = require('./events.db')
const mapDb = require('../map/map.db')
const serversDb = require('../servers/servers.db')
/** Hard ceiling on a page, whatever a caller asks for. */
const MAX_LIMIT = 200
@@ -51,7 +54,7 @@ function parseKinds(raw) {
* refused: naming it in an error would confirm the kind exists, which is a small
* thing to leak and a free one to avoid.
*/
async function recent({ serverId, admin = false, presence = false, kind = null, wipeId = null, limit }) {
async function recent({ serverId, admin = false, staff = false, presence = false, kind = null, wipeId = null, limit }) {
const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
// Every requested kind was refused. Answering with an empty list is right —
@@ -65,7 +68,38 @@ async function recent({ serverId, admin = false, presence = false, kind = null,
limit: boundedLimit(limit),
})
return rows.map(shape)
const shaped = rows.map(shape)
if (!shaped.some((r) => r.kind === 'world.event')) return shaped
// Protocol 14 (§14.3.1): each world event is read for THIS viewer — its type
// allowed, its names to the presence audience, its Steam ids to staff, its
// credit by the server's rule — and given a grid from the map's geometry.
// Both reads only when the page holds a world event at all.
const view = await worldView(serverId, { staff: staff || admin, presence })
return shaped.map((row) => (row.kind === 'world.event' ? worldRow(row, view) : row)).filter(Boolean)
}
/** What reading a world event for one viewer needs: the server's credit rule and map geometry. */
async function worldView(serverId, { staff, presence }) {
const [server, meta] = await Promise.all([
serversDb.getServer(serverId).catch(() => null),
mapDb.getMeta(serverId).catch(() => null),
])
return {
staff,
presence,
rule: worldEvents.creditRuleOf(server && server.creditRule),
geometry: meta
? { worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }
: null,
}
}
/** One world-event row for a viewer, or null when they may not see it. The row's own Steam id is staff's too. */
function worldRow(row, view) {
const frame = worldEvents.shapeFor(row.frame, view)
if (!frame) return null
return { ...row, steamId: view.staff ? row.steamId : null, frame }
}
/**
@@ -123,6 +157,9 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, pre
npcKills: Number(r.npcKills) || 0,
structures: Number(r.structures) || 0,
playtimeSec: Number(r.playtimeSec) || 0,
// Protocol 14 (D356): Safecracker's and Scavenger's counts.
cratesHacked: Number(r.cratesHacked) || 0,
dropsLooted: Number(r.dropsLooted) || 0,
// Withheld below the presence audience. A tally refreshes it every minute a
// player is on, so a `lastSeen` of forty seconds ago is the Online tab by
// another name. The ORDER still uses it as a tie-break — that says who was

View File

@@ -24,7 +24,7 @@ async function listServers({ enabledOnly = false } = {}) {
return core.query(
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
protocol, enabled, sort_order AS sortOrder, announce_news AS announceNews,
news_delivery AS newsDelivery,
news_delivery AS newsDelivery, credit_rule AS creditRule,
wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz,
DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt,
created_at AS createdAt, updated_at AS updatedAt
@@ -37,7 +37,7 @@ async function listServers({ enabledOnly = false } = {}) {
async function getServer(id) {
const rows = await core.query(
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
protocol, enabled, sort_order AS sortOrder,
protocol, enabled, sort_order AS sortOrder, credit_rule AS creditRule,
wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz,
DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt,
created_at AS createdAt, updated_at AS updatedAt
@@ -97,6 +97,15 @@ async function setSchedule(id, { wipeRule, wipeDay, wipeTime, wipeTz, wipeAnchor
)
}
/**
* One server's credit rule (D372). Its own statement, like the schedule, and for
* the same reason: written only when a save carries one, so a client that
* predates the rule cannot reset it to the default.
*/
async function setCreditRule(id, rule) {
await core.query(`UPDATE ${SERVERS} SET credit_rule = ? WHERE id = ?`, [rule, id])
}
async function deleteServer(id) {
await core.query(`DELETE FROM ${SERVERS} WHERE id = ?`, [id])
}
@@ -256,6 +265,7 @@ module.exports = {
getServer,
upsertServer,
setSchedule,
setCreditRule,
deleteServer,
listState,
getState,

View File

@@ -17,6 +17,7 @@
const core = require('../../core')
const db = require('./servers.db')
const { nextWipe } = require('./nextWipe')
const worldEvents = require('../../worldEvents')
const log = core.logger('servers')
@@ -59,6 +60,8 @@ function withToken(row) {
announceNews: Boolean(Number(row.announceNews)),
// D142: where that post goes — chat, or a popup.
newsDelivery: row.newsDelivery === 'popup' ? 'popup' : 'chat',
// D372: who a downed helicopter, Chinook or Bradley credits, for ingest.
creditRule: worldEvents.creditRuleOf(row.creditRule),
}
}
@@ -212,6 +215,8 @@ async function listForAdmin(now = Date.now()) {
// bridge makes. Anything but `patched` is said on the servers page.
zoneHelper: (state && state.zoneHelper) || null,
schedule: scheduleOf(row),
// D372: who a downed helicopter, Chinook or Bradley credits on this server.
creditRule: worldEvents.creditRuleOf(row.creditRule),
}
})
}

View File

@@ -20,7 +20,8 @@ const crypto = require('node:crypto')
/**
* The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three
* conditions and their shipped titles, the richest (§11.2.2, Magnate), plus
* conditions and their shipped titles, the richest (§11.2.2, Magnate), the
* two world-event counts (§14.3.1, Safecracker and Scavenger), plus
* `playtime`, which predates them and has no default title, so a playtime rule
* always carries its own text.
*
@@ -65,6 +66,9 @@ const STATS = {
rockets: { label: 'Rockets fired', title: 'Artillerist', source: { sum: 'rockets' } },
explosives: { label: 'Explosives thrown', title: 'Demolitionist', source: { sum: 'explosives' } },
missions: { label: 'Missions completed', title: 'Wayfarer', source: { sum: 'missions' } },
// Protocol 14, Rust's own world events (D356): a hack counts for its starter, once per crate.
crateshacked: { label: 'Locked crates hacked', title: 'Safecracker', source: { sum: 'crates_hacked' } },
dropslooted: { label: 'Airdrops looted first', title: 'Scavenger', source: { sum: 'drops_looted' } },
richest: { label: 'Richest (Economics)', title: 'Magnate', source: { balance: 'balance' } },
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
profilekills: { label: 'Kills of an NPC profile', title: null, source: { npcProfile: true } },

View File

@@ -113,6 +113,11 @@ async function putServer(req, res) {
})
if (schedule) await db.setSchedule(id, schedule)
// D372: written only when the save carries one, like the schedule. The router
// has already refused a word this build does not know.
const creditRule = req.body.creditRule === undefined ? null : req.body.creditRule
if (creditRule) await db.setCreditRule(id, creditRule)
await core.activity.log({
req,
action: 'rust.server.save',
@@ -125,6 +130,8 @@ async function putServer(req, res) {
// The schedule as written, when the save carried one. Nothing in it is a
// secret, and "who moved the wipe" is a question players will ask.
...(schedule ? { schedule: { ...schedule, wipeOnceAt: schedule.wipeOnceAt ? schedule.wipeOnceAt.toISOString() : null } } : {}),
// The credit rule as written, when the save carried one (D372).
...(creditRule ? { creditRule } : {}),
},
})

View File

@@ -22,6 +22,7 @@ const core = require('../../core')
const express = core.express
const admin = require('./rust.controller')
const worldEvents = require('../../worldEvents')
const { requireRole, validate } = core.middleware
const { body, param } = core.validator
@@ -63,7 +64,7 @@ adminRustRouter.put(
'/servers/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Create or update a Rust server'
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. A 400 carries one sentence per problem in `errors`.'
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. `creditRule` (protocol 14, D372) is who a downed patrol helicopter, Chinook or Bradley credits, for the feed line and for the Rotorbane and Tankbreaker counts alike: `blow` (the killing blow, the default; for the patrol helicopter the crippling hit, else the last hit), `last`, `most` (the most damage) or `all` (everyone who hurt it, one each). It is written only when present, and applies from then on. A 400 carries one sentence per problem in `errors`.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerSave" } } } } */
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Invalid body' } */
@@ -91,6 +92,8 @@ adminRustRouter.put(
body('wipeTz').optional({ values: 'null' }).isString().isLength({ max: 64 }),
body('wipeAnchor').optional({ values: 'falsy' }).isISO8601({ strict: true }).isLength({ max: 10 }),
body('wipeOnceAt').optional({ values: 'falsy' }).isISO8601({ strict: true }),
// Protocol 14 (D372): who a downed helicopter, Chinook or Bradley credits.
body('creditRule').optional().isIn(worldEvents.CREDIT_RULE_IDS),
validate,
admin.putServer,
)

View File

@@ -84,6 +84,9 @@ async function getServer(req, res) {
* feed carries only what names nobody — a wipe, a start, a shutdown — and says
* so with `presenceHidden`, so a page can explain a quiet feed instead of
* implying a quiet server.
*
* `staff` is the same re-read rung, and reaches only what a world event carries
* (protocol 14, D353): a Steam id and a position. It never widens the kinds.
*/
async function listEvents(req, res) {
try {
@@ -93,6 +96,7 @@ async function listEvents(req, res) {
events: await events.recent({
serverId: req.params.id,
presence: presence.visible,
staff: presence.level === 'staff',
kind: req.query.kind,
wipeId: req.query.wipe || null,
limit: req.query.limit,

View File

@@ -68,7 +68,7 @@ rustRouter.get(
'/servers/:id/events',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Recent events on one Rust server'
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld.'
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule`.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
@@ -85,7 +85,7 @@ rustRouter.get(
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336).'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, or balance (one wipe on a server with Economics)', schema: { type: 'string' } }
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, or balance (one wipe on a server with Economics)', schema: { type: 'string' } }
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'The leaderboard' } */
siteMode,

View File

@@ -428,6 +428,13 @@ const worldOwned = (server, { runId } = {}) =>
const worldZone = (server, body) => request(server, '/world/zone', { method: 'POST', body })
const worldPlace = (server, body) => request(server, '/world/place', { method: 'POST', body })
/**
* Run one of the game's own world events for a run (protocol 14, §20.5). `data.kind`
* is `world.called` (with `called`, and `repeat: true` for a key the plugin has
* seen) or `world.error` with a reason. What it starts is kept: there is no revert.
*/
const worldCall = (server, body) => request(server, '/world/call', { method: 'POST', body })
/**
* Give back what a run owns: named ids, else everything under a key, else the
* whole run. `data` lists `removed`, `gone` (already not there — a success)
@@ -564,6 +571,7 @@ module.exports = {
worldOwned,
worldZone,
worldPlace,
worldCall,
worldRevert,
tallyOpen,
tallySnapshot,

View File

@@ -109,6 +109,12 @@ module.exports = {
wipeTz: { type: 'string', nullable: true, example: 'America/Chicago' },
wipeAnchor: { type: 'string', format: 'date', nullable: true, example: null },
wipeOnceAt: { type: 'string', format: 'date-time', nullable: true, example: null },
creditRule: {
type: 'string',
enum: ['blow', 'last', 'most', 'all'],
description: 'Who a downed patrol helicopter, Chinook or Bradley credits (D372): the killing blow, the last hit, the most damage, or everyone who hurt it. Written only when present.',
example: 'blow',
},
},
},
RustAdminServerList: {
@@ -139,6 +145,7 @@ module.exports = {
},
bootId: { type: 'string', nullable: true, example: 'boot-20260915T194502Z' },
sidecarProtocol: { type: 'integer', nullable: true, example: 1 },
creditRule: { type: 'string', enum: ['blow', 'last', 'most', 'all'], description: 'Who a downed patrol helicopter, Chinook or Bradley credits on this server (D372).', example: 'blow' },
zoneHelper: {
type: 'object',
nullable: true,

View File

@@ -95,7 +95,7 @@ test('every kind is classified exactly once', () => {
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
})
test('the classification covers exactly the event kinds the protocol defines, through protocol 13', () => {
test('the classification covers exactly the event kinds the protocol defines, through protocol 14', () => {
// The spec lives in another repository, so the list is restated here rather
// than parsed — and restating it is the point: adding a kind to the protocol
// without deciding who may see it has to fail somewhere, and this is where.
@@ -150,6 +150,9 @@ test('the classification covers exactly the event kinds the protocol defines, th
'npc.boss.spawned',
'npc.boss.phase',
'npc.boss.died',
// Protocol 14 (§20.5): Rust's own world events, one kind told apart by
// `event`. Public as a kind; `worldEvents.js` filters per type and per viewer.
'world.event',
]
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
@@ -165,6 +168,10 @@ test('the classification covers exactly the event kinds the protocol defines, th
for (const kind of ['npc.boss.spawned', 'npc.boss.died']) {
assert.equal(catalogue.isPublic(kind), true, `${kind} is said to the whole server, so it is public (D286)`)
}
// D353, D376: the world event is public and names nobody below the presence
// audience — the names come off per viewer, so the kind is not a presence kind.
assert.equal(catalogue.isPublic('world.event'), true)
assert.equal(catalogue.isPresence('world.event'), false)
})
test('every kind that names a player who was on is behind the presence setting', () => {
@@ -187,6 +194,7 @@ test('every kind that names a player who was on is behind the presence setting',
// A presence kind is a subset of the public ones, never a staff kind widened.
for (const kind of catalogue.PRESENCE_KINDS) assert.ok(catalogue.PUBLIC_KINDS.includes(kind))
// And what is left names nobody.
assert.deepEqual(catalogue.kindsFor({}).sort(), ['npc.boss.spawned', 'server.initialized', 'server.shutdown', 'server.wipe'])
// And what is left names nobody — a world event included, once `worldEvents.shapeFor`
// has taken its names off for a viewer below the presence audience (D376).
assert.deepEqual(catalogue.kindsFor({}).sort(), ['npc.boss.spawned', 'server.initialized', 'server.shutdown', 'server.wipe', 'world.event'])
})

View File

@@ -165,6 +165,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
'rust.participation.open',
'rust.perm.give',
'rust.perm.lend',
'rust.world.call',
'rust.zone.open',
])
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
@@ -176,6 +177,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
'rust.permissions',
'rust.permissions.kept',
'rust.prefabs',
'rust.world.calls',
'rust.zone.minutes',
])
@@ -200,8 +202,9 @@ test('the event verbs are registered, and every budget has a verb that spends it
// What a teardown can give back is declared honestly: a line said, a
// collect filed, coins paid (D335) and permissions given for good (D346) are
// not undone.
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give'])
// not undone, and nor is a world event called (D354): a cargo ship or a drop
// may already hold players or their loot.
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give', 'rust.world.call'])
for (const a of actions) {
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
// Every param source is one this module registers.

View File

@@ -47,9 +47,11 @@ const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...
// ── The categories ────────────────────────────────────────────────────────
test('twenty-four categories carry a default title, and playtime carries none (D172, §11.2.2)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 24)
test('twenty-six categories carry a default title, and playtime carries none (D172, §11.2.2, D356)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 26)
assert.strictEqual(titles.STATS.richest.title, 'Magnate')
assert.strictEqual(titles.STATS.crateshacked.title, 'Safecracker')
assert.strictEqual(titles.STATS.dropslooted.title, 'Scavenger')
assert.ok(!titles.CATEGORY_IDS.includes('playtime'))
assert.strictEqual(titles.STATS.playtime.title, null)
assert.strictEqual(titles.STATS.animalkills.title, 'Beastbane')

View File

@@ -0,0 +1,395 @@
// ── Rust's own world events (PLAN_REDESIGNS §14.3.1, protocol 14) ──────────
//
// Each test is one way the feature could look right and be wrong:
//
// the catalogue drifts from the protocol's 24 types, and a new one is public
// the four credit rules (D372) disagree with their own table — or with the
// count, so the line says Alice and Rotorbane counts Bob
// a name reaches a viewer below the presence audience (D376), a Steam id or a
// position reaches anybody but staff (D353)
// a grid is clamped onto the edge of the map for something off it
// the tally's two new counts are dropped on the floor
// a trigger declares a variable its emitter never fills, or names a Steam id
// the call step takes a position for an event that has no route to one (D378)
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/** Installs a core whose `db.query` records every statement and answers `rows`. */
function withRecorder(rows = []) {
const statements = []
const ctx = fakeCtx({
db: {
query: (sql, params = []) => {
statements.push({ sql, params })
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return { ctx, statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) }
}
withRecorder()
const worldEvents = require('../worldEvents')
const catalogue = require('../catalogue')
const { TRIGGERS } = require('../engagement/triggers')
const A = '76561198000000001'
const B = '76561198000000002'
const C = '76561198000000003'
const NOW = Date.now()
/** A 6000 map of the game's own 41 cells of 146.3 m — the rigs' geometry. */
const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 }
const world = (event, over = {}) => ({ kind: 'world.event', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', event, ...over })
/** Bob did 61 %, Alice the crippling hit — D372's own example. */
const downed = (over = {}) => world('heli.downed', {
x: 812,
z: -340.5,
credit: { blow: A, last: B, most: B, damagers: [{ steamId: B, damage: 6120 }, { steamId: A, damage: 3880 }] },
names: { [A]: 'Alice', [B]: 'Bob' },
...over,
})
// ── The catalogue ──────────────────────────────────────────────────────────
test('the types are exactly the 24 the protocol defines, and an unknown one is staff only', () => {
// Sourced from docs/rust-link/PROTOCOL.md §20.5 (PLAN_REDESIGNS §14.3.1, D379).
const PROTOCOL_14 = [
'heli.arrived', 'heli.left', 'heli.downed',
'bradley.spawned', 'bradley.destroyed',
'cargo.arrived', 'cargo.docking', 'cargo.docked', 'cargo.undocked', 'cargo.left',
'chinook.arrived', 'chinook.dropped', 'chinook.downed',
'airdrop.called', 'airdrop.landed', 'airdrop.looted',
'crate.hack.started', 'crate.unlocked', 'crate.looted',
'vendor.arrived', 'vendor.left',
'satellite.crashed',
'deepsea.opened', 'deepsea.closed',
]
assert.deepStrictEqual([...worldEvents.TYPE_IDS].sort(), [...PROTOCOL_14].sort())
assert.ok(catalogue.isPublic('world.event'))
// Default deny, per type: a newer plugin's type is not served to the public.
const future = world('meteor.landed', { x: 1, z: 1, steamId: A, name: 'Alice' })
assert.strictEqual(worldEvents.shapeFor(future, { presence: true }), null)
assert.strictEqual(worldEvents.shapeFor(future, { staff: true }).event, 'meteor.landed')
})
// ── Credit (D372, D379) ────────────────────────────────────────────────────
test('each of the four credit rules picks what its table says', () => {
assert.deepStrictEqual(worldEvents.credited(downed(), 'blow'), [A])
assert.deepStrictEqual(worldEvents.credited(downed(), 'last'), [B])
assert.deepStrictEqual(worldEvents.credited(downed(), 'most'), [B])
assert.deepStrictEqual(worldEvents.credited(downed(), 'all'), [B, A])
// An unknown rule is the default, the killing blow.
assert.deepStrictEqual(worldEvents.credited(downed(), 'loudest'), [A])
})
test('with no killing blow, the killing-blow rule falls back to the last hit', () => {
const noBlow = downed({ credit: { last: B, most: A, damagers: [{ steamId: A, damage: 9 }, { steamId: B, damage: 1 }] } })
assert.deepStrictEqual(worldEvents.credited(noBlow, 'blow'), [B])
assert.deepStrictEqual(worldEvents.credited(noBlow, 'most'), [A])
})
test('a death nobody touched credits nobody under any rule, and counts nothing', () => {
const nobody = world('bradley.destroyed', { where: 'road', x: 0, z: 0, credit: { damagers: [] } })
for (const rule of worldEvents.CREDIT_RULE_IDS) {
assert.deepStrictEqual(worldEvents.credited(nobody, rule), [], rule)
assert.deepStrictEqual(worldEvents.killsOf(nobody, rule), [], rule)
}
})
test('the count and the line credit the same players, under every rule', () => {
for (const rule of worldEvents.CREDIT_RULE_IDS) {
const line = worldEvents.shapeFor(downed(), { presence: true, rule }).credited.map((c) => c.name)
const count = worldEvents.killsOf(downed(), rule).map((k) => k.name)
assert.deepStrictEqual(line, count, rule)
}
// Each kill is the column the type counts toward: Rotorbane for both helicopters, Tankbreaker for Bradley.
assert.strictEqual(worldEvents.killsOf(downed(), 'blow')[0].field, 'heliKills')
assert.strictEqual(worldEvents.killsOf(world('chinook.downed', { credit: { blow: A } }), 'blow')[0].field, 'heliKills')
assert.strictEqual(worldEvents.killsOf(world('bradley.destroyed', { where: 'launch_site', credit: { blow: A } }), 'blow')[0].field, 'apcKills')
// Nothing else counts.
assert.deepStrictEqual(worldEvents.killsOf(world('heli.arrived'), 'blow'), [])
})
test('a junk id in a credit block is never credited', () => {
const junk = downed({ credit: { blow: 'not-a-steam-id', last: '123', damagers: [{ steamId: '{}', damage: 1 }] } })
for (const rule of worldEvents.CREDIT_RULE_IDS) assert.deepStrictEqual(worldEvents.credited(junk, rule), [], rule)
})
// ── Who may read what (D353, D376) ─────────────────────────────────────────
test('below the presence audience a line names nobody, and carries no position', () => {
const signedOut = worldEvents.shapeFor(downed(), { presence: false, rule: 'blow', geometry: GEOMETRY })
assert.strictEqual(signedOut.event, 'heli.downed')
assert.strictEqual(signedOut.grid, worldEvents.gridOf(GEOMETRY, 812, -340.5))
assert.strictEqual(signedOut.creditedCount, 1)
assert.strictEqual(signedOut.credited, undefined)
for (const field of ['x', 'z', 'credit', 'names', 'steamId', 'name']) assert.strictEqual(signedOut[field], undefined, field)
const hack = worldEvents.shapeFor(world('crate.hack.started', { where: 'oilrig_large', steamId: A, name: 'Alice', x: 1, z: 1 }), { presence: false })
assert.strictEqual(hack.name, undefined)
assert.strictEqual(hack.steamId, undefined)
assert.strictEqual(hack.where, 'oilrig_large')
})
test('the presence audience reads names but no Steam id; staff read both, and the position', () => {
const member = worldEvents.shapeFor(downed(), { presence: true, rule: 'all' })
assert.deepStrictEqual(member.credited, [{ name: 'Bob' }, { name: 'Alice' }])
assert.strictEqual(member.x, undefined)
const staff = worldEvents.shapeFor(downed(), { staff: true, presence: true, rule: 'all' })
assert.deepStrictEqual(staff.credited, [{ name: 'Bob', steamId: B }, { name: 'Alice', steamId: A }])
assert.strictEqual(staff.x, 812)
assert.ok(staff.credit, 'staff see the whole credit block')
const loot = world('airdrop.looted', { steamId: A, name: 'Alice', x: 5, z: 5 })
assert.deepStrictEqual(
[worldEvents.shapeFor(loot, { presence: true }).name, worldEvents.shapeFor(loot, { presence: true }).steamId],
['Alice', undefined],
)
assert.strictEqual(worldEvents.shapeFor(loot, { staff: true, presence: true }).steamId, A)
})
test('a line with nobody in it reads the same for everybody', () => {
const docking = world('cargo.docking', { harbour: 'harbor_1', harbourName: 'Harbor', x: 100, z: 100 })
const a = worldEvents.shapeFor(docking, { presence: false, geometry: GEOMETRY })
const b = worldEvents.shapeFor(docking, { presence: true, geometry: GEOMETRY })
assert.deepStrictEqual(a, b)
assert.strictEqual(a.harbourName, 'Harbor')
})
// ── The grid ───────────────────────────────────────────────────────────────
test('the grid is the game’s, and nothing off the map gets one', () => {
// The north-west corner is A0; the 41st column is AO.
assert.strictEqual(worldEvents.gridOf(GEOMETRY, -2999, 2999), 'A0')
assert.strictEqual(worldEvents.gridOf(GEOMETRY, 2999, -2999), 'AO40')
// The Deep Sea and its ghost ships sit thousands of metres off the map.
assert.strictEqual(worldEvents.gridOf(GEOMETRY, -5200, 0), null)
// No geometry yet, or no position: no grid, rather than a guess.
assert.strictEqual(worldEvents.gridOf(null, 0, 0), null)
assert.strictEqual(worldEvents.gridOf(GEOMETRY, undefined, 0), null)
})
// ── Ingest ─────────────────────────────────────────────────────────────────
test('a downed helicopter counts toward Rotorbane for whom the server’s rule credits', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', { id: 1, kind: 'world.event', frame: downed() }, { id: 'main', creditRule: 'all' })
const adds = rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('heli_kills'))
assert.strictEqual(adds.length, 2)
assert.deepStrictEqual(adds.map((s) => s.params[2]).sort(), [A, B].sort())
// Under the default rule, only the killing blow.
const rec2 = withRecorder()
await apply('main', { id: 2, kind: 'world.event', frame: downed() }, { id: 'main' })
const once = rec2.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('heli_kills'))
assert.strictEqual(once.length, 1)
assert.strictEqual(once[0].params[2], A)
})
test('a Bradley counts toward Tankbreaker, and an arrival counts nothing', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', { id: 1, kind: 'world.event', frame: world('bradley.destroyed', { where: 'launch_site', credit: { blow: C }, names: { [C]: 'Cleo' } }) }, { id: 'main' })
const apc = rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('apc_kills'))
assert.strictEqual(apc.length, 1)
assert.strictEqual(apc[0].params[2], C)
const rec2 = withRecorder()
await apply('main', { id: 2, kind: 'world.event', frame: world('heli.arrived', { x: 1, z: 1 }) }, { id: 'main' })
assert.strictEqual(rec2.touching('rust_player_wipe_stats').length, 0)
})
test('the tally’s crates hacked and drops looted are added up (D356)', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', { id: 1, kind: 'player.tally', frame: { kind: 'player.tally', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', steamId: A, name: 'Alice', cratesHacked: 2, dropsLooted: 1 } })
const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('crates_hacked'))
assert.ok(titleAdd, 'the tally reached the title columns')
assert.ok(titleAdd.sql.includes('drops_looted'))
const cols = titleAdd.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+)\)/)[1].split(', ')
assert.strictEqual(titleAdd.params[3 + cols.indexOf('crates_hacked')], 2)
assert.strictEqual(titleAdd.params[3 + cols.indexOf('drops_looted')], 1)
})
// ── The feed ───────────────────────────────────────────────────────────────
test('the feed reads each world event for its viewer, with the server’s rule and grid', async () => {
const stored = downed()
withRecorder((sql) => {
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'world.event', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stored) }]
if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', creditRule: 'most', enabled: 1 }]
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
return []
})
const events = require('../model/events/events.model')
const [signedOut] = await events.recent({ serverId: 'main', presence: false })
assert.strictEqual(signedOut.frame.creditRule, 'most')
assert.strictEqual(signedOut.frame.credited, undefined)
assert.strictEqual(signedOut.frame.grid, worldEvents.gridOf(GEOMETRY, 812, -340.5))
const [member] = await events.recent({ serverId: 'main', presence: true })
assert.deepStrictEqual(member.frame.credited, [{ name: 'Bob' }])
})
// ── Triggers (D355) ────────────────────────────────────────────────────────
test('one public trigger per type, none of them declaring a Steam id', () => {
const world = TRIGGERS.filter((t) => t.id.startsWith('rust.world.'))
assert.deepStrictEqual(world.map((t) => t.id.slice('rust.world.'.length)).sort(), [...worldEvents.TYPE_IDS].sort())
for (const t of world) {
assert.strictEqual(t.ceiling, 'everyone', t.id)
assert.strictEqual(t.subjectKey, 'subject', t.id)
assert.ok(t.variables.some((v) => v.name === 'subject'), t.id)
assert.ok(!t.variables.some((v) => /steam/i.test(v.name)), `${t.id} declares a Steam id`)
}
})
test('a world event fires its trigger with the variables it declares, and a headline', async (t) => {
const { ctx } = withRecorder((sql) => (sql.includes('FROM rust_map_images') ? [{ serverId: 'main', ...GEOMETRY }] : []))
const emit = require('../engagement/emit')
emit.reset()
const server = { id: 'main', name: 'Main', creditRule: 'all' }
await emit.onEvent(server, { id: 1, kind: 'world.event', frame: downed() })
await emit.onEvent(server, { id: 2, kind: 'world.event', frame: world('crate.hack.started', { where: 'oilrig_small', steamId: A, name: 'Alice', x: 1, z: 1 }) })
await emit.onEvent(server, { id: 3, kind: 'world.event', frame: world('meteor.landed') })
const calls = ctx.events.emit.calls
assert.strictEqual(calls.length, 2, 'an unknown type fires nothing')
const [heliId, heli] = calls[0]
assert.strictEqual(heliId, 'rust.world.heli.downed')
assert.strictEqual(heli.data.player, 'Bob and Alice')
assert.strictEqual(heli.data.credited, 2)
assert.strictEqual(heli.data.subject, 'main:heli.downed')
assert.ok(heli.data.grid)
assert.match(heli.data.intro, /downed by Bob and Alice at /)
const [crateId, crate] = calls[1]
assert.strictEqual(crateId, 'rust.world.crate.hack.started')
assert.strictEqual(crate.data.subject, 'main:crate.hack.started:oilrig_small')
assert.match(crate.data.intro, /Alice started hacking the locked crate on Small Oil Rig on Main/)
// Every variable a trigger fills is one it declares.
for (const [id, envelope] of calls) {
const declared = new Set(TRIGGERS.find((x) => x.id === id).variables.map((v) => v.name))
for (const name of Object.keys(envelope.data)) assert.ok(declared.has(name), `${id} fills ${name}, which it does not declare`)
}
})
test('an old world event tells nobody (D63)', async () => {
const { ctx } = withRecorder()
const emit = require('../engagement/emit')
emit.reset()
await emit.onEvent({ id: 'main', name: 'Main' }, { id: 1, kind: 'world.event', frame: world('heli.arrived', { t: NOW - 3 * 3600 * 1000 }) })
assert.strictEqual(ctx.events.emit.calls.length, 0)
})
// ── The call step (D354, D367, D378) ───────────────────────────────────────
function stubCall(t, reply) {
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const saved = { worldCall: client.worldCall, getServer: serversDb.getServer }
const calls = []
serversDb.getServer = async (id) => (id === 'main' ? { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' } : null)
client.worldCall = async (server, body) => {
calls.push({ server: server.id, body })
return typeof reply === 'function' ? reply(body) : reply
}
t.after(() => {
client.worldCall = saved.worldCall
serversDb.getServer = saved.getServer
})
return calls
}
const callStep = () => require('../eventWorldCall').ACTIONS[0]
const ok = (data) => ({ ok: true, status: 'ok', data })
test('the call step is kept, priced per event, and mirrors the plugin’s list', () => {
const step = callStep()
assert.strictEqual(step.id, 'rust.world.call')
assert.strictEqual(step.reversible, 'none')
assert.strictEqual(step.risk, 'change')
assert.strictEqual(step.revert, undefined)
assert.deepStrictEqual(step.cost({ event: 'airdrop', count: 3 }), { 'rust.world.calls': 3 })
assert.deepStrictEqual(step.cost({ event: 'cargo' }), { 'rust.world.calls': 1 })
const { EVENTS } = require('../eventWorldCall')
assert.deepStrictEqual(EVENTS.map((e) => e.value).sort(), ['airdrop', 'bradley_road', 'cargo', 'chinook', 'f15', 'patrol_heli', 'satellite', 'travelling_vendor'])
assert.deepStrictEqual(EVENTS.filter((e) => e.placeable).map((e) => e.value).sort(), ['airdrop', 'patrol_heli'])
})
test('a call is refused in the dry run for a bad event, a bad count, a position it cannot take, or no server', async (t) => {
const calls = stubCall(t, ok({ kind: 'world.called', called: 1 }))
const step = callStep()
const run = (params) => step.perform({ runId: 7, idempotencyKey: '7:1', params, verify: true })
assert.match((await run({ server: 'main', event: 'meteor' })).error, /not one of the world events/)
assert.match((await run({ server: 'main', event: 'cargo', count: 2 })).error, /call 1 to 1/)
assert.match((await run({ server: 'main', event: 'airdrop', count: 6 })).error, /call 1 to 5/)
// D378: the Chinook goes where the game chooses.
assert.match((await run({ server: 'main', event: 'chinook', x: 10, z: 10 })).error, /only an airdrop and a patrol helicopter take a position/)
assert.match((await run({ event: 'airdrop' })).error, /which server/)
assert.ok((await run({ server: 'main', event: 'airdrop', x: 10, z: 10 })).ok)
for (const r of [await run({ server: 'main', event: 'meteor' })]) assert.strictEqual(r.retry, false)
assert.strictEqual(calls.length, 0, 'the dry run calls nothing')
})
test('a call sends the key, the run and the position, and reads the plugin’s answer', async (t) => {
const calls = stubCall(t, (body) => ok({ kind: 'world.called', key: body.key, event: body.event, called: body.count, repeat: false }))
const step = callStep()
const result = await step.perform({ runId: 7, idempotencyKey: '7:2', params: { event: 'airdrop', count: 2, monument: 'main/powerplant_1', offsetX: 10, y: 99 } })
assert.ok(result.ok)
assert.deepStrictEqual(result.detail, { event: 'airdrop', called: 2 })
assert.deepStrictEqual(calls[0], {
server: 'main',
body: { runId: '7', key: '7:2', event: 'airdrop', count: 2, monument: 'powerplant_1', offsetX: 10, offsetZ: 0 },
})
})
test('a refusal the plugin would repeat is not retried; a game that is down is', async (t) => {
stubCall(t, (body) => (body.event === 'cargo'
? ok({ kind: 'world.error', reason: 'too-many', message: 'a run calls at most 10 world events on this server' })
: { ok: false, status: 'http-503' }))
const step = callStep()
const refused = await step.perform({ runId: 7, idempotencyKey: '7:3', params: { server: 'main', event: 'cargo' } })
assert.strictEqual(refused.ok, false)
assert.strictEqual(refused.retry, false)
assert.match(refused.error, /at most 10/)
const down = await step.perform({ runId: 7, idempotencyKey: '7:4', params: { server: 'main', event: 'airdrop' } })
assert.strictEqual(down.ok, false)
assert.strictEqual(down.retry, undefined)
assert.match(down.error, /has no game connected/)
})
test('a repeat of a key says so and calls nothing new', async (t) => {
stubCall(t, ok({ kind: 'world.called', called: 1, repeat: true }))
const result = await callStep().perform({ runId: 7, idempotencyKey: '7:5', params: { server: 'main', event: 'patrol_heli' } })
assert.ok(result.ok)
assert.strictEqual(result.detail.repeat, true)
})

278
server/worldEvents.js Normal file
View File

@@ -0,0 +1,278 @@
// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ──────────
//
// The patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked
// crates, the Travelling Vendor, the satellite and the Deep Sea reach this
// module as ONE frame kind, `world.event`, whose `event` says which (D349,
// D353). This file is the one place that knows the 24 of them, and the three
// readers — the feed, the ingest and the engagement triggers — all ask it.
//
// ── Why `event` and not `type` ─────────────────────────────────────────────
//
// The plan's example put the world event's type in `type`, which is the
// envelope's field: the sidecar files a frame on it (`event`, `snapshot`,
// `reply`), and it dropped every world event as untyped on the rig. Protocol
// 13's `world.expired` hit the same collision and settled on its own field; so
// does this one (stage 1, 2026-10-15).
//
// ── Who gets the credit (D372, D379) ───────────────────────────────────────
//
// For a patrol helicopter, a Chinook and a Bradley the plugin sends every
// candidate — the killing blow, the last hit, the most damage, everyone who
// hurt it — and decides nothing. The server's rule, chosen by its operator,
// picks from them HERE, for the feed line and for Rotorbane / Tankbreaker
// alike, so the two can never disagree. A rule changed applies from then on:
// the counts already written stay as they were.
//
// ── Who may read what (D353, D376) ─────────────────────────────────────────
//
// The event and its grid are public. A player's name shows only to the
// operator's presence audience — with presence left at staff, a signed-out
// visitor reads "Patrol helicopter downed at G8" — and a Steam id only to staff.
// A position never leaves this module: the map has its own audience for where
// things are (D111), and the feed must not be a way round it. An event type
// this build does not know is staff's alone (default deny, as `catalogue.js`).
/**
* Every type the protocol defines (PROTOCOL.md §20.5), and what each carries.
*
* `player` names one player (`steamId`, `name`)
* `credit` carries a credit block, and which tally column it counts toward
*/
const TYPES = Object.freeze({
'heli.arrived': {},
'heli.left': {},
'heli.downed': { credit: 'heliKills' },
'bradley.spawned': {},
'bradley.destroyed': { credit: 'apcKills' },
'cargo.arrived': {},
'cargo.docking': {},
'cargo.docked': {},
'cargo.undocked': {},
'cargo.left': {},
'chinook.arrived': {},
'chinook.dropped': {},
// D379: a Chinook shot down counted toward Rotorbane under D174, and still does.
'chinook.downed': { credit: 'heliKills' },
'airdrop.called': { player: true },
'airdrop.landed': { player: true },
'airdrop.looted': { player: true },
'crate.hack.started': { player: true },
'crate.unlocked': { player: true },
'crate.looted': { player: true },
'vendor.arrived': {},
'vendor.left': {},
'satellite.crashed': {},
'deepsea.opened': {},
'deepsea.closed': {},
})
const TYPE_IDS = Object.freeze(Object.keys(TYPES))
/** The operator's credit rules (D372), in the order the form offers them. `blow` is the default. */
const CREDIT_RULES = Object.freeze([
{ id: 'blow', label: 'Killing blow', description: 'The player whose hit destroyed it; for the patrol helicopter, the crippling hit, else the last hit.' },
{ id: 'last', label: 'Last hit', description: 'The last player to hurt it.' },
{ id: 'most', label: 'Most damage', description: 'The player who dealt it the most damage.' },
{ id: 'all', label: 'Everyone who hurt it', description: 'Every player who damaged it, one each.' },
])
const CREDIT_RULE_IDS = Object.freeze(CREDIT_RULES.map((r) => r.id))
const DEFAULT_CREDIT_RULE = 'blow'
/** A stored or posted rule, narrowed to one this build knows. */
function creditRuleOf(value) {
return CREDIT_RULE_IDS.includes(value) ? value : DEFAULT_CREDIT_RULE
}
const STEAM_ID = /^\d{17}$/
function steamId(value) {
const text = value === undefined || value === null ? '' : String(value)
return STEAM_ID.test(text) ? text : null
}
function isKnownType(event) {
return Object.prototype.hasOwnProperty.call(TYPES, event)
}
/**
* Who a downed helicopter, Chinook or Bradley credits under `rule`: a list of
* Steam ids, empty when nobody qualifies. D372's table, exactly:
*
* blow `blow`, else `last`
* last `last`
* most `most`
* all every `damagers` entry, one each (and the blow, if it dealt nothing)
*/
function credited(frame, rule) {
const credit = (frame && frame.credit) || {}
const one = (id) => (steamId(id) ? [steamId(id)] : [])
switch (creditRuleOf(rule)) {
case 'last':
return one(credit.last)
case 'most':
return one(credit.most)
case 'all': {
const ids = []
for (const row of Array.isArray(credit.damagers) ? credit.damagers : []) {
const id = steamId(row && row.steamId)
if (id && !ids.includes(id)) ids.push(id)
}
const blow = steamId(credit.blow)
if (blow && !ids.includes(blow)) ids.push(blow)
return ids
}
default:
return one(credit.blow).length ? one(credit.blow) : one(credit.last)
}
}
/** The name a frame carries for a credited Steam id, or null. */
function nameOf(frame, id) {
const names = (frame && frame.names) || {}
const name = names[id]
return typeof name === 'string' && name ? name : null
}
/**
* The grid label for a world position, as the in-game map writes it (D119): the
* game's own cell count and size, which the map stores from the plugin's
* `map.info`. Null without a geometry, and null OFF the map — the Deep Sea and
* its ghost ships are thousands of metres outside it, and a clamped edge cell
* there would be a confident wrong answer. The client's `mapGeometry.js` holds
* the same arithmetic for the map; this copy is for the lines the server writes.
*/
function gridOf(geometry, x, z) {
const g = geometry || {}
const xs = Number(x)
const zs = Number(z)
if (!(g.gridCells > 0) || !(g.gridCellSize > 0) || !(g.worldSize > 0)) return null
if (!Number.isFinite(xs) || !Number.isFinite(zs)) return null
const half = g.worldSize / 2
if (Math.abs(xs) > half || Math.abs(zs) > half) return null
const clamp = (v) => Math.max(0, Math.min(g.gridCells - 1, v))
const col = clamp(Math.floor((xs + half) / g.gridCellSize))
const row = clamp(Math.floor((half - zs) / g.gridCellSize))
return `${columnName(col)}${row}`
}
/** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */
function columnName(index) {
let name = ''
let n = index + 1
while (n > 0) {
const r = (n - 1) % 26
name = String.fromCharCode(65 + r) + name
n = Math.floor((n - 1) / 26)
}
return name
}
/**
* One stored `world.event` frame as a viewer may read it, or null when the
* viewer may not read it at all. **Pure.**
*
* `staff` the viewer is an admin or a moderator: Steam ids, and a type this
* build does not know
* `presence` the viewer is in the server's presence audience: names
* `rule` the server's credit rule
* `geometry` the server's map geometry, for the grid
*
* What comes back keeps the plugin's own fields for what it describes (`event`,
* `where`, `count`, `harbour`/`harbourName`, `monument`/`monumentName`) and
* adds `grid` and, for a downed one, `credited` — `[{ name?, steamId? }]` by
* the rule — with `creditRule` beside it. Positions, the raw credit block and
* the name table are not passed on: every one of them is either a position or
* a list of who was there.
*/
function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDIT_RULE, geometry = null } = {}) {
if (!frame || frame.kind !== 'world.event') return frame
const event = frame.event
const known = isKnownType(event)
if (!known && !staff) return null
const out = {}
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId', 'event', 'where', 'count', 'harbour', 'harbourName', 'monument', 'monumentName']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
const grid = gridOf(geometry, frame.x, frame.z)
if (grid) out.grid = grid
if (staff) {
if (frame.x !== undefined) out.x = frame.x
if (frame.z !== undefined) out.z = frame.z
}
// One player, named.
if (known && TYPES[event].player && steamId(frame.steamId)) {
if (presence && frame.name) out.name = frame.name
if (staff) out.steamId = frame.steamId
}
// Credit, by the server's rule.
if (known && TYPES[event].credit) {
out.creditRule = creditRuleOf(rule)
const ids = credited(frame, rule)
out.creditedCount = ids.length
if (presence || staff) {
out.credited = ids.map((id) => ({
...(nameOf(frame, id) ? { name: nameOf(frame, id) } : {}),
...(staff ? { steamId: id } : {}),
}))
}
if (staff && frame.credit) out.credit = frame.credit
}
if (!known) {
// Staff only, and everything, so a newer plugin's type is at least readable.
return { ...frame, ...out }
}
return out
}
/**
* What one `world.event` adds to the counts: a credited kill of a helicopter,
* a Chinook or a Bradley, for each player the server's rule names. `[{ steamId,
* name, field }]`, where `field` is the tally field the kill would have been
* (`heliKills`, `apcKills`), so ingest adds it the way it adds a tally's.
*/
function killsOf(frame, rule) {
if (!frame || frame.kind !== 'world.event' || !isKnownType(frame.event)) return []
const field = TYPES[frame.event].credit
if (!field) return []
return credited(frame, rule).map((id) => ({ steamId: id, name: nameOf(frame, id), field }))
}
/**
* The names a line credits, as a phrase: "Alice", "Alice and Bob", "Alice, Bob
* and 2 others". Used by the notifications' headlines; the client writes its own
* from the same list.
*/
function namesPhrase(names, total = names.length) {
const shown = names.filter(Boolean).slice(0, 2)
if (shown.length === 0) return null
const more = Math.max(0, total - shown.length)
if (more > 0) return `${shown.join(', ')} and ${more} other${more === 1 ? '' : 's'}`
return shown.length === 2 ? `${shown[0]} and ${shown[1]}` : shown[0]
}
module.exports = {
TYPES,
TYPE_IDS,
CREDIT_RULES,
CREDIT_RULE_IDS,
DEFAULT_CREDIT_RULE,
creditRuleOf,
isKnownType,
credited,
nameOf,
gridOf,
shapeFor,
killsOf,
namesPhrase,
}

View File

@@ -1604,7 +1604,7 @@
"Admin · Rust"
],
"summary": "Create or update a Rust server",
"description": "Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. A 400 carries one sentence per problem in `errors`.",
"description": "Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. `creditRule` (protocol 14, D372) is who a downed patrol helicopter, Chinook or Bradley credits, for the feed line and for the Rotorbane and Tankbreaker counts alike: `blow` (the killing blow, the default; for the patrol helicopter the crippling hit, else the last hit), `last`, `most` (the most damage) or `all` (everyone who hurt it, one each). It is written only when present, and applies from then on. A 400 carries one sentence per problem in `errors`.",
"parameters": [
{
"name": "id",
@@ -3203,7 +3203,7 @@
"Public · Rust"
],
"summary": "Recent events on one Rust server",
"description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld.",
"description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule`.",
"parameters": [
{
"name": "id",
@@ -3282,7 +3282,7 @@
"name": "sort",
"in": "query",
"required": false,
"description": "kills, deaths, npcKills, playtime, or balance (one wipe on a server with Economics)",
"description": "kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, or balance (one wipe on a server with Economics)",
"schema": {
"type": "string"
}
@@ -4292,6 +4292,35 @@
},
"example": {}
}
},
"creditRule": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"blow",
"last",
"most",
"all"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "Who a downed patrol helicopter, Chinook or Bradley credits (D372): the killing blow, the last hit, the most damage, or everyone who hurt it. Written only when present."
},
"example": {
"type": "string",
"example": "blow"
}
}
}
}
}
@@ -4483,6 +4512,35 @@
}
}
},
"creditRule": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"blow",
"last",
"most",
"all"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "Who a downed patrol helicopter, Chinook or Bradley credits on this server (D372)."
},
"example": {
"type": "string",
"example": "blow"
}
}
},
"zoneHelper": {
"type": "object",
"properties": {
@@ -4860,6 +4918,8 @@
"rockets",
"explosives",
"missions",
"crateshacked",
"dropslooted",
"richest",
"playtime",
"profilekills"