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:
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,8 @@ const STATE = 'rust_server_state'
|
||||
async function listServers({ enabledOnly = false } = {}) {
|
||||
return core.query(
|
||||
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
|
||||
protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt
|
||||
protocol, enabled, sort_order AS sortOrder, announce_news AS announceNews,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM ${SERVERS}
|
||||
${enabledOnly ? 'WHERE enabled = 1' : ''}
|
||||
ORDER BY sort_order ASC, id ASC`,
|
||||
|
||||
@@ -48,12 +48,33 @@ function withToken(row) {
|
||||
}
|
||||
}
|
||||
|
||||
return { id: row.id, name: row.name, baseUrl: row.sidecarBaseUrl, token, protocol: row.protocol }
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
baseUrl: row.sidecarBaseUrl,
|
||||
token,
|
||||
protocol: row.protocol,
|
||||
// D104: whether a published news post is said in this server's chat.
|
||||
announceNews: Boolean(Number(row.announceNews)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many servers were enabled when this process last looked. An action's
|
||||
* `cost()` is synchronous and cannot ask the database, so `rust.announce` to
|
||||
* every server is priced at this — refreshed by every poll, which runs
|
||||
* continuously. One until the first poll, which is the least a fleet can be.
|
||||
*/
|
||||
let enabledCount = 1
|
||||
|
||||
function lastEnabledCount() {
|
||||
return enabledCount
|
||||
}
|
||||
|
||||
/** Every enabled server, with tokens, for the poller. */
|
||||
async function listForPolling() {
|
||||
const rows = await db.listServers({ enabledOnly: true })
|
||||
enabledCount = rows.length
|
||||
return rows.map(withToken)
|
||||
}
|
||||
|
||||
@@ -165,6 +186,7 @@ module.exports = {
|
||||
STALE_AFTER_MS,
|
||||
withToken,
|
||||
listForPolling,
|
||||
lastEnabledCount,
|
||||
listPublic,
|
||||
getPublic,
|
||||
listForAdmin,
|
||||
|
||||
@@ -34,7 +34,7 @@ async function getServerPresence(serverId) {
|
||||
/** Every configured server with its override, in the operator's own order. */
|
||||
async function listServerPresence() {
|
||||
return core.query(
|
||||
`SELECT id, name, enabled, presence_audience AS presence
|
||||
`SELECT id, name, enabled, presence_audience AS presence, announce_news AS announceNews
|
||||
FROM ${SERVERS}
|
||||
ORDER BY sort_order ASC, id ASC`,
|
||||
)
|
||||
@@ -52,4 +52,12 @@ async function setServerPresence(serverId, value) {
|
||||
await core.query(`UPDATE ${SERVERS} SET presence_audience = ? WHERE id = ?`, [value, serverId])
|
||||
}
|
||||
|
||||
module.exports = { getSetting, setSetting, getServerPresence, listServerPresence, setServerPresence }
|
||||
/**
|
||||
* Turns one server's news switch on or off (D104). Existence is the model's
|
||||
* question, asked with a read first, for the reason given above.
|
||||
*/
|
||||
async function setServerNews(serverId, on) {
|
||||
await core.query(`UPDATE ${SERVERS} SET announce_news = ? WHERE id = ?`, [on ? 1 : 0, serverId])
|
||||
}
|
||||
|
||||
module.exports = { getSetting, setSetting, getServerPresence, listServerPresence, setServerPresence, setServerNews }
|
||||
|
||||
@@ -184,6 +184,12 @@ async function describe() {
|
||||
}
|
||||
}),
|
||||
},
|
||||
// D104/D106: whether a published news post is said in each server's chat.
|
||||
// On this page because it is the one that lists every server with a setting
|
||||
// of its own, and it answers the same kind of question — what a server shows.
|
||||
news: {
|
||||
servers: servers.map((s) => ({ id: s.id, name: s.name, enabled: Boolean(s.enabled), on: Boolean(Number(s.announceNews)) })),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +203,7 @@ async function describe() {
|
||||
* Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
|
||||
* sentence the page can show.
|
||||
*/
|
||||
async function update({ fleet, servers, clanRoster } = {}, actor = null) {
|
||||
async function update({ fleet, servers, clanRoster, news } = {}, actor = null) {
|
||||
if (fleet !== undefined && !isAudience(fleet)) {
|
||||
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
|
||||
}
|
||||
@@ -221,6 +227,17 @@ async function update({ fleet, servers, clanRoster } = {}, actor = null) {
|
||||
}
|
||||
}
|
||||
|
||||
const newsChanges = Object.entries(news || {})
|
||||
for (const [id, value] of newsChanges) {
|
||||
if (typeof value !== 'boolean') {
|
||||
return { ok: false, status: 400, message: `News in game chat is on or off for server ${id}, not "${value}".` }
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if ((await db.getServerPresence(id)) === undefined) {
|
||||
return { ok: false, status: 404, message: `There is no server called ${id}.` }
|
||||
}
|
||||
}
|
||||
|
||||
const userId = actor && actor.id != null ? actor.id : null
|
||||
|
||||
if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
|
||||
@@ -229,6 +246,10 @@ async function update({ fleet, servers, clanRoster } = {}, actor = null) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.setServerPresence(id, value)
|
||||
}
|
||||
for (const [id, on] of newsChanges) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.setServerNews(id, on)
|
||||
}
|
||||
|
||||
// What was written, for the controller's audit row. Recorded there rather than
|
||||
// here because the activity log takes the REQUEST (who, from where), and a
|
||||
@@ -239,6 +260,7 @@ async function update({ fleet, servers, clanRoster } = {}, actor = null) {
|
||||
...(fleet !== undefined ? { fleet } : {}),
|
||||
...(clanRoster !== undefined ? { clanRoster } : {}),
|
||||
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
|
||||
...(newsChanges.length ? { news: Object.fromEntries(newsChanges) } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user