refactor(rust): one placing verb per kind — rust.crate.place and rust.npc.place (D97)
Core learns which caps an action accepts by pricing its declared examples once, and drops a dimension priced at zero. A single rust.prefab.place whose cost moved between rust.prefabs and rust.npcs by its prefab param could only ever show the crates cap, so D89's separate dial for fights was unreachable. Two verbs, each pricing exactly one dimension, with the prefab source split to match (rust.options.crates / rust.options.npcs). The switchboard can now allow crates and leave NPCs off. The plugin's world.place is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -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