diff --git a/server/eventWorld.js b/server/eventWorld.js index 7f38226..16d97f1 100644 --- a/server/eventWorld.js +++ b/server/eventWorld.js @@ -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) ─────────────────────────────────────────────────────── diff --git a/server/test/entry.test.js b/server/test/entry.test.js index 537c5ec..2f7f0fe 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -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()) diff --git a/server/test/world.test.js b/server/test/world.test.js index 6da7f04..9b16339 100644 --- a/server/test/world.test.js +++ b/server/test/world.test.js @@ -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, []) })