feat(rust): the world verbs, their budgets and the reconcile watch (phase 13a, protocol 9) #15

Merged
whitlocktech merged 3 commits from feat/phase-13a-world into edge 2026-09-24 10:11:04 +00:00
3 changed files with 144 additions and 110 deletions
Showing only changes of commit fab31f23e8 - Show all commits

View File

@@ -1,7 +1,7 @@
// ── What an event MAKES on a Rust server (PLAN.md §28, protocol 9) ──────── // ── What an event MAKES on a Rust server (PLAN.md §28, protocol 9) ────────
// //
// A lease borrows a value that was already there. These two verbs make // A lease borrows a value that was already there. These three verbs make
// something that was not — a zone, and crates or NPCs placed in the world — and // something that was not — a zone, crates, NPCs — and
// give it back at teardown. Everything that decides what is allowed lives on the // give it back at teardown. Everything that decides what is allowed lives on the
// plugin: the allowlist, the bounds, the monument vocabulary, the registry of // plugin: the allowlist, the bounds, the monument vocabulary, the registry of
// what each run owns. What is here is the contract's half: declarations core can // what each run owns. What is here is the contract's half: declarations core can
@@ -372,6 +372,91 @@ const WORLD_COMMON = {
reconcile, reconcile,
} }
/**
* One placing verb per KIND (D97), not one verb for both.
*
* Core learns which caps an action accepts by pricing that action's declared
* EXAMPLES once, and drops a dimension priced at zero. So a single verb whose
* cost moved between `rust.prefabs` and `rust.npcs` by its `prefab` param could
* only ever show the operator the crates cap, and D89's separate dial for fights
* would be unreachable. Two verbs, each pricing exactly one dimension, is also
* what lets the switchboard allow crates and leave NPCs off.
*/
function placeVerb({ id, kind, budget, max, label, description, source, example }) {
const noun = kind === 'npc' ? 'NPCs' : 'crates'
return {
...WORLD_COMMON,
id,
label,
description,
cost: (p) => ({ [budget]: Math.max(0, Math.round(Number(p.count) || 0)) }),
params: [
{
name: 'prefab',
type: 'string',
required: true,
example,
source,
description: `Which of the server's own ${noun} to place.`,
},
{
name: 'count',
type: 'int',
required: true,
example: 3,
description: `How many — 1 to ${max} at a time.`,
},
{
name: 'spread',
type: 'float',
required: false,
example: 10,
description: `How widely to scatter a group, up to ${MAX_SPREAD} m. Left blank, 10.`,
},
...LOCATION_PARAMS,
],
async perform({ runId, idempotencyKey, params, verify }) {
const known = PLACEABLE.find((x) => x.key === String(params.prefab || '').trim())
if (!known || known.kind !== kind) {
return { ok: false, retry: false, error: `"${params.prefab}" is not one of the ${noun} a Rust server places for events` }
}
const count = Number(params.count)
if (!Number.isInteger(count) || count < 1 || count > max) {
return { ok: false, retry: false, error: `place 1 to ${max} ${noun} at a time, and "${params.count}" is not that` }
}
const spread = num(params.spread)
if (Number.isNaN(spread) || (spread !== undefined && (spread < 0 || spread > MAX_SPREAD))) {
return { ok: false, retry: false, error: `a scatter is 0 to ${MAX_SPREAD} m, not "${params.spread}"` }
}
const where = location(params)
if (!where.ok) return { ok: false, retry: false, error: where.error }
const found = await serverFor(where.serverId)
if (!found.ok) return found
if (verify) return { ok: true }
return place(
found.server,
client.worldPlace,
{
runId: String(runId),
key: idempotencyKey,
prefab: known.key,
count,
...(spread === undefined ? {} : { spread }),
...where.wire,
},
known.label.toLowerCase(),
)
},
}
}
const ACTIONS = [ const ACTIONS = [
{ {
...WORLD_COMMON, ...WORLD_COMMON,
@@ -436,85 +521,28 @@ const ACTIONS = [
) )
}, },
}, },
{ placeVerb({
...WORLD_COMMON, id: 'rust.crate.place',
id: 'rust.prefab.place', kind: 'crate',
label: 'Place crates or NPCs', budget: 'rust.prefabs',
max: MAX_CRATES,
label: 'Place crates',
description: description:
'Crates, a supply drop or NPCs at a monument or a point, scattered a little. Taken away at teardown; a crate somebody looted is simply gone.', 'Crates, barrels or a supply drop at a monument or a point, scattered a little. Taken away at teardown; a crate somebody looted is simply gone.',
cost: (p) => { source: 'rust.options.crates',
const known = PLACEABLE.find((x) => x.key === String(p.prefab || '').trim())
const count = Math.max(0, Math.round(Number(p.count) || 0))
return { [known && known.kind === 'npc' ? 'rust.npcs' : 'rust.prefabs']: count }
},
params: [
{
name: 'prefab',
type: 'string',
required: true,
example: 'crate.elite', example: 'crate.elite',
source: 'rust.options.prefabs', }),
description: "What to place. The list is the server's own allowlist: crates and NPCs, never vehicles.", placeVerb({
}, id: 'rust.npc.place',
{ kind: 'npc',
name: 'count', budget: 'rust.npcs',
type: 'int', max: MAX_NPCS,
required: true, label: 'Place NPCs',
example: 3, description:
description: `How many — up to ${MAX_CRATES} crates or ${MAX_NPCS} NPCs at a time.`, 'Scientists or guards at a monument or a point. Taken away at teardown. The game does not save NPCs, so a restart ends them; the ledger then says so.',
}, source: 'rust.options.npcs',
{ example: 'npc.scientist',
name: 'spread', }),
type: 'float',
required: false,
example: 10,
description: `How widely to scatter a group, up to ${MAX_SPREAD} m. Left blank, 10.`,
},
...LOCATION_PARAMS,
],
async perform({ runId, idempotencyKey, params, verify }) {
const known = PLACEABLE.find((x) => x.key === String(params.prefab || '').trim())
if (!known) return { ok: false, retry: false, error: `"${params.prefab}" is not something a Rust server places for events` }
const max = known.kind === 'npc' ? MAX_NPCS : MAX_CRATES
const count = Number(params.count)
if (!Number.isInteger(count) || count < 1 || count > max) {
return {
ok: false,
retry: false,
error: `place 1 to ${max} ${known.kind === 'npc' ? 'NPCs' : 'crates'} at a time, and "${params.count}" is not that`,
}
}
const spread = num(params.spread)
if (Number.isNaN(spread) || (spread !== undefined && (spread < 0 || spread > MAX_SPREAD))) {
return { ok: false, retry: false, error: `a scatter is 0 to ${MAX_SPREAD} m, not "${params.spread}"` }
}
const where = location(params)
if (!where.ok) return { ok: false, retry: false, error: where.error }
const found = await serverFor(where.serverId)
if (!found.ok) return found
if (verify) return { ok: true }
return place(
found.server,
client.worldPlace,
{
runId: String(runId),
key: idempotencyKey,
prefab: known.key,
count,
...(spread === undefined ? {} : { spread }),
...where.wire,
},
known.label.toLowerCase(),
)
},
},
] ]
const OPTION_SOURCES = [ const OPTION_SOURCES = [
@@ -541,16 +569,16 @@ const OPTION_SOURCES = [
return bounded(rows, 'rust.options.monuments') return bounded(rows, 'rust.options.monuments')
}, },
}, },
{ // From the mirror, so both answer with every server off (the field they fill
// From the mirror, so it answers with every server off (the field it fills // must never be taken away by an outage, MODULE_API §2.4). One per verb (D97).
// must never be taken away by an outage, MODULE_API §2.4). ...['crate', 'npc'].map((kind) => ({
id: 'rust.options.prefabs', id: kind === 'npc' ? 'rust.options.npcs' : 'rust.options.crates',
label: 'Things to place', label: kind === 'npc' ? 'NPCs' : 'Crates',
description: 'Crates and NPCs a Rust server places for events.', description: `The ${kind === 'npc' ? 'NPCs' : 'crates'} a Rust server places for events.`,
async resolve() { async resolve() {
return PLACEABLE.map((p) => ({ value: p.key, label: p.label, group: p.kind === 'npc' ? 'NPCs' : 'Crates' })) return PLACEABLE.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label }))
},
}, },
})),
] ]
// ── The watch (§11.1) ─────────────────────────────────────────────────────── // ── The watch (§11.1) ───────────────────────────────────────────────────────

View File

@@ -153,19 +153,19 @@ test('the world verbs are registered, and every budget has a verb that spends it
const actions = api.record.eventActions const actions = api.record.eventActions
const budgets = api.record.eventBudgets const budgets = api.record.eventBudgets
assert.deepStrictEqual(actions.map((a) => a.id).sort(), ['rust.prefab.place', 'rust.zone.open']) assert.deepStrictEqual(actions.map((a) => a.id).sort(), ['rust.crate.place', 'rust.npc.place', 'rust.zone.open'])
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), ['rust.npcs', 'rust.prefabs', 'rust.zone.minutes']) assert.deepStrictEqual(budgets.map((b) => b.id).sort(), ['rust.npcs', 'rust.prefabs', 'rust.zone.minutes'])
// D79/D89: no dimension without a verb that spends it. A crate step and an // D79/D89/D97: every dimension has a verb that spends it, and each verb spends
// NPC step are priced on different dials, and a zone on its minutes. // exactly ONE — priced from its own declared examples, which is how core
// decides which cap boxes the switchboard shows. A verb whose dimension moved
// with its params would hide the other dial from every operator.
const spent = new Set() const spent = new Set()
const place = actions.find((a) => a.id === 'rust.prefab.place') for (const a of actions) {
for (const cost of [ const example = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
place.cost({ prefab: 'crate.elite', count: 3 }), const dims = Object.keys(a.cost(example)).filter((id) => a.cost(example)[id] > 0)
place.cost({ prefab: 'npc.scientist', count: 2 }), assert.strictEqual(dims.length, 1, `${a.id} prices ${dims.join(', ')}`)
actions.find((a) => a.id === 'rust.zone.open').cost({ minutes: 90 }), spent.add(dims[0])
]) {
for (const id of Object.keys(cost)) spent.add(id)
} }
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort()) assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())

View File

@@ -129,7 +129,7 @@ test('a dry run checks everything it can and sends nothing', async (t) => {
const zone = await action('rust.zone.open').perform({ const zone = await action('rust.zone.open').perform({
runId: 7, idempotencyKey: 'k1', verify: true, params: { monument: 'main/airfield_1', radius: 40, minutes: 60 }, runId: 7, idempotencyKey: 'k1', verify: true, params: { monument: 'main/airfield_1', radius: 40, minutes: 60 },
}) })
const place = await action('rust.prefab.place').perform({ const place = await action('rust.crate.place').perform({
runId: 7, idempotencyKey: 'k2', verify: true, params: { monument: 'main/airfield_1', prefab: 'crate.elite', count: 3 }, runId: 7, idempotencyKey: 'k2', verify: true, params: { monument: 'main/airfield_1', prefab: 'crate.elite', count: 3 },
}) })
assert.deepStrictEqual([zone, place], [{ ok: true }, { ok: true }]) assert.deepStrictEqual([zone, place], [{ ok: true }, { ok: true }])
@@ -138,15 +138,18 @@ test('a dry run checks everything it can and sends nothing', async (t) => {
test('every authoring mistake is refused for good, before anything is sent', async (t) => { test('every authoring mistake is refused for good, before anything is sent', async (t) => {
const calls = stub(t) const calls = stub(t)
const place = action('rust.prefab.place') const crates = action('rust.crate.place')
const npcs = action('rust.npc.place')
const zone = action('rust.zone.open') const zone = action('rust.zone.open')
const run = (a, params) => a.perform({ runId: 7, idempotencyKey: 'k', params }) const run = (a, params) => a.perform({ runId: 7, idempotencyKey: 'k', params })
for (const result of [ for (const result of [
await run(place, { monument: 'main/a', prefab: 'minicopter', count: 1 }), await run(crates, { monument: 'main/a', prefab: 'minicopter', count: 1 }),
await run(place, { monument: 'main/a', prefab: 'crate.elite', count: 26 }), await run(crates, { monument: 'main/a', prefab: 'npc.scientist', count: 1 }), // D97: the other verb's
await run(place, { monument: 'main/a', prefab: 'npc.scientist', count: 21 }), await run(npcs, { monument: 'main/a', prefab: 'crate.elite', count: 1 }),
await run(place, { monument: 'main/a', prefab: 'crate.elite', count: 1, spread: 51 }), await run(crates, { monument: 'main/a', prefab: 'crate.elite', count: 26 }),
await run(npcs, { monument: 'main/a', prefab: 'npc.scientist', count: 21 }),
await run(crates, { monument: 'main/a', prefab: 'crate.elite', count: 1, spread: 51 }),
await run(zone, { monument: 'main/a', radius: 4, minutes: 10 }), await run(zone, { monument: 'main/a', radius: 4, minutes: 10 }),
await run(zone, { monument: 'main/a', radius: 40 }), // D96: minutes are required await run(zone, { monument: 'main/a', radius: 40 }), // D96: minutes are required
await run(zone, { monument: 'main/a', radius: 40, minutes: 7 * 24 * 60 + 1 }), await run(zone, { monument: 'main/a', radius: 40, minutes: 7 * 24 * 60 + 1 }),
@@ -182,7 +185,7 @@ test('one resource per thing placed, and a repeated key is said to be one', asyn
data: { kind: 'world.ok', repeat: true, placed: [{ id: '101', kind: 'npc', prefab: 'npc.scientist' }] }, data: { kind: 'world.ok', repeat: true, placed: [{ id: '101', kind: 'npc', prefab: 'npc.scientist' }] },
}), }),
}) })
const result = await action('rust.prefab.place').perform({ const result = await action('rust.npc.place').perform({
runId: 7, idempotencyKey: 'k', params: { x: -604, z: -342, server: 'main', prefab: 'npc.scientist', count: 1 }, runId: 7, idempotencyKey: 'k', params: { x: -604, z: -342, server: 'main', prefab: 'npc.scientist', count: 1 },
}) })
assert.strictEqual(result.ok, true) assert.strictEqual(result.ok, true)
@@ -194,7 +197,7 @@ test('the switch being off is a refusal with the switch named, for good (D94)',
stub(t, { stub(t, {
place: async () => ({ ok: true, data: { kind: 'world.error', reason: 'events-disabled', message: 'set EventsEnabled' } }), place: async () => ({ ok: true, data: { kind: 'world.error', reason: 'events-disabled', message: 'set EventsEnabled' } }),
}) })
const result = await action('rust.prefab.place').perform({ const result = await action('rust.crate.place').perform({
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 }, runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
}) })
assert.deepStrictEqual(result, { ok: false, retry: false, error: 'set EventsEnabled' }) assert.deepStrictEqual(result, { ok: false, retry: false, error: 'set EventsEnabled' })
@@ -202,7 +205,7 @@ test('the switch being off is a refusal with the switch named, for good (D94)',
test('a game that is down or slow is left to core to retry', async (t) => { test('a game that is down or slow is left to core to retry', async (t) => {
stub(t, { place: async () => ({ ok: false, status: 'http-503' }) }) stub(t, { place: async () => ({ ok: false, status: 'http-503' }) })
const result = await action('rust.prefab.place').perform({ const result = await action('rust.crate.place').perform({
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 }, runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
}) })
assert.strictEqual(result.ok, false) assert.strictEqual(result.ok, false)
@@ -296,10 +299,13 @@ test('the monument source lists each server\'s map as whole values, numbered whe
assert.deepStrictEqual(narrowed.map((r) => r.value), ['main/powerplant_1']) assert.deepStrictEqual(narrowed.map((r) => r.value), ['main/powerplant_1'])
}) })
test('the prefab source answers with every server off', async (t) => { test('the crate and NPC sources split the allowlist, and answer with every server off (D97)', async (t) => {
const calls = stub(t) const calls = stub(t)
const rows = await source('rust.options.prefabs').resolve() const crates = await source('rust.options.crates').resolve()
assert.strictEqual(rows.length, world.PLACEABLE.length) const npcs = await source('rust.options.npcs').resolve()
assert.strictEqual(crates.length + npcs.length, world.PLACEABLE.length)
assert.ok(crates.every((r) => !r.value.startsWith('npc.')))
assert.ok(npcs.every((r) => r.value.startsWith('npc.')))
assert.deepStrictEqual(calls.monuments, []) assert.deepStrictEqual(calls.monuments, [])
}) })