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

@@ -320,3 +320,61 @@ test('names are lowered, because the store lowers them', () => {
// push one name, find another, and report its own grant as drift for ever.
assert.deepEqual(payload.grants[0].permissions, ['kits.gold'])
})
// ── What events granted (phase 13b, D84, D102, D103) ─────────────────────────
test('an event grant is unioned with the admin grants, reaches only its server, and credits the account that played', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const set = {
...authored(),
runGrants: [
// User 1 won on main with their second account; the grant reaches both
// accounts (D28), the credit only the one that took part (D103).
{ runId: '41', stepId: '7', userId: 1, serverId: 'main', steamId: '7656099', permission: 'Kits.Event', kit: 'event', credit: 1 },
// The same permission an admin already grants user 1: one row in the game.
{ runId: '41', stepId: '8', userId: 1, serverId: 'main', steamId: '7656001', permission: 'kits.gold', kit: 'gold', credit: 1 },
// A kit anybody may redeem: a credit and no permission at all.
{ runId: '41', stepId: '9', userId: 2, serverId: 'main', steamId: '7656002', permission: '', kit: 'starter', credit: 1 },
// A win on another server reaches nothing here (D102).
{ runId: '42', stepId: '1', userId: 2, serverId: 'creative', steamId: '7656002', permission: 'kits.creative', kit: 'c', credit: 1 },
// An account unlinked since the win earns its credit nowhere.
{ runId: '43', stepId: '1', userId: 2, serverId: 'main', steamId: '7656777', permission: '', kit: 'starter', credit: 1 },
],
}
const { payload, rows, hash } = model.buildDesired('main', set)
const grantsFor = (steamId) => (payload.grants.find((g) => g.steamId === steamId) || { permissions: [] }).permissions.sort()
assert.deepEqual(grantsFor('7656001'), ['kits.event', 'kits.gold'])
assert.deepEqual(grantsFor('7656099'), ['kits.event', 'kits.gold'])
assert.ok(!payload.managed.includes('kits.creative'))
assert.strictEqual(rows.filter((r) => r.kind === 'grant' && r.object === 'kits.gold').length, 2)
assert.deepEqual(payload.credits, [
{ steamId: '7656001', kit: 'gold', count: 1 },
{ steamId: '7656002', kit: 'starter', count: 1 },
{ steamId: '7656099', kit: 'event', count: 1 },
])
// Credits push but never enter the pushed ledger: a use of a kit is not
// something in the permission store to retire.
assert.ok(!rows.some((r) => r.kind === 'credit'))
// A revert of the last reward still moves the digest, so it is pushed.
const withdrawn = model.buildDesired('main', { ...set, runGrants: set.runGrants.filter((r) => r.stepId !== '9') })
assert.notStrictEqual(withdrawn.hash, hash)
})
test('the permission an admin grants survives an event revert of the same one (D84)', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const event = { runId: '41', stepId: '8', userId: 1, serverId: 'main', steamId: '7656001', permission: 'kits.gold', kit: 'gold', credit: 0 }
const before = model.buildDesired('main', { ...authored(), runGrants: [event] })
const after = model.buildDesired('main', { ...authored(), runGrants: [] })
// Nothing to retire: the admin grant still desires every row the event did.
assert.deepEqual(model.retirements(before.rows, after.rows), [])
assert.deepEqual(after.payload.credits, [])
})