Files
Module-Rust/server/eventPermRewards.js
wtclaude 63c82d5a2f
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in -1m32s
feat(rust): permissions as event rewards — lend or give any registered permission (PLAN_REDESIGNS §12)
D342-D346 (docs#342). Two steps over the permission mirror's own tables, so
no plugin, sidecar or protocol change:

- rust.perm.lend, "Permissions until the event ends": a kit reward's lease,
  one rust_perm_run_grants row per recipient per permission, the message on
  one row each; change/ledger with rust.kit.entitle's revert and reconcile;
  budget rust.permissions.
- rust.perm.give, "Permissions for good": a standing rust_perm_grants row per
  website user per permission, scoped to the step's server, source `event`,
  note "event run <run>, step <step>"; a grant already there is left alone,
  and a retry's own grants are counted as given; the message rides a
  notice-only run-grant row; irreversible/none; budget rust.permissions.kept.
- Both: several permissions through core's `multiple` param (MODULE_API
  1.14.0, at most 10); a required "I am sure" toggle refused unticked in the
  dry run and at run time, naming the permissions (D343); a permission the
  server's catalogue lacks refused by name; a blank message tells nobody.
- rust.options.permissions: the enabled servers' catalogues, grouped by the
  registering plugin (by prefix when none), sorted plugin then permission
  without case, a row noted "Only on X" when not on every server.
- Permission manager: an event-given grant shows "given by event run N,
  step M"; staff turn it off like any grant.
- coreApi ^1.14.0; ci/core-ref.json pinned to website#213's head until it
  merges; engagement-triggers.json regenerated; routes.manifest.json
  unchanged (no new routes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 15:49:44 -05:00

525 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Permissions as event rewards (PLAN_REDESIGNS.md §12, D342–D346) ───────
//
// Any permission a server's plugins registered can be a reward — night vision,
// a chat colour, a `/remove` command — with no code written for the perk. Two
// verbs, because core gives an action one risk class (D346):
//
// rust.perm.lend until the run ends: a kit reward's lease, one
// `rust_perm_run_grants` row per recipient per permission,
// withdrawn by the step's revert (`change` / `ledger`)
// rust.perm.give for good: a standing `rust_perm_grants` row per website
// user per permission, scoped to the step's server, source
// `event` (`irreversible` / `none`); staff take it back in
// the permission manager
//
// …and the picker both use, `rust.options.permissions`: the enabled servers'
// catalogues, grouped by the plugin that registered each permission.
//
// Nothing here is new machinery. Both write rows the permission mirror already
// pushes, so there is no plugin code, no sidecar code and no protocol change
// (§12.1). The message is D333's notice and rides `perm.sync` like a kit
// reward's.
//
// ── The safety is the author's own "I am sure" (D343) ───────────────────────
//
// No blocklist and no allowlist: the bridge's own `runicgateway.*` permissions
// are in the picker like any other. A required toggle in the step says the
// author meant it, and unticked the dry run and the run refuse, naming what the
// step would have given.
const permDb = require('./model/permissions/permissions.db')
const linksDb = require('./model/links/links.db')
const servers = require('./model/servers/servers.model')
const core = require('./core')
const { serverFor, bounded } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
const rewards = require('./eventRewards')
const log = core.logger('perm-rewards')
const { MAX_RECIPIENTS, MAX_CHAT, DELIVERIES, MODES, oneOf, checkCount, pickRecipients, peopleOf, usersFor, readTally, entitlementRef } = rewards
/** At most this many permissions in one step (§12.6): a perk bundle, with a price that stays readable. */
const MAX_PERMISSIONS = 10
/** A permission name as Oxide and Carbon store it: lower case, and at most the column's width. */
const PERMISSION_MAX = 128
/** What a for-good grant says it came from, in the permission manager (§12.5). */
const SOURCE = 'event'
const BUDGETS = [
{
id: 'rust.permissions',
label: 'Permissions lent',
unit: 'permissions',
description:
'Permissions an event lends until it ends: one per recipient per permission. A mode that is not a count is priced at the most it could lend.',
},
{
id: 'rust.permissions.kept',
label: 'Permissions given for good',
unit: 'permissions',
description:
'Permissions an event gives for good: one per recipient per permission. Ending the event does not take them back; staff remove them in the permission manager. A mode that is not a count is priced at the most it could give.',
},
]
// ── The params both steps check ──────────────────────────────────────────────
/**
* The picked permissions as a list, checked: lower case, trimmed, each once, at
* least one and at most MAX_PERMISSIONS. Core hands a `multiple` param over as
* an array (MODULE_API 1.14.0); a bare value is a list of one.
*/
function checkPermissions(raw) {
const items = raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw]
const out = []
for (const item of items) {
const name = String(item === undefined || item === null ? '' : item).trim().toLowerCase()
if (!name) continue
if (name.length > PERMISSION_MAX || /\s/.test(name)) return { ok: false, error: `"${item}" is not a permission name` }
if (!out.includes(name)) out.push(name)
}
if (!out.length) return { ok: false, error: 'pick at least one permission' }
if (out.length > MAX_PERMISSIONS) return { ok: false, error: `a step gives at most ${MAX_PERMISSIONS} permissions, not ${out.length}` }
return { ok: true, value: out }
}
/** The "I am sure" toggle (D343). Only a real `true` passes; a string is not a tick. */
function checkConfirm(params, permissions) {
if (params.confirm === true) return { ok: true }
return { ok: false, error: `Tick "I am sure" to make ${permissions.join(', ')} a reward` }
}
/**
* The step's message, checked like a kit reward's (D333): `{ ok, text,
* delivery }`, text null for a step that tells nobody — the default (§12.6),
* because a permission's name means nothing to a player and only the author can
* say what it does. `{permissions}` is the names; measured with them in place.
*/
function permNotice(params, permissions) {
const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
if (!text) return { ok: true, text: null, delivery: null }
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
const delivery = oneOf(rawDelivery, DELIVERIES)
if (!delivery) return { ok: false, error: `a reward's message goes to chat or to a popup, not to "${rawDelivery}"` }
const said = sayPermissions(text, permissions)
if (said.length > MAX_CHAT) {
return { ok: false, error: `a reward's message is at most ${MAX_CHAT} characters with the permissions in it, not ${said.length}` }
}
return { ok: true, text, delivery }
}
const sayPermissions = (text, permissions) => text.replace(/\{permissions\}/g, permissions.join(', '))
/**
* Every picked permission must be in the server's catalogue (§12.2), and one
* that is not is refused by name rather than skipped (§12.6): the plugin may be
* unloaded for the wipe, and a reward that silently gives less than it says is
* the worse failure. Runs in the dry run too.
*/
async function checkCatalogue(server, permissions) {
const name = server.name || server.id
const registered = new Set(
(await permDb.listCatalogue())
.filter((row) => row.serverId === server.id)
.map((row) => String(row.permission).toLowerCase()),
)
if (!registered.size) {
return {
ok: false,
error: `${name} has reported no permissions yet — its plugins' permissions arrive with its next permission sync`,
}
}
const missing = permissions.filter((p) => !registered.has(p))
if (missing.length) {
return {
ok: false,
retry: false,
error: `${name} has no permission ${missing.join(', ')} — its plugins did not register ${missing.length === 1 ? 'it' : 'them'}`,
}
}
return { ok: true }
}
/**
* Everything both steps check before touching anything, in the order an author
* fixes them: the permissions, the toggle, the mode and its count, the message,
* the server, its catalogue, the bound. `{ ok, permissions, mode, count, notice,
* server }` or a refusal core reads as permanent.
*/
async function checkStep(params, verb) {
const permissions = checkPermissions(params.permissions)
if (!permissions.ok) return { ok: false, retry: false, error: permissions.error }
const confirm = checkConfirm(params, permissions.value)
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
const mode = oneOf(params.recipients, MODES)
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
const count = checkCount(mode, params.count)
if (!count.ok) return { ok: false, retry: false, error: count.error }
const notice = permNotice(params, permissions.value)
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
const found = await serverFor(String(params.server || '').trim())
if (!found.ok) return found
const server = found.server
const listed = await checkCatalogue(server, permissions.value)
if (!listed.ok) return listed
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
return { ok: false, retry: false, error: `${server.name || server.id} ${verb} permissions to at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
}
return { ok: true, permissions: permissions.value, mode, count, notice, server }
}
/**
* The people a mode picks, as website users (D101, D103): `{ ok, people,
* missed }`, each person `{ steamId, name, userId }` and each website user once —
* of two linked accounts that both took part, the earlier in the list, the
* higher score, is the one. `linked` reads no tally (§2.5).
*/
async function recipientsOf({ server, mode, count, runId, idempotencyKey, verb }) {
const name = server.name || server.id
let picked
let bound = MAX_RECIPIENTS
let users
if (mode === 'linked') {
// One past the bound, so "too many" is a sentence and not a silent cut.
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
if (picked.length > bound) {
return { ok: false, retry: false, error: `${name} has more than ${bound} linked players, and ${verb} permissions to at most ${bound} in one step — reward from a tally instead` }
}
} else {
const tally = await readTally(server, runId)
if (!tally.ok) return tally
picked = pickRecipients(peopleOf(tally.data), mode, count, idempotencyKey)
bound = Number(tally.data.maxRecipients) || bound
if (picked.length > bound) {
return { ok: false, retry: false, error: `${picked.length} people qualify, and ${name} ${verb} permissions to at most ${bound} in one step — pick a count-based mode or a higher bar` }
}
users = await usersFor(picked.map((p) => p.steamId))
}
const people = []
const byUser = new Set()
const missed = []
for (const p of picked) {
const userId = users.get(p.steamId)
if (!userId) {
missed.push(p.name)
continue
}
if (byUser.has(userId)) continue
byUser.add(userId)
people.push({ ...p, userId })
}
return { ok: true, people, missed }
}
const missedDetail = (missed) =>
missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}
/** People priced before the tally is read: the count for a count, the bound for every other mode. */
function pricedPeople(p) {
const mode = oneOf(p.recipients, MODES)
const n = Math.round(Number(p.count) || 0)
return mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
}
/** How many permissions a step names, as its price counts them: at least one, at most the bound. */
function pricedPermissions(p) {
const checked = checkPermissions(p.permissions)
return checked.ok ? checked.value.length : 1
}
// ── The params, declared once for both steps (§12.5: "the same params") ─────
const paramsFor = (messageExample) => [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'The server the permissions are given on. Each one must be registered there by one of its plugins.' },
{ name: 'permissions', type: 'string', required: true, example: 'nightvision.allowed', source: 'rust.options.permissions',
multiple: true, max: MAX_PERMISSIONS,
description: `The permissions, by the plugin that registered them — at most ${MAX_PERMISSIONS}.` },
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
description: 'Who gets them: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
{ name: 'count', type: 'float', required: false, example: 3,
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
{ name: 'message', type: 'string', required: false, example: messageExample,
description: 'Said once to each player, in game: at once if they are up, otherwise when they next wake on the server. {permissions} is the names. Leave it empty to say nothing — a permission\'s name means little to a player, so say what it does.' },
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
{ name: 'confirm', type: 'boolean', required: true, example: false,
description: 'I am sure I want to give these permissions as a reward. Unticked, the step refuses in the dry run and when it runs.' },
]
// ── Until the run ends (§12.4) ───────────────────────────────────────────────
const kitEntitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle')
const permLend = {
id: 'rust.perm.lend',
label: 'Permissions until the event ends',
description:
'Lends the permissions you pick to the people a mode picks from this run\'s tally — or every linked player of the server — until the run ends. Waits for them if they are offline. Someone who already held one keeps it when the event ends.',
// A kit reward's lease with other permissions (D346): the same rows, the same
// revert, so the same risk. Core reverts it at the end of every run.
risk: 'change',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: paramsFor('You can use /nv until the event ends'),
cost: (p) => ({ 'rust.permissions': pricedPeople(p) * pricedPermissions(p) }),
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const step = await checkStep(params, 'lends')
if (!step.ok) return step
if (verify) return { ok: true }
const { permissions, mode, count, notice, server } = step
const name = server.name || server.id
const ref = entitlementRef(server.id, runId, stepId)
const resource = { kind: 'permission-lease', ref, payload: { serverId: server.id, permissions } }
// As a kit reward's: the rows ARE the lease, and a repeated key finds them.
const existing = await permDb.listRunGrantsForStep(runId, stepId)
if (existing.length) {
return {
ok: true,
resources: [resource],
detail: { repeat: true, lent: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was lent' },
}
}
const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey, verb: 'lends' })
if (!picked.ok) return picked
// One row per recipient per permission; the message on the first only, so
// each person is told once (the notice key is per run, step and user).
const said = notice.text ? sayPermissions(notice.text, permissions) : null
const rows = []
for (const p of picked.people) {
permissions.forEach((permission, i) => {
rows.push({
runId,
stepId,
idemKey: idempotencyKey,
userId: p.userId,
serverId: server.id,
steamId: p.steamId,
permission,
kit: '',
credit: false,
...(said && i === 0 ? { notice: said, noticeDelivery: notice.delivery } : {}),
})
})
}
await permDb.insertRunGrants(rows)
await permDb.markDirty(server.id)
log.info('permissions lent', { server: server.id, run: runId, step: stepId, permissions, mode, people: picked.people.length, missed: picked.missed.length })
return {
ok: true,
resources: [resource],
detail: {
server: name,
permissions,
mode,
...(count.value === null ? {} : { count: count.value }),
lent: picked.people.length,
...(said && picked.people.length ? { told: `${picked.people.length} will be told in game, by ${notice.delivery}` } : {}),
...missedDetail(picked.missed),
},
}
},
// The lease ends as a kit reward's does: the step's rows go and the next sync
// revokes what nobody else grants. A player who already held a permission
// keeps it, because the push reads the union (§12.1).
revert: (ctx) => kitEntitle.revert(ctx),
reconcile: (ctx) => kitEntitle.reconcile(ctx),
}
// ── For good (§12.5) ─────────────────────────────────────────────────────────
/** What a for-good grant's note says, in the permission manager. */
const grantNote = (runId, stepId) => `event run ${runId}, step ${stepId}`
const permGive = {
id: 'rust.perm.give',
label: 'Permissions for good',
description:
'Gives the permissions you pick to the people a mode picks, for good. Ending or cancelling the event does not take them back; staff remove them in the permission manager.',
// Irreversible, as `rust.coins.pay` is (D335, D346): core reverts a ledgered
// step at the end of EVERY run, so a ledger here would take the gift back.
risk: 'irreversible',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: paramsFor('You can use /nv from now on'),
cost: (p) => ({ 'rust.permissions.kept': pricedPeople(p) * pricedPermissions(p) }),
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const step = await checkStep(params, 'gives')
if (!step.ok) return step
if (verify) return { ok: true }
const { permissions, mode, count, notice, server } = step
const name = server.name || server.id
const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey, verb: 'gives' })
if (!picked.ok) return picked
// A grant already there is left alone (§12.5). One this step's own first
// attempt wrote carries its note, and is counted as given, not as held: a
// retry after a lost answer reads the same as the attempt that landed.
const note = grantNote(runId, stepId)
const held = await permDb.listGrantsForUsers(picked.people.map((p) => p.userId), server.id)
const heldBy = new Map(held.map((g) => [`${g.userId}:${String(g.permission).toLowerCase()}`, g]))
const rows = []
let alreadyHeld = 0
let repeated = 0
for (const p of picked.people) {
for (const permission of permissions) {
const there = heldBy.get(`${p.userId}:${permission}`)
if (there) {
if (there.source === SOURCE && there.note === note) repeated++
else alreadyHeld++
continue
}
rows.push({ userId: p.userId, permission, scope: server.id, source: SOURCE, note })
}
}
const inserted = await permDb.insertGrants(rows)
// The message rides a notice-only row (§12.5): no permission, no kit, no
// credit, so the push grants nothing for it and tells it once. Nothing
// reverts this step, so the row stays — a sentence that has been said.
const said = notice.text ? sayPermissions(notice.text, permissions) : null
if (said && picked.people.length) {
await permDb.insertRunGrants(picked.people.map((p) => ({
runId,
stepId,
idemKey: idempotencyKey,
userId: p.userId,
serverId: server.id,
steamId: p.steamId,
permission: '',
kit: '',
credit: false,
notice: said,
noticeDelivery: notice.delivery,
})))
}
if (inserted || said) await permDb.markDirty(server.id)
log.info('permissions given', { server: server.id, run: runId, step: stepId, permissions, mode, people: picked.people.length, given: inserted, alreadyHeld, repeated })
return {
ok: true,
detail: {
server: name,
permissions,
mode,
...(count.value === null ? {} : { count: count.value }),
people: picked.people.length,
given: inserted + repeated,
...(alreadyHeld ? { alreadyHeld, alreadyHeldNote: 'already held: the grant was there before, and is left as it was' } : {}),
...(repeated ? { repeat: true, repeatNote: 'answered from the first attempt; nothing was given twice' } : {}),
...(said && picked.people.length ? { told: `${picked.people.length} will be told in game, by ${notice.delivery}` } : {}),
...missedDetail(picked.missed),
},
}
},
}
// ── The picker (§12.2) ───────────────────────────────────────────────────────
/** A permission's group: the plugin that registered it, or its prefix, saying so (the permission manager's rule). */
function groupOf(permission, owner) {
if (owner) return owner
const prefix = permission.includes('.') ? permission.slice(0, permission.indexOf('.')) : permission
return `${prefix} (not registered by a plugin)`
}
/**
* The picker's rows from the catalogue and the enabled servers. Pure, so the
* rule is testable without a database: each permission once, grouped by its
* plugin, sorted by group and then permission without case, and a permission not
* on every enabled server says where it is (`note`, MODULE_API 1.13.0).
*/
function permissionRows(catalogue, enabled) {
const names = new Map(enabled.map((s) => [s.id, s.name || s.id]))
const byPermission = new Map()
for (const row of catalogue) {
if (!names.has(row.serverId)) continue
const permission = String(row.permission || '').trim()
if (!permission) continue
const key = permission.toLowerCase()
if (!byPermission.has(key)) byPermission.set(key, { permission, owner: null, on: new Set() })
const entry = byPermission.get(key)
if (!entry.owner && row.owner) entry.owner = String(row.owner)
entry.on.add(row.serverId)
}
const rows = [...byPermission.values()].map((e) => {
const group = groupOf(e.permission, e.owner)
const row = { value: e.permission, label: e.permission, group }
if (e.on.size < names.size) {
const where = enabled.filter((s) => e.on.has(s.id)).map((s) => names.get(s.id))
row.note = `Only on ${where.join(', ')}`
}
return row
})
const fold = (s) => s.toLowerCase()
rows.sort((a, b) => fold(a.group).localeCompare(fold(b.group)) || fold(a.value).localeCompare(fold(b.value)))
return rows
}
const OPTION_SOURCES = [
{
id: 'rust.options.permissions',
label: 'Permissions',
description: "Every permission the enabled servers' plugins registered, grouped by plugin; one missing from a server says where it is.",
async resolve() {
const [catalogue, enabled] = await Promise.all([permDb.listCatalogue(), servers.listForPolling()])
return bounded(permissionRows(catalogue, enabled), 'rust.options.permissions')
},
},
]
const ACTIONS = [permLend, permGive]
module.exports = {
MAX_PERMISSIONS,
SOURCE,
BUDGETS,
ACTIONS,
OPTION_SOURCES,
checkPermissions,
checkConfirm,
permNotice,
permissionRows,
grantNote,
}