feat(rust): Dynamic PVP's zones on the feed and the map (PLAN_REDESIGNS §14.3.3) #46

Merged
whitlocktech merged 1 commits from feat/dynamic-pvp into edge 2026-10-15 06:18:01 -05:00
20 changed files with 569 additions and 30 deletions

View File

@@ -122,6 +122,11 @@ one now, kept when the run ends: an airdrop or a patrol helicopter can be sent t
life and goes with it. A ruleset TruePVE does not have fails the step when it runs, with the name. life and goes with it. A ruleset TruePVE does not have fails the step when it runs, with the name.
Without it either one says "TruePVE is not installed on this server" Without it either one says "TruePVE is not installed on this server"
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.2). (`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.2).
- **Dynamic PVP** (HunterZ, 5.2.0; needs ZoneManager and TruePVE). With it: the PvP zones it opens
around a downed patrol helicopter, a Bradley, the cargo ship and the rest show on the live map's
**PvP zones** layer (public by default, set in Admin → Rust visibility) and as a public feed line —
*"A PvP zone opened around the downed patrol helicopter · G7 · for 10 min"* — that names nobody.
Its zones are its own: no step and no trigger (`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.3).
News and event lines said in chat can wear one styled group's title and colours — the News and event lines said in chat can wear one styled group's title and colours — the
**announcement voice**, chosen in Admin → Rust servers. The line is said by the bridge plugin with no **announcement voice**, chosen in Admin → Rust servers. The line is said by the bridge plugin with no

View File

@@ -33,6 +33,7 @@
"configEdit.js", "configEdit.js",
"core.js", "core.js",
"db", "db",
"dynamicPvp.js",
"engagement", "engagement",
"eventLeases.js", "eventLeases.js",
"eventPermRewards.js", "eventPermRewards.js",

View File

@@ -21,7 +21,7 @@ import Empty from './Empty.jsx'
import usePolled from '../hooks/usePolled.js' import usePolled from '../hooks/usePolled.js'
import { ago } from '../lib/format.js' import { ago } from '../lib/format.js'
import { boundsOf, countdown, fromLatLng, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js' import { boundsOf, countdown, fromLatLng, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
import { eventTip, isBoss, titlesFrom, unknownRuns } from '../lib/mapEvents.js' import { eventTip, isBoss, pvpTip, titlesFrom, unknownRuns } from '../lib/mapEvents.js'
import api, { BASE } from '../api.js' import api, { BASE } from '../api.js'
const STYLE_ID = 'rust-leaflet-css' const STYLE_ID = 'rust-leaflet-css'
@@ -35,6 +35,7 @@ const LAYERS = [
{ id: 'events', label: 'Site events' }, { id: 'events', label: 'Site events' },
{ id: 'players', label: 'Players' }, { id: 'players', label: 'Players' },
{ id: 'bases', label: 'Bases' }, { id: 'bases', label: 'Bases' },
{ id: 'pvp', label: 'PvP zones' },
] ]
const COLOURS = { const COLOURS = {
@@ -53,6 +54,7 @@ const COLOURS = {
vending: '#26a69a', vending: '#26a69a',
self: '#00e5ff', self: '#00e5ff',
mate: '#7cffb2', mate: '#7cffb2',
pvp: '#ff1744',
} }
const WORLD_NAMES = { const WORLD_NAMES = {
@@ -79,7 +81,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
const [leaflet, setLeaflet] = useState(null) const [leaflet, setLeaflet] = useState(null)
const [leafletError, setLeafletError] = useState(null) const [leafletError, setLeafletError] = useState(null)
const [shown, setShown] = useState({ grid: true, world: true, events: true, players: true, bases: true, mates: true }) const [shown, setShown] = useState({ grid: true, world: true, events: true, players: true, bases: true, pvp: true, mates: true })
useEffect(() => { useEffect(() => {
let alive = true let alive = true
@@ -160,7 +162,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
map.fitBounds(bounds) map.fitBounds(bounds)
const made = {} const made = {}
for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'mates', 'pins']) made[id] = L.layerGroup().addTo(map) for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'pvp', 'mates', 'pins']) made[id] = L.layerGroup().addTo(map)
// The labels are a layer of their own inside the grid's, shown only when a cell // The labels are a layer of their own inside the grid's, shown only when a cell
// is wide enough on screen to hold one: at the fitted zoom a 20-cell map's // is wide enough on screen to hold one: at the fitted zoom a 20-cell map's
// labels overlap into a wall of text (found on the phase 14 walk). // labels overlap into a wall of text (found on the phase 14 walk).
@@ -238,7 +240,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
const { L } = leaflet const { L } = leaflet
const answer = live.data || {} const answer = live.data || {}
const at = (p) => toLatLng(geometry, p.x, p.z) const at = (p) => toLatLng(geometry, p.x, p.z)
for (const id of ['world', 'events', 'players', 'bases', 'mates']) made[id].clearLayers() for (const id of ['world', 'events', 'players', 'bases', 'pvp', 'mates']) made[id].clearLayers()
for (const w of answer.world || []) { for (const w of answer.world || []) {
let tip = WORLD_NAMES[w.kind] || w.kind let tip = WORLD_NAMES[w.kind] || w.kind
@@ -274,6 +276,13 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
dot(L, at(b), COLOURS[b.kind] || COLOURS.tc, 4).bindTooltip(b.kind === 'tc' ? 'Tool cupboard' : 'Vending machine').addTo(made.bases) dot(L, at(b), COLOURS[b.kind] || COLOURS.tc, 4).bindTooltip(b.kind === 'tc' ? 'Tool cupboard' : 'Vending machine').addTo(made.bases)
} }
// Dynamic PVP's own zones (§14.3.3): a red ring with its event's name.
for (const z of answer.pvp || []) {
L.circle(at(z), { radius: px(Number(z.radius) || 0), color: COLOURS.pvp, weight: 2, fillOpacity: 0.1 })
.bindTooltip(escape(pvpTip(z)))
.addTo(made.pvp)
}
for (const m of answer.mates || []) { for (const m of answer.mates || []) {
dot(L, at(m), m.self ? COLOURS.self : COLOURS.mate, m.self ? 8 : 6, 2) dot(L, at(m), m.self ? COLOURS.self : COLOURS.mate, m.self ? 8 : 6, 2)
.bindTooltip(escape(m.self ? `You${m.online ? '' : ' (asleep, offline)'}` : m.name || 'Clan mate')) .bindTooltip(escape(m.self ? `You${m.online ? '' : ' (asleep, offline)'}` : m.name || 'Clan mate'))
@@ -299,7 +308,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
const map = mapRef.current const map = mapRef.current
const made = groups.current const made = groups.current
if (!map || !made) return if (!map || !made) return
const want = { grid: shown.grid, monuments: shown.world, world: shown.world, events: shown.events, players: shown.players, bases: shown.bases, mates: shown.mates } const want = { grid: shown.grid, monuments: shown.world, world: shown.world, events: shown.events, players: shown.players, bases: shown.bases, pvp: shown.pvp, mates: shown.mates }
for (const [id, on] of Object.entries(want)) { for (const [id, on] of Object.entries(want)) {
if (on && !map.hasLayer(made[id])) made[id].addTo(map) if (on && !map.hasLayer(made[id])) made[id].addTo(map)
if (!on && map.hasLayer(made[id])) map.removeLayer(made[id]) if (!on && map.hasLayer(made[id])) map.removeLayer(made[id])
@@ -381,6 +390,7 @@ function Legend({ meta, live, shown, onToggle }) {
events: [COLOURS.event], events: [COLOURS.event],
players: [COLOURS.online, COLOURS.sleeping], players: [COLOURS.online, COLOURS.sleeping],
bases: [COLOURS.tc, COLOURS.vending], bases: [COLOURS.tc, COLOURS.vending],
pvp: [COLOURS.pvp],
} }
return ( return (

View File

@@ -47,6 +47,9 @@ export const FEED_KINDS = Object.freeze([
// Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1): the server // 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. // reads each one for the viewer, so a name is here only where it may be.
'world.event', 'world.event',
// Dynamic PVP's zones opening (§14.3.3). A closing (`pvp.zone.deleted`) is
// staff's and is not asked for here; the map shows a zone go.
'pvp.zone.created',
]) ])
/** The filters the feed offers, and the kinds each one asks the API for. */ /** The filters the feed offers, and the kinds each one asks the API for. */
@@ -61,7 +64,7 @@ export const FILTERS = Object.freeze([
}, },
{ id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] }, { id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] },
{ id: 'bosses', label: 'Bosses', kinds: ['npc.boss.spawned', 'npc.boss.died'] }, { id: 'bosses', label: 'Bosses', kinds: ['npc.boss.spawned', 'npc.boss.died'] },
{ id: 'world', label: 'World events', kinds: ['world.event'] }, { id: 'world', label: 'World events', kinds: ['world.event', 'pvp.zone.created'] },
]) ])
export function kindsFor(filterId) { export function kindsFor(filterId) {
@@ -151,6 +154,10 @@ export function describe(row) {
case 'world.event': case 'world.event':
return worldLine(frame) return worldLine(frame)
case 'pvp.zone.created':
case 'pvp.zone.deleted':
return pvpLine(row.kind, frame)
default: default:
return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' } return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' }
} }
@@ -303,4 +310,40 @@ function worldLine(frame) {
} }
} }
/**
* What a Dynamic PVP zone is around, by its event (Dynamic PVP 5.2.0's own
* names). The server sends `event` to the public only for these; a monument's or
* an operator's own event reaches staff as written.
*/
export const PVP_AROUND = Object.freeze({
Helicopter: 'the downed patrol helicopter',
Bradley: 'the destroyed Bradley',
Satellite: 'the crashed satellite',
TimedSupply: 'an airdrop',
SupplySignal: 'a signalled airdrop',
CargoShip: 'the cargo ship',
HackableCrate: 'a locked crate',
ExcavatorIgnition: 'the Giant Excavator',
GhostShip: 'a ghost ship',
DeepSeaIsland: 'a Deep Sea island',
IslandCannon: 'an island cannon',
})
/** How long a zone was given, as a detail: "for 10 min", or cargo's whole visit. */
function pvpLasts(frame) {
const sec = Number(frame.durationSec)
if (!(sec > 0)) return frame.event === 'CargoShip' ? 'while the ship is here' : ''
return sec < 120 ? `for ${Math.round(sec)} s` : `for ${Math.round(sec / 60)} min`
}
/** Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3). The line names nobody. */
function pvpLine(kind, frame) {
const around = PVP_AROUND[frame.event] ? ` around ${PVP_AROUND[frame.event]}` : frame.event ? ` (${frame.event})` : ''
if (kind === 'pvp.zone.deleted') {
return { tone: 'server', actor: null, verb: `The PvP zone${around} has closed`, subject: null, detail: '' }
}
const detail = [frame.grid || null, pvpLasts(frame) || null].filter(Boolean).join(' · ')
return { tone: 'kill', actor: null, verb: `A PvP zone opened${around}`, subject: null, detail }
}
export default { describe, FEED_KINDS, FILTERS, kindsFor } export default { describe, FEED_KINDS, FILTERS, kindsFor }

View File

@@ -42,3 +42,26 @@ export function eventTip(row, titles) {
export function isBoss(row) { export function isBoss(row) {
return Boolean(row && (row.boss || row.kind === 'boss')) return Boolean(row && (row.boss || row.kind === 'boss'))
} }
// ── Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3) ──────────────
/** Dynamic PVP's own events as the map names them; a monument's or an operator's is shown as written. */
export const PVP_NAMES = Object.freeze({
Helicopter: 'Patrol helicopter',
Bradley: 'Bradley APC',
Satellite: 'Satellite crash',
TimedSupply: 'Airdrop',
SupplySignal: 'Signalled airdrop',
CargoShip: 'Cargo ship',
HackableCrate: 'Locked crate',
ExcavatorIgnition: 'Giant Excavator',
GhostShip: 'Ghost ship',
DeepSeaIsland: 'Deep Sea island',
IslandCannon: 'Island cannon',
})
/** A Dynamic PVP ring's tooltip: "PvP zone · Patrol helicopter". */
export function pvpTip(z) {
const name = PVP_NAMES[z.event] || z.event
return name ? `PvP zone · ${name}` : 'PvP zone'
}

View File

@@ -262,7 +262,14 @@ export function integrationsLine(r) {
const line = `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.` const line = `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
const withEconomics = r.integrations.economics ? `${line} ${economicsLine(r.integrations.economics)}` : line const withEconomics = r.integrations.economics ? `${line} ${economicsLine(r.integrations.economics)}` : line
const withBackpacks = r.integrations.backpacks ? `${withEconomics} ${backpacksLine(r.integrations.backpacks)}` : withEconomics const withBackpacks = r.integrations.backpacks ? `${withEconomics} ${backpacksLine(r.integrations.backpacks)}` : withEconomics
return r.integrations.truePve ? `${withBackpacks} ${truePveLine(r.integrations.truePve)}` : withBackpacks const withTruePve = r.integrations.truePve ? `${withBackpacks} ${truePveLine(r.integrations.truePve)}` : withBackpacks
return r.integrations.dynamicPvp ? `${withTruePve} ${dynamicPvpLine(r.integrations.dynamicPvp)}` : withTruePve
}
/** Dynamic PVP (PLAN_REDESIGNS §14.3.3), from protocol 14: its zones on the feed and the map. */
export function dynamicPvpLine(d) {
if (!d.loaded) return 'Dynamic PVP is not loaded — no PvP zones of its own on the feed or the map.'
return `${`Dynamic PVP ${d.version || ''}`.trim()} is loaded — its PvP zones show on the feed and the map.`
} }
/** TruePVE (PLAN_REDESIGNS §14.3.2), from protocol 14: what a zone step may do with PvP. */ /** TruePVE (PLAN_REDESIGNS §14.3.2), from protocol 14: what a zone step may do with PvP. */

View File

@@ -384,6 +384,7 @@ const MAP_LAYERS = [
{ id: 'events', label: 'Site events', hint: 'The zones, crates and NPCs this site’s events placed, while they run.' }, { id: 'events', label: 'Site events', hint: 'The zones, crates and NPCs this site’s events placed, while they run.' },
{ id: 'players', label: 'Players', hint: 'Where everybody is, with names, and the sleepers.' }, { id: 'players', label: 'Players', hint: 'Where everybody is, with names, and the sleepers.' },
{ id: 'bases', label: 'Bases', hint: 'Tool cupboards and vending machines, as positions only.' }, { id: 'bases', label: 'Bases', hint: 'Tool cupboards and vending machines, as positions only.' },
{ id: 'pvp', label: 'PvP zones', hint: 'Dynamic PVP’s own PvP zones around a downed helicopter, a Bradley, cargo and the rest. Needs Dynamic PVP on the server.' },
] ]
const RANK = { public: 0, signed_in: 1, staff: 2 } const RANK = { public: 0, signed_in: 1, staff: 2 }

View File

@@ -203,7 +203,43 @@ test('every type the server knows has its own words, and an unknown one says its
assert.equal(describe(world({ event: 'meteor.landed' })).verb, 'meteor.landed') assert.equal(describe(world({ event: 'meteor.landed' })).verb, 'meteor.landed')
}) })
test('the world filter asks for world events, which the server serves publicly', () => { test('the world filter asks for world events and Dynamic PVP zones opening, which the server serves publicly', () => {
assert.deepEqual(kindsFor('world'), ['world.event']) assert.deepEqual(kindsFor('world'), ['world.event', 'pvp.zone.created'])
assert.ok(FEED_KINDS.includes('world.event')) assert.ok(FEED_KINDS.includes('pvp.zone.created'))
assert.ok(!FEED_KINDS.includes('pvp.zone.deleted'), 'a zone closing is a staff line')
})
// ── Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3) ─────────────
const pvp = (kind, frame) => row(kind, { kind, ...frame })
test('a zone opening says what it is around, where, and for how long, and names nobody', () => {
const heli = describe(pvp('pvp.zone.created', { event: 'Helicopter', grid: 'G7', durationSec: 600 }))
assert.equal(heli.verb, 'A PvP zone opened around the downed patrol helicopter')
assert.equal(heli.detail, 'G7 · for 10 min')
assert.equal(heli.actor, null)
assert.equal(heli.subject, null)
const cargo = describe(pvp('pvp.zone.created', { event: 'CargoShip', grid: 'Q9', durationSec: -1 }))
assert.equal(cargo.verb, 'A PvP zone opened around the cargo ship')
assert.equal(cargo.detail, 'Q9 · while the ship is here')
assert.equal(describe(pvp('pvp.zone.created', { event: 'Bradley', durationSec: 90 })).detail, 'for 90 s')
})
test('a zone the public are not told the event of is just a zone; staff read a monument’s name as written', () => {
assert.equal(describe(pvp('pvp.zone.created', { grid: 'C3' })).verb, 'A PvP zone opened')
assert.equal(describe(pvp('pvp.zone.created', { event: 'Airfield' })).verb, 'A PvP zone opened (Airfield)')
})
test('a zone closing, for a staff reader, says which one closed', () => {
assert.equal(describe(pvp('pvp.zone.deleted', { event: 'Helicopter' })).verb, 'The PvP zone around the downed patrol helicopter has closed')
assert.equal(describe(pvp('pvp.zone.deleted', {})).verb, 'The PvP zone has closed')
})
test('every one of Dynamic PVP’s own events has words, and the server and client agree on which they are', () => {
const { GENERAL_EVENTS } = createRequire(import.meta.url)('../../server/dynamicPvp.js')
for (const event of GENERAL_EVENTS) {
assert.ok(!describe(pvp('pvp.zone.created', { event })).verb.includes(`(${event})`), `${event} has no words of its own`)
}
}) })

View File

@@ -6,7 +6,9 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { eventTip, isBoss, titlesFrom, unknownRuns } from '../src/lib/mapEvents.js' import { createRequire } from 'node:module'
import { eventTip, isBoss, pvpTip, PVP_NAMES, titlesFrom, unknownRuns } from '../src/lib/mapEvents.js'
const CALENDAR = { const CALENDAR = {
entries: [ entries: [
@@ -47,3 +49,13 @@ test('a boss is an event boss or a world one', () => {
test('the run ids still to look up', () => { test('the run ids still to look up', () => {
assert.deepEqual(unknownRuns([{ runId: '41' }, { runId: '7' }, { runId: '7' }, {}], { 41: 'x' }), ['7']) assert.deepEqual(unknownRuns([{ runId: '41' }, { runId: '7' }, { runId: '7' }, {}], { 41: 'x' }), ['7'])
}) })
// Dynamic PVP's rings (PLAN_REDESIGNS §14.3.3): its own events by a plain name,
// a monument's or an operator's as written, and nothing at all as just a zone.
test('a Dynamic PVP ring says what it is around', () => {
assert.equal(pvpTip({ event: 'Helicopter' }), 'PvP zone · Patrol helicopter')
assert.equal(pvpTip({ event: 'Airfield' }), 'PvP zone · Airfield')
assert.equal(pvpTip({}), 'PvP zone')
const { GENERAL_EVENTS } = createRequire(import.meta.url)('../../server/dynamicPvp.js')
assert.deepEqual(Object.keys(PVP_NAMES).sort(), [...GENERAL_EVENTS].sort())
})

View File

@@ -66,6 +66,10 @@ const PUBLIC_KINDS = Object.freeze([
// position to nobody. So it is not a presence kind — the event names nobody // 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. // once `worldEvents.shapeFor` has read it for a viewer below that audience.
'world.event', 'world.event',
// Protocol 14: Dynamic PVP opened a PvP zone (PLAN_REDESIGNS §14.3.3). It
// names nobody, and `dynamicPvp.shapeFor` keeps its position for staff and an
// operator's own event names off public pages.
'pvp.zone.created',
]) ])
/** /**
@@ -128,6 +132,9 @@ const STAFF_KINDS = Object.freeze([
'npc.placement.changed', 'npc.placement.changed',
// Runicnpc stage 6: a boss's phase is said in game only to the players near it (D286). // Runicnpc stage 6: a boss's phase is said in game only to the players near it (D286).
'npc.boss.phase', 'npc.boss.phase',
// Protocol 14 (§14.3.3): a Dynamic PVP zone closed. The plan keeps its close
// a staff line: the public learns a zone opened, and the map shows it going.
'pvp.zone.deleted',
]) ])
/** /**

71
server/dynamicPvp.js Normal file
View File

@@ -0,0 +1,71 @@
// ── Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3) ──────────────
//
// HunterZ's Dynamic PVP opens its own temporary PvP zones around a downed
// patrol helicopter, a Bradley, a locked crate, an airdrop, the cargo ship and
// the rest. They are the plugin's own: there is no step and no trigger here,
// only the feed's lines (`pvp.zone.created`, public; `pvp.zone.deleted`,
// staff's) and the map's `pvp` layer.
//
// ── Who may read what ──────────────────────────────────────────────────────
//
// As a world event is read (`worldEvents.js`, D353): the zone and its grid are
// public, and its position is staff's, because the map has its own audience for
// where things are (D111). A zone names nobody.
//
// **Only Dynamic PVP's own vanilla events carry their name to the public.** Its
// monument zones are named after the monument and an operator's own events by
// whatever the operator typed into its config; those reach staff as written and
// everyone else as "A PvP zone opened at G7".
const { gridOf } = require('./worldEvents')
/** Dynamic PVP 5.2.0's own events (its `GeneralEventType`), as its hooks name them. */
const GENERAL_EVENTS = Object.freeze([
'Bradley',
'Helicopter',
'Satellite',
'TimedSupply',
'SupplySignal',
'CargoShip',
'HackableCrate',
'ExcavatorIgnition',
'GhostShip',
'DeepSeaIsland',
'IslandCannon',
])
const KINDS = Object.freeze(['pvp.zone.created', 'pvp.zone.deleted'])
function isGeneral(event) {
return GENERAL_EVENTS.includes(event)
}
/**
* One `pvp.zone.*` frame for one viewer: `{ staff, geometry }`. Passes
* `kind`, `type`, `t`, `serverId`, `wipeId`, `radius`, `shape`, `durationSec`,
* adds `grid`, and `event` when it is one of Dynamic PVP's own or the viewer is
* staff. Staff also get `x`, `z` and `zoneId`. Any other frame is returned as is.
*/
function shapeFor(frame, { staff = false, geometry = null } = {}) {
if (!frame || !KINDS.includes(frame.kind)) return frame
const out = {}
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId', 'radius', 'shape', 'durationSec']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
const grid = gridOf(geometry, frame.x, frame.z)
if (grid) out.grid = grid
if (typeof frame.event === 'string' && frame.event && (staff || isGeneral(frame.event))) out.event = frame.event
if (staff) {
for (const key of ['x', 'z', 'zoneId']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
}
return out
}
module.exports = { GENERAL_EVENTS, KINDS, isGeneral, shapeFor }

View File

@@ -14,6 +14,7 @@
const catalogue = require('../../catalogue') const catalogue = require('../../catalogue')
const worldEvents = require('../../worldEvents') const worldEvents = require('../../worldEvents')
const dynamicPvp = require('../../dynamicPvp')
const db = require('./events.db') const db = require('./events.db')
const mapDb = require('../map/map.db') const mapDb = require('../map/map.db')
const serversDb = require('../servers/servers.db') const serversDb = require('../servers/servers.db')
@@ -69,14 +70,17 @@ async function recent({ serverId, admin = false, staff = false, presence = false
}) })
const shaped = rows.map(shape) const shaped = rows.map(shape)
if (!shaped.some((r) => r.kind === 'world.event')) return shaped if (!shaped.some((r) => r.kind === 'world.event' || dynamicPvp.KINDS.includes(r.kind))) return shaped
// Protocol 14 (§14.3.1): each world event is read for THIS viewer — its type // 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 // 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. // 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. // Dynamic PVP's zone lines (§14.3.3) are read the same way for their grid and
// position. Both reads only when the page holds one of them at all.
const view = await worldView(serverId, { staff: staff || admin, presence }) const view = await worldView(serverId, { staff: staff || admin, presence })
return shaped.map((row) => (row.kind === 'world.event' ? worldRow(row, view) : row)).filter(Boolean) return shaped
.map((row) => (row.kind === 'world.event' ? worldRow(row, view) : dynamicPvp.KINDS.includes(row.kind) ? pvpRow(row, view) : row))
.filter(Boolean)
} }
/** What reading a world event for one viewer needs: the server's credit rule and map geometry. */ /** What reading a world event for one viewer needs: the server's credit rule and map geometry. */
@@ -95,6 +99,11 @@ async function worldView(serverId, { staff, presence }) {
} }
} }
/** One Dynamic PVP row for a viewer (§14.3.3): its grid for everyone, its position for staff. */
function pvpRow(row, view) {
return { ...row, frame: dynamicPvp.shapeFor(row.frame, view) }
}
/** 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. */ /** 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) { function worldRow(row, view) {
const frame = worldEvents.shapeFor(row.frame, view) const frame = worldEvents.shapeFor(row.frame, view)

View File

@@ -7,15 +7,16 @@
// viewer is SENT only the layers they may see — a hidden layer is absent from the // viewer is SENT only the layers they may see — a hidden layer is absent from the
// answer, never present and hidden by the page (§30.2). // answer, never present and hidden by the page (§30.2).
// //
// ── The four layers ─────────────────────────────────────────────────────── // ── The five layers ───────────────────────────────────────────────────────
// //
// world monuments, and the world's own events: cargo, the patrol // world monuments, and the world's own events: cargo, the patrol
// helicopter, the Chinook, Bradley, supply drops, locked crates // helicopter, the Chinook, Bradley, supply drops, locked crates
// events what this site's events placed: zones, crates, NPCs (phase 13a) // events what this site's events placed: zones, crates, NPCs (phase 13a)
// players who is on the server and where, and the sleepers // players who is on the server and where, and the sleepers
// bases tool cupboards and player vending machines, as positions only // bases tool cupboards and player vending machines, as positions only
// pvp Dynamic PVP's own PvP zones, where they are now (protocol 14)
// //
// Defaults: the first two public, the last two staff. The audiences are the // Defaults: world, events and pvp public; players and bases staff. The audiences are the
// presence rungs (`staff` · `signed_in` · `public`) and follow its asymmetric // presence rungs (`staff` · `signed_in` · `public`) and follow its asymmetric
// fallbacks: an unknown stored word narrows to staff, an unknown viewer is // fallbacks: an unknown stored word narrows to staff, an unknown viewer is
// public (`model/visibility`). // public (`model/visibility`).
@@ -42,9 +43,11 @@ const visibilityDb = require('../visibility/visibility.db')
const log = core.logger('map') const log = core.logger('map')
const LAYERS = Object.freeze(['world', 'events', 'players', 'bases']) // `pvp` (protocol 14, PLAN_REDESIGNS §14.3.3) is Dynamic PVP's own zones, public by
// default: the game draws them for everyone near one, and they name nobody.
const LAYERS = Object.freeze(['world', 'events', 'players', 'bases', 'pvp'])
const DEFAULTS = Object.freeze({ world: 'public', events: 'public', players: 'staff', bases: 'staff' }) const DEFAULTS = Object.freeze({ world: 'public', events: 'public', players: 'staff', bases: 'staff', pvp: 'public' })
/** D118: on, because it shows a member nothing the game does not already show them. */ /** D118: on, because it shows a member nothing the game does not already show them. */
const DEFAULT_MATES = true const DEFAULT_MATES = true
@@ -329,6 +332,8 @@ function project(live, acc, ids = { own: new Set(), mates: new Set() }) {
out.players = Array.isArray(live.players) ? live.players : [] out.players = Array.isArray(live.players) ? live.players : []
if (live.playersTruncated) out.playersTruncated = true if (live.playersTruncated) out.playersTruncated = true
} }
// Dynamic PVP's zones (§14.3.3): an older plugin's answer has no `pvp`, and it is empty.
if (acc.layers.pvp.visible) out.pvp = Array.isArray(live.pvp) ? live.pvp : []
if (acc.layers.bases.visible) { if (acc.layers.bases.visible) {
out.bases = Array.isArray(live.bases) ? live.bases : [] out.bases = Array.isArray(live.bases) ? live.bases : []
if (live.basesTruncated) out.basesTruncated = true if (live.basesTruncated) out.basesTruncated = true

View File

@@ -27,7 +27,7 @@ visibilityRouter.get(
'/', '/',
// #swagger.tags = ['Admin · Rust'] // #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Who may see who is online, and who may see a clan roster' // #swagger.summary = 'Who may see who is online, and who may see a clan roster'
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world` and `events` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.' // #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world`, `events` and `pvp` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.'
/* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */ /* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
requireRole('admin'), requireRole('admin'),
visibility.read, visibility.read,
@@ -37,7 +37,7 @@ visibilityRouter.put(
'/', '/',
// #swagger.tags = ['Admin · Rust'] // #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat' // #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat'
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.' // #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`, `pvp`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */ /* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */ /* #swagger.responses[400] = { description: 'An audience that does not exist' } */

View File

@@ -191,7 +191,7 @@ rustRouter.get(
'/servers/:id/map', '/servers/:id/map',
// #swagger.tags = ['Public · Rust'] // #swagger.tags = ['Public · Rust']
// #swagger.summary = 'One Rust server’s map' // #swagger.summary = 'One Rust server’s map'
// #swagger.description = 'Where the picture of the current map is, the geometry to draw it with (world size, the picture’s size and ocean margin in pixels, and the game’s own grid), the monuments when the viewer may see the world layer — only the labels drawn on this server, so a hidden label such as Substation is not sent at all (D196) — and which of the four layers — `world`, `events`, `players`, `bases` — this viewer gets. A hidden layer says which audience can see it and never what it holds. The players layer can never be wider than who may see who is online (`cappedByPresence`). `mates` says whether this viewer gets their own position and their online clan mates’. `picture` is null when the game has no picture of its map; `geometry` is null when the server has never described its map.' // #swagger.description = 'Where the picture of the current map is, the geometry to draw it with (world size, the picture’s size and ocean margin in pixels, and the game’s own grid), the monuments when the viewer may see the world layer — only the labels drawn on this server, so a hidden label such as Substation is not sent at all (D196) — and which of the five layers — `world`, `events`, `players`, `bases`, `pvp` — this viewer gets. A hidden layer says which audience can see it and never what it holds. The players layer can never be wider than who may see who is online (`cappedByPresence`). `mates` says whether this viewer gets their own position and their online clan mates’. `picture` is null when the game has no picture of its map; `geometry` is null when the server has never described its map.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } } // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The map, as this viewer may see it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMap" } } } } */ /* #swagger.responses[200] = { description: 'The map, as this viewer may see it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMap" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */ /* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
@@ -216,7 +216,7 @@ rustRouter.get(
'/servers/:id/map/live', '/servers/:id/map/live',
// #swagger.tags = ['Public · Rust'] // #swagger.tags = ['Public · Rust']
// #swagger.summary = 'What moves on one Rust server’s map' // #swagger.summary = 'What moves on one Rust server’s map'
// #swagger.description = 'The positions this viewer may see, asked of the game while somebody is looking and held for five seconds, so any number of viewers cost one ask. **A layer the viewer may not see is absent from the answer**, not empty: `world` (world events and locked crates), `events` (what the site’s events placed), `players` (online players and sleepers, with names) and `bases` (tool cupboards and vending machines, positions only). `mates` is the viewer’s own position and their online first-party clan mates’, for a linked viewer when the server allows it. `live: false` with a `reason` when the game did not answer. Positions are never stored.' // #swagger.description = 'The positions this viewer may see, asked of the game while somebody is looking and held for five seconds, so any number of viewers cost one ask. **A layer the viewer may not see is absent from the answer**, not empty: `world` (world events and locked crates), `events` (what the site’s events placed), `players` (online players and sleepers, with names) and `bases` (tool cupboards and vending machines, positions only) and `pvp` (Dynamic PVP’s own PvP zones, where each is now; protocol 14). `mates` is the viewer’s own position and their online first-party clan mates’, for a linked viewer when the server allows it. `live: false` with a `reason` when the game did not answer. Positions are never stored.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } } // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The positions this viewer may see', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMapLive" } } } } */ /* #swagger.responses[200] = { description: 'The positions this viewer may see', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMapLive" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */ /* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */

View File

@@ -265,6 +265,11 @@ module.exports = {
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.2): where loaded, a zone step may turn off PvP protection or name a TruePVE ruleset.', description: 'Protocol 14 (PLAN_REDESIGNS §14.3.2): where loaded, a zone step may turn off PvP protection or name a TruePVE ruleset.',
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '2.4.6' } }, properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '2.4.6' } },
}, },
dynamicPvp: {
type: 'object',
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.3): where loaded, its PvP zones reach the feed and the map’s pvp layer.',
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '5.2.0' } },
},
}, },
}, },
}, },
@@ -771,7 +776,7 @@ module.exports = {
type: 'object', type: 'object',
description: 'The live map’s switches and each server’s picture (phase 14).', description: 'The live map’s switches and each server’s picture (phase 14).',
properties: { properties: {
layers: { type: 'array', items: { type: 'string' }, example: ['world', 'events', 'players', 'bases'] }, layers: { type: 'array', items: { type: 'string' }, example: ['world', 'events', 'players', 'bases', 'pvp'] },
minorLabels: { minorLabels: {
type: 'array', type: 'array',
items: { type: 'string' }, items: { type: 'string' },
@@ -785,6 +790,7 @@ module.exports = {
events: { $ref: '#/components/schemas/RustAudience' }, events: { $ref: '#/components/schemas/RustAudience' },
players: { $ref: '#/components/schemas/RustAudience' }, players: { $ref: '#/components/schemas/RustAudience' },
bases: { $ref: '#/components/schemas/RustAudience' }, bases: { $ref: '#/components/schemas/RustAudience' },
pvp: { $ref: '#/components/schemas/RustAudience' },
mates: { type: 'boolean', example: true }, mates: { type: 'boolean', example: true },
markers: { type: 'object', description: 'D196: a monument label key (lower case) to true (drawn) or false (hidden). Only the labels somebody switched; every other label follows the default.', additionalProperties: { type: 'boolean' }, example: { substation: true } }, markers: { type: 'object', description: 'D196: a monument label key (lower case) to true (drawn) or false (hidden). Only the labels somebody switched; every other label follows the default.', additionalProperties: { type: 'boolean' }, example: { substation: true } },
}, },
@@ -922,6 +928,7 @@ module.exports = {
events: { $ref: '#/components/schemas/RustMapLayer' }, events: { $ref: '#/components/schemas/RustMapLayer' },
players: { $ref: '#/components/schemas/RustMapLayer' }, players: { $ref: '#/components/schemas/RustMapLayer' },
bases: { $ref: '#/components/schemas/RustMapLayer' }, bases: { $ref: '#/components/schemas/RustMapLayer' },
pvp: { $ref: '#/components/schemas/RustMapLayer' },
}, },
}, },
mates: { mates: {
@@ -1004,6 +1011,21 @@ module.exports = {
}, },
}, },
basesTruncated: { type: 'boolean', description: 'More than the plugin’s MapMaxBases.' }, basesTruncated: { type: 'boolean', description: 'More than the plugin’s MapMaxBases.' },
pvp: {
type: 'array',
description: 'Protocol 14 (§14.3.3): Dynamic PVP’s own PvP zones, where each is now (cargo’s moves with the ship). A cube zone’s radius is half its longer side.',
items: {
type: 'object',
properties: {
zoneId: { type: 'string', example: '1412203045' },
event: { type: 'string', description: 'Dynamic PVP’s event name: one of its own (Helicopter, Bradley, CargoShip …), a monument’s, or an operator’s.', example: 'Helicopter' },
radius: { type: 'number', example: 100 },
shape: { type: 'string', enum: ['sphere', 'cube'], example: 'sphere' },
x: { type: 'number' },
z: { type: 'number' },
},
},
},
mates: { mates: {
type: 'array', type: 'array',
description: 'The viewer’s own positions (their sleeper too) and their online first-party clan mates on this server.', description: 'The viewer’s own positions (their sleeper too) and their online first-party clan mates on this server.',

View File

@@ -153,6 +153,10 @@ test('the classification covers exactly the event kinds the protocol defines, th
// Protocol 14 (§20.5): Rust's own world events, one kind told apart by // 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. // `event`. Public as a kind; `worldEvents.js` filters per type and per viewer.
'world.event', 'world.event',
// Protocol 14 (§20.7): Dynamic PVP's zones. The opening is public and names
// nobody; the closing is a staff line (PLAN_REDESIGNS §14.3.3).
'pvp.zone.created',
'pvp.zone.deleted',
] ]
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort()) assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
@@ -172,6 +176,9 @@ test('the classification covers exactly the event kinds the protocol defines, th
// audience — the names come off per viewer, so the kind is not a presence kind. // 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.isPublic('world.event'), true)
assert.equal(catalogue.isPresence('world.event'), false) assert.equal(catalogue.isPresence('world.event'), false)
assert.equal(catalogue.isPublic('pvp.zone.created'), true)
assert.equal(catalogue.isPresence('pvp.zone.created'), false)
assert.equal(catalogue.isPublic('pvp.zone.deleted'), false, 'a zone closing is a staff line')
}) })
test('every kind that names a player who was on is behind the presence setting', () => { test('every kind that names a player who was on is behind the presence setting', () => {
@@ -196,5 +203,6 @@ test('every kind that names a player who was on is behind the presence setting',
// And what is left names nobody — a world event included, once `worldEvents.shapeFor` // 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). // 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']) // Dynamic PVP's opening names nobody at all.
assert.deepEqual(catalogue.kindsFor({}).sort(), ['npc.boss.spawned', 'pvp.zone.created', 'server.initialized', 'server.shutdown', 'server.wipe', 'world.event'])
}) })

View File

@@ -0,0 +1,102 @@
// ── Dynamic PVP's zones (PLAN_REDESIGNS §14.3.3, protocol 14) ──────────────
//
// Each test is one way the feature could look right and be wrong:
//
// a zone's position reaches a viewer below staff through the feed, round the
// map's own audience (D111)
// an operator's own event name, typed into Dynamic PVP's config, reaches a
// public page
// the feed shapes a zone line without its grid
// a frame of another kind is changed by the zone reader
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/** Installs a core whose `db.query` answers `rows`. */
function withRows(rows = []) {
const ctx = fakeCtx({
db: { query: (sql, params = []) => Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows), pool: {} },
})
require('../core')._reset()
require('../core').init(ctx)
}
withRows()
const dynamicPvp = require('../dynamicPvp')
const { gridOf } = require('../worldEvents')
const NOW = Date.now()
const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 }
const created = (over = {}) => ({
kind: 'pvp.zone.created',
type: 'event',
t: NOW,
serverId: 'main',
wipeId: 'w1',
zoneId: '1412203045',
event: 'Helicopter',
x: 812,
z: -340.5,
radius: 100,
shape: 'sphere',
durationSec: 600,
...over,
})
test('the public read the zone, its grid and how long it lasts, and never where it is', () => {
const out = dynamicPvp.shapeFor(created(), { staff: false, geometry: GEOMETRY })
assert.strictEqual(out.event, 'Helicopter')
assert.strictEqual(out.grid, gridOf(GEOMETRY, 812, -340.5))
assert.strictEqual(out.radius, 100)
assert.strictEqual(out.durationSec, 600)
for (const hidden of ['x', 'z', 'zoneId']) assert.strictEqual(hidden in out, false, `${hidden} is staff's`)
})
test('staff read the position and the zone id too', () => {
const out = dynamicPvp.shapeFor(created(), { staff: true, geometry: GEOMETRY })
assert.strictEqual(out.x, 812)
assert.strictEqual(out.z, -340.5)
assert.strictEqual(out.zoneId, '1412203045')
})
test("a monument's or an operator's own event name is staff's alone", () => {
for (const event of ['Airfield', 'My secret arena']) {
assert.strictEqual('event' in dynamicPvp.shapeFor(created({ event }), { staff: false, geometry: GEOMETRY }), false)
assert.strictEqual(dynamicPvp.shapeFor(created({ event }), { staff: true, geometry: GEOMETRY }).event, event)
}
for (const event of dynamicPvp.GENERAL_EVENTS) assert.ok(dynamicPvp.isGeneral(event))
})
test('without a map geometry a line has no grid, and off the map neither', () => {
assert.strictEqual('grid' in dynamicPvp.shapeFor(created(), { geometry: null }), false)
assert.strictEqual('grid' in dynamicPvp.shapeFor(created({ x: 9000 }), { geometry: GEOMETRY }), false)
})
test('a frame of another kind is passed back untouched', () => {
const other = { kind: 'world.event', event: 'heli.downed', x: 1, z: 2 }
assert.strictEqual(dynamicPvp.shapeFor(other, { staff: false }), other)
})
test('the feed reads a zone line for its viewer, with the map’s grid', async () => {
const stored = created({ event: 'Airfield' })
withRows((sql) => {
if (sql.includes('FROM rust_events')) return [{ id: 11, kind: 'pvp.zone.created', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stored) }]
if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', 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' })
assert.strictEqual(signedOut.frame.grid, gridOf(GEOMETRY, 812, -340.5))
assert.strictEqual('x' in signedOut.frame, false)
assert.strictEqual('event' in signedOut.frame, false)
const [staff] = await events.recent({ serverId: 'main', staff: true })
assert.strictEqual(staff.frame.x, 812)
assert.strictEqual(staff.frame.event, 'Airfield')
})

View File

@@ -84,17 +84,19 @@ const LIVE = {
{ steamId: '7005', name: 'My alt, asleep', x: 5, z: 5, sleeping: true, online: false }, { steamId: '7005', name: 'My alt, asleep', x: 5, z: 5, sleeping: true, online: false },
], ],
bases: [{ kind: 'tc', x: 9, z: 9 }], bases: [{ kind: 'tc', x: 9, z: 9 }],
pvp: [{ zoneId: '1412203045', event: 'Helicopter', x: 30, z: 40, radius: 100, shape: 'sphere' }],
} }
test('a fresh install shows the world and events to anybody, and positions to staff only', async (t) => { test('a fresh install shows the world and events to anybody, and positions to staff only', async (t) => {
settings(t) settings(t)
assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', mates: true, markers: {} }) assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', pvp: 'public', mates: true, markers: {} })
const acc = await map.access({}, 'main') const acc = await map.access({}, 'main')
assert.equal(acc.layers.world.visible, true) assert.equal(acc.layers.world.visible, true)
assert.equal(acc.layers.events.visible, true) assert.equal(acc.layers.events.visible, true)
assert.equal(acc.layers.players.visible, false) assert.equal(acc.layers.players.visible, false)
assert.equal(acc.layers.bases.visible, false) assert.equal(acc.layers.bases.visible, false)
assert.equal(acc.layers.pvp.visible, true, 'Dynamic PVP zones name nobody (§14.3.3)')
assert.equal(acc.mates.visible, false, 'an anonymous viewer has no accounts to be shown') assert.equal(acc.mates.visible, false, 'an anonymous viewer has no accounts to be shown')
}) })
@@ -158,6 +160,19 @@ test('a hidden layer is absent from the answer, not an empty array', async (t) =
} }
}) })
test('§14.3.3: the pvp layer passes Dynamic PVP zones through, is empty from an older plugin, and can be hidden', async (t) => {
settings(t)
assert.deepStrictEqual(map.project(LIVE, await map.access({}, 'main')).pvp, LIVE.pvp)
const { pvp, ...older } = LIVE
assert.deepStrictEqual(map.project(older, await map.access({}, 'main')).pvp, [])
})
test('§14.3.3: a pvp layer set to staff is absent for the public', async (t) => {
settings(t, { stored: { 'map.layer.pvp.audience': 'staff' } })
const out = map.project(LIVE, await map.access({}, 'main'))
assert.equal(Object.prototype.hasOwnProperty.call(out, 'pvp'), false)
})
test('staff get every layer', async (t) => { test('staff get every layer', async (t) => {
settings(t, { viewer: { level: 'staff', userId: 1 } }) settings(t, { viewer: { level: 'staff', userId: 1 } })
const out = map.project({ ...LIVE, basesTruncated: true }, await map.access({}, 'main')) const out = map.project({ ...LIVE, basesTruncated: true }, await map.access({}, 'main'))

View File

@@ -1948,7 +1948,7 @@
"Admin · Rust" "Admin · Rust"
], ],
"summary": "Who may see who is online, and who may see a clan roster", "summary": "Who may see who is online, and who may see a clan roster",
"description": "The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world` and `events` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.", "description": "The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world`, `events` and `pvp` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.",
"responses": { "responses": {
"200": { "200": {
"description": "The fleet default and each server", "description": "The fleet default and each server",
@@ -1970,7 +1970,7 @@
"Admin · Rust" "Admin · Rust"
], ],
"summary": "Change who may see who is online, who may see a clan roster, or which servers say news in chat", "summary": "Change who may see who is online, who may see a clan roster, or which servers say news in chat",
"description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.", "description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`, `pvp`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.",
"responses": { "responses": {
"200": { "200": {
"description": "Saved; answers the new state", "description": "Saved; answers the new state",
@@ -3313,7 +3313,7 @@
"Public · Rust" "Public · Rust"
], ],
"summary": "One Rust server’s map", "summary": "One Rust server’s map",
"description": "Where the picture of the current map is, the geometry to draw it with (world size, the picture’s size and ocean margin in pixels, and the game’s own grid), the monuments when the viewer may see the world layer — only the labels drawn on this server, so a hidden label such as Substation is not sent at all (D196) — and which of the four layers — `world`, `events`, `players`, `bases` — this viewer gets. A hidden layer says which audience can see it and never what it holds. The players layer can never be wider than who may see who is online (`cappedByPresence`). `mates` says whether this viewer gets their own position and their online clan mates’. `picture` is null when the game has no picture of its map; `geometry` is null when the server has never described its map.", "description": "Where the picture of the current map is, the geometry to draw it with (world size, the picture’s size and ocean margin in pixels, and the game’s own grid), the monuments when the viewer may see the world layer — only the labels drawn on this server, so a hidden label such as Substation is not sent at all (D196) — and which of the five layers — `world`, `events`, `players`, `bases`, `pvp` — this viewer gets. A hidden layer says which audience can see it and never what it holds. The players layer can never be wider than who may see who is online (`cappedByPresence`). `mates` says whether this viewer gets their own position and their online clan mates’. `picture` is null when the game has no picture of its map; `geometry` is null when the server has never described its map.",
"parameters": [ "parameters": [
{ {
"name": "id", "name": "id",
@@ -3399,7 +3399,7 @@
"Public · Rust" "Public · Rust"
], ],
"summary": "What moves on one Rust server’s map", "summary": "What moves on one Rust server’s map",
"description": "The positions this viewer may see, asked of the game while somebody is looking and held for five seconds, so any number of viewers cost one ask. **A layer the viewer may not see is absent from the answer**, not empty: `world` (world events and locked crates), `events` (what the site’s events placed), `players` (online players and sleepers, with names) and `bases` (tool cupboards and vending machines, positions only). `mates` is the viewer’s own position and their online first-party clan mates’, for a linked viewer when the server allows it. `live: false` with a `reason` when the game did not answer. Positions are never stored.", "description": "The positions this viewer may see, asked of the game while somebody is looking and held for five seconds, so any number of viewers cost one ask. **A layer the viewer may not see is absent from the answer**, not empty: `world` (world events and locked crates), `events` (what the site’s events placed), `players` (online players and sleepers, with names) and `bases` (tool cupboards and vending machines, positions only) and `pvp` (Dynamic PVP’s own PvP zones, where each is now; protocol 14). `mates` is the viewer’s own position and their online first-party clan mates’, for a linked viewer when the server allows it. `live: false` with a `reason` when the game did not answer. Positions are never stored.",
"parameters": [ "parameters": [
{ {
"name": "id", "name": "id",
@@ -5371,6 +5371,50 @@
} }
} }
} }
},
"dynamicPvp": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Protocol 14 (PLAN_REDESIGNS §14.3.3): where loaded, its PvP zones reach the feed and the map’s pvp layer."
},
"properties": {
"type": "object",
"properties": {
"loaded": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"version": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "5.2.0"
}
}
}
}
}
}
} }
} }
} }
@@ -8685,7 +8729,8 @@
"world", "world",
"events", "events",
"players", "players",
"bases" "bases",
"pvp"
], ],
"items": { "items": {
"type": "string" "type": "string"
@@ -8748,6 +8793,9 @@
"bases": { "bases": {
"$ref": "#/components/schemas/RustAudience" "$ref": "#/components/schemas/RustAudience"
}, },
"pvp": {
"$ref": "#/components/schemas/RustAudience"
},
"mates": { "mates": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -9633,6 +9681,9 @@
}, },
"bases": { "bases": {
"$ref": "#/components/schemas/RustMapLayer" "$ref": "#/components/schemas/RustMapLayer"
},
"pvp": {
"$ref": "#/components/schemas/RustMapLayer"
} }
} }
} }
@@ -10277,6 +10328,117 @@
} }
} }
}, },
"pvp": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"description": {
"type": "string",
"example": "Protocol 14 (§14.3.3): Dynamic PVP’s own PvP zones, where each is now (cargo’s moves with the ship). A cube zone’s radius is half its longer side."
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"zoneId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "1412203045"
}
}
},
"event": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"description": {
"type": "string",
"example": "Dynamic PVP’s event name: one of its own (Helicopter, Bradley, CargoShip …), a monument’s, or an operator’s."
},
"example": {
"type": "string",
"example": "Helicopter"
}
}
},
"radius": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "number"
},
"example": {
"type": "number",
"example": 100
}
}
},
"shape": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"sphere",
"cube"
],
"items": {
"type": "string"
}
},
"example": {
"type": "string",
"example": "sphere"
}
}
},
"x": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "number"
}
}
},
"z": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "number"
}
}
}
}
}
}
}
}
},
"mates": { "mates": {
"type": "object", "type": "object",
"properties": { "properties": {