Files
Module-Rust/server/worldEvents.js
wtclaude 8aff9b33a6
All checks were successful
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 48s
PR Checks / server-tests (pull_request) Successful in 7m16s
fix(rust): send reward notices, and keep a downed line's credit rule (W21, W4)
W21: the permission push named its fields one by one and left out
`notices`, so no kit reward, lend or give ever told its player
anything (D333). The body is now built by `pushBody`, which carries
every field the payload has; a test fails if a payload field is
dropped again.

W4: a downed patrol helicopter, Chinook or Bradley is stored with the
credit rule in force when it arrived, and the feed reads that stamp.
Before, the line re-credited past rows with the server's current rule
while the Rotorbane/Tankbreaker counts kept the old one. Rows stored
before this change keep today's reading.

PLAN_LAST_FIXES.md stage 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-21 06:16:32 -05:00

301 lines
11 KiB
JavaScript

// ── 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, for a row stored before the frame
* carried its own (`stampRule`)
* `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 = ruleOf(frame, rule)
const ids = credited(frame, out.creditRule)
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
}
/**
* The frame as it is stored: a downed helicopter, Chinook or Bradley carries
* the rule that credited it when it arrived (`creditRule`), so the feed line
* keeps naming who the counts were given to after the server's rule changes
* (W4). Every other frame is stored as sent.
*/
function stampRule(frame, rule) {
if (!frame || frame.kind !== 'world.event' || !isKnownType(frame.event) || !TYPES[frame.event].credit) return frame
return { ...frame, creditRule: creditRuleOf(rule) }
}
/**
* The rule a stored line is read by: its own stamp, or — for a row from before
* the stamp — the server's rule now, which is how those rows always read.
*/
function ruleOf(frame, rule) {
return frame && CREDIT_RULE_IDS.includes(frame.creditRule) ? frame.creditRule : creditRuleOf(rule)
}
/**
* 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,
stampRule,
ruleOf,
nameOf,
gridOf,
shapeFor,
killsOf,
namesPhrase,
}