feat(rust): the world verbs, their budgets and the reconcile watch (phase 13a, protocol 9) #15
@@ -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) ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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, [])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user