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

@@ -28,6 +28,7 @@ const GROUPS = 'rust_perm_groups'
const GROUP_PERMISSIONS = 'rust_perm_group_permissions'
const GROUP_MEMBERS = 'rust_perm_group_members'
const GRANTS = 'rust_perm_grants'
const RUN_GRANTS = 'rust_perm_run_grants'
const PUSHED = 'rust_perm_pushed'
const DRIFT = 'rust_perm_drift'
const REVOCATIONS = 'rust_perm_revocations'
@@ -190,6 +191,76 @@ async function deleteGrant(id) {
return Number(result.affectedRows || 0) > 0
}
// ---- what events granted (phase 13b) ----
//
// `rust_perm_run_grants` is authored by `rust.kit.entitle`, never by a person,
// and it is read beside `rust_perm_grants` rather than merged into it (D84): the
// push unions the two, and a revert deletes exactly one step's rows.
/** Every event grant, for the push. Small: one row per recipient per reward step still standing. */
async function listRunGrants() {
return core.query(
`SELECT run_id AS runId, step_id AS stepId, user_id AS userId, server_id AS serverId,
steam_id AS steamId, permission, kit, credit
FROM ${RUN_GRANTS}`,
)
}
/** One step's rows. A repeated key finds them here and writes nothing new. */
async function listRunGrantsForStep(runId, stepId) {
return core.query(
`SELECT user_id AS userId, server_id AS serverId, steam_id AS steamId, permission, kit, credit
FROM ${RUN_GRANTS}
WHERE run_id = ? AND step_id = ?`,
[String(runId), String(stepId)],
)
}
/**
* One step's recipients, in one statement. `INSERT IGNORE` against the unique
* key, so a retry that races the first attempt writes each row once.
*/
async function insertRunGrants(rows) {
if (!rows.length) return 0
const result = await core.query(
`INSERT IGNORE INTO ${RUN_GRANTS} (run_id, step_id, idem_key, user_id, server_id, steam_id, permission, kit, credit)
VALUES ${placeholders(rows, 9)}`,
rows.flatMap((r) => [
String(r.runId),
String(r.stepId),
String(r.idemKey || ''),
r.userId,
r.serverId,
r.steamId,
r.permission || '',
r.kit,
r.credit ? 1 : 0,
]),
)
return Number(result.affectedRows || 0)
}
/** Withdraw one step's rows. Returns the servers they were on; none is a success. */
async function deleteRunGrantsForStep(runId, stepId) {
return deleteRunGrantsWhere('run_id = ? AND step_id = ?', [String(runId), String(stepId)])
}
/** The same, found by core's idempotency key — the revert of an answer core lost. */
async function deleteRunGrantsForKey(runId, idemKey) {
if (!idemKey) return []
return deleteRunGrantsWhere('run_id = ? AND idem_key = ?', [String(runId), String(idemKey)])
}
async function deleteRunGrantsWhere(where, params) {
const found = await core.query(`SELECT DISTINCT server_id AS serverId FROM ${RUN_GRANTS} WHERE ${where}`, params)
if (!found.length) return []
await core.query(`DELETE FROM ${RUN_GRANTS} WHERE ${where}`, params)
return found.map((row) => row.serverId)
}
/**
* One website account by name, for the authoring form.
*
@@ -463,6 +534,7 @@ async function listCatalogue() {
module.exports = {
GROUPS,
GRANTS,
RUN_GRANTS,
PUSHED,
DRIFT,
listGroups,
@@ -478,6 +550,11 @@ module.exports = {
getGrant,
insertGrant,
deleteGrant,
listRunGrants,
listRunGrantsForStep,
insertRunGrants,
deleteRunGrantsForStep,
deleteRunGrantsForKey,
findUserByUsername,
listLinks,
listGroupsForUser,

View File

@@ -278,12 +278,13 @@ async function forPlayer(userId, steamIds, serverRows) {
* server is six times the queries for the same rows.
*/
async function readAuthored() {
const [groups, groupPermissions, members, grants, links] = await Promise.all([
const [groups, groupPermissions, members, grants, links, runGrants] = await Promise.all([
db.listGroups(),
db.listGroupPermissions(),
db.listGroupMembers(),
db.listGrants(),
db.listLinks(),
db.listRunGrants(),
])
const steamIdsByUser = new Map()
@@ -293,7 +294,7 @@ async function readAuthored() {
steamIdsByUser.get(link.userId).push(link.steamId)
}
return { groups, groupPermissions, members, grants, steamIdsByUser }
return { groups, groupPermissions, members, grants, runGrants, steamIdsByUser }
}
/**
@@ -313,6 +314,7 @@ async function readAuthored() {
*/
function buildDesired(serverId, authored) {
const { groups, groupPermissions, members, grants, steamIdsByUser } = authored
const runGrants = authored.runGrants || []
const scopedGroups = groups.filter((group) => inScope(group.scope, serverId))
const groupNames = new Set(scopedGroups.map((group) => group.name))
@@ -378,6 +380,54 @@ function buildDesired(serverId, authored) {
}
}
// ── What events granted (phase 13b, D84) ──────────────────────────────
//
// Unioned with the admin grants above through the same `seenGrant`, so a
// permission held both ways is ONE row in the game — and withdrawing either
// leaves the other standing, because the next build still finds it.
//
// An event grant reaches only the kit's server (D102), and like any grant it
// reaches every account the user has linked (D28).
//
// The CREDIT is different: one win is one extra use, on the account that took
// part, and only while that account is still linked to the user who won it.
const credits = new Map()
for (const row of runGrants) {
if (row.serverId !== serverId) continue
const linked = steamIdsByUser.get(row.userId) || []
const permission = normaliseName(row.permission)
if (permission) {
managed.add(permission)
for (const steamId of linked) {
const key = `${steamId}:${permission}`
if (seenGrant.has(key)) continue
seenGrant.add(key)
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
permissionsBySteamId.get(steamId).push(permission)
rows.push({ kind: 'grant', subject: steamId, object: permission })
}
}
if (Number(row.credit) && linked.includes(row.steamId)) {
// A Steam id is digits, so the first bar is always the split; a kit name
// may contain one.
const key = `${row.steamId}|${row.kit}`
credits.set(key, (credits.get(key) || 0) + 1)
}
}
const creditRows = [...credits.entries()]
.map(([key, count]) => {
const bar = key.indexOf('|')
return { steamId: key.slice(0, bar), kit: key.slice(bar + 1), count }
})
.sort((a, b) => (a.steamId + a.kit).localeCompare(b.steamId + b.kit))
const payload = {
groups: scopedGroups.map((group) => ({
name: group.name,
@@ -391,9 +441,21 @@ function buildDesired(serverId, authored) {
permissions,
})),
managed: [...managed].sort(),
// Always sent, even empty: to the plugin an absent field means "this site
// says nothing about credits", and an empty one means "nobody has any" —
// which is what a revert of the last reward must be able to say (D103).
credits: creditRows,
}
return { payload, rows, hash: hashRows(rows) }
// Credits are in the digest, so a new reward or a revert pushes, but they are
// NOT in `rows`: those are the pushed ledger's, and a use of a kit is not
// something in the permission store to retire.
const hashed = [
...rows,
...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
]
return { payload, rows, hash: hashRows(hashed) }
}
/**