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:
@@ -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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user