feat(rust): TruePVE in the zone step — no PvP protection, or a ruleset (PLAN_REDESIGNS §14.3.2) #45

Merged
whitlocktech merged 2 commits from feat/truepve-zone into edge 2026-10-15 06:06:14 -05:00
7 changed files with 197 additions and 7 deletions
Showing only changes of commit 4af731288a - Show all commits

View File

@@ -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.`
}
/**

View File

@@ -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',
)

View File

@@ -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,
}
})
}

View File

@@ -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
}

View File

@@ -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' } },
},
},
},
},

View File

@@ -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'])
})

View File

@@ -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"
}
}
}
}
}
}
}
}
}