feat(rust): the rewards — tally, kit reward, chat and the news leg (phase 13b, protocol 10)

Four event verbs and the announce leg, per PLAN.md §29:

- rust.participation.open / .collect: the plugin counts who takes part
  (seconds, kills or both, in a zone this run opened or the whole server)
  and collect files them as the run's participants, keyed by Steam id.
- rust.kit.entitle: the five recipient modes (D101), rows in the new
  rust_perm_run_grants (D84) unioned into the permission push, one extra
  use of the kit per reward as site-held credits on perm.sync (D103),
  and the rust.kit.entitled notice deferred from phase 10 (D64).
- rust.announce: one server or every server (D105).
- rust.chat announce leg, speaking only on servers whose new news switch
  is on (D104) - a card on Admin -> Rust visibility (D106).

Budgets rust.grants and rust.announcements; the kit source and four
fixed-choice sources (core has no enum param type). rust_perm_run_grants
carries core's idempotency key so a revert of a lost answer can find its
rows. Protocol 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-24 07:00:44 -05:00
parent 9753dda4af
commit cc185db26b
27 changed files with 2096 additions and 45 deletions

View File

@@ -34,6 +34,7 @@
"db",
"engagement",
"eventLeases.js",
"eventRewards.js",
"eventWorld.js",
"index.js",
"ingest.js",

View File

@@ -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() {
<ClanBoards servers={clans.servers || []} />
</Card>
<Card title="News in game chat" subtitle="a published news post, said in each server’s chat">
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
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.
</p>
{newsRows.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
)}
{newsRows.map((s) => (
<label
key={s.id}
className="sans"
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 0',
borderTop: '1px solid var(--line-soft)',
fontSize: '0.86rem',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={Boolean(news[s.id])}
onChange={(e) => setNews((prev) => ({ ...prev, [s.id]: e.target.checked }))}
aria-label={`Say news in ${s.name}’s chat`}
/>
<span style={{ minWidth: 180, color: 'var(--head)' }}>
{s.name}
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
</span>
<span className="dim" style={{ fontSize: '0.78rem' }}>{news[s.id] ? 'says news' : 'off'}</span>
</label>
))}
</Card>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button type="submit" className="btn" disabled={busy || !dirty}>
{busy ? 'Saving…' : 'Save'}

View File

@@ -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": [

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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)',

View File

@@ -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])))

854
server/eventRewards.js Normal file
View File

@@ -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
}
/** `<server>:<runId>` for a tally, `<server>:<runId>:<stepId>` 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 ───────────────────────
/**
* `<serverId>/<kit>` 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,
}

View File

@@ -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,
})
}

View File

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

View File

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

View File

@@ -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`,

View File

@@ -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,

View File

@@ -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 }

View File

@@ -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) } : {}),
},
}
}

View File

@@ -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 }
: {}),
})
}

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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,
}

View File

@@ -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: {

View File

@@ -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', () => {

View File

@@ -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.

View File

@@ -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, [])
})

419
server/test/rewards.test.js Normal file
View File

@@ -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')
})

View File

@@ -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. */

View File

@@ -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
}
}
}
}
}
}
}