feat(rust): the rewards — tally, kit reward, chat and the news leg (phase 13b, protocol 10)

Four event verbs and the announce leg, per PLAN.md §29:

- rust.participation.open / .collect: the plugin counts who takes part
  (seconds, kills or both, in a zone this run opened or the whole server)
  and collect files them as the run's participants, keyed by Steam id.
- rust.kit.entitle: the five recipient modes (D101), rows in the new
  rust_perm_run_grants (D84) unioned into the permission push, one extra
  use of the kit per reward as site-held credits on perm.sync (D103),
  and the rust.kit.entitled notice deferred from phase 10 (D64).
- rust.announce: one server or every server (D105).
- rust.chat announce leg, speaking only on servers whose new news switch
  is on (D104) - a card on Admin -> Rust visibility (D106).

Budgets rust.grants and rust.announcements; the kit source and four
fixed-choice sources (core has no enum param type). rust_perm_run_grants
carries core's idempotency key so a revert of a lost answer can find its
rows. Protocol 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-24 07:00:44 -05:00
parent 9753dda4af
commit cc185db26b
27 changed files with 2096 additions and 45 deletions

View File

@@ -143,34 +143,58 @@ test('nothing is registered that has nothing behind it yet', () => {
// leases and option sources, and kept budgets here on purpose (D79): a lease
// spends none, and a dimension nothing spends is a dial that does nothing.
// Phase 13a deleted the budgets and actions lines, and registered each budget
// beside the verb that spends it (below). The announce leg is 13b's.
assert.deepStrictEqual(api.record.legs, [])
// beside the verb that spends it (below). Phase 13b registered the announce
// leg (D104) — the post hook is still absent: nothing in game mirrors a post.
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['rust.chat'])
assert.strictEqual(api.record.hooks.post, undefined)
})
test('the world verbs are registered, and every budget has a verb that spends it (phase 13a)', () => {
test('the event verbs are registered, and every budget has a verb that spends it (phases 13a, 13b)', () => {
const { api } = register()
const actions = api.record.eventActions
const budgets = api.record.eventBudgets
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(actions.map((a) => a.id).sort(), [
'rust.announce',
'rust.crate.place',
'rust.kit.entitle',
'rust.npc.place',
'rust.participation.collect',
'rust.participation.open',
'rust.zone.open',
])
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
'rust.announcements',
'rust.grants',
'rust.npcs',
'rust.prefabs',
'rust.zone.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.
// D79/D89/D97: every dimension has a verb that spends it, and a verb that
// spends anything 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. The two tally verbs spend nothing: counting people costs no loot.
const spent = new Set()
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)
if (a.id.startsWith('rust.participation.')) {
assert.strictEqual(dims.length, 0, `${a.id} prices ${dims.join(', ')}`)
continue
}
assert.strictEqual(dims.length, 1, `${a.id} prices ${dims.join(', ')}`)
spent.add(dims[0])
}
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
// What a teardown can give back is declared honestly: a line said and a
// collect filed are not undone.
const once = new Set(['rust.announce', 'rust.participation.collect'])
for (const a of actions) {
assert.strictEqual(a.reversible, 'ledger')
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
if (once.has(a.id)) continue
assert.strictEqual(typeof a.revert, 'function')
assert.strictEqual(typeof a.reconcile, 'function')
// Every param source is one this module registers.