Files
Module-Rust/server/test/zones.test.js
wtclaude 8361b19c24 feat(rust): zone presets, and the zone step's options, messages and dome (PLAN_REDESIGNS §3, D210)
Admin → Rust zone presets: named sets of ZoneManager flags and settings for
one server, several, or every server (D195), ticked in groups read from each
server's own ZoneManager list in its last hello (D211). A flag not on a
covered server is refused on save with the server named; two presets of one
name may not share a server.

rust.zone.open gains options (one line, NoBuild, radiation=10), enterMessage,
leaveMessage, delivery, dome and domeStack. The options field's dropdown is
rust.options.zone_presets, whose row VALUE is the preset's line, so picking
one copies it into the step (D210) and no published event changes when a
preset does. The line and the dome are checked against the server's hello
on save and in a dry run; bad-option and dome-unavailable are permanent.

Schema: rust_zone_presets, rust_zone_preset_servers. Swagger fragment and
routes.manifest regenerated against the pinned core f0e7d2a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 05:25:44 -05:00

283 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Zones: presets, options and the dome (PLAN_REDESIGNS §3, D210, D211) ───
//
// What each test guards:
//
// a step's options line reads back as the flags and settings it names, spelled
// as the server's own ZoneManager spells them (D211)
// a flag the server does not have is refused on the form, not at 4 a.m.
// a flag this module does not know is drawn under Other, never hidden
// a preset is checked against every server it covers, and one that has not
// said is left to the plugin
// two presets of one name may not share a server; across servers they may (D189)
// 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
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
require('../core')._reset()
require('../core').init(fakeCtx())
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const zonesDb = require('../model/zones/zones.db')
const zones = require('../model/zones/zones.model')
const options = require('../model/zones/zoneOptions')
const voice = require('../model/permissions/voice')
const world = require('../eventWorld')
const action = (id) => world.ACTIONS.find((a) => a.id === id)
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 } = {}) {
return {
id,
name: id.toUpperCase(),
enabled: true,
zoneManager: flags ? { version: '3.1.14', flags } : null,
zoneDomes: domes,
}
}
/** Stubs the preset store and the servers' hellos for one test. */
function stubZones(t, { servers = [server('main')], presets = [] } = {}) {
const saved = { ...zonesDb }
const store = presets.map((p) => ({ ...p }))
const writes = []
zonesDb.listZoneServers = async () => servers
zonesDb.listPresets = async () => store.map((p) => ({ ...p }))
zonesDb.getPreset = async (id) => store.find((p) => p.id === id) || null
zonesDb.savePreset = async (row) => {
writes.push(row)
const id = row.id || store.length + 100
const next = { id, ...row, servers: row.allServers ? [] : row.servers, updatedAt: null }
const at = store.findIndex((p) => p.id === id)
if (at >= 0) store[at] = next
else store.push(next)
return id
}
zonesDb.deletePreset = async (id) => {
const at = store.findIndex((p) => p.id === id)
if (at >= 0) store.splice(at, 1)
}
t.after(() => Object.assign(zonesDb, saved))
return { store, writes }
}
function stubWorld(t) {
const calls = []
const saved = { getServer: serversDb.getServer, worldZone: client.worldZone, currentFormat: voice.currentFormat }
serversDb.getServer = async (id) => ({ id, name: id, sidecarBaseUrl: `http://${id}:1`, sidecarTokenEnc: null, enabled: 1 })
client.worldZone = async (srv, body) => {
calls.push({ server: srv.id, body })
return { ok: true, status: 'ok', data: { kind: 'world.ok', placed: [{ id: 'rg-7-1-1', kind: 'zone', name: 'Z' }] } }
}
voice.currentFormat = async () => '<color=#ff0>[Event]</color> {message}'
t.after(() => {
serversDb.getServer = saved.getServer
client.worldZone = saved.worldZone
voice.currentFormat = saved.currentFormat
})
return calls
}
// ── the options line ───────────────────────────────────────────────────────
test('an options line reads as flags and settings, spelled as the server spells them', () => {
const read = options.parse('nobuild, NOPLAYERLOOT radiation=10 safezone=true permission=Arena_1', FLAGS)
assert.deepStrictEqual(read, {
ok: true,
flags: ['NoBuild', 'NoPlayerLoot'],
settings: { radiation: 10, safezone: true, permission: 'arena_1' },
})
assert.deepStrictEqual(options.parse('', FLAGS), { ok: true, flags: [], settings: {} })
assert.deepStrictEqual(options.parse(undefined), { ok: true, flags: [], settings: {} })
})
test('a flag the server lacks, or a setting out of range, is refused', () => {
for (const line of [
'NoFlying',
'radiation=501',
'comfort=2',
'temperature=-101',
'safezone=maybe',
'permission=has.dots',
'rotation=90',
'radiation=1 radiation=2',
'No-Build',
]) {
const read = options.parse(line, FLAGS)
assert.strictEqual(read.ok, false, line)
assert.ok(read.error, line)
}
})
test('without a flag list the flags are kept as written, and the plugin checks them', () => {
assert.deepStrictEqual(options.parse('NoFlying', null), { ok: true, flags: ['NoFlying'], settings: {} })
})
test('format and parse are inverses', () => {
const preset = { flags: ['NoBuild', 'PvpGod'], settings: { safezone: false, radiation: 2.5 } }
const line = options.format(preset)
assert.strictEqual(line, 'NoBuild, PvpGod, radiation=2.5, safezone=false')
const back = options.parse(line, FLAGS)
assert.deepStrictEqual({ flags: back.flags, settings: back.settings }, preset)
})
test('a flag this module does not know is drawn under Other, never hidden', () => {
const groups = options.grouped(FLAGS)
assert.deepStrictEqual(groups.map((g) => g.group), ['Combat', 'Building', 'Loot', 'Communication', 'Other'])
assert.deepStrictEqual(groups.find((g) => g.group === 'Other').flags.map((f) => f.name), ['SomethingNew'])
assert.strictEqual(groups.flatMap((g) => g.flags).length, FLAGS.length)
})
// ── presets ────────────────────────────────────────────────────────────────
test('a preset is checked against every server it covers, and saved as the server spells it', async (t) => {
const { writes } = stubZones(t, {
servers: [server('main'), server('alt', { flags: ['NoBuild'] }), server('dark', { flags: null })],
})
await assert.rejects(zones.create({ name: 'Arena', flags: ['NoPlayerLoot'], servers: ['main', 'alt'] }), (err) => {
assert.strictEqual(err.status, 400)
assert.match(err.message, /ALT/)
return true
})
// `dark` has not said which flags it has: skipped, and the plugin checks.
const saved = await zones.create({ name: 'Arena', flags: ['noplayerloot'], settings: { radiation: '5' }, servers: ['main', 'dark'] }, 9)
assert.deepStrictEqual(writes[0], {
name: 'Arena', flags: ['NoPlayerLoot'], settings: { radiation: 5 }, allServers: false, servers: ['main', 'dark'],
})
assert.strictEqual(saved.name, 'Arena')
})
test('a preset names a server, or every server, and only ones that exist', async (t) => {
stubZones(t)
await assert.rejects(zones.create({ name: 'A', flags: [] }), /at least one server/)
await assert.rejects(zones.create({ name: 'A', servers: ['nowhere'] }), (err) => err.status === 404)
await assert.rejects(zones.create({ name: '', servers: ['main'] }), /name/)
})
test('two presets of one name may not share a server; on different servers they may (D189)', async (t) => {
stubZones(t, {
servers: [server('main'), server('alt')],
presets: [{ id: 1, name: 'Arena', flags: [], settings: {}, allServers: false, servers: ['main'] }],
})
await assert.rejects(zones.create({ name: 'arena', servers: ['main', 'alt'] }), (err) => err.status === 409)
await assert.rejects(zones.create({ name: 'Arena', allServers: true }), (err) => err.status === 409)
const alt = await zones.create({ name: 'Arena', servers: ['alt'] })
assert.deepStrictEqual(alt.servers, ['alt'])
// Editing the first keeps its own name without clashing with itself.
const same = await zones.update(1, { name: 'Arena', flags: ['PvpGod'], servers: ['main'] })
assert.deepStrictEqual(same.flags, ['PvpGod'])
})
test('the zone step’s dropdown VALUE is the options line, so picking a preset copies it (D210)', async (t) => {
stubZones(t, {
servers: [server('main')],
presets: [
{ id: 1, name: 'Arena', flags: ['NoBuild'], settings: { radiation: 10 }, allServers: false, servers: ['main'] },
{ id: 2, name: 'Calm', flags: ['PvpGod'], settings: {}, allServers: true, servers: [] },
],
})
assert.deepStrictEqual(await source('rust.options.zone_presets').resolve({}), [
{ value: 'NoBuild, radiation=10', label: 'Arena', group: 'MAIN' },
{ value: 'PvpGod', label: 'Calm', group: 'Every server' },
])
})
// ── the step ───────────────────────────────────────────────────────────────
const PATCHED = { loaded: true, version: '2.0.2', helper: { state: 'patched', version: '0.1.0' }, ready: true }
test('a zone crosses with its flags, settings, messages in the chat voice, and its dome', async (t) => {
stubZones(t, { servers: [server('main', { domes: PATCHED })] })
const calls = stubWorld(t)
const result = await action('rust.zone.open').perform({
runId: 7,
idempotencyKey: 'k1',
params: {
monument: 'main/airfield_1', radius: 40, minutes: 30,
options: 'nobuild, pvpgod, radiation=10',
enterMessage: ' In you go. ', leaveMessage: 'Out.',
dome: 'Red', domeStack: 2,
},
})
assert.strictEqual(result.ok, true, result.error)
assert.deepStrictEqual(calls[0].body, {
runId: '7', key: 'k1', monument: 'airfield_1', offsetX: 0, offsetZ: 0, radius: 40, holdMs: 1800000,
flags: ['NoBuild', 'PvpGod'],
settings: { radiation: 10 },
enterMessage: 'In you go.',
leaveMessage: 'Out.',
delivery: 'chat',
format: '<color=#ff0>[Event]</color> {message}',
dome: { type: 1, stack: 2 },
})
})
test('a zone with no options crosses exactly as it always did', async (t) => {
stubZones(t)
const calls = stubWorld(t)
await action('rust.zone.open').perform({ runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 5 } })
assert.deepStrictEqual(Object.keys(calls[0].body).sort(), ['holdMs', 'key', 'monument', 'offsetX', 'offsetZ', 'radius', 'runId'])
})
test('a popup carries no chat voice', async (t) => {
stubZones(t)
const calls = stubWorld(t)
await action('rust.zone.open').perform({
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 5, enterMessage: 'Hi', delivery: 'popup' },
})
assert.strictEqual(calls[0].body.delivery, 'popup')
assert.strictEqual(calls[0].body.format, undefined)
})
test('a bad option, message or dome is refused for good, before anything is sent', async (t) => {
const run = (params) =>
action('rust.zone.open').perform({ runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 5, ...params } })
for (const [domes, params, words] of [
[PATCHED, { options: 'NoFlying' }, /not a flag/],
[PATCHED, { enterMessage: 'x'.repeat(257) }, /256/],
[PATCHED, { delivery: 'shout' }, /chat or to a popup/],
[PATCHED, { dome: 'orange' }, /standard, red/],
[PATCHED, { dome: 'red', domeStack: 11 }, /1 to 10/],
[{ loaded: false }, { dome: 'red' }, /ZoneDomes is not loaded/],
[{ loaded: true, helper: { state: 'missing' } }, { dome: 'red' }, /RunicGatewayDomes\.cs is not installed/],
[{ loaded: true, helper: { state: 'unsupported' } }, { dome: 'red' }, /could not patch/],
]) {
await t.test(String(words), async (tt) => {
stubZones(tt, { servers: [server('main', { domes })] })
const calls = stubWorld(tt)
const result = await run(params)
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, false)
assert.match(result.error, words)
assert.strictEqual(calls.length, 0)
})
}
})
test('a dry run checks the options and the dome and sends nothing', async (t) => {
stubZones(t, { servers: [server('main', { domes: PATCHED })] })
const calls = stubWorld(t)
const result = await action('rust.zone.open').perform({
runId: 7, idempotencyKey: 'k', verify: true, params: { monument: 'main/a', radius: 40, minutes: 5, options: 'NoBuild', dome: 'standard' },
})
assert.deepStrictEqual(result, { ok: true })
assert.strictEqual(calls.length, 0)
})
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'])
})