feat(rust): TruePVE in the zone step — no PvP protection, or a ruleset (PLAN_REDESIGNS §14.3.2) #45
@@ -261,7 +261,14 @@ export function integrationsLine(r) {
|
||||
const one = (name, x, without) => (x && x.loaded ? `${name} ${x.version || ''} is loaded`.trim() : `${name} is not loaded — ${without}`)
|
||||
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
|
||||
return 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
|
||||
}
|
||||
|
||||
/** TruePVE (PLAN_REDESIGNS §14.3.2), from protocol 14: what a zone step may do with PvP. */
|
||||
export function truePveLine(tp) {
|
||||
if (!tp.loaded) return 'TruePVE is not loaded — a zone step cannot change PvP rules.'
|
||||
return `${`TruePVE ${tp.version || ''}`.trim()} is loaded — a zone step may turn off PvP protection or name a ruleset.`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,6 +62,7 @@ const ZONE_MESSAGE_MAX = 256
|
||||
const DOME_STACK_MAX = 10
|
||||
/** A blank stack: 3 spheres, chosen by eye at noon in RunicNPC's 9d player session (D326). */
|
||||
const DOME_STACK_DEFAULT = 3
|
||||
const TRUEPVE_RULESET_MAX = 64
|
||||
|
||||
/**
|
||||
* ZoneDomes' own sphere types, by the number its API takes (D194). Only Standard
|
||||
@@ -127,6 +128,10 @@ const PERMANENT = new Set([
|
||||
// the server (`escort-offline`) is left to retry: they may join.
|
||||
'no-zone',
|
||||
'runicnpc-old',
|
||||
// PLAN_REDESIGNS §14.3.2 (D368): no TruePVE on the server, or no ruleset of
|
||||
// the name the step typed. Only the call can tell the second.
|
||||
'truepve-missing',
|
||||
'truepve-ruleset',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
@@ -165,6 +170,28 @@ function domeMissing(mods) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone's TruePVE mapping (§14.3.2, D368): `{ exclude: true }` for "no PvP
|
||||
* protection", `{ ruleset }` for a ruleset staff typed, or null for neither.
|
||||
* Both is refused, and either is refused where the server said it has no
|
||||
* TruePVE. A server that has not said is left to the plugin (`truepve-missing`),
|
||||
* as a dome is. Whether the ruleset exists is the plugin's to answer.
|
||||
*/
|
||||
function truePveMapping(params, mods) {
|
||||
const raw = params.noPvpProtection
|
||||
const exclude = raw === true || raw === 'true'
|
||||
const ruleset = params.pvpRuleset === undefined || params.pvpRuleset === null ? '' : String(params.pvpRuleset).trim()
|
||||
if (exclude && ruleset) {
|
||||
return { ok: false, error: 'a zone has no PvP protection or a TruePVE ruleset, not both' }
|
||||
}
|
||||
if (ruleset.length > TRUEPVE_RULESET_MAX) {
|
||||
return { ok: false, error: `a TruePVE ruleset's name is at most ${TRUEPVE_RULESET_MAX} characters` }
|
||||
}
|
||||
if (!exclude && !ruleset) return { ok: true, wire: null }
|
||||
if (mods && mods.truePve && !mods.truePve.loaded) return { ok: false, error: 'TruePVE is not installed on this server' }
|
||||
return { ok: true, wire: exclude ? { exclude: true } : { ruleset } }
|
||||
}
|
||||
|
||||
/** A number param, or undefined when left blank. */
|
||||
function num(raw) {
|
||||
if (raw === undefined || raw === null || raw === '') return undefined
|
||||
@@ -731,6 +758,20 @@ const ACTIONS = [
|
||||
'A ZoneDomes dome over the zone. "standard" is a full shaded dome; the colours show only where the sphere meets the ground or a building. Needs ZoneDomes and RunicGatewayDomes.cs on the server. Blank is no dome.',
|
||||
},
|
||||
{ name: 'domeStack', type: 'int', required: false, example: DOME_STACK_DEFAULT, description: `How many spheres the dome stacks, 1 to ${DOME_STACK_MAX}; more is darker. Left blank, ${DOME_STACK_DEFAULT}.` },
|
||||
{
|
||||
name: 'noPvpProtection',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
example: true,
|
||||
description: 'No PvP protection in this zone: TruePVE leaves it alone, so players can hurt each other inside. Needs TruePVE on the server. Not with a ruleset.',
|
||||
},
|
||||
{
|
||||
name: 'pvpRuleset',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'pvp-arena',
|
||||
description: `Or this TruePVE ruleset in the zone, by its name in TruePVE's config (names are case-sensitive, up to ${TRUEPVE_RULESET_MAX} characters). A name TruePVE does not have fails the step when it runs. Needs TruePVE on the server.`,
|
||||
},
|
||||
],
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
@@ -785,6 +826,10 @@ const ACTIONS = [
|
||||
dome = { type: kind.type, stack: stack === undefined ? DOME_STACK_DEFAULT : stack }
|
||||
}
|
||||
|
||||
// §14.3.2: TruePVE's rules in the zone, where the server said it has TruePVE.
|
||||
const truePve = truePveMapping(params, mods)
|
||||
if (!truePve.ok) return { ok: false, retry: false, error: truePve.error }
|
||||
|
||||
// The dry run stops here, and has checked everything it can without the
|
||||
// game. It does not ask whether the monument exists: a step authored for
|
||||
// next wipe's map would fail every dry run until the wipe.
|
||||
@@ -810,6 +855,7 @@ const ACTIONS = [
|
||||
...(hasMessage ? { delivery } : {}),
|
||||
...(format ? { format } : {}),
|
||||
...(dome ? { dome } : {}),
|
||||
...(truePve.wire ? { truePve: truePve.wire } : {}),
|
||||
},
|
||||
'zone',
|
||||
)
|
||||
|
||||
@@ -99,15 +99,17 @@ async function deletePreset(id) {
|
||||
|
||||
/**
|
||||
* Each configured server, in the operator's order, with what its last hello said
|
||||
* about ZoneManager (`{ version, flags }`) and ZoneDomes (`{ loaded, version,
|
||||
* helper, ready }`). Either is null for a server that never said: no ZoneManager,
|
||||
* a plugin before protocol 13, or a server never reached.
|
||||
* about ZoneManager (`{ version, flags }`), ZoneDomes (`{ loaded, version,
|
||||
* helper, ready }`) and TruePVE (`{ loaded, version }`). Each is null for a server
|
||||
* that never said: no ZoneManager, a plugin before protocol 13 (14 for TruePVE), or
|
||||
* a server never reached.
|
||||
*/
|
||||
async function listZoneServers() {
|
||||
const rows = await core.query(
|
||||
`SELECT s.id, s.name, s.enabled,
|
||||
JSON_EXTRACT(st.raw, '$.zoneManager') AS zoneManager,
|
||||
JSON_EXTRACT(st.raw, '$.integrations.zoneDomes') AS zoneDomes
|
||||
JSON_EXTRACT(st.raw, '$.integrations.zoneDomes') AS zoneDomes,
|
||||
JSON_EXTRACT(st.raw, '$.integrations.truePve') AS truePve
|
||||
FROM ${SERVERS} s
|
||||
LEFT JOIN ${STATE} st ON st.server_id = s.id
|
||||
ORDER BY s.sort_order ASC, s.id ASC`,
|
||||
@@ -115,6 +117,7 @@ async function listZoneServers() {
|
||||
return rows.map((r) => {
|
||||
const zm = json(r.zoneManager, null)
|
||||
const zd = json(r.zoneDomes, null)
|
||||
const tp = json(r.truePve, null)
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -135,6 +138,9 @@ async function listZoneServers() {
|
||||
ready: zd.ready === true,
|
||||
}
|
||||
: null,
|
||||
// Protocol 14 (PLAN_REDESIGNS §14.3.2): null from a plugin older than TruePVE's support.
|
||||
truePve:
|
||||
tp && typeof tp === 'object' ? { loaded: tp.loaded === true, version: typeof tp.version === 'string' ? tp.version : null } : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ async function optionRows() {
|
||||
}))
|
||||
}
|
||||
|
||||
/** What one server's last hello said about ZoneManager and ZoneDomes, or null. */
|
||||
/** What one server's last hello said about ZoneManager, ZoneDomes and TruePVE, or null. */
|
||||
async function serverZoneMods(serverId) {
|
||||
return (await db.listZoneServers()).find((s) => s.id === serverId) || null
|
||||
}
|
||||
|
||||
@@ -260,6 +260,11 @@ module.exports = {
|
||||
sizes: { type: 'array', items: { type: 'integer' }, example: [6, 12, 18, 24, 30, 36, 42, 48, 96, 144] },
|
||||
},
|
||||
},
|
||||
truePve: {
|
||||
type: 'object',
|
||||
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' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
// the step's dropdown VALUE is the line itself, so picking copies it (D210)
|
||||
// the zone crosses with its flags, settings, messages and dome
|
||||
// a dome on a server without ZoneDomes or its helper is refused with the reason
|
||||
// TruePVE's rules cross as exclude or one ruleset, never both, and never to a
|
||||
// server that said it has no TruePVE (§14.3.2, D368); a ruleset TruePVE does
|
||||
// not have fails for good, with the plugin's words
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
@@ -34,13 +37,14 @@ const source = (id) => world.OPTION_SOURCES.find((s) => s.id === id)
|
||||
|
||||
const FLAGS = ['AutoLights', 'NoBuild', 'NoPlayerLoot', 'PvpGod', 'NoChat', 'SomethingNew']
|
||||
|
||||
function server(id, { flags = FLAGS, domes = null } = {}) {
|
||||
function server(id, { flags = FLAGS, domes = null, truePve = null } = {}) {
|
||||
return {
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
enabled: true,
|
||||
zoneManager: flags ? { version: '3.1.14', flags } : null,
|
||||
zoneDomes: domes,
|
||||
truePve,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +291,84 @@ test('a dry run checks the options and the dome and sends nothing', async (t) =>
|
||||
assert.strictEqual(calls.length, 0)
|
||||
})
|
||||
|
||||
// ── TruePVE in the zone (§14.3.2, D368) ────────────────────────────────────
|
||||
|
||||
const TRUEPVE = { loaded: true, version: '2.4.6' }
|
||||
const zoneRun = (params, extra = {}) =>
|
||||
action('rust.zone.open').perform({ runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 5, ...params }, ...extra })
|
||||
|
||||
test('no PvP protection crosses as exclude, and a ruleset by its name, trimmed', async (t) => {
|
||||
for (const [params, wire] of [
|
||||
[{ noPvpProtection: true }, { exclude: true }],
|
||||
[{ noPvpProtection: 'true' }, { exclude: true }],
|
||||
[{ pvpRuleset: ' pvp-arena ' }, { ruleset: 'pvp-arena' }],
|
||||
]) {
|
||||
await t.test(JSON.stringify(params), async (tt) => {
|
||||
stubZones(tt, { servers: [server('main', { truePve: TRUEPVE })] })
|
||||
const calls = stubWorld(tt)
|
||||
const result = await zoneRun(params)
|
||||
assert.strictEqual(result.ok, true, result.error)
|
||||
assert.deepStrictEqual(calls[0].body.truePve, wire)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('left unset, switched off or blank, a zone sends no truePve at all', async (t) => {
|
||||
stubZones(t, { servers: [server('main', { truePve: { loaded: false } })] })
|
||||
const calls = stubWorld(t)
|
||||
for (const params of [{}, { noPvpProtection: false, pvpRuleset: '' }, { noPvpProtection: null, pvpRuleset: ' ' }]) {
|
||||
const result = await zoneRun(params)
|
||||
assert.strictEqual(result.ok, true, result.error)
|
||||
}
|
||||
assert.ok(calls.every((c) => !('truePve' in c.body)))
|
||||
})
|
||||
|
||||
test('both, a long name, or a server without TruePVE is refused for good, at verify too', async (t) => {
|
||||
for (const [truePve, params, words] of [
|
||||
[TRUEPVE, { noPvpProtection: true, pvpRuleset: 'pvp-arena' }, /not both/],
|
||||
[TRUEPVE, { pvpRuleset: 'x'.repeat(65) }, /64 characters/],
|
||||
[{ loaded: false }, { noPvpProtection: true }, /TruePVE is not installed on this server/],
|
||||
[{ loaded: false }, { pvpRuleset: 'pvp-arena' }, /TruePVE is not installed on this server/],
|
||||
]) {
|
||||
for (const verify of [true, false]) {
|
||||
await t.test(`${words} verify=${verify}`, async (tt) => {
|
||||
stubZones(tt, { servers: [server('main', { truePve })] })
|
||||
const calls = stubWorld(tt)
|
||||
const result = await zoneRun(params, { verify })
|
||||
assert.strictEqual(result.ok, false)
|
||||
assert.strictEqual(result.retry, false)
|
||||
assert.match(result.error, words)
|
||||
assert.strictEqual(calls.length, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a server that has not said whether it has TruePVE is left to the plugin', async (t) => {
|
||||
stubZones(t, { servers: [server('main', { truePve: null })] })
|
||||
const calls = stubWorld(t)
|
||||
const result = await zoneRun({ noPvpProtection: true })
|
||||
assert.strictEqual(result.ok, true, result.error)
|
||||
assert.deepStrictEqual(calls[0].body.truePve, { exclude: true })
|
||||
})
|
||||
|
||||
test('a ruleset TruePVE does not have, or no TruePVE, fails for good with the plugin’s words', async (t) => {
|
||||
for (const [reason, message] of [
|
||||
['truepve-ruleset', "TruePVE on this server has no ruleset named 'pvp-arna' (names are case-sensitive), so the zone was not opened"],
|
||||
['truepve-missing', 'TruePVE is not loaded on this server, so a zone cannot change its PvP rules'],
|
||||
]) {
|
||||
await t.test(reason, async (tt) => {
|
||||
stubZones(tt, { servers: [server('main', { truePve: TRUEPVE })] })
|
||||
stubWorld(tt)
|
||||
client.worldZone = async () => ({ ok: true, status: 'ok', data: { kind: 'world.error', reason, message } })
|
||||
const result = await zoneRun({ pvpRuleset: 'pvp-arna' })
|
||||
assert.strictEqual(result.ok, false)
|
||||
assert.strictEqual(result.retry, false)
|
||||
assert.strictEqual(result.error, message)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('the dome dropdown offers ZoneDomes’ five colours', async () => {
|
||||
assert.deepStrictEqual((await source('rust.options.domes').resolve()).map((r) => r.value), ['standard', 'red', 'blue', 'green', 'purple'])
|
||||
})
|
||||
|
||||
@@ -5327,6 +5327,50 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"truePve": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Protocol 14 (PLAN_REDESIGNS §14.3.2): where loaded, a zone step may turn off PvP protection or name a TruePVE ruleset."
|
||||
},
|
||||
"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": "2.4.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user