diff --git a/ci/bundle.json b/ci/bundle.json
index c5d0c57..3df9ecf 100644
--- a/ci/bundle.json
+++ b/ci/bundle.json
@@ -34,6 +34,7 @@
"db",
"engagement",
"eventLeases.js",
+ "eventRewards.js",
"eventWorld.js",
"index.js",
"ingest.js",
diff --git a/client/src/routes/admin/Visibility.jsx b/client/src/routes/admin/Visibility.jsx
index 428d2fc..041018e 100644
--- a/client/src/routes/admin/Visibility.jsx
+++ b/client/src/routes/admin/Visibility.jsx
@@ -19,6 +19,12 @@
// lists each server's clan board: a server whose clans cannot be read, one at
// the game's 100-clan ceiling (D55), and one running the uMod Clans plugin,
// whose clans are a separate system and never Teams (D47).
+//
+// Phase 13b adds a third (D104, D106): whether a published news post is also
+// said in each server's in-game chat. Off by default, because core sends every
+// post to every registered leg — without a switch, the day this module updated,
+// every post would start appearing in every server's chat. It lives here because
+// this is the one page that lists every server with a setting of its own.
import { useCallback, useEffect, useState } from 'react'
@@ -87,6 +93,7 @@ export default function Visibility() {
const [fleet, setFleet] = useState('staff')
const [clanRoster, setClanRoster] = useState('members')
const [servers, setServers] = useState({})
+ const [news, setNews] = useState({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
@@ -98,6 +105,7 @@ export default function Visibility() {
setFleet(state.presence.fleet)
setClanRoster((state.clans && state.clans.roster) || 'members')
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
+ setNews(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, Boolean(s.on)])))
}, [])
useEffect(() => {
@@ -114,7 +122,9 @@ export default function Visibility() {
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
const clans = data.clans || { audiences: [], roster: 'members', servers: [] }
const dirtyClans = clanRoster !== clans.roster
- const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans
+ const newsRows = (data.news && data.news.servers) || []
+ const dirtyNews = newsRows.filter((s) => Boolean(news[s.id]) !== Boolean(s.on))
+ const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans || dirtyNews.length > 0
const effective = (id) => servers[id] || fleet
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
@@ -131,6 +141,7 @@ export default function Visibility() {
if (dirtyServers.length) {
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
}
+ if (dirtyNews.length) body.news = Object.fromEntries(dirtyNews.map((s) => [s.id, Boolean(news[s.id])]))
load(await api.adminVisibility.save(body))
setSaved(true)
setReloads((n) => n + 1)
@@ -225,6 +236,43 @@ export default function Visibility() {
+
+
+ When a news post is published, its title is said in the chat of every server switched on
+ here. A server that is down when a post is published is skipped rather than told late.
+
+ {newsRows.length === 0 && (
+ No servers are configured yet.
+ )}
+ {newsRows.map((s) => (
+
+ setNews((prev) => ({ ...prev, [s.id]: e.target.checked }))}
+ aria-label={`Say news in ${s.name}’s chat`}
+ />
+
+ {s.name}
+ {!s.enabled && · disabled }
+
+ {news[s.id] ? 'says news' : 'off'}
+
+ ))}
+
+
{busy ? 'Saving…' : 'Save'}
diff --git a/engagement-triggers.json b/engagement-triggers.json
index 859e228..e3a9027 100644
--- a/engagement-triggers.json
+++ b/engagement-triggers.json
@@ -316,6 +316,81 @@
}
]
},
+ {
+ "id": "rust.kit.entitled",
+ "label": "An event rewarded you a kit",
+ "description": "An event on a Rust server rewarded you: a kit is waiting in the in-game Kits menu, with one extra use.",
+ "kind": "event",
+ "subjectKey": "rewardKey",
+ "audience": "owner",
+ "ceiling": "owner",
+ "version": 1,
+ "variables": [
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
+ {
+ "name": "rewardKey",
+ "type": "string",
+ "required": true,
+ "example": "41:7",
+ "description": "The run and step that awarded it. The cooldown subject; not meant for display."
+ },
+ {
+ "name": "kit",
+ "type": "string",
+ "required": true,
+ "example": "vip-starter",
+ "description": "The kit name, as the server Kits plugin has it."
+ },
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "mode",
+ "type": "string",
+ "required": false,
+ "example": "top",
+ "description": "How the recipients were chosen: everyone, top, minScore, random or topPercent."
+ },
+ {
+ "name": "accountUrl",
+ "type": "url",
+ "required": false,
+ "example": "/player/rust",
+ "description": "Site-relative path to your Rust account page."
+ }
+ ]
+ },
{
"id": "rust.leaderboard.topped",
"label": "A new kills leader",
@@ -1059,6 +1134,28 @@
}
]
},
+ {
+ "key": "rewards-v1",
+ "rules": [
+ {
+ "trigger_id": "rust.kit.entitled",
+ "audience": "owner",
+ "channels": [
+ "email",
+ "inapp"
+ ],
+ "template_keys": {
+ "email": "notify.event",
+ "inapp": "inapp.event",
+ "digest": "notify.digest"
+ },
+ "conditions": null,
+ "cooldown_seconds": 0,
+ "delay_seconds": 0,
+ "cancel_on": []
+ }
+ ]
+ },
{
"key": "clans-v1",
"rules": [
diff --git a/server/db/purge.sql b/server/db/purge.sql
index deec5e9..2dfd9c4 100644
--- a/server/db/purge.sql
+++ b/server/db/purge.sql
@@ -19,6 +19,9 @@
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
+-- Phase 13b.
+DROP TABLE IF EXISTS rust_perm_run_grants;
+
-- Phase 7b.
DROP TABLE IF EXISTS rust_clan_boards;
DROP TABLE IF EXISTS rust_clan_members;
diff --git a/server/db/schema.sql b/server/db/schema.sql
index cb7f68a..8fd8456 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -792,3 +792,51 @@ CREATE TABLE IF NOT EXISTS rust_clan_boards (
CONSTRAINT fk_rust_clan_boards_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+
+-- ── What an event granted (phase 13b, protocol 10) ────────────────────────
+--
+-- `rust.kit.entitle`'s ledger: one row per run, step, website user and
+-- permission (PLAN.md §29, D84). It is a table of its own, and not rows in
+-- `rust_perm_grants`, because that table is UNIQUE on (user, permission,
+-- scope): an admin grant of the same kit would collide with an event's, and a
+-- revert deleting "the" row would take the admin's grant with it. The push reads
+-- the UNION of the two, so a permission held both ways survives either being
+-- withdrawn.
+--
+-- The grant reaches every account the user has linked (D28), like any other.
+-- The CREDIT does not: `steam_id` is the account that took part, and one win is
+-- one extra use of the kit on that account (D103). `permission` is empty for a
+-- kit anybody may redeem — the credit is then the whole reward.
+--
+-- `idem_key` is core's idempotency key for the step. It is what a revert has
+-- when core lost the answer and holds no resource: without it the rows a lost
+-- answer wrote would be a grant nothing could ever withdraw.
+--
+-- `server_id` is the kit's server and the only one the grant reaches (D102).
+-- There is no foreign key to `rust_servers`: a server deleted mid-event must not
+-- delete the ledger a revert needs to find.
+CREATE TABLE IF NOT EXISTS rust_perm_run_grants (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ run_id VARCHAR(64) NOT NULL,
+ step_id VARCHAR(64) NOT NULL,
+ idem_key VARCHAR(190) NOT NULL DEFAULT '',
+ user_id INT NOT NULL,
+ server_id VARCHAR(64) NOT NULL,
+ steam_id VARCHAR(32) NOT NULL,
+ permission VARCHAR(128) NOT NULL DEFAULT '',
+ kit VARCHAR(128) NOT NULL,
+ credit TINYINT(1) NOT NULL DEFAULT 1,
+ granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_rust_perm_run_grant (run_id, step_id, user_id, permission),
+ KEY idx_rust_perm_run_grant_server (server_id),
+ KEY idx_rust_perm_run_grant_key (run_id, idem_key),
+ CONSTRAINT fk_rust_perm_run_grants_user
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- The news switch (D104): whether a published news post is also said in this
+-- server's chat. Off, because core enqueues every registered leg for every
+-- post, and without it the day this module updates every post would start
+-- appearing in every server's chat.
+ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS announce_news TINYINT(1) NOT NULL DEFAULT 0;
diff --git a/server/engagement/emit.js b/server/engagement/emit.js
index 9021220..07ae830 100644
--- a/server/engagement/emit.js
+++ b/server/engagement/emit.js
@@ -129,6 +129,11 @@ const HEADLINES = Object.freeze({
intro: `The Steam account ${d.player || d.steamId} was linked with an in-game code. `
+ 'If that was not you, unlink it from your Rust account page.',
}),
+ 'rust.kit.entitled': (d) => ({
+ title: `You earned ${d.kit} on ${d.server}`,
+ intro: `An event on ${d.server} rewarded you: the ${d.kit} kit is waiting in the Kits menu, `
+ + 'with one extra use. Redeem it in game.',
+ }),
'rust.clan.member.left': (d) => ({
title: `${d.member || 'A member'} left ${d.clan}`,
intro: `${d.member || 'A member'} left ${d.clan} on ${d.server}.`,
@@ -546,12 +551,38 @@ function linked({ userId, steamId, name }) {
}
}
+/**
+ * `rust.kit.entitled` for each user one reward step granted (phase 13b). Never
+ * throws: a notification that could not be raised must not fail the grant it
+ * is about. Returns how many were raised.
+ */
+function entitled({ userIds, kit, server, mode, runId, stepId }) {
+ let raised = 0
+ try {
+ const rewardKey = `${runId}:${stepId}`
+ for (const userId of new Set(userIds || [])) {
+ const uid = Number(userId)
+ if (!Number.isInteger(uid) || uid < 1) continue
+ const ok = fire(T['rust.kit.entitled'], {
+ data: { rewardKey, kit: String(kit), ...serverVars(server), ...(mode ? { mode: String(mode) } : {}), accountUrl: PATHS.account },
+ ownerUserId: uid,
+ dedupeKey: dedupeKey('entitled', runId, stepId, uid),
+ })
+ if (ok) raised++
+ }
+ } catch (err) {
+ log.warn('could not raise the reward notification', { error: err.message })
+ }
+ return raised
+}
+
module.exports = {
onEvent,
serverObserved,
checkLeader,
sweepLoginDenied,
linked,
+ entitled,
reset,
dedupeKey,
headline,
diff --git a/server/engagement/seeds.js b/server/engagement/seeds.js
index 490b1c0..c1f84d1 100644
--- a/server/engagement/seeds.js
+++ b/server/engagement/seeds.js
@@ -32,7 +32,7 @@
// ── One rule group per family ─────────────────────────────────────────────
//
// A group is seeded ONCE (per deployment, per key), so a rule appended to a
-// group in a later version reaches fresh installs only. Seven families, seven
+// group in a later version reaches fresh installs only. Eight families, eight
// keys: a future raid rule takes `raid-v2` without disturbing anybody's clan
// rules. Every rule is disabled — core ignores `enabled` rather than trusting it
// — so installing this module mails nobody until an operator decides it should.
@@ -234,6 +234,25 @@ const RULE_GROUPS = Object.freeze([
},
],
},
+ {
+ // Its own group, not a rule appended to `account-v1`: a group is seeded once,
+ // so an appended rule would reach fresh installs only (R7).
+ key: 'rewards-v1',
+ note: 'module-rust: an event rewarded you a kit (disabled)',
+ rules: [
+ {
+ trigger_id: 'rust.kit.entitled',
+ name: 'Kit reward earned',
+ audience: 'owner',
+ // Email as well: a reward granted at 03:00 is news the person reads the
+ // next morning, before they are next in game or on the site.
+ channels: ['email', 'inapp'],
+ template_keys: GENERIC,
+ cooldown_seconds: 0,
+ max_sends_per_hour: 200,
+ },
+ ],
+ },
{
key: 'clans-v1',
note: 'module-rust: clan departures and disbands (disabled)',
diff --git a/server/engagement/triggers.js b/server/engagement/triggers.js
index ea8485c..96320b0 100644
--- a/server/engagement/triggers.js
+++ b/server/engagement/triggers.js
@@ -218,6 +218,39 @@ const ACCOUNT = {
],
}
+// ── A reward (phase 13b) ───────────────────────────────────────────────────
+//
+// Deferred from phase 10 (D64) to the phase that grants something. Emitted once
+// per recipient USER when an event's `rust.kit.entitle` writes their rows, with
+// that user as `ownerUserId` — so, like the link notice, `owner` is both the
+// ceiling and the only audience there is: a reward is nobody else's news.
+//
+// The subject is the run and the step, so one award is one notification however
+// often a retried step writes the same rows.
+
+const REWARD = {
+ id: 'rust.kit.entitled',
+ label: 'An event rewarded you a kit',
+ description: 'An event on a Rust server rewarded you: a kit is waiting in the in-game Kits menu, with one extra use.',
+ kind: 'event',
+ subjectKey: 'rewardKey',
+ audience: 'owner',
+ ceiling: 'owner',
+ version: V1,
+ variables: [
+ ...HEADLINE,
+ { name: 'rewardKey', type: 'string', required: true, example: '41:7',
+ description: 'The run and step that awarded it. The cooldown subject; not meant for display.' },
+ { name: 'kit', type: 'string', required: true, example: 'vip-starter',
+ description: 'The kit name, as the server Kits plugin has it.' },
+ ...SERVER,
+ { name: 'mode', type: 'string', required: false, example: 'top',
+ description: 'How the recipients were chosen: everyone, top, minScore, random or topPercent.' },
+ { name: 'accountUrl', type: 'url', required: false, example: '/player/rust',
+ description: 'Site-relative path to your Rust account page.' },
+ ],
+}
+
// ── Clans ──────────────────────────────────────────────────────────────────
//
// `members` ceiling — clan membership is the clan's business (D49). Recipients
@@ -374,7 +407,7 @@ const MODERATION = [
},
]
-const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, ...CLANS, ...MODERATION])
+const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION])
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))
diff --git a/server/eventRewards.js b/server/eventRewards.js
new file mode 100644
index 0000000..8c0beb2
--- /dev/null
+++ b/server/eventRewards.js
@@ -0,0 +1,854 @@
+// ── What an event GIVES on a Rust server (PLAN.md §29, protocol 10) ───────
+//
+// 13a made things in the world. This file records who was there, gives them
+// something they can redeem, and can tell the server. Four verbs:
+//
+// rust.participation.open the game starts counting who takes part (D81)
+// rust.participation.collect core files the count as the run's participants
+// rust.kit.entitle the right to redeem a kit, and one more use of
+// it (R16, D103), for the people a mode picks
+// rust.announce one line in a server's chat, or every server's
+//
+// …and the announce leg, `rust.chat`, which says a published news post in the
+// chat of every server whose switch is on (D104).
+//
+// ── Who decides what ─────────────────────────────────────────────────────────
+//
+// The GAME counts: presence, kills, the score (D81, D99). The SITE picks the
+// recipients and holds the reward: the tally is read, a mode chosen per event
+// picks from it (D101), and a row per recipient goes into
+// `rust_perm_run_grants`, which the permission mirror pushes like any other
+// grant (D84). So a reward granted at 03:00 to somebody offline is waiting when
+// they next log in, and a wipe cannot take it away: the site re-pushes it.
+//
+// ── An action is never handed the participants ──────────────────────────────
+//
+// Core records participants from `collect`'s answer, but does not give them to
+// a later step. So `kit.entitle` reads the tally from the plugin itself, as
+// `uo.item.grant` reads it from the shard, and does not depend on a collect step
+// having run.
+
+const crypto = require('node:crypto')
+
+const core = require('./core')
+const client = require('./sidecarClient')
+const servers = require('./model/servers/servers.model')
+const permDb = require('./model/permissions/permissions.db')
+const linksDb = require('./model/links/links.db')
+const emit = require('./engagement/emit')
+const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
+const { BUDGET_MS } = require('./eventWorld')
+
+const log = core.logger('rewards')
+
+// Mirrors of the plugin's bounds (§29.5). The plugin's are authoritative, and
+// an operator may set them lower; these price a step and refuse a bad one on
+// the authoring form rather than at four in the morning.
+const MAX_RECIPIENTS = 100
+const MAX_CHAT = 256
+const TALLY_MAX_MINUTES = 7 * 24 * 60
+const MAX_KILL_WEIGHT = 1000
+const DEFAULT_KILL_WEIGHT = 5
+
+const SCORES = ['seconds', 'kills', 'both']
+const KILLS_OF = ['players', 'npcs', 'both']
+const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent']
+
+/** The fleet, in `rust.announce`'s `server` param (D105). */
+const EVERY_SERVER = '*'
+
+/** The plugin's refusals a second attempt would repeat. */
+const PERMANENT = new Set([
+ 'events-disabled',
+ 'malformed',
+ 'out-of-range',
+ 'already-open',
+ 'no-zone',
+ 'ambiguous-zone',
+ 'too-many',
+ 'too-long',
+ 'kits-missing',
+])
+
+const BUDGETS = [
+ {
+ id: 'rust.grants',
+ label: 'Kit rewards',
+ unit: 'rewards',
+ description:
+ 'Kits an event rewards: one per recipient, each the right to redeem the kit and one more use of it. A mode that is not a count is priced at the most it could grant.',
+ },
+ {
+ id: 'rust.announcements',
+ label: 'Chat announcements',
+ unit: 'lines',
+ description: 'Lines an event says in a server\'s chat: one per server reached.',
+ },
+]
+
+/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
+function transportFailure(server, result, what) {
+ const permanent = result.status === 'not-configured' || result.status === 'no-token'
+ return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) }
+}
+
+/** A plugin refusal carried in a 200, classified by its reason. */
+function refusal(server, data, what) {
+ return {
+ ok: false,
+ ...(PERMANENT.has(data && data.reason) ? { retry: false } : {}),
+ error: pluginError(data, `${server.name || server.id} refused the ${what}`),
+ }
+}
+
+/** One word from a fixed set, or null. Compared without case: an author types these. */
+function oneOf(raw, allowed) {
+ const text = String(raw === undefined || raw === null ? '' : raw).trim().toLowerCase()
+ return allowed.find((a) => a.toLowerCase() === text) || null
+}
+
+/** `:` for a tally, `::` for a reward (§29.3). */
+const tallyRef = (serverId, runId) => `${serverId}:${runId}`
+const entitlementRef = (serverId, runId, stepId) => `${serverId}:${runId}:${stepId}`
+
+/** A ref's parts, split at every colon — a server id has none and core's ids are numbers. */
+function refParts(ref) {
+ const [serverId, runId, stepId] = String(ref || '').split(':')
+ return { serverId: serverId || null, runId: runId || null, stepId: stepId || null }
+}
+
+// ── Picking recipients (D101) ────────────────────────────────────────────────
+
+/**
+ * The mode's `count`, checked. Returns `{ ok, value }` or a refusal sentence.
+ * `everyone` takes none.
+ */
+function checkCount(mode, raw) {
+ if (mode === 'everyone') return { ok: true, value: null }
+
+ const value = Number(raw)
+ if (mode === 'top' || mode === 'random') {
+ if (!Number.isInteger(value) || value < 1 || value > MAX_RECIPIENTS) {
+ return { ok: false, error: `${mode} names 1 to ${MAX_RECIPIENTS} people, and "${raw}" is not that` }
+ }
+ } else if (mode === 'topPercent') {
+ if (!Number.isFinite(value) || value <= 0 || value > 100) {
+ return { ok: false, error: `topPercent is a percentage above 0 and at most 100, not "${raw}"` }
+ }
+ } else if (!Number.isFinite(value) || value < 0) {
+ return { ok: false, error: `minScore is a score of 0 or more, not "${raw}"` }
+ }
+
+ return { ok: true, value }
+}
+
+/** Highest first; a tie keeps the order the game joined them in, so a list reads the same twice. */
+function ranked(people) {
+ return [...people].sort((a, b) => b.score - a.score || a.joinedAt - b.joinedAt || a.steamId.localeCompare(b.steamId))
+}
+
+/** The first `n` of a ranked list, and everybody tied with the last one in (D101). */
+function withTies(list, n) {
+ if (n <= 0 || !list.length) return []
+ if (n >= list.length) return list
+ const floor = list[n - 1].score
+ return list.filter((p, i) => i < n || p.score === floor)
+}
+
+/**
+ * Who a mode picks from a tally. Pure, and the whole of D101:
+ *
+ * everyone every participant who scored above zero
+ * top the N highest scores, ties in
+ * minScore a score of at least X
+ * random N drawn from everyone who took part, seeded by the step's key
+ * topPercent the highest X per cent, rounded up, ties in
+ *
+ * A score of zero earns nothing in the ranked modes: "the highest scores" of a
+ * tally where nobody scored is nobody. `random` draws from everyone present,
+ * which is the point of a raffle.
+ *
+ * **The draw is seeded by the idempotency key**, so a retry after a lost answer
+ * draws the same winners — each person's place is a hash of the key and their
+ * Steam id, which needs no generator state to reproduce.
+ */
+function pickRecipients(people, mode, count, seedKey) {
+ const scored = ranked(people.filter((p) => p.score > 0))
+
+ switch (mode) {
+ case 'everyone':
+ return scored
+ case 'top':
+ return withTies(scored, count)
+ case 'minScore':
+ return ranked(people.filter((p) => p.score >= count))
+ case 'topPercent':
+ return withTies(scored, Math.ceil((scored.length * count) / 100))
+ case 'random': {
+ const draw = (p) => crypto.createHash('sha256').update(`${seedKey}\u0000${p.steamId}`).digest('hex')
+ return [...people].sort((a, b) => draw(a).localeCompare(draw(b))).slice(0, count)
+ }
+ default:
+ return []
+ }
+}
+
+/** The tally's rows as numbers, whatever the wire carried. */
+function peopleOf(data) {
+ return ((data && data.people) || [])
+ .filter((p) => p && p.steamId)
+ .map((p) => ({
+ steamId: String(p.steamId),
+ name: p.name ? String(p.name) : String(p.steamId),
+ seconds: Number(p.seconds) || 0,
+ kills: Number(p.kills) || 0,
+ score: Number(p.score) || 0,
+ joinedAt: Number(p.joinedAt) || 0,
+ }))
+}
+
+/** Steam id -> website user, for the ids that are linked. */
+async function usersFor(steamIds) {
+ const ids = [...new Set(steamIds.map(String))]
+ if (!ids.length) return new Map()
+ const rows = await linksDb.userIdsForSteamIds(ids)
+ return new Map(rows.map((r) => [String(r.steamId), Number(r.userId)]))
+}
+
+/** The tally for a run on one server, or a classified failure. */
+async function readTally(server, runId) {
+ const result = await client.tallySnapshot(server, runId)
+ if (!result.ok) return transportFailure(server, result, 'tally')
+ const data = result.data || {}
+ if (data.kind !== 'tally.snapshot') {
+ // `no-tally` is permanent for THIS step: the tally it reads was never opened
+ // on this server, or teardown already closed it.
+ if (data.reason === 'no-tally') {
+ return {
+ ok: false,
+ retry: false,
+ error: `${server.name || server.id} holds no tally for this run — open one with rust.participation.open on the same server first`,
+ }
+ }
+ return refusal(server, data, 'tally')
+ }
+ return { ok: true, data }
+}
+
+// ── The kit, as its server's Kits plugin describes it ───────────────────────
+
+/**
+ * `/` split at the FIRST slash: a server id never contains one,
+ * and a kit name is whatever an operator typed into Kits.
+ */
+function splitKit(value) {
+ const text = String(value || '').trim()
+ const slash = text.indexOf('/')
+ if (slash <= 0 || slash === text.length - 1) return null
+ return { serverId: text.slice(0, slash), kit: text.slice(slash + 1) }
+}
+
+/** What a kit rewards, as the source's label says it and the verb checks it (R16, D103). */
+function kitReward(row) {
+ const permission = String(row.permission || '').trim().toLowerCase()
+ const max = Number(row.max) || 0
+ return { permission, max, rewardsNothing: !permission && max <= 0 }
+}
+
+async function readKit(server, kit) {
+ const result = await client.kits(server)
+ if (!result.ok) return transportFailure(server, result, 'kits')
+ const data = result.data || {}
+ if (data.kind !== 'kits.list') return refusal(server, data, 'kit list')
+
+ const row = (data.kits || []).find((k) => k && String(k.name).toLowerCase() === kit.toLowerCase())
+ if (!row) return { ok: false, retry: false, error: `${server.name || server.id} has no kit called "${kit}"` }
+
+ const reward = kitReward(row)
+ if (reward.rewardsNothing) {
+ return {
+ ok: false,
+ retry: false,
+ error: `the kit "${row.name}" is open to everyone and has no use limit, so a reward of it gives nobody anything — give it a permission or a maximum number of uses in Kits`,
+ }
+ }
+
+ return { ok: true, kit: String(row.name), ...reward, maxRecipients: Number(data.maxRecipients) || MAX_RECIPIENTS }
+}
+
+// ── The verbs ────────────────────────────────────────────────────────────────
+
+const participationOpen = {
+ id: 'rust.participation.open',
+ label: 'Start counting participants',
+ description:
+ 'The game counts who takes part from here on: time present, kills, or both — in a zone this run opened, or on the whole server. It stops after its minutes; teardown forgets it.',
+ // It watches rather than changes anything, but it is ledgered, like
+ // `uo.participation.open`: the game holds a tally for the run, and teardown
+ // gives it back.
+ risk: 'inspect',
+ reversible: 'ledger',
+ version: 1,
+ budgetMs: BUDGET_MS,
+ params: [
+ { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
+ description: 'Which server counts.' },
+ { name: 'zone', type: 'string', required: false, example: 'Airfield brawl', source: 'rust.options.runZones',
+ description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts (D100).' },
+ { name: 'score', type: 'string', required: true, example: 'both', source: 'rust.options.scoreModes',
+ description: 'What earns a place: seconds present, kills, or both.' },
+ { name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsOf',
+ description: 'Whose deaths count as a kill: players, NPCs (animals included), or both. The last hit gets it. Needed unless the score is seconds.' },
+ { name: 'killWeight', type: 'float', required: false, example: DEFAULT_KILL_WEIGHT,
+ description: `For a score of both: how many minutes one kill is worth. Left blank, ${DEFAULT_KILL_WEIGHT}.` },
+ { name: 'minutes', type: 'int', required: false, example: 60,
+ description: `How long it counts, up to ${TALLY_MAX_MINUTES} (seven days). Left blank, seven days. The game forgets a tally seven days after it opened, however long it counted.` },
+ ],
+ cost: () => ({}),
+
+ async perform({ runId, idempotencyKey, params, verify }) {
+ const score = oneOf(params.score, SCORES)
+ if (!score) return { ok: false, retry: false, error: `a tally scores seconds, kills or both, not "${params.score}"` }
+
+ const killsOf = score === 'seconds' ? null : oneOf(params.killsOf, KILLS_OF)
+ if (score !== 'seconds' && !killsOf) {
+ return { ok: false, retry: false, error: 'a tally that counts kills says whose: players, npcs or both' }
+ }
+
+ let killWeight
+ if (score === 'both') {
+ const raw = params.killWeight
+ killWeight = raw === undefined || raw === null || raw === '' ? DEFAULT_KILL_WEIGHT : Number(raw)
+ if (!Number.isFinite(killWeight) || killWeight < 0 || killWeight > MAX_KILL_WEIGHT) {
+ return { ok: false, retry: false, error: `a kill is worth 0 to ${MAX_KILL_WEIGHT} minutes, not "${raw}"` }
+ }
+ }
+
+ let minutes
+ if (params.minutes !== undefined && params.minutes !== null && params.minutes !== '') {
+ minutes = Number(params.minutes)
+ if (!Number.isInteger(minutes) || minutes < 1 || minutes > TALLY_MAX_MINUTES) {
+ return { ok: false, retry: false, error: `a tally counts for 1 to ${TALLY_MAX_MINUTES} minutes, not "${params.minutes}"` }
+ }
+ }
+
+ const zone = String(params.zone || '').trim()
+ const found = await serverFor(String(params.server || '').trim())
+ if (!found.ok) return found
+
+ // Whether the zone exists is not asked in a dry run: it is opened by an
+ // earlier step of the same run, so before the run it never does.
+ if (verify) return { ok: true }
+
+ const result = await client.tallyOpen(found.server, {
+ runId: String(runId),
+ key: idempotencyKey,
+ score,
+ ...(killsOf ? { killsOf } : {}),
+ ...(killWeight === undefined ? {} : { killWeight }),
+ ...(minutes === undefined ? {} : { holdMs: minutes * 60000 }),
+ ...(zone ? { zone } : {}),
+ })
+ if (!result.ok) return transportFailure(found.server, result, 'tally')
+ const data = result.data || {}
+ if (data.kind !== 'tally.ok') return refusal(found.server, data, 'tally')
+
+ return {
+ ok: true,
+ resources: [
+ {
+ kind: 'tally',
+ ref: tallyRef(found.server.id, runId),
+ payload: { serverId: found.server.id, score, ...(zone ? { zone } : {}) },
+ },
+ ],
+ detail: {
+ server: found.server.name || found.server.id,
+ counting: zone ? `in the zone "${zone}"` : 'on the whole server',
+ ...(data.repeat ? { repeat: true, note: 'answered from the first attempt; the tally was already open' } : {}),
+ },
+ }
+ },
+
+ /**
+ * Forget the tally on every server the ledger names — or, when core lost the
+ * answer and holds none, on every server, since `runId` is all a tally is
+ * keyed by. A tally already gone is a success.
+ */
+ async revert({ runId, resources }) {
+ const targets = resources && resources.length
+ ? [...new Set(resources.map((r) => (r.payload && r.payload.serverId) || refParts(r.ref).serverId))]
+ : (await servers.listForPolling()).map((s) => s.id)
+
+ const failed = []
+ const errors = []
+ for (const serverId of targets) {
+ const found = await serverFor(serverId)
+ if (!found.ok) {
+ // A server deleted or switched off cannot be asked, and its tally ends on
+ // its own seven days after it opened; the ledger row is not held for it.
+ continue
+ }
+ const result = await client.tallyClose(found.server, { runId: String(runId) })
+ const refused = result.ok && (!result.data || result.data.kind !== 'tally.ok')
+ if (!result.ok || refused) {
+ failed.push(...(resources || []).filter((r) => refParts(r.ref).serverId === serverId).map((r) => r.ref))
+ errors.push(result.ok ? pluginError(result.data, `${found.server.name || found.server.id} refused to close the tally`) : transportError(found.server, result, 'tally'))
+ }
+ }
+
+ if (!errors.length) return { ok: true }
+ if (!resources || !resources.length || failed.length === resources.length) return { ok: false, error: errors.join('; ') }
+ return { ok: true, failed }
+ },
+
+ /** A tally is in force while its server still holds it. A server that cannot be asked has said nothing. */
+ async reconcile({ runId, resources }) {
+ const inForce = []
+ for (const r of resources || []) {
+ const found = await serverFor(refParts(r.ref).serverId)
+ if (!found.ok) {
+ inForce.push(r.ref)
+ continue
+ }
+ const result = await client.tallySnapshot(found.server, runId)
+ const gone = result.ok && result.data && result.data.kind !== 'tally.snapshot' && result.data.reason === 'no-tally'
+ if (!gone) inForce.push(r.ref)
+ }
+ return { ok: true, inForce }
+ },
+}
+
+const participationCollect = {
+ id: 'rust.participation.collect',
+ label: 'Record participants',
+ description:
+ 'Files everybody the tally counted as this run\'s participants, with their score, time and kills. The tally keeps counting if its minutes are not up.',
+ risk: 'inspect',
+ reversible: 'none',
+ version: 1,
+ budgetMs: BUDGET_MS,
+ params: [
+ { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
+ description: 'The server whose tally to read.' },
+ ],
+ cost: () => ({}),
+
+ async perform({ runId, params, verify }) {
+ const found = await serverFor(String(params.server || '').trim())
+ if (!found.ok) return found
+ if (verify) return { ok: true }
+
+ const tally = await readTally(found.server, runId)
+ if (!tally.ok) return tally
+
+ const people = peopleOf(tally.data)
+ const users = await usersFor(people.map((p) => p.steamId))
+
+ return {
+ ok: true,
+ // The member vocabulary is the Steam id, as the team provider's is.
+ participants: people.map((p) => ({
+ memberKey: p.steamId,
+ ...(users.has(p.steamId) ? { userId: users.get(p.steamId) } : {}),
+ score: p.score,
+ ...(p.joinedAt > 0 ? { joinedAt: new Date(p.joinedAt).toISOString() } : {}),
+ meta: { name: p.name, seconds: p.seconds, kills: p.kills },
+ })),
+ detail: {
+ server: found.server.name || found.server.id,
+ participants: people.length,
+ linked: users.size,
+ ...(Number(tally.data.overflow) > 0 ? { overflow: Number(tally.data.overflow) } : {}),
+ },
+ }
+ },
+}
+
+const kitEntitle = {
+ id: 'rust.kit.entitle',
+ label: 'Reward a kit',
+ description:
+ 'Gives the people a mode picks from this run\'s tally the right to redeem a kit on its server, and one more use of it. Waits for them if they are offline. Teardown withdraws what is not yet redeemed.',
+ risk: 'change',
+ reversible: 'ledger',
+ version: 1,
+ budgetMs: BUDGET_MS,
+ params: [
+ { name: 'kit', type: 'string', required: true, example: 'main/vip-starter', source: 'rust.options.kits',
+ description: 'The kit, as server/kit. The reward reaches only that server (D102).' },
+ { name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientModes',
+ description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, or the top X per cent.' },
+ { 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.' },
+ ],
+
+ // Priced before the tally is read, so at the most it could grant: the count
+ // for a count, and the server's recipient bound for every other mode. An
+ // author who wants a tight cap picks a count.
+ cost: (p) => {
+ const mode = oneOf(p.recipients, MODES)
+ const n = Math.round(Number(p.count) || 0)
+ return { 'rust.grants': mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS }
+ },
+
+ async perform({ runId, stepId, idempotencyKey, params, verify }) {
+ const parsed = splitKit(params.kit)
+ if (!parsed) return { ok: false, retry: false, error: `"${params.kit}" is not a kit — pick one from the list, as server/kit` }
+
+ 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 found = await serverFor(parsed.serverId)
+ if (!found.ok) return found
+ const server = found.server
+
+ const kit = await readKit(server, parsed.kit)
+ if (!kit.ok) return kit
+
+ if ((mode === 'top' || mode === 'random') && count.value > kit.maxRecipients) {
+ return { ok: false, retry: false, error: `${server.name || server.id} rewards at most ${kit.maxRecipients} people in one step, not ${count.value}` }
+ }
+
+ if (verify) return { ok: true }
+
+ const ref = entitlementRef(server.id, runId, stepId)
+ const resource = { kind: 'entitlement', ref, payload: { serverId: server.id, kit: kit.kit } }
+
+ // A repeated key finds its rows already written and changes nothing — the
+ // rows ARE the grant, and a set written twice is the same set.
+ const existing = await permDb.listRunGrantsForStep(runId, stepId)
+ if (existing.length) {
+ return {
+ ok: true,
+ resources: [resource],
+ detail: { repeat: true, granted: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was granted' },
+ }
+ }
+
+ const tally = await readTally(server, runId)
+ if (!tally.ok) return tally
+
+ const picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
+ const bound = Number(tally.data.maxRecipients) || kit.maxRecipients
+ if (picked.length > bound) {
+ return {
+ ok: false,
+ retry: false,
+ error: `${picked.length} people qualify, and ${server.name || server.id} rewards at most ${bound} in one step — pick a count-based mode or a higher bar`,
+ }
+ }
+
+ const users = await usersFor(picked.map((p) => p.steamId))
+ const rows = []
+ const byUser = new Set()
+ const missed = []
+
+ for (const p of picked) {
+ const userId = users.get(p.steamId)
+ if (!userId) {
+ missed.push(p.name)
+ continue
+ }
+ // One reward per website user: two linked accounts that both took part are
+ // one person, and one win is one use (D103). The higher score, being
+ // earlier in the list, is the account that gets the credit.
+ if (byUser.has(userId)) continue
+ byUser.add(userId)
+ rows.push({
+ runId,
+ stepId,
+ idemKey: idempotencyKey,
+ userId,
+ serverId: server.id,
+ steamId: p.steamId,
+ permission: kit.permission,
+ kit: kit.kit,
+ credit: kit.max > 0,
+ })
+ }
+
+ await permDb.insertRunGrants(rows)
+ await permDb.markDirty(server.id)
+
+ emit.entitled({ userIds: [...byUser], kit: kit.kit, server, mode, runId, stepId })
+
+ log.info('kit rewarded', { server: server.id, run: runId, step: stepId, kit: kit.kit, mode, granted: rows.length, missed: missed.length })
+
+ return {
+ ok: true,
+ resources: [resource],
+ detail: {
+ kit: kit.kit,
+ server: server.name || server.id,
+ mode,
+ ...(count.value === null ? {} : { count: count.value }),
+ granted: rows.length,
+ ...(missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}),
+ },
+ }
+ },
+
+ /**
+ * Withdraw a step's rows and push. The permission goes and each unredeemed
+ * credit is put back by the plugin; a redemption already made stands (R16).
+ * A row already gone is a success.
+ */
+ async revert({ runId, resources, idempotencyKey }) {
+ const touched = new Set()
+
+ if (!resources || !resources.length) {
+ for (const serverId of await permDb.deleteRunGrantsForKey(runId, idempotencyKey)) touched.add(serverId)
+ } else {
+ for (const r of resources) {
+ const { runId: refRun, stepId } = refParts(r.ref)
+ if (!stepId) continue
+ for (const serverId of await permDb.deleteRunGrantsForStep(refRun || runId, stepId)) touched.add(serverId)
+ }
+ }
+
+ for (const serverId of touched) await permDb.markDirty(serverId)
+ return { ok: true }
+ },
+
+ /** The site holds the entitlement and re-pushes it, so a restart or a wipe cannot take it away. */
+ async reconcile({ resources }) {
+ return { ok: true, inForce: (resources || []).map((r) => r.ref) }
+ },
+}
+
+/**
+ * Say one line on one server, and classify the answer. `repeat` is a success:
+ * the plugin remembered the key, and the line was already said.
+ */
+async function sayOn(server, body) {
+ const result = await client.chat(server, body)
+ if (!result.ok) return { state: 'down', result }
+ const data = result.data || {}
+ if (data.kind !== 'chat.ok') return { state: 'refused', data }
+ return { state: data.said === false ? 'repeat' : 'said', data }
+}
+
+const announce = {
+ id: 'rust.announce',
+ label: 'Say it in game chat',
+ description: 'One line in a Rust server\'s chat, or in every server\'s. A line said cannot be taken back.',
+ risk: 'notify',
+ reversible: 'none',
+ version: 1,
+ budgetMs: BUDGET_MS,
+ params: [
+ { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.chatServers',
+ description: 'Which server, or * for every server (D105).' },
+ { name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!',
+ description: `The line, up to ${MAX_CHAT} characters.` },
+ ],
+
+ // One per server reached. `*` is priced at the enabled servers when core asks,
+ // which is synchronous — so at the count this module last saw.
+ cost: (p) => ({ 'rust.announcements': String(p.server || '').trim() === EVERY_SERVER ? Math.max(1, servers.lastEnabledCount()) : 1 }),
+
+ async perform({ runId, idempotencyKey, params, verify }) {
+ const message = String(params.message || '').replace(/\s+/g, ' ').trim()
+ if (!message) return { ok: false, retry: false, error: 'a chat line needs a message' }
+ if (message.length > MAX_CHAT) {
+ return { ok: false, retry: false, error: `a chat line is at most ${MAX_CHAT} characters, and this one is ${message.length}` }
+ }
+
+ const target = String(params.server || '').trim()
+ let list
+ if (target === EVERY_SERVER) {
+ list = await servers.listForPolling()
+ if (!list.length) return { ok: false, retry: false, error: 'there are no enabled Rust servers to say it on' }
+ } else {
+ const found = await serverFor(target)
+ if (!found.ok) return found
+ list = [found.server]
+ }
+
+ if (verify) return { ok: true }
+
+ const body = { key: idempotencyKey || `run:${runId}`, message, event: true }
+ const outcomes = await Promise.all(list.map(async (server) => ({ server, ...(await sayOn(server, body)) })))
+ const name = (o) => o.server.name || o.server.id
+
+ // One server named: its answer is the step's.
+ if (target !== EVERY_SERVER) {
+ const o = outcomes[0]
+ if (o.state === 'down') return transportFailure(o.server, o.result, 'chat')
+ if (o.state === 'refused') return refusal(o.server, o.data, 'chat line')
+ return { ok: true, detail: { said: [name(o)], ...(o.state === 'repeat' ? { repeat: true } : {}) } }
+ }
+
+ // Every server: a success for each that took the line, and the rest named
+ // (D104's reason — a line said an hour late in a restarted server is noise).
+ const said = outcomes.filter((o) => o.state === 'said' || o.state === 'repeat').map(name)
+ const down = outcomes.filter((o) => o.state === 'down').map(name)
+ const refused = outcomes.filter((o) => o.state === 'refused').map((o) => `${name(o)}: ${pluginError(o.data, 'refused')}`)
+
+ if (!said.length && refused.length) return { ok: false, retry: false, error: refused.join('; ') }
+ if (!said.length) return { ok: false, error: `no server could be reached: ${down.join(', ')}` }
+
+ return { ok: true, detail: { said, ...(down.length ? { down } : {}), ...(refused.length ? { refused } : {}) } }
+ },
+}
+
+// ── The announce leg (D104) ──────────────────────────────────────────────────
+
+/** A news post as one chat line: its title, or failing that its excerpt, flattened and bounded. */
+function chatLine(post) {
+ const text = String((post && (post.title || post.excerpt)) || '').replace(/\s+/g, ' ').trim()
+ return text.length > MAX_CHAT ? `${text.slice(0, MAX_CHAT - 1)}…` : text
+}
+
+/**
+ * The plugin's memory of recent keys, keyed off the post: its id when core's
+ * news path gives one, else what it says — `core.announce` hands a leg a post
+ * with no id. Either way a retried leg never says the same line twice.
+ */
+function chatKey(post, line) {
+ if (post && post.id !== undefined && post.id !== null) return `news:${post.id}`
+ return `news:${crypto.createHash('sha1').update(line).digest('hex')}`
+}
+
+const LEG = {
+ leg: 'rust.chat',
+ label: 'Rust in-game chat',
+
+ /**
+ * Say a post in the chat of every server whose switch is on. Never throws, as
+ * every leg client must not. The answer is one outcome per switched-on
+ * server, for `classify`.
+ */
+ async dispatch(post) {
+ try {
+ const line = chatLine(post)
+ if (!line) return { ok: false, empty: true, outcomes: [] }
+
+ const list = (await servers.listForPolling()).filter((s) => s.announceNews)
+ const key = chatKey(post, line)
+ const outcomes = await Promise.all(
+ list.map(async (server) => ({ server: server.name || server.id, ...(await sayOn(server, { key, message: line })) })),
+ )
+ return { ok: true, outcomes }
+ } catch (err) {
+ log.warn('news chat leg failed', { error: err.message })
+ return { ok: false, error: err.message, outcomes: [] }
+ }
+ },
+
+ /**
+ * `done` when every switched-on server that is up took the line — or when no
+ * server is switched on, since there is nothing to deliver. `retry` only when
+ * every switched-on server is down. A server that refused is named; one that
+ * was down is skipped, never queued (D104).
+ */
+ classify(result) {
+ if (!result || (!result.ok && !result.empty && !result.outcomes)) return { outcome: 'retry', error: (result && result.error) || 'no answer' }
+ if (result.empty) return { outcome: 'terminal', error: 'the post has no title or excerpt to say' }
+ if (!result.ok) return { outcome: 'retry', error: result.error || 'the leg failed' }
+
+ const outcomes = result.outcomes || []
+ if (!outcomes.length) return { outcome: 'done' }
+
+ const took = outcomes.filter((o) => o.state === 'said' || o.state === 'repeat')
+ const down = outcomes.filter((o) => o.state === 'down').map((o) => o.server)
+ const refused = outcomes.filter((o) => o.state === 'refused').map((o) => `${o.server}: ${pluginError(o.data, 'refused')}`)
+
+ if (down.length === outcomes.length) return { outcome: 'retry', error: `every server is down: ${down.join(', ')}` }
+ if (!took.length) return { outcome: 'terminal', error: refused.join('; ') }
+
+ const notes = [...(down.length ? [`skipped (down): ${down.join(', ')}`] : []), ...refused]
+ return notes.length ? { outcome: 'done', error: notes.join('; ') } : { outcome: 'done' }
+ },
+}
+
+// ── Option sources ───────────────────────────────────────────────────────────
+
+/** A fixed choice as a dropdown — core has no enum type, so a source is how a field offers words. */
+const fixed = (id, label, description, rows) => ({ id, label, description, async resolve() { return rows } })
+
+const OPTION_SOURCES = [
+ {
+ // Live from each server's Kits, so the form offers only kits that exist. The
+ // label says what a reward of each one gives (R16, D103).
+ id: 'rust.options.kits',
+ label: 'Kits',
+ description: "Each server's Kits, flagged by what a reward of one gives.",
+ searchable: true,
+ async resolve({ q } = {}) {
+ const term = String(q || '').trim().toLowerCase()
+ const answers = await perServer((server) => client.kits(server))
+ const rows = []
+ for (const { server, result } of answers) {
+ const data = result.data || {}
+ if (data.kind !== 'kits.list') continue
+ for (const k of data.kits || []) {
+ if (!k || !k.name) continue
+ const value = `${server.id}/${k.name}`
+ if (term && !value.toLowerCase().includes(term)) continue
+ const reward = kitReward(k)
+ const flags = [
+ reward.permission ? null : 'open to everyone',
+ reward.max > 0 ? `${reward.max} use${reward.max === 1 ? '' : 's'}` : null,
+ reward.rewardsNothing ? 'rewards nothing' : null,
+ ].filter(Boolean)
+ rows.push({ value, label: flags.length ? `${k.name} · ${flags.join(' · ')}` : String(k.name), group: server.name || server.id })
+ }
+ }
+ return bounded(rows, 'rust.options.kits')
+ },
+ },
+ // Free text: the zones a run will open do not exist when it is authored, and
+ // the name is checked when the step runs (D100). Declared so the field is
+ // documented rather than a bare box, and answers nothing.
+ fixed('rust.options.runZones', 'Zones this run opens', 'The name an earlier "Open a zone" step of the same run gave its zone. Type it; it is checked when the step runs.', []),
+ fixed('rust.options.scoreModes', 'Score', 'What earns a place in a tally.', [
+ { value: 'seconds', label: 'Seconds present' },
+ { value: 'kills', label: 'Kills' },
+ { value: 'both', label: 'Both — minutes plus a weight per kill' },
+ ]),
+ fixed('rust.options.killsOf', 'Kills of', 'Whose deaths count as a kill.', [
+ { value: 'players', label: 'Players' },
+ { value: 'npcs', label: 'NPCs, animals included' },
+ { value: 'both', label: 'Players and NPCs' },
+ ]),
+ fixed('rust.options.recipientModes', 'Recipients', 'Who a reward goes to (D101).', [
+ { value: 'everyone', label: 'Everyone who scored' },
+ { value: 'top', label: 'The top N (ties in)' },
+ { value: 'minScore', label: 'A score of at least X' },
+ { value: 'random', label: 'N drawn at random' },
+ { value: 'topPercent', label: 'The top X per cent (ties in)' },
+ ]),
+ {
+ id: 'rust.options.chatServers',
+ label: 'Chat servers',
+ description: 'Every enabled server, or * for all of them.',
+ async resolve() {
+ const list = await servers.listForPolling()
+ return [{ value: EVERY_SERVER, label: 'Every server' }, ...list.map((s) => ({ value: s.id, label: s.name || s.id }))]
+ },
+ },
+]
+
+const ACTIONS = [participationOpen, participationCollect, kitEntitle, announce]
+
+module.exports = {
+ MAX_RECIPIENTS,
+ MAX_CHAT,
+ EVERY_SERVER,
+ BUDGETS,
+ ACTIONS,
+ LEG,
+ OPTION_SOURCES,
+ pickRecipients,
+ checkCount,
+ splitKit,
+ kitReward,
+ chatLine,
+ chatKey,
+ refParts,
+}
diff --git a/server/index.js b/server/index.js
index 959459d..238e882 100644
--- a/server/index.js
+++ b/server/index.js
@@ -58,6 +58,7 @@ module.exports = function register(ctx, api) {
const seeds = require('./engagement/seeds')
const eventLeases = require('./eventLeases')
const eventWorld = require('./eventWorld')
+ const eventRewards = require('./eventRewards')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -129,8 +130,9 @@ module.exports = function register(ctx, api) {
// refresh. Registration is a claim, not a call: nothing here touches the
// database, and the seeds are written by core after the schema is up.
//
- // **Not registered, and that is D62:** no announce leg and no post hook. Both
- // need something in game to deliver to, and phase 10 reaches no game.
+ // The announce leg arrived with phase 13b (D62, D104), when there was a chat
+ // verb to deliver through; it is registered below with the rewards. There is
+ // still no post hook: nothing in game mirrors a post as state.
api.registerEventTriggers(TRIGGERS)
api.registerNotificationStreams(STREAMS)
api.registerAudiences(AUDIENCES)
@@ -160,19 +162,32 @@ module.exports = function register(ctx, api) {
// The world verbs (PLAN.md §28, protocol 9): what an event MAKES and gives
// back — a zone, crates, NPCs — and the budgets that price them, each declared
// beside the verb that spends it (D79, D89). A lease spends none of them.
- api.registerEventBudgets(eventWorld.BUDGETS)
- api.registerEventActions(eventWorld.ACTIONS)
+ //
+ // The rewards (PLAN.md §29, protocol 10) join them: the tally, the kit reward
+ // and the chat line, with the two budgets they spend. Registered in the same
+ // calls' neighbours, not merged into eventWorld's arrays, so each file keeps
+ // its own statement of what it declares.
+ api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS])
+ api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS])
+
+ // News in game chat (D104). Core enqueues every registered leg for every
+ // published post, so the leg itself sends only to the servers whose switch an
+ // operator turned on — off by default, and a server that is down is skipped.
+ api.registerAnnounceLeg(eventRewards.LEG)
// ONE call for every option source: core takes a batch once, as this module's
// complete statement, and refuses a second.
- api.registerEventOptionSources([...eventLeases.OPTION_SOURCES, ...eventWorld.OPTION_SOURCES])
+ api.registerEventOptionSources([
+ ...eventLeases.OPTION_SOURCES,
+ ...eventWorld.OPTION_SOURCES,
+ ...eventRewards.OPTION_SOURCES,
+ ])
- // Everything else this module will register — the rewards and the announce
- // leg (13b), the slash commands — is deliberately absent. Each arrives
- // with the phase that has something real to put in it. A registration
- // with nothing behind it is worse than a missing one: a declared trigger
- // nothing emits and a declared slot nothing fills are both surfaces an operator
- // can configure and then wait on.
+ // Everything else this module will register — the slash commands — is
+ // deliberately absent, and arrives with the phase that has something real to
+ // put in it. A registration with nothing behind it is worse than a missing
+ // one: a declared trigger nothing emits and a declared slot nothing fills are
+ // both surfaces an operator can configure and then wait on.
log.info('registered', {
version: require('../module.json').version,
@@ -183,8 +198,9 @@ module.exports = function register(ctx, api) {
streams: STREAMS.length,
audiences: AUDIENCES.length,
leases: eventLeases.LEASES.length,
- actions: eventWorld.ACTIONS.length,
- budgets: eventWorld.BUDGETS.length,
- optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length,
+ actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length,
+ budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length,
+ announceLeg: eventRewards.LEG.leg,
+ optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length,
})
}
diff --git a/server/model/permissions/permissions.db.js b/server/model/permissions/permissions.db.js
index d33ca48..9eab355 100644
--- a/server/model/permissions/permissions.db.js
+++ b/server/model/permissions/permissions.db.js
@@ -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,
diff --git a/server/model/permissions/permissions.model.js b/server/model/permissions/permissions.model.js
index db85b18..0ef145e 100644
--- a/server/model/permissions/permissions.model.js
+++ b/server/model/permissions/permissions.model.js
@@ -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) }
}
/**
diff --git a/server/model/servers/servers.db.js b/server/model/servers/servers.db.js
index 0c4e242..ca61734 100644
--- a/server/model/servers/servers.db.js
+++ b/server/model/servers/servers.db.js
@@ -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`,
diff --git a/server/model/servers/servers.model.js b/server/model/servers/servers.model.js
index 40736b8..5377360 100644
--- a/server/model/servers/servers.model.js
+++ b/server/model/servers/servers.model.js
@@ -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,
diff --git a/server/model/visibility/visibility.db.js b/server/model/visibility/visibility.db.js
index 4e0a5e7..6eb63fd 100644
--- a/server/model/visibility/visibility.db.js
+++ b/server/model/visibility/visibility.db.js
@@ -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 }
diff --git a/server/model/visibility/visibility.model.js b/server/model/visibility/visibility.model.js
index d796a84..de73f6b 100644
--- a/server/model/visibility/visibility.model.js
+++ b/server/model/visibility/visibility.model.js
@@ -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) } : {}),
},
}
}
diff --git a/server/permSync.js b/server/permSync.js
index 9f2bdb9..ad53b59 100644
--- a/server/permSync.js
+++ b/server/permSync.js
@@ -215,6 +215,7 @@ async function syncOne(server, { authored, sync, state, force }) {
groups: desired.payload.groups,
grants: desired.payload.grants,
managed: desired.payload.managed,
+ credits: desired.payload.credits,
retire,
})
@@ -333,6 +334,9 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
foreign: (report.foreign || []).length,
pending: (report.pending || []).length,
notLanded: (report.notLanded || []).length,
+ ...(report.creditsApplied !== undefined
+ ? { creditsApplied: report.creditsApplied, creditsWithdrawn: report.creditsWithdrawn }
+ : {}),
})
}
diff --git a/server/router/admin/visibility.controller.js b/server/router/admin/visibility.controller.js
index 0bab4bf..6fb2a2f 100644
--- a/server/router/admin/visibility.controller.js
+++ b/server/router/admin/visibility.controller.js
@@ -29,8 +29,8 @@ async function read(req, res) {
async function update(req, res) {
try {
- const { fleet, servers, clanRoster } = req.body || {}
- const result = await visibility.update({ fleet, servers, clanRoster }, req.user)
+ const { fleet, servers, clanRoster, news } = req.body || {}
+ const result = await visibility.update({ fleet, servers, clanRoster, news }, req.user)
if (!result.ok) {
res.status(result.status || 400).json({ message: result.message })
return
diff --git a/server/router/admin/visibility.router.js b/server/router/admin/visibility.router.js
index 92e7f04..87e8f98 100644
--- a/server/router/admin/visibility.router.js
+++ b/server/router/admin/visibility.router.js
@@ -36,8 +36,8 @@ visibilityRouter.get(
visibilityRouter.put(
'/',
// #swagger.tags = ['Admin · Rust']
- // #swagger.summary = 'Change who may see who is online, or who may see a clan roster'
- // #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, or any of them together. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.'
+ // #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat'
+ // #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
@@ -46,6 +46,7 @@ visibilityRouter.put(
body('fleet').optional().isIn(AUDIENCES).withMessage(`fleet must be one of ${AUDIENCES.join(', ')}`),
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
body('clanRoster').optional().isIn(CLAN_AUDIENCES).withMessage(`clanRoster must be one of ${CLAN_AUDIENCES.join(', ')}`),
+ body('news').optional().isObject().withMessage('news maps a server id to true or false'),
validate,
visibility.update,
)
diff --git a/server/sidecarClient.js b/server/sidecarClient.js
index 715d574..8a2f48e 100644
--- a/server/sidecarClient.js
+++ b/server/sidecarClient.js
@@ -63,7 +63,9 @@ const TIMEOUT_MS = 12000
* a value on a server and give it back (PLAN.md §27); **9** adds the world
* verbs — `/world/monuments`, `/world/owned`, `/world/zone`, `/world/place` and
* `/world/revert` — what an event places in the world and gives back (PLAN.md
- * §28). The bump lands here in the same change as the emitters,
+ * §28); **10** adds the rewards — `/tally/open`, `/tally/snapshot`,
+ * `/tally/close`, `/kits` and `/chat`, and a `credits` field on the permission
+ * sync (PLAN.md §29). The bump lands here in the same change as the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
@@ -73,7 +75,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
-const PROTOCOL_VERSION = 9
+const PROTOCOL_VERSION = 10
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -362,6 +364,28 @@ const worldPlace = (server, body) => request(server, '/world/place', { method: '
*/
const worldRevert = (server, body) => request(server, '/world/revert', { method: 'POST', body })
+/**
+ * Start counting who takes part in a run (protocol 10). `data.kind` is
+ * `tally.ok` or `tally.error`; a repeated key is answered `repeat: true`.
+ */
+const tallyOpen = (server, body) => request(server, '/tally/open', { method: 'POST', body })
+
+/** Who has taken part in a run so far, scored by the plugin. `tally.error` `no-tally` when it holds none. */
+const tallySnapshot = (server, runId) =>
+ request(server, `/tally/snapshot?runId=${encodeURIComponent(String(runId))}`)
+
+/** Stop counting and forget. `closed: false` means it was already gone, which is a success. */
+const tallyClose = (server, body) => request(server, '/tally/close', { method: 'POST', body })
+
+/** The kits this server's Kits plugin has, and the plugin's reward bounds. `kits.error` when Kits is not loaded. */
+const kits = (server) => request(server, '/kits')
+
+/**
+ * Say one line in the server's chat. `chat.ok` carries `said: false` when the
+ * key was said in the last ten minutes — a retry, answered and not repeated.
+ */
+const chat = (server, body) => request(server, '/chat', { method: 'POST', body })
+
module.exports = {
TIMEOUT_MS,
LEASE_TIMEOUT_MS,
@@ -388,5 +412,10 @@ module.exports = {
worldZone,
worldPlace,
worldRevert,
+ tallyOpen,
+ tallySnapshot,
+ tallyClose,
+ kits,
+ chat,
joinUrl,
}
diff --git a/server/swagger/doc.js b/server/swagger/doc.js
index 78c630a..3bba06b 100644
--- a/server/swagger/doc.js
+++ b/server/swagger/doc.js
@@ -560,6 +560,24 @@ module.exports = {
},
},
},
+ news: {
+ type: 'object',
+ description: 'Whether a published news post is also said in each server’s in-game chat. Off by default (D104).',
+ properties: {
+ servers: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', example: 'main' },
+ name: { type: 'string', example: 'Main · Vanilla' },
+ enabled: { type: 'boolean', example: true },
+ on: { type: 'boolean', example: false },
+ },
+ },
+ },
+ },
+ },
},
},
RustClanAudience: {
@@ -652,6 +670,12 @@ module.exports = {
example: { main: 'public', pvp: null },
},
clanRoster: { $ref: '#/components/schemas/RustClanAudience' },
+ news: {
+ type: 'object',
+ description: 'A server id to whether a published news post is said in its in-game chat.',
+ additionalProperties: { type: 'boolean' },
+ example: { main: true },
+ },
},
},
RustSidecarProbe: {
diff --git a/server/test/engagement.test.js b/server/test/engagement.test.js
index 6445e01..fc191c3 100644
--- a/server/test/engagement.test.js
+++ b/server/test/engagement.test.js
@@ -140,9 +140,10 @@ test('the ceilings are the ones §25.2 decided', () => {
for (const id of ['rust.clan.member.left', 'rust.clan.member.kicked', 'rust.clan.disbanded']) {
assert.strictEqual(by[id], 'members', id)
}
- // D64: core's team.member.joined already covers it, and kits wait for phase 13.
+ // D64: core's team.member.joined already covers it. The kit reward waited for
+ // the phase that grants one (13b), and a reward is nobody else's news.
assert.strictEqual(by['rust.clan.member.added'], undefined)
- assert.strictEqual(by['rust.kit.entitled'], undefined)
+ assert.strictEqual(by['rust.kit.entitled'], 'owner')
})
test('a clan path survives core\'s url check, colons and all', () => {
diff --git a/server/test/entry.test.js b/server/test/entry.test.js
index 2f7f0fe..934deb0 100644
--- a/server/test/entry.test.js
+++ b/server/test/entry.test.js
@@ -143,34 +143,58 @@ test('nothing is registered that has nothing behind it yet', () => {
// leases and option sources, and kept budgets here on purpose (D79): a lease
// spends none, and a dimension nothing spends is a dial that does nothing.
// Phase 13a deleted the budgets and actions lines, and registered each budget
- // beside the verb that spends it (below). The announce leg is 13b's.
- assert.deepStrictEqual(api.record.legs, [])
+ // beside the verb that spends it (below). Phase 13b registered the announce
+ // leg (D104) — the post hook is still absent: nothing in game mirrors a post.
+ assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['rust.chat'])
assert.strictEqual(api.record.hooks.post, undefined)
})
-test('the world verbs are registered, and every budget has a verb that spends it (phase 13a)', () => {
+test('the event verbs are registered, and every budget has a verb that spends it (phases 13a, 13b)', () => {
const { api } = register()
const actions = api.record.eventActions
const budgets = api.record.eventBudgets
- assert.deepStrictEqual(actions.map((a) => a.id).sort(), ['rust.crate.place', 'rust.npc.place', 'rust.zone.open'])
- assert.deepStrictEqual(budgets.map((b) => b.id).sort(), ['rust.npcs', 'rust.prefabs', 'rust.zone.minutes'])
+ assert.deepStrictEqual(actions.map((a) => a.id).sort(), [
+ 'rust.announce',
+ 'rust.crate.place',
+ 'rust.kit.entitle',
+ 'rust.npc.place',
+ 'rust.participation.collect',
+ 'rust.participation.open',
+ 'rust.zone.open',
+ ])
+ assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
+ 'rust.announcements',
+ 'rust.grants',
+ 'rust.npcs',
+ 'rust.prefabs',
+ 'rust.zone.minutes',
+ ])
- // D79/D89/D97: every dimension has a verb that spends it, and each verb spends
- // exactly ONE — priced from its own declared examples, which is how core
- // decides which cap boxes the switchboard shows. A verb whose dimension moved
- // with its params would hide the other dial from every operator.
+ // D79/D89/D97: every dimension has a verb that spends it, and a verb that
+ // spends anything spends exactly ONE — priced from its own declared examples,
+ // which is how core decides which cap boxes the switchboard shows. A verb
+ // whose dimension moved with its params would hide the other dial from every
+ // operator. The two tally verbs spend nothing: counting people costs no loot.
const spent = new Set()
for (const a of actions) {
const example = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
const dims = Object.keys(a.cost(example)).filter((id) => a.cost(example)[id] > 0)
+ if (a.id.startsWith('rust.participation.')) {
+ assert.strictEqual(dims.length, 0, `${a.id} prices ${dims.join(', ')}`)
+ continue
+ }
assert.strictEqual(dims.length, 1, `${a.id} prices ${dims.join(', ')}`)
spent.add(dims[0])
}
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
+ // What a teardown can give back is declared honestly: a line said and a
+ // collect filed are not undone.
+ const once = new Set(['rust.announce', 'rust.participation.collect'])
for (const a of actions) {
- assert.strictEqual(a.reversible, 'ledger')
+ assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
+ if (once.has(a.id)) continue
assert.strictEqual(typeof a.revert, 'function')
assert.strictEqual(typeof a.reconcile, 'function')
// Every param source is one this module registers.
diff --git a/server/test/permissions.test.js b/server/test/permissions.test.js
index 9a06078..600e133 100644
--- a/server/test/permissions.test.js
+++ b/server/test/permissions.test.js
@@ -320,3 +320,61 @@ test('names are lowered, because the store lowers them', () => {
// push one name, find another, and report its own grant as drift for ever.
assert.deepEqual(payload.grants[0].permissions, ['kits.gold'])
})
+
+// ── What events granted (phase 13b, D84, D102, D103) ─────────────────────────
+
+test('an event grant is unioned with the admin grants, reaches only its server, and credits the account that played', () => {
+ withCore()
+ const model = require('../model/permissions/permissions.model')
+
+ const set = {
+ ...authored(),
+ runGrants: [
+ // User 1 won on main with their second account; the grant reaches both
+ // accounts (D28), the credit only the one that took part (D103).
+ { runId: '41', stepId: '7', userId: 1, serverId: 'main', steamId: '7656099', permission: 'Kits.Event', kit: 'event', credit: 1 },
+ // The same permission an admin already grants user 1: one row in the game.
+ { runId: '41', stepId: '8', userId: 1, serverId: 'main', steamId: '7656001', permission: 'kits.gold', kit: 'gold', credit: 1 },
+ // A kit anybody may redeem: a credit and no permission at all.
+ { runId: '41', stepId: '9', userId: 2, serverId: 'main', steamId: '7656002', permission: '', kit: 'starter', credit: 1 },
+ // A win on another server reaches nothing here (D102).
+ { runId: '42', stepId: '1', userId: 2, serverId: 'creative', steamId: '7656002', permission: 'kits.creative', kit: 'c', credit: 1 },
+ // An account unlinked since the win earns its credit nowhere.
+ { runId: '43', stepId: '1', userId: 2, serverId: 'main', steamId: '7656777', permission: '', kit: 'starter', credit: 1 },
+ ],
+ }
+
+ const { payload, rows, hash } = model.buildDesired('main', set)
+ const grantsFor = (steamId) => (payload.grants.find((g) => g.steamId === steamId) || { permissions: [] }).permissions.sort()
+
+ assert.deepEqual(grantsFor('7656001'), ['kits.event', 'kits.gold'])
+ assert.deepEqual(grantsFor('7656099'), ['kits.event', 'kits.gold'])
+ assert.ok(!payload.managed.includes('kits.creative'))
+ assert.strictEqual(rows.filter((r) => r.kind === 'grant' && r.object === 'kits.gold').length, 2)
+
+ assert.deepEqual(payload.credits, [
+ { steamId: '7656001', kit: 'gold', count: 1 },
+ { steamId: '7656002', kit: 'starter', count: 1 },
+ { steamId: '7656099', kit: 'event', count: 1 },
+ ])
+ // Credits push but never enter the pushed ledger: a use of a kit is not
+ // something in the permission store to retire.
+ assert.ok(!rows.some((r) => r.kind === 'credit'))
+
+ // A revert of the last reward still moves the digest, so it is pushed.
+ const withdrawn = model.buildDesired('main', { ...set, runGrants: set.runGrants.filter((r) => r.stepId !== '9') })
+ assert.notStrictEqual(withdrawn.hash, hash)
+})
+
+test('the permission an admin grants survives an event revert of the same one (D84)', () => {
+ withCore()
+ const model = require('../model/permissions/permissions.model')
+
+ const event = { runId: '41', stepId: '8', userId: 1, serverId: 'main', steamId: '7656001', permission: 'kits.gold', kit: 'gold', credit: 0 }
+ const before = model.buildDesired('main', { ...authored(), runGrants: [event] })
+ const after = model.buildDesired('main', { ...authored(), runGrants: [] })
+
+ // Nothing to retire: the admin grant still desires every row the event did.
+ assert.deepEqual(model.retirements(before.rows, after.rows), [])
+ assert.deepEqual(after.payload.credits, [])
+})
diff --git a/server/test/rewards.test.js b/server/test/rewards.test.js
new file mode 100644
index 0000000..134bbe9
--- /dev/null
+++ b/server/test/rewards.test.js
@@ -0,0 +1,419 @@
+// ── The rewards (PLAN.md §29, protocol 10) ────────────────────────────────
+//
+// Who took part, what they may redeem, and a line in chat. Every test here is
+// one of the ways the contract's half can look right and be wrong:
+//
+// each recipient mode picks what D101 says, ties in, and a retried draw is the same draw
+// a reward is priced before the tally is read, so at the most it could grant
+// a kit that rewards nothing is refused, and a mode's count is checked
+// one person with two accounts is one reward, and an unlinked winner is named
+// a repeated key writes nothing new; a revert deletes the step's rows and pushes
+// a lost answer is reverted by its key
+// an entitlement is always in force — the site holds it
+// a tally's teardown closes it; "cannot ask" is not "gone"
+// a line to every server succeeds for those that took it and names the rest
+// the news leg speaks only where the switch is on, and retries only when all are down
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const { fakeCtx } = require('./_fakes')
+
+require('../core')._reset()
+require('../core').init(fakeCtx())
+
+const client = require('../sidecarClient')
+const serversDb = require('../model/servers/servers.db')
+const servers = require('../model/servers/servers.model')
+const permDb = require('../model/permissions/permissions.db')
+const linksDb = require('../model/links/links.db')
+const emit = require('../engagement/emit')
+const rewards = require('../eventRewards')
+
+const action = (id) => rewards.ACTIONS.find((a) => a.id === id)
+const source = (id) => rewards.OPTION_SOURCES.find((s) => s.id === id)
+
+const ROWS = {
+ main: { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 },
+ alt: { id: 'alt', name: 'Alt', sidecarBaseUrl: 'http://alt:1', sidecarTokenEnc: null, enabled: 1 },
+}
+
+const ok = (data) => ({ ok: true, status: 'ok', data })
+const person = (steamId, score, extra = {}) => ({ steamId, name: `p${steamId}`, seconds: 60, kills: 0, score, joinedAt: Number(steamId), ...extra })
+
+const KIT = { name: 'vip', permission: 'kits.vip', max: 1, cooldown: 0 }
+
+/** Replace the module's collaborators for one test, and put them back after. */
+function stub(t, { kits, snapshot, tallyOpen, tallyClose, chat, polling, links, existing } = {}) {
+ const calls = { grants: [], dirty: [], deleted: [], emitted: [], chat: [], open: [], close: [] }
+ const saved = {
+ getServer: serversDb.getServer,
+ listForPolling: servers.listForPolling,
+ kits: client.kits,
+ tallySnapshot: client.tallySnapshot,
+ tallyOpen: client.tallyOpen,
+ tallyClose: client.tallyClose,
+ chat: client.chat,
+ insertRunGrants: permDb.insertRunGrants,
+ listRunGrantsForStep: permDb.listRunGrantsForStep,
+ deleteRunGrantsForStep: permDb.deleteRunGrantsForStep,
+ deleteRunGrantsForKey: permDb.deleteRunGrantsForKey,
+ markDirty: permDb.markDirty,
+ userIdsForSteamIds: linksDb.userIdsForSteamIds,
+ entitled: emit.entitled,
+ }
+
+ serversDb.getServer = async (id) => ROWS[id] || null
+ servers.listForPolling = async () =>
+ (polling || [{ id: 'main' }]).map((s) => ({ name: ROWS[s.id] ? ROWS[s.id].name : s.id, baseUrl: `http://${s.id}:1`, token: 't', ...s }))
+ client.kits = async (server) => (kits ? kits(server) : ok({ kind: 'kits.list', kits: [KIT], maxRecipients: 100 }))
+ client.tallySnapshot = async (server, runId) =>
+ snapshot ? snapshot(server, runId) : ok({ kind: 'tally.snapshot', runId, people: [], maxRecipients: 100 })
+ client.tallyOpen = async (server, body) => {
+ calls.open.push({ server: server.id, body })
+ return tallyOpen ? tallyOpen(server, body) : ok({ kind: 'tally.ok', runId: body.runId })
+ }
+ client.tallyClose = async (server, body) => {
+ calls.close.push({ server: server.id, body })
+ return tallyClose ? tallyClose(server, body) : ok({ kind: 'tally.ok', closed: true })
+ }
+ client.chat = async (server, body) => {
+ calls.chat.push({ server: server.id, body })
+ return chat ? chat(server, body) : ok({ kind: 'chat.ok', said: true })
+ }
+ permDb.insertRunGrants = async (rows) => {
+ calls.grants.push(...rows)
+ return rows.length
+ }
+ permDb.listRunGrantsForStep = async () => existing || []
+ permDb.deleteRunGrantsForStep = async (runId, stepId) => {
+ calls.deleted.push({ runId, stepId })
+ return ['main']
+ }
+ permDb.deleteRunGrantsForKey = async (runId, key) => {
+ calls.deleted.push({ runId, key })
+ return key ? ['main'] : []
+ }
+ permDb.markDirty = async (scope) => calls.dirty.push(scope)
+ linksDb.userIdsForSteamIds = async (ids) =>
+ ids.filter((id) => links && links[id]).map((id) => ({ steamId: id, userId: links[id] }))
+ emit.entitled = (args) => {
+ calls.emitted.push(args)
+ return (args.userIds || []).length
+ }
+
+ t.after(() => {
+ serversDb.getServer = saved.getServer
+ servers.listForPolling = saved.listForPolling
+ Object.assign(client, {
+ kits: saved.kits,
+ tallySnapshot: saved.tallySnapshot,
+ tallyOpen: saved.tallyOpen,
+ tallyClose: saved.tallyClose,
+ chat: saved.chat,
+ })
+ Object.assign(permDb, {
+ insertRunGrants: saved.insertRunGrants,
+ listRunGrantsForStep: saved.listRunGrantsForStep,
+ deleteRunGrantsForStep: saved.deleteRunGrantsForStep,
+ deleteRunGrantsForKey: saved.deleteRunGrantsForKey,
+ markDirty: saved.markDirty,
+ })
+ linksDb.userIdsForSteamIds = saved.userIdsForSteamIds
+ emit.entitled = saved.entitled
+ })
+
+ return calls
+}
+
+// ── Picking ──────────────────────────────────────────────────────────────────
+
+const TALLY = [person('1', 50), person('2', 40), person('3', 40), person('4', 10), person('5', 0)]
+const ids = (list) => list.map((p) => p.steamId)
+
+test('every verb outlives the client, which outlives the sidecar', () => {
+ assert.ok(10000 < client.TIMEOUT_MS)
+ for (const a of rewards.ACTIONS) assert.ok(client.TIMEOUT_MS < a.budgetMs, `${a.id} budgetMs`)
+})
+
+test('everyone is everyone who scored; a score of zero earns nothing (D101)', () => {
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'everyone', null, 'k')), ['1', '2', '3', '4'])
+})
+
+test('top N keeps everybody tied with the last one in', () => {
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'top', 1, 'k')), ['1'])
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'top', 2, 'k')), ['1', '2', '3'])
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'top', 99, 'k')), ['1', '2', '3', '4'])
+})
+
+test('top per cent rounds up and keeps ties — 10% of 11 is 2 (§29.2)', () => {
+ const eleven = Array.from({ length: 11 }, (_, i) => person(String(i + 1), 100 - i))
+ assert.deepStrictEqual(ids(rewards.pickRecipients(eleven, 'topPercent', 10, 'k')), ['1', '2'])
+ // 25% of the four who scored is one, and the tie at 40 does not arise — but
+ // 50% is two, which lands on a tie and takes both.
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'topPercent', 25, 'k')), ['1'])
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'topPercent', 50, 'k')), ['1', '2', '3'])
+})
+
+test('a minimum score is at least X, and zero means everyone who took part', () => {
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'minScore', 40, 'k')), ['1', '2', '3'])
+ assert.strictEqual(rewards.pickRecipients(TALLY, 'minScore', 0, 'k').length, 5)
+})
+
+test('a retried draw draws the same winners, and another key draws differently', () => {
+ const many = Array.from({ length: 40 }, (_, i) => person(String(1000 + i), 0))
+ const first = ids(rewards.pickRecipients(many, 'random', 5, 'key-a'))
+ assert.strictEqual(first.length, 5)
+ assert.deepStrictEqual(ids(rewards.pickRecipients([...many].reverse(), 'random', 5, 'key-a')), first)
+ assert.notDeepStrictEqual(ids(rewards.pickRecipients(many, 'random', 5, 'key-b')), first)
+})
+
+test('a count is checked against its mode', () => {
+ assert.strictEqual(rewards.checkCount('everyone', undefined).ok, true)
+ assert.strictEqual(rewards.checkCount('top', 0).ok, false)
+ assert.strictEqual(rewards.checkCount('top', 101).ok, false)
+ assert.strictEqual(rewards.checkCount('random', 2.5).ok, false)
+ assert.strictEqual(rewards.checkCount('topPercent', 0).ok, false)
+ assert.strictEqual(rewards.checkCount('topPercent', 100).ok, true)
+ assert.strictEqual(rewards.checkCount('minScore', -1).ok, false)
+ assert.strictEqual(rewards.checkCount('minScore', 12.5).ok, true)
+})
+
+test('a reward is priced at the most it could grant, before the tally is read (§29.2)', () => {
+ const cost = action('rust.kit.entitle').cost
+ assert.deepStrictEqual(cost({ recipients: 'top', count: 3 }), { 'rust.grants': 3 })
+ assert.deepStrictEqual(cost({ recipients: 'random', count: 7 }), { 'rust.grants': 7 })
+ for (const mode of ['everyone', 'minScore', 'topPercent']) {
+ assert.deepStrictEqual(cost({ recipients: mode, count: 10 }), { 'rust.grants': rewards.MAX_RECIPIENTS }, mode)
+ }
+})
+
+test('the kit source says what a reward of each kit gives, and flags one that gives nothing', async (t) => {
+ stub(t, {
+ kits: () => ok({
+ kind: 'kits.list',
+ kits: [
+ { name: 'vip', permission: 'kits.vip', max: 0 },
+ { name: 'starter', permission: '', max: 3 },
+ { name: 'free', permission: '', max: 0 },
+ ],
+ }),
+ })
+ const rows = await source('rust.options.kits').resolve({})
+ assert.deepStrictEqual(rows.map((r) => r.value), ['main/vip', 'main/starter', 'main/free'])
+ assert.strictEqual(rows[0].label, 'vip')
+ assert.match(rows[1].label, /open to everyone · 3 uses/)
+ assert.match(rows[2].label, /rewards nothing/)
+})
+
+// ── rust.kit.entitle ─────────────────────────────────────────────────────────
+
+const ENTITLE = { runId: 41, stepId: 7, idempotencyKey: 'key-41-7', params: { kit: 'main/vip', recipients: 'top', count: 2 } }
+
+test('the reward writes one row per linked winner, credits the account that played, and pushes', async (t) => {
+ const calls = stub(t, {
+ snapshot: () => ok({ kind: 'tally.snapshot', people: TALLY, maxRecipients: 100 }),
+ links: { 1: 10, 2: 20 },
+ })
+ const res = await action('rust.kit.entitle').perform(ENTITLE)
+
+ assert.strictEqual(res.ok, true)
+ assert.deepStrictEqual(res.resources, [{ kind: 'entitlement', ref: 'main:41:7', payload: { serverId: 'main', kit: 'vip' } }])
+ assert.deepStrictEqual(calls.grants.map((r) => [r.userId, r.steamId, r.permission, r.credit, r.idemKey]), [
+ [10, '1', 'kits.vip', true, 'key-41-7'],
+ [20, '2', 'kits.vip', true, 'key-41-7'],
+ ])
+ assert.deepStrictEqual(calls.dirty, ['main'])
+ // Top 2 is three people with the tie; the third linked nothing and is named.
+ assert.strictEqual(res.detail.granted, 2)
+ assert.deepStrictEqual(res.detail.missed, ['p3'])
+ assert.deepStrictEqual(calls.emitted[0].userIds, [10, 20])
+})
+
+test('two accounts one person holds are one reward, on the higher-scoring account', async (t) => {
+ const calls = stub(t, {
+ snapshot: () => ok({ kind: 'tally.snapshot', people: [person('1', 5), person('2', 9)] }),
+ links: { 1: 10, 2: 10 },
+ })
+ await action('rust.kit.entitle').perform({ ...ENTITLE, params: { kit: 'main/vip', recipients: 'everyone' } })
+ assert.deepStrictEqual(calls.grants.map((r) => r.steamId), ['2'])
+})
+
+test('a kit with no use limit is a permission and no credit; a kit that rewards nothing is refused', async (t) => {
+ let calls = stub(t, {
+ kits: () => ok({ kind: 'kits.list', kits: [{ name: 'vip', permission: 'kits.vip', max: 0 }] }),
+ snapshot: () => ok({ kind: 'tally.snapshot', people: [person('1', 5)] }),
+ links: { 1: 10 },
+ })
+ await action('rust.kit.entitle').perform(ENTITLE)
+ assert.strictEqual(calls.grants[0].credit, false)
+
+ calls = stub(t, { kits: () => ok({ kind: 'kits.list', kits: [{ name: 'vip', permission: '', max: 0 }] }) })
+ const res = await action('rust.kit.entitle').perform(ENTITLE)
+ assert.strictEqual(res.ok, false)
+ assert.strictEqual(res.retry, false)
+ assert.match(res.error, /gives nobody anything/)
+})
+
+test('a dry run checks the kit and the mode and reads no tally', async (t) => {
+ let read = false
+ const calls = stub(t, { snapshot: () => { read = true; return ok({ kind: 'tally.snapshot', people: [] }) } })
+ const res = await action('rust.kit.entitle').perform({ ...ENTITLE, verify: true })
+ assert.deepStrictEqual(res, { ok: true })
+ assert.strictEqual(read, false)
+ assert.strictEqual(calls.grants.length, 0)
+
+ const bad = await action('rust.kit.entitle').perform({ ...ENTITLE, verify: true, params: { kit: 'vip', recipients: 'top', count: 2 } })
+ assert.strictEqual(bad.retry, false)
+ assert.match(bad.error, /server\/kit/)
+})
+
+test('a repeated key finds its rows and writes nothing new', async (t) => {
+ const calls = stub(t, { existing: [{ userId: 10 }, { userId: 20 }] })
+ const res = await action('rust.kit.entitle').perform(ENTITLE)
+ assert.strictEqual(res.ok, true)
+ assert.strictEqual(res.detail.repeat, true)
+ assert.strictEqual(calls.grants.length, 0)
+ assert.strictEqual(calls.emitted.length, 0)
+})
+
+test('more qualifying than the server rewards is refused for good, never trimmed (§29.5)', async (t) => {
+ stub(t, {
+ snapshot: () => ok({ kind: 'tally.snapshot', people: TALLY, maxRecipients: 3 }),
+ links: { 1: 1, 2: 2, 3: 3, 4: 4 },
+ })
+ const res = await action('rust.kit.entitle').perform({ ...ENTITLE, params: { kit: 'main/vip', recipients: 'everyone' } })
+ assert.strictEqual(res.ok, false)
+ assert.strictEqual(res.retry, false)
+ assert.match(res.error, /4 people qualify/)
+})
+
+test('no tally on the server is refused for good, and names the verb that opens one', async (t) => {
+ stub(t, { snapshot: () => ok({ kind: 'tally.error', reason: 'no-tally' }) })
+ const res = await action('rust.kit.entitle').perform(ENTITLE)
+ assert.strictEqual(res.retry, false)
+ assert.match(res.error, /rust\.participation\.open/)
+})
+
+test('a revert deletes the step\'s rows and pushes; a lost answer is found by its key', async (t) => {
+ const calls = stub(t)
+ const a = action('rust.kit.entitle')
+ assert.deepStrictEqual(await a.revert({ runId: 41, resources: [{ kind: 'entitlement', ref: 'main:41:7' }] }), { ok: true })
+ assert.deepStrictEqual(calls.deleted[0], { runId: '41', stepId: '7' })
+ assert.deepStrictEqual(calls.dirty, ['main'])
+
+ assert.deepStrictEqual(await a.revert({ runId: 41, resources: [], idempotencyKey: 'key-41-7' }), { ok: true })
+ assert.deepStrictEqual(calls.deleted[1], { runId: 41, key: 'key-41-7' })
+})
+
+test('an entitlement is always in force: the site holds it, and a wipe cannot take it', async () => {
+ const res = await action('rust.kit.entitle').reconcile({ runId: 41, resources: [{ ref: 'main:41:7' }] })
+ assert.deepStrictEqual(res, { ok: true, inForce: ['main:41:7'] })
+})
+
+// ── The tally ────────────────────────────────────────────────────────────────
+
+test('a tally crosses with its key, its score and its zone; kills need a whose', async (t) => {
+ const calls = stub(t)
+ const open = action('rust.participation.open')
+ const res = await open.perform({
+ runId: 41,
+ idempotencyKey: 'k',
+ params: { server: 'main', zone: 'Arena', score: 'Both', killsOf: 'npcs', minutes: 30 },
+ })
+ assert.strictEqual(res.ok, true)
+ assert.deepStrictEqual(calls.open[0].body, { runId: '41', key: 'k', score: 'both', killsOf: 'npcs', killWeight: 5, holdMs: 1800000, zone: 'Arena' })
+ assert.strictEqual(res.resources[0].ref, 'main:41')
+
+ const missing = await open.perform({ runId: 41, params: { server: 'main', score: 'kills' } })
+ assert.strictEqual(missing.retry, false)
+})
+
+test('the plugin\'s permanent refusals stay refused; the switch is named', async (t) => {
+ stub(t, { tallyOpen: () => ok({ kind: 'tally.error', reason: 'events-disabled', message: 'events are switched off' }) })
+ const res = await action('rust.participation.open').perform({ runId: 1, params: { server: 'main', score: 'seconds' } })
+ assert.strictEqual(res.retry, false)
+ assert.match(res.error, /switched off/)
+})
+
+test('collect files each person by Steam id, with the website user where linked', async (t) => {
+ stub(t, {
+ snapshot: () => ok({ kind: 'tally.snapshot', people: [person('1', 3.5, { kills: 1 }), person('2', 1)] }),
+ links: { 1: 10 },
+ })
+ const res = await action('rust.participation.collect').perform({ runId: 41, params: { server: 'main' } })
+ assert.strictEqual(res.participants.length, 2)
+ assert.deepStrictEqual(res.participants[0], {
+ memberKey: '1',
+ userId: 10,
+ score: 3.5,
+ joinedAt: new Date(1).toISOString(),
+ meta: { name: 'p1', seconds: 60, kills: 1 },
+ })
+ assert.strictEqual(res.participants[1].userId, undefined)
+})
+
+test('teardown closes the tally; a lost answer closes it on every server; "cannot ask" keeps it in force', async (t) => {
+ const calls = stub(t, { polling: [{ id: 'main' }, { id: 'alt' }] })
+ const open = action('rust.participation.open')
+ assert.deepStrictEqual(await open.revert({ runId: 41, resources: [{ ref: 'main:41', payload: { serverId: 'main' } }] }), { ok: true })
+ assert.deepStrictEqual(calls.close.map((c) => c.server), ['main'])
+
+ await open.revert({ runId: 41, resources: [] })
+ assert.deepStrictEqual(calls.close.map((c) => c.server), ['main', 'main', 'alt'])
+
+ stub(t, { snapshot: () => ({ ok: false, status: 'http-503' }) })
+ assert.deepStrictEqual(await open.reconcile({ runId: 41, resources: [{ ref: 'main:41' }] }), { ok: true, inForce: ['main:41'] })
+ stub(t, { snapshot: () => ok({ kind: 'tally.error', reason: 'no-tally' }) })
+ assert.deepStrictEqual(await open.reconcile({ runId: 41, resources: [{ ref: 'main:41' }] }), { ok: true, inForce: [] })
+})
+
+// ── Chat ─────────────────────────────────────────────────────────────────────
+
+test('one server: its answer is the step\'s, and a line carries the key and says it is an event\'s', async (t) => {
+ const calls = stub(t, { chat: () => ({ ok: false, status: 'http-503' }) })
+ const res = await action('rust.announce').perform({ runId: 1, idempotencyKey: 'k1', params: { server: 'main', message: ' Go\n now ' } })
+ assert.strictEqual(res.ok, false)
+ assert.notStrictEqual(res.retry, false)
+ assert.deepStrictEqual(calls.chat[0].body, { key: 'k1', message: 'Go now', event: true })
+})
+
+test('every server: a success for those that took it, the rest named (D104, D105)', async (t) => {
+ stub(t, {
+ polling: [{ id: 'main' }, { id: 'alt' }],
+ chat: (server) => (server.id === 'alt' ? { ok: false, status: 'http-503' } : ok({ kind: 'chat.ok', said: true })),
+ })
+ const res = await action('rust.announce').perform({ runId: 1, idempotencyKey: 'k', params: { server: '*', message: 'hi' } })
+ assert.strictEqual(res.ok, true)
+ assert.deepStrictEqual(res.detail, { said: ['Main'], down: ['Alt'] })
+})
+
+test('a line too long is refused on the form', async (t) => {
+ stub(t)
+ const res = await action('rust.announce').perform({ runId: 1, params: { server: 'main', message: 'x'.repeat(rewards.MAX_CHAT + 1) }, verify: true })
+ assert.strictEqual(res.retry, false)
+})
+
+test('the news leg speaks only where the switch is on, keyed by the post', async (t) => {
+ const calls = stub(t, { polling: [{ id: 'main', announceNews: true }, { id: 'alt', announceNews: false }] })
+ const result = await rewards.LEG.dispatch({ id: 9, title: 'Wipe tonight', excerpt: 'Long text' })
+ assert.deepStrictEqual(calls.chat, [{ server: 'main', body: { key: 'news:9', message: 'Wipe tonight' } }])
+ assert.deepStrictEqual(rewards.LEG.classify(result), { outcome: 'done' })
+})
+
+test('the leg: nobody switched on is done; all down is retry; some down is done and named', () => {
+ const { classify } = rewards.LEG
+ assert.deepStrictEqual(classify({ ok: true, outcomes: [] }), { outcome: 'done' })
+ assert.strictEqual(classify({ ok: true, outcomes: [{ server: 'A', state: 'down' }] }).outcome, 'retry')
+ const some = classify({ ok: true, outcomes: [{ server: 'A', state: 'down' }, { server: 'B', state: 'said' }] })
+ assert.strictEqual(some.outcome, 'done')
+ assert.match(some.error, /skipped \(down\): A/)
+ assert.strictEqual(classify({ ok: false, empty: true, outcomes: [] }).outcome, 'terminal')
+})
+
+test('a post with no id is keyed by what it says; a long title is bounded to one line', () => {
+ const line = rewards.chatLine({ title: 'x'.repeat(400) })
+ assert.strictEqual(line.length, rewards.MAX_CHAT)
+ assert.match(rewards.chatKey({ title: 'Hi' }, 'Hi'), /^news:[0-9a-f]{40}$/)
+ assert.strictEqual(rewards.chatLine({ title: null, excerpt: 'Body\ntext' }), 'Body text')
+})
diff --git a/server/test/visibility.test.js b/server/test/visibility.test.js
index 077aad6..98603ec 100644
--- a/server/test/visibility.test.js
+++ b/server/test/visibility.test.js
@@ -185,6 +185,31 @@ test('the clan roster audience defaults to members, and a bad one writes nothing
}
})
+test('news in game chat is off by default, on or off per server, and validated whole (D104, D106)', async () => {
+ const { model, written, restore } = setup({ overrides: { main: null } })
+ const db = require('../model/visibility/visibility.db')
+ const news = []
+ db.setServerNews = async (id, on) => news.push({ id, on })
+ try {
+ // A row that never had the column set reads as off.
+ assert.deepEqual((await model.describe()).news.servers, [{ id: 'main', name: 'MAIN', enabled: true, on: false }])
+
+ const notBoolean = await model.update({ news: { main: 'yes' } })
+ assert.equal(notBoolean.status, 400)
+ const unknown = await model.update({ fleet: 'public', news: { main: true, nope: true } })
+ assert.equal(unknown.status, 404)
+ assert.deepEqual(news, [])
+ assert.deepEqual(written.settings, [], 'the fleet change beside it was not written either')
+
+ const ok = await model.update({ news: { main: true } }, { id: 3 })
+ assert.equal(ok.ok, true)
+ assert.deepEqual(news, [{ id: 'main', on: true }])
+ assert.deepEqual(ok.changed.news, { main: true })
+ } finally {
+ restore()
+ }
+})
+
// ── The public routes ─────────────────────────────────────────────────────
/** A response double recording what a handler answered. */
diff --git a/swagger-fragment.json b/swagger-fragment.json
index 0bc1119..dea9219 100644
--- a/swagger-fragment.json
+++ b/swagger-fragment.json
@@ -710,8 +710,8 @@
"tags": [
"Admin · Rust"
],
- "summary": "Change who may see who is online, or who may see a clan roster",
- "description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, or any of them together. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.",
+ "summary": "Change who may see who is online, who may see a clan roster, or which servers say news in chat",
+ "description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.",
"responses": {
"200": {
"description": "Saved; answers the new state",
@@ -4513,6 +4513,99 @@
}
}
}
+ },
+ "news": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "Whether a published news post is also said in each server’s in-game chat. Off by default (D104)."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "servers": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "main"
+ }
+ }
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "Main · Vanilla"
+ }
+ }
+ },
+ "enabled": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "on": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
@@ -5152,6 +5245,37 @@
},
"clanRoster": {
"$ref": "#/components/schemas/RustClanAudience"
+ },
+ "news": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "A server id to whether a published news post is said in its in-game chat."
+ },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ }
+ }
+ },
+ "example": {
+ "type": "object",
+ "properties": {
+ "main": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ }
+ }
}
}
}