From 4af731288aeccf6bce421cebd43f2305bf927568 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 03:03:36 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20TruePVE=20in=20the=20zone=20step=20(PLA?= =?UTF-8?q?N=5FREDESIGNS=20=C2=A714.3.2,=20WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 of the §14.3 integration plan (D362, D368): world.zone's optional truePve ({exclude: true} or {ruleset}), all or nothing, unmapped on every path that erases one of our zones, with truepve.json for erases while TruePVE is away. Docs and PRs still to come. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/routes/admin/ChatTitles.jsx | 9 ++- server/eventWorld.js | 46 ++++++++++++++ server/model/zones/zones.db.js | 14 +++-- server/model/zones/zones.model.js | 2 +- server/swagger/doc.js | 5 ++ server/test/zones.test.js | 84 +++++++++++++++++++++++++- swagger-fragment.json | 44 ++++++++++++++ 7 files changed, 197 insertions(+), 7 deletions(-) diff --git a/client/src/routes/admin/ChatTitles.jsx b/client/src/routes/admin/ChatTitles.jsx index e89c07e..6f3a715 100644 --- a/client/src/routes/admin/ChatTitles.jsx +++ b/client/src/routes/admin/ChatTitles.jsx @@ -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.` } /** diff --git a/server/eventWorld.js b/server/eventWorld.js index 8035d62..fb32a4e 100644 --- a/server/eventWorld.js +++ b/server/eventWorld.js @@ -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', ) diff --git a/server/model/zones/zones.db.js b/server/model/zones/zones.db.js index 2a71843..ec93601 100644 --- a/server/model/zones/zones.db.js +++ b/server/model/zones/zones.db.js @@ -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, } }) } diff --git a/server/model/zones/zones.model.js b/server/model/zones/zones.model.js index 3e2be6f..21de78f 100644 --- a/server/model/zones/zones.model.js +++ b/server/model/zones/zones.model.js @@ -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 } diff --git a/server/swagger/doc.js b/server/swagger/doc.js index 8b3d75f..11006ed 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -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' } }, + }, }, }, }, diff --git a/server/test/zones.test.js b/server/test/zones.test.js index 6b95c3f..4801fd0 100644 --- a/server/test/zones.test.js +++ b/server/test/zones.test.js @@ -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']) }) diff --git a/swagger-fragment.json b/swagger-fragment.json index dfd6964..8ed51bd 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -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" + } + } + } + } + } + } } } }