Files
Module-Rust/server/eventRewards.js
wtclaude 42029734ad
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Successful in 50s
PR Checks / server-tests (pull_request) Successful in 7m45s
fix(rust): lowercase the reward option-source ids, and test the grammar
The first boot against real core refused the whole module at register:
`rust.options.runZones` fails core's EVENT_ID grammar, which is lowercase
dotted segments only. The fake api validates none of it, so 310 green
tests said nothing. The four fixed-choice sources and the chat-server
source are renamed, and entry.test now holds every action, budget,
lease and option-source id against a copy of the grammar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 07:47:01 -05:00

855 lines
36 KiB
JavaScript

// ── 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,
}