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) ────────
//
// A lease borrows a value that was already there. These two verbs make
// something that was not — a zone, and crates or NPCs placed in the world — and
// A lease borrows a value that was already there. These three verbs make
// something that was not — a zone, crates, NPCs — and
// 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
// what each run owns. What is here is the contract's half: declarations core can
@@ -372,6 +372,91 @@ const WORLD_COMMON = {
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 = [
{
...WORLD_COMMON,
@@ -436,85 +521,28 @@ const ACTIONS = [
)
},
},
{
...WORLD_COMMON,
id: 'rust.prefab.place',
label: 'Place crates or NPCs',
placeVerb({
id: 'rust.crate.place',
kind: 'crate',
budget: 'rust.prefabs',
max: MAX_CRATES,
label: 'Place crates',
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.',
cost: (p) => {
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',
source: 'rust.options.prefabs',
description: "What to place. The list is the server's own allowlist: crates and NPCs, never vehicles.",
},
{
name: 'count',
type: 'int',
required: true,
example: 3,
description: `How many — up to ${MAX_CRATES} crates or ${MAX_NPCS} NPCs 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) 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(),
)
},
},
'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.',
source: 'rust.options.crates',
example: 'crate.elite',
}),
placeVerb({
id: 'rust.npc.place',
kind: 'npc',
budget: 'rust.npcs',
max: MAX_NPCS,
label: 'Place NPCs',
description:
'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',
}),
]
const OPTION_SOURCES = [
@@ -541,16 +569,16 @@ const OPTION_SOURCES = [
return bounded(rows, 'rust.options.monuments')
},
},
{
// 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).
id: 'rust.options.prefabs',
label: 'Things to place',
description: 'Crates and NPCs a Rust server places for events.',
// From the mirror, so both answer with every server off (the field they fill
// must never be taken away by an outage, MODULE_API §2.4). One per verb (D97).
...['crate', 'npc'].map((kind) => ({
id: kind === 'npc' ? 'rust.options.npcs' : 'rust.options.crates',
label: kind === 'npc' ? 'NPCs' : 'Crates',
description: `The ${kind === 'npc' ? 'NPCs' : 'crates'} a Rust server places for events.`,
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) ───────────────────────────────────────────────────────

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 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'])
// D79/D89: no dimension without a verb that spends it. A crate step and an
// NPC step are priced on different dials, and a zone on its minutes.
// D79/D89/D97: every dimension has a verb that spends it, and each verb spends
// 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 place = actions.find((a) => a.id === 'rust.prefab.place')
for (const cost of [
place.cost({ prefab: 'crate.elite', count: 3 }),
place.cost({ prefab: 'npc.scientist', count: 2 }),
actions.find((a) => a.id === 'rust.zone.open').cost({ minutes: 90 }),
]) {
for (const id of Object.keys(cost)) spent.add(id)
for (const a of actions) {
const example = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
const dims = Object.keys(a.cost(example)).filter((id) => a.cost(example)[id] > 0)
assert.strictEqual(dims.length, 1, `${a.id} prices ${dims.join(', ')}`)
spent.add(dims[0])
}
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({
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 },
})
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) => {
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 run = (a, params) => a.perform({ runId: 7, idempotencyKey: 'k', params })
for (const result of [
await run(place, { monument: 'main/a', prefab: 'minicopter', count: 1 }),
await run(place, { monument: 'main/a', prefab: 'crate.elite', count: 26 }),
await run(place, { monument: 'main/a', prefab: 'npc.scientist', count: 21 }),
await run(place, { monument: 'main/a', prefab: 'crate.elite', count: 1, spread: 51 }),
await run(crates, { monument: 'main/a', prefab: 'minicopter', count: 1 }),
await run(crates, { monument: 'main/a', prefab: 'npc.scientist', count: 1 }), // D97: the other verb's
await run(npcs, { monument: 'main/a', prefab: 'crate.elite', count: 1 }),
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: 40 }), // D96: minutes are required
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' }] },
}),
})
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 },
})
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, {
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 },
})
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) => {
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 },
})
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'])
})
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 rows = await source('rust.options.prefabs').resolve()
assert.strictEqual(rows.length, world.PLACEABLE.length)
const crates = await source('rust.options.crates').resolve()
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, [])
})