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
283 lines
12 KiB
JavaScript
283 lines
12 KiB
JavaScript
// ── 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'])
|
||
})
|