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
This commit is contained in:
524
server/eventPermRewards.js
Normal file
524
server/eventPermRewards.js
Normal file
@@ -0,0 +1,524 @@
|
||||
// ── 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,
|
||||
}
|
||||
Reference in New Issue
Block a user