feat(rust): Dynamic PVP's zones on the feed and the map (PLAN_REDESIGNS §14.3.3)
Stage 4 of the §14.3 integration plan, on protocol 14 (docs rust-link/PROTOCOL.md §20.7). - pvp.zone.created is a public kind, pvp.zone.deleted a staff kind. Each row is read per viewer by the new dynamicPvp.js: grid for everyone, the event's name only for Dynamic PVP's own vanilla events, position and zone id for staff only. - The feed's world filter carries the opening: "A PvP zone opened around the downed patrol helicopter · G7 · for 10 min". It names nobody. - The map gains a fifth layer, `pvp` (PvP zones), public by default with its own audience in Admin → Rust visibility, drawn as a red ring with its event's name. - Server settings' plugin line names Dynamic PVP; the swagger schemas and route descriptions carry the layer and the integration (fragment regenerated); ci/bundle.json ships dynamicPvp.js; README lists it. Tests: server 611/611 (dynamicPvp.test.js new, catalogue and map extended) and every check; client 89/89 (feed and map tooltip), build and externals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -66,6 +66,10 @@ const PUBLIC_KINDS = Object.freeze([
|
||||
// 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',
|
||||
// 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',
|
||||
// Runicnpc stage 6: a boss's phase is said in game only to the players near it (D286).
|
||||
'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
71
server/dynamicPvp.js
Normal 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 }
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
const catalogue = require('../../catalogue')
|
||||
const worldEvents = require('../../worldEvents')
|
||||
const dynamicPvp = require('../../dynamicPvp')
|
||||
const db = require('./events.db')
|
||||
const mapDb = require('../map/map.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)
|
||||
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
|
||||
// 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.
|
||||
// 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 })
|
||||
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. */
|
||||
@@ -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. */
|
||||
function worldRow(row, view) {
|
||||
const frame = worldEvents.shapeFor(row.frame, view)
|
||||
|
||||
@@ -7,15 +7,16 @@
|
||||
// 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).
|
||||
//
|
||||
// ── The four layers ───────────────────────────────────────────────────────
|
||||
// ── The five layers ───────────────────────────────────────────────────────
|
||||
//
|
||||
// world monuments, and the world's own events: cargo, the patrol
|
||||
// helicopter, the Chinook, Bradley, supply drops, locked crates
|
||||
// events what this site's events placed: zones, crates, NPCs (phase 13a)
|
||||
// players who is on the server and where, and the sleepers
|
||||
// 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
|
||||
// fallbacks: an unknown stored word narrows to staff, an unknown viewer is
|
||||
// public (`model/visibility`).
|
||||
@@ -42,9 +43,11 @@ const visibilityDb = require('../visibility/visibility.db')
|
||||
|
||||
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. */
|
||||
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 : []
|
||||
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) {
|
||||
out.bases = Array.isArray(live.bases) ? live.bases : []
|
||||
if (live.basesTruncated) out.basesTruncated = true
|
||||
|
||||
@@ -27,7 +27,7 @@ visibilityRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #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" } } } } */
|
||||
requireRole('admin'),
|
||||
visibility.read,
|
||||
@@ -37,7 +37,7 @@ visibilityRouter.put(
|
||||
'/',
|
||||
// #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.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.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' } */
|
||||
|
||||
@@ -191,7 +191,7 @@ rustRouter.get(
|
||||
'/servers/:id/map',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #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.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' } */
|
||||
@@ -216,7 +216,7 @@ rustRouter.get(
|
||||
'/servers/:id/map/live',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #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.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' } */
|
||||
|
||||
@@ -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.',
|
||||
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',
|
||||
description: 'The live map’s switches and each server’s picture (phase 14).',
|
||||
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: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
@@ -785,6 +790,7 @@ module.exports = {
|
||||
events: { $ref: '#/components/schemas/RustAudience' },
|
||||
players: { $ref: '#/components/schemas/RustAudience' },
|
||||
bases: { $ref: '#/components/schemas/RustAudience' },
|
||||
pvp: { $ref: '#/components/schemas/RustAudience' },
|
||||
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 } },
|
||||
},
|
||||
@@ -922,6 +928,7 @@ module.exports = {
|
||||
events: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
players: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
bases: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
pvp: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
},
|
||||
},
|
||||
mates: {
|
||||
@@ -1004,6 +1011,21 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
type: 'array',
|
||||
description: 'The viewer’s own positions (their sleeper too) and their online first-party clan mates on this server.',
|
||||
|
||||
@@ -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
|
||||
// `event`. Public as a kind; `worldEvents.js` filters per type and per viewer.
|
||||
'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())
|
||||
@@ -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.
|
||||
assert.equal(catalogue.isPublic('world.event'), true)
|
||||
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', () => {
|
||||
@@ -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`
|
||||
// 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'])
|
||||
})
|
||||
|
||||
102
server/test/dynamicPvp.test.js
Normal file
102
server/test/dynamicPvp.test.js
Normal 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')
|
||||
})
|
||||
@@ -84,17 +84,19 @@ const LIVE = {
|
||||
{ steamId: '7005', name: 'My alt, asleep', x: 5, z: 5, sleeping: true, online: false },
|
||||
],
|
||||
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) => {
|
||||
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')
|
||||
assert.equal(acc.layers.world.visible, true)
|
||||
assert.equal(acc.layers.events.visible, true)
|
||||
assert.equal(acc.layers.players.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')
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
settings(t, { viewer: { level: 'staff', userId: 1 } })
|
||||
const out = map.project({ ...LIVE, basesTruncated: true }, await map.access({}, 'main'))
|
||||
|
||||
Reference in New Issue
Block a user