Files
Module-Rust/server/test/zones.test.js
wtclaude 9c35906d0e
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / frozen-manifest (pull_request) Successful in 1m1s
PR Checks / server-tests (pull_request) Successful in 7m20s
fix(rust): a zone resource carries its deadline as until (W1)
Stage 4 of docs/modules/rust/PLAN_LAST_FIXES.md, the module's half of W1.
A placed zone now reports `until`, reckoned from the answer as now plus
holdMs, so it lands a round trip after the game's own deadline and never
before it. Core's reconcile then files a zone gone past that deadline as
expired, where a reconnect used to log resource.orphaned five seconds
before the game's world.expired arrived. Crates and NPCs send no
holdMs, so they carry no until.

No core pin change: `until` has been in the resource contract all along,
and a core without the reconcile half only shows it on the console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 00:38:49 -05:00

402 lines
18 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
// 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')
const { fakeCtx } = require('./_fakes')
require('../core')._reset()
require('../core').init(fakeCtx())
// The stored status is the whole answer in these tests: nothing asks a
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
require('../liveStatus').refresh = async () => false
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, truePve = null } = {}) {
return {
id,
name: id.toUpperCase(),
enabled: true,
zoneManager: flags ? { version: '3.1.14', flags } : null,
zoneDomes: domes,
truePve,
}
}
/** 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 resource carries its deadline as until, a crate none (W1)', async (t) => {
// Core's reconcile reads `until`: a zone gone past it is expired, not
// orphaned. Reckoned from the answer, so it never falls before the game's own.
stubZones(t)
stubWorld(t)
const before = Date.now()
const result = await action('rust.zone.open').perform({
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 30 },
})
assert.strictEqual(result.ok, true, result.error)
const until = Date.parse(result.resources[0].until)
assert.ok(until >= before + 1800000 && until <= Date.now() + 1800000, result.resources[0].until)
const saved = client.worldPlace
client.worldPlace = async () => ({ ok: true, status: 'ok', data: { kind: 'world.ok', placed: [{ id: 'rg-7-1-2', kind: 'crate' }] } })
t.after(() => { client.worldPlace = saved })
const crate = await action('rust.crate.place').perform({
runId: 7, idempotencyKey: 'k2', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
})
assert.strictEqual(crate.ok, true, crate.error)
assert.strictEqual('until' in crate.resources[0], false)
})
test('a dome with its stack left blank stacks 3 spheres (D326)', 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', params: { monument: 'main/a', radius: 40, minutes: 5, dome: 'standard', domeStack: '' },
})
assert.strictEqual(result.ok, true, result.error)
assert.deepStrictEqual(calls[0].body.dome, { type: 0, stack: 3 })
})
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)
})
// ── 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'])
})