// ── What an event GIVES on a Rust server (PLAN.md §29, protocol 10) ─────── // // 13a made things in the world. This file records who was there, gives them // something they can redeem, and can tell the server. Four verbs: // // rust.participation.open the game starts counting who takes part (D81) // rust.participation.collect core files the count as the run's participants // rust.kit.entitle the right to redeem a kit, and one more use of // it (R16, D103), for the people a mode picks // rust.announce one line in a server's chat, or every server's // // …and the announce leg, `rust.chat`, which says a published news post in the // chat of every server whose switch is on (D104). // // ── Who decides what ───────────────────────────────────────────────────────── // // The GAME counts: presence, kills, the score (D81, D99). The SITE picks the // recipients and holds the reward: the tally is read, a mode chosen per event // picks from it (D101), and a row per recipient goes into // `rust_perm_run_grants`, which the permission mirror pushes like any other // grant (D84). So a reward granted at 03:00 to somebody offline is waiting when // they next log in, and a wipe cannot take it away: the site re-pushes it. // // ── An action is never handed the participants ────────────────────────────── // // Core records participants from `collect`'s answer, but does not give them to // a later step. So `kit.entitle` reads the tally from the plugin itself, as // `uo.item.grant` reads it from the shard, and does not depend on a collect step // having run. const crypto = require('node:crypto') const core = require('./core') const client = require('./sidecarClient') const servers = require('./model/servers/servers.model') const permDb = require('./model/permissions/permissions.db') const linksDb = require('./model/links/links.db') const emit = require('./engagement/emit') const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases') const { BUDGET_MS } = require('./eventWorld') const log = core.logger('rewards') // Mirrors of the plugin's bounds (§29.5). The plugin's are authoritative, and // an operator may set them lower; these price a step and refuse a bad one on // the authoring form rather than at four in the morning. const MAX_RECIPIENTS = 100 const MAX_CHAT = 256 const TALLY_MAX_MINUTES = 7 * 24 * 60 const MAX_KILL_WEIGHT = 1000 const DEFAULT_KILL_WEIGHT = 5 const SCORES = ['seconds', 'kills', 'both'] const KILLS_OF = ['players', 'npcs', 'both'] const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent'] /** The fleet, in `rust.announce`'s `server` param (D105). */ const EVERY_SERVER = '*' /** The plugin's refusals a second attempt would repeat. */ const PERMANENT = new Set([ 'events-disabled', 'malformed', 'out-of-range', 'already-open', 'no-zone', 'ambiguous-zone', 'too-many', 'too-long', 'kits-missing', ]) const BUDGETS = [ { id: 'rust.grants', label: 'Kit rewards', unit: 'rewards', description: 'Kits an event rewards: one per recipient, each the right to redeem the kit and one more use of it. A mode that is not a count is priced at the most it could grant.', }, { id: 'rust.announcements', label: 'Chat announcements', unit: 'lines', description: 'Lines an event says in a server\'s chat: one per server reached.', }, ] /** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */ function transportFailure(server, result, what) { const permanent = result.status === 'not-configured' || result.status === 'no-token' return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) } } /** A plugin refusal carried in a 200, classified by its reason. */ function refusal(server, data, what) { return { ok: false, ...(PERMANENT.has(data && data.reason) ? { retry: false } : {}), error: pluginError(data, `${server.name || server.id} refused the ${what}`), } } /** One word from a fixed set, or null. Compared without case: an author types these. */ function oneOf(raw, allowed) { const text = String(raw === undefined || raw === null ? '' : raw).trim().toLowerCase() return allowed.find((a) => a.toLowerCase() === text) || null } /** `:` for a tally, `::` for a reward (§29.3). */ const tallyRef = (serverId, runId) => `${serverId}:${runId}` const entitlementRef = (serverId, runId, stepId) => `${serverId}:${runId}:${stepId}` /** A ref's parts, split at every colon — a server id has none and core's ids are numbers. */ function refParts(ref) { const [serverId, runId, stepId] = String(ref || '').split(':') return { serverId: serverId || null, runId: runId || null, stepId: stepId || null } } // ── Picking recipients (D101) ──────────────────────────────────────────────── /** * The mode's `count`, checked. Returns `{ ok, value }` or a refusal sentence. * `everyone` takes none. */ function checkCount(mode, raw) { if (mode === 'everyone') return { ok: true, value: null } const value = Number(raw) if (mode === 'top' || mode === 'random') { if (!Number.isInteger(value) || value < 1 || value > MAX_RECIPIENTS) { return { ok: false, error: `${mode} names 1 to ${MAX_RECIPIENTS} people, and "${raw}" is not that` } } } else if (mode === 'topPercent') { if (!Number.isFinite(value) || value <= 0 || value > 100) { return { ok: false, error: `topPercent is a percentage above 0 and at most 100, not "${raw}"` } } } else if (!Number.isFinite(value) || value < 0) { return { ok: false, error: `minScore is a score of 0 or more, not "${raw}"` } } return { ok: true, value } } /** Highest first; a tie keeps the order the game joined them in, so a list reads the same twice. */ function ranked(people) { return [...people].sort((a, b) => b.score - a.score || a.joinedAt - b.joinedAt || a.steamId.localeCompare(b.steamId)) } /** The first `n` of a ranked list, and everybody tied with the last one in (D101). */ function withTies(list, n) { if (n <= 0 || !list.length) return [] if (n >= list.length) return list const floor = list[n - 1].score return list.filter((p, i) => i < n || p.score === floor) } /** * Who a mode picks from a tally. Pure, and the whole of D101: * * everyone every participant who scored above zero * top the N highest scores, ties in * minScore a score of at least X * random N drawn from everyone who took part, seeded by the step's key * topPercent the highest X per cent, rounded up, ties in * * A score of zero earns nothing in the ranked modes: "the highest scores" of a * tally where nobody scored is nobody. `random` draws from everyone present, * which is the point of a raffle. * * **The draw is seeded by the idempotency key**, so a retry after a lost answer * draws the same winners — each person's place is a hash of the key and their * Steam id, which needs no generator state to reproduce. */ function pickRecipients(people, mode, count, seedKey) { const scored = ranked(people.filter((p) => p.score > 0)) switch (mode) { case 'everyone': return scored case 'top': return withTies(scored, count) case 'minScore': return ranked(people.filter((p) => p.score >= count)) case 'topPercent': return withTies(scored, Math.ceil((scored.length * count) / 100)) case 'random': { const draw = (p) => crypto.createHash('sha256').update(`${seedKey}\u0000${p.steamId}`).digest('hex') return [...people].sort((a, b) => draw(a).localeCompare(draw(b))).slice(0, count) } default: return [] } } /** The tally's rows as numbers, whatever the wire carried. */ function peopleOf(data) { return ((data && data.people) || []) .filter((p) => p && p.steamId) .map((p) => ({ steamId: String(p.steamId), name: p.name ? String(p.name) : String(p.steamId), seconds: Number(p.seconds) || 0, kills: Number(p.kills) || 0, score: Number(p.score) || 0, joinedAt: Number(p.joinedAt) || 0, })) } /** Steam id -> website user, for the ids that are linked. */ async function usersFor(steamIds) { const ids = [...new Set(steamIds.map(String))] if (!ids.length) return new Map() const rows = await linksDb.userIdsForSteamIds(ids) return new Map(rows.map((r) => [String(r.steamId), Number(r.userId)])) } /** The tally for a run on one server, or a classified failure. */ async function readTally(server, runId) { const result = await client.tallySnapshot(server, runId) if (!result.ok) return transportFailure(server, result, 'tally') const data = result.data || {} if (data.kind !== 'tally.snapshot') { // `no-tally` is permanent for THIS step: the tally it reads was never opened // on this server, or teardown already closed it. if (data.reason === 'no-tally') { return { ok: false, retry: false, error: `${server.name || server.id} holds no tally for this run — open one with rust.participation.open on the same server first`, } } return refusal(server, data, 'tally') } return { ok: true, data } } // ── The kit, as its server's Kits plugin describes it ─────────────────────── /** * `/` split at the FIRST slash: a server id never contains one, * and a kit name is whatever an operator typed into Kits. */ function splitKit(value) { const text = String(value || '').trim() const slash = text.indexOf('/') if (slash <= 0 || slash === text.length - 1) return null return { serverId: text.slice(0, slash), kit: text.slice(slash + 1) } } /** What a kit rewards, as the source's label says it and the verb checks it (R16, D103). */ function kitReward(row) { const permission = String(row.permission || '').trim().toLowerCase() const max = Number(row.max) || 0 return { permission, max, rewardsNothing: !permission && max <= 0 } } async function readKit(server, kit) { const result = await client.kits(server) if (!result.ok) return transportFailure(server, result, 'kits') const data = result.data || {} if (data.kind !== 'kits.list') return refusal(server, data, 'kit list') const row = (data.kits || []).find((k) => k && String(k.name).toLowerCase() === kit.toLowerCase()) if (!row) return { ok: false, retry: false, error: `${server.name || server.id} has no kit called "${kit}"` } const reward = kitReward(row) if (reward.rewardsNothing) { return { ok: false, retry: false, error: `the kit "${row.name}" is open to everyone and has no use limit, so a reward of it gives nobody anything — give it a permission or a maximum number of uses in Kits`, } } return { ok: true, kit: String(row.name), ...reward, maxRecipients: Number(data.maxRecipients) || MAX_RECIPIENTS } } // ── The verbs ──────────────────────────────────────────────────────────────── const participationOpen = { id: 'rust.participation.open', label: 'Start counting participants', description: 'The game counts who takes part from here on: time present, kills, or both — in a zone this run opened, or on the whole server. It stops after its minutes; teardown forgets it.', // It watches rather than changes anything, but it is ledgered, like // `uo.participation.open`: the game holds a tally for the run, and teardown // gives it back. risk: 'inspect', reversible: 'ledger', version: 1, budgetMs: BUDGET_MS, params: [ { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers', description: 'Which server counts.' }, { name: 'zone', type: 'string', required: false, example: 'Airfield brawl', source: 'rust.options.runzones', description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts (D100).' }, { name: 'score', type: 'string', required: true, example: 'both', source: 'rust.options.scoremodes', description: 'What earns a place: seconds present, kills, or both.' }, { name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsof', description: 'Whose deaths count as a kill: players, NPCs (animals included), or both. The last hit gets it. Needed unless the score is seconds.' }, { name: 'killWeight', type: 'float', required: false, example: DEFAULT_KILL_WEIGHT, description: `For a score of both: how many minutes one kill is worth. Left blank, ${DEFAULT_KILL_WEIGHT}.` }, { name: 'minutes', type: 'int', required: false, example: 60, description: `How long it counts, up to ${TALLY_MAX_MINUTES} (seven days). Left blank, seven days. The game forgets a tally seven days after it opened, however long it counted.` }, ], cost: () => ({}), async perform({ runId, idempotencyKey, params, verify }) { const score = oneOf(params.score, SCORES) if (!score) return { ok: false, retry: false, error: `a tally scores seconds, kills or both, not "${params.score}"` } const killsOf = score === 'seconds' ? null : oneOf(params.killsOf, KILLS_OF) if (score !== 'seconds' && !killsOf) { return { ok: false, retry: false, error: 'a tally that counts kills says whose: players, npcs or both' } } let killWeight if (score === 'both') { const raw = params.killWeight killWeight = raw === undefined || raw === null || raw === '' ? DEFAULT_KILL_WEIGHT : Number(raw) if (!Number.isFinite(killWeight) || killWeight < 0 || killWeight > MAX_KILL_WEIGHT) { return { ok: false, retry: false, error: `a kill is worth 0 to ${MAX_KILL_WEIGHT} minutes, not "${raw}"` } } } let minutes if (params.minutes !== undefined && params.minutes !== null && params.minutes !== '') { minutes = Number(params.minutes) if (!Number.isInteger(minutes) || minutes < 1 || minutes > TALLY_MAX_MINUTES) { return { ok: false, retry: false, error: `a tally counts for 1 to ${TALLY_MAX_MINUTES} minutes, not "${params.minutes}"` } } } const zone = String(params.zone || '').trim() const found = await serverFor(String(params.server || '').trim()) if (!found.ok) return found // Whether the zone exists is not asked in a dry run: it is opened by an // earlier step of the same run, so before the run it never does. if (verify) return { ok: true } const result = await client.tallyOpen(found.server, { runId: String(runId), key: idempotencyKey, score, ...(killsOf ? { killsOf } : {}), ...(killWeight === undefined ? {} : { killWeight }), ...(minutes === undefined ? {} : { holdMs: minutes * 60000 }), ...(zone ? { zone } : {}), }) if (!result.ok) return transportFailure(found.server, result, 'tally') const data = result.data || {} if (data.kind !== 'tally.ok') return refusal(found.server, data, 'tally') return { ok: true, resources: [ { kind: 'tally', ref: tallyRef(found.server.id, runId), payload: { serverId: found.server.id, score, ...(zone ? { zone } : {}) }, }, ], detail: { server: found.server.name || found.server.id, counting: zone ? `in the zone "${zone}"` : 'on the whole server', ...(data.repeat ? { repeat: true, note: 'answered from the first attempt; the tally was already open' } : {}), }, } }, /** * Forget the tally on every server the ledger names — or, when core lost the * answer and holds none, on every server, since `runId` is all a tally is * keyed by. A tally already gone is a success. */ async revert({ runId, resources }) { const targets = resources && resources.length ? [...new Set(resources.map((r) => (r.payload && r.payload.serverId) || refParts(r.ref).serverId))] : (await servers.listForPolling()).map((s) => s.id) const failed = [] const errors = [] for (const serverId of targets) { const found = await serverFor(serverId) if (!found.ok) { // A server deleted or switched off cannot be asked, and its tally ends on // its own seven days after it opened; the ledger row is not held for it. continue } const result = await client.tallyClose(found.server, { runId: String(runId) }) const refused = result.ok && (!result.data || result.data.kind !== 'tally.ok') if (!result.ok || refused) { failed.push(...(resources || []).filter((r) => refParts(r.ref).serverId === serverId).map((r) => r.ref)) errors.push(result.ok ? pluginError(result.data, `${found.server.name || found.server.id} refused to close the tally`) : transportError(found.server, result, 'tally')) } } if (!errors.length) return { ok: true } if (!resources || !resources.length || failed.length === resources.length) return { ok: false, error: errors.join('; ') } return { ok: true, failed } }, /** A tally is in force while its server still holds it. A server that cannot be asked has said nothing. */ async reconcile({ runId, resources }) { const inForce = [] for (const r of resources || []) { const found = await serverFor(refParts(r.ref).serverId) if (!found.ok) { inForce.push(r.ref) continue } const result = await client.tallySnapshot(found.server, runId) const gone = result.ok && result.data && result.data.kind !== 'tally.snapshot' && result.data.reason === 'no-tally' if (!gone) inForce.push(r.ref) } return { ok: true, inForce } }, } const participationCollect = { id: 'rust.participation.collect', label: 'Record participants', description: 'Files everybody the tally counted as this run\'s participants, with their score, time and kills. The tally keeps counting if its minutes are not up.', risk: 'inspect', reversible: 'none', version: 1, budgetMs: BUDGET_MS, params: [ { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers', description: 'The server whose tally to read.' }, ], cost: () => ({}), async perform({ runId, params, verify }) { const found = await serverFor(String(params.server || '').trim()) if (!found.ok) return found if (verify) return { ok: true } const tally = await readTally(found.server, runId) if (!tally.ok) return tally const people = peopleOf(tally.data) const users = await usersFor(people.map((p) => p.steamId)) return { ok: true, // The member vocabulary is the Steam id, as the team provider's is. participants: people.map((p) => ({ memberKey: p.steamId, ...(users.has(p.steamId) ? { userId: users.get(p.steamId) } : {}), score: p.score, ...(p.joinedAt > 0 ? { joinedAt: new Date(p.joinedAt).toISOString() } : {}), meta: { name: p.name, seconds: p.seconds, kills: p.kills }, })), detail: { server: found.server.name || found.server.id, participants: people.length, linked: users.size, ...(Number(tally.data.overflow) > 0 ? { overflow: Number(tally.data.overflow) } : {}), }, } }, } const kitEntitle = { id: 'rust.kit.entitle', label: 'Reward a kit', description: 'Gives the people a mode picks from this run\'s tally the right to redeem a kit on its server, and one more use of it. Waits for them if they are offline. Teardown withdraws what is not yet redeemed.', risk: 'change', reversible: 'ledger', version: 1, budgetMs: BUDGET_MS, params: [ { name: 'kit', type: 'string', required: true, example: 'main/vip-starter', source: 'rust.options.kits', description: 'The kit, as server/kit. The reward reaches only that server (D102).' }, { name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes', description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, or the top X per cent.' }, { name: 'count', type: 'float', required: false, example: 3, description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone.' }, ], // Priced before the tally is read, so at the most it could grant: the count // for a count, and the server's recipient bound for every other mode. An // author who wants a tight cap picks a count. cost: (p) => { const mode = oneOf(p.recipients, MODES) const n = Math.round(Number(p.count) || 0) return { 'rust.grants': mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS } }, async perform({ runId, stepId, idempotencyKey, params, verify }) { const parsed = splitKit(params.kit) if (!parsed) return { ok: false, retry: false, error: `"${params.kit}" is not a kit — pick one from the list, as server/kit` } const mode = oneOf(params.recipients, MODES) if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` } const count = checkCount(mode, params.count) if (!count.ok) return { ok: false, retry: false, error: count.error } const found = await serverFor(parsed.serverId) if (!found.ok) return found const server = found.server const kit = await readKit(server, parsed.kit) if (!kit.ok) return kit if ((mode === 'top' || mode === 'random') && count.value > kit.maxRecipients) { return { ok: false, retry: false, error: `${server.name || server.id} rewards at most ${kit.maxRecipients} people in one step, not ${count.value}` } } if (verify) return { ok: true } const ref = entitlementRef(server.id, runId, stepId) const resource = { kind: 'entitlement', ref, payload: { serverId: server.id, kit: kit.kit } } // A repeated key finds its rows already written and changes nothing — the // rows ARE the grant, and a set written twice is the same set. const existing = await permDb.listRunGrantsForStep(runId, stepId) if (existing.length) { return { ok: true, resources: [resource], detail: { repeat: true, granted: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was granted' }, } } const tally = await readTally(server, runId) if (!tally.ok) return tally const picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey) const bound = Number(tally.data.maxRecipients) || kit.maxRecipients if (picked.length > bound) { return { ok: false, retry: false, error: `${picked.length} people qualify, and ${server.name || server.id} rewards at most ${bound} in one step — pick a count-based mode or a higher bar`, } } const users = await usersFor(picked.map((p) => p.steamId)) const rows = [] const byUser = new Set() const missed = [] for (const p of picked) { const userId = users.get(p.steamId) if (!userId) { missed.push(p.name) continue } // One reward per website user: two linked accounts that both took part are // one person, and one win is one use (D103). The higher score, being // earlier in the list, is the account that gets the credit. if (byUser.has(userId)) continue byUser.add(userId) rows.push({ runId, stepId, idemKey: idempotencyKey, userId, serverId: server.id, steamId: p.steamId, permission: kit.permission, kit: kit.kit, credit: kit.max > 0, }) } await permDb.insertRunGrants(rows) await permDb.markDirty(server.id) emit.entitled({ userIds: [...byUser], kit: kit.kit, server, mode, runId, stepId }) log.info('kit rewarded', { server: server.id, run: runId, step: stepId, kit: kit.kit, mode, granted: rows.length, missed: missed.length }) return { ok: true, resources: [resource], detail: { kit: kit.kit, server: server.name || server.id, mode, ...(count.value === null ? {} : { count: count.value }), granted: rows.length, ...(missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}), }, } }, /** * Withdraw a step's rows and push. The permission goes and each unredeemed * credit is put back by the plugin; a redemption already made stands (R16). * A row already gone is a success. */ async revert({ runId, resources, idempotencyKey }) { const touched = new Set() if (!resources || !resources.length) { for (const serverId of await permDb.deleteRunGrantsForKey(runId, idempotencyKey)) touched.add(serverId) } else { for (const r of resources) { const { runId: refRun, stepId } = refParts(r.ref) if (!stepId) continue for (const serverId of await permDb.deleteRunGrantsForStep(refRun || runId, stepId)) touched.add(serverId) } } for (const serverId of touched) await permDb.markDirty(serverId) return { ok: true } }, /** The site holds the entitlement and re-pushes it, so a restart or a wipe cannot take it away. */ async reconcile({ resources }) { return { ok: true, inForce: (resources || []).map((r) => r.ref) } }, } /** * Say one line on one server, and classify the answer. `repeat` is a success: * the plugin remembered the key, and the line was already said. */ async function sayOn(server, body) { const result = await client.chat(server, body) if (!result.ok) return { state: 'down', result } const data = result.data || {} if (data.kind !== 'chat.ok') return { state: 'refused', data } return { state: data.said === false ? 'repeat' : 'said', data } } const announce = { id: 'rust.announce', label: 'Say it in game chat', description: 'One line in a Rust server\'s chat, or in every server\'s. A line said cannot be taken back.', risk: 'notify', reversible: 'none', version: 1, budgetMs: BUDGET_MS, params: [ { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.chatservers', description: 'Which server, or * for every server (D105).' }, { name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!', description: `The line, up to ${MAX_CHAT} characters.` }, ], // One per server reached. `*` is priced at the enabled servers when core asks, // which is synchronous — so at the count this module last saw. cost: (p) => ({ 'rust.announcements': String(p.server || '').trim() === EVERY_SERVER ? Math.max(1, servers.lastEnabledCount()) : 1 }), async perform({ runId, idempotencyKey, params, verify }) { const message = String(params.message || '').replace(/\s+/g, ' ').trim() if (!message) return { ok: false, retry: false, error: 'a chat line needs a message' } if (message.length > MAX_CHAT) { return { ok: false, retry: false, error: `a chat line is at most ${MAX_CHAT} characters, and this one is ${message.length}` } } const target = String(params.server || '').trim() let list if (target === EVERY_SERVER) { list = await servers.listForPolling() if (!list.length) return { ok: false, retry: false, error: 'there are no enabled Rust servers to say it on' } } else { const found = await serverFor(target) if (!found.ok) return found list = [found.server] } if (verify) return { ok: true } const body = { key: idempotencyKey || `run:${runId}`, message, event: true } const outcomes = await Promise.all(list.map(async (server) => ({ server, ...(await sayOn(server, body)) }))) const name = (o) => o.server.name || o.server.id // One server named: its answer is the step's. if (target !== EVERY_SERVER) { const o = outcomes[0] if (o.state === 'down') return transportFailure(o.server, o.result, 'chat') if (o.state === 'refused') return refusal(o.server, o.data, 'chat line') return { ok: true, detail: { said: [name(o)], ...(o.state === 'repeat' ? { repeat: true } : {}) } } } // Every server: a success for each that took the line, and the rest named // (D104's reason — a line said an hour late in a restarted server is noise). const said = outcomes.filter((o) => o.state === 'said' || o.state === 'repeat').map(name) const down = outcomes.filter((o) => o.state === 'down').map(name) const refused = outcomes.filter((o) => o.state === 'refused').map((o) => `${name(o)}: ${pluginError(o.data, 'refused')}`) if (!said.length && refused.length) return { ok: false, retry: false, error: refused.join('; ') } if (!said.length) return { ok: false, error: `no server could be reached: ${down.join(', ')}` } return { ok: true, detail: { said, ...(down.length ? { down } : {}), ...(refused.length ? { refused } : {}) } } }, } // ── The announce leg (D104) ────────────────────────────────────────────────── /** A news post as one chat line: its title, or failing that its excerpt, flattened and bounded. */ function chatLine(post) { const text = String((post && (post.title || post.excerpt)) || '').replace(/\s+/g, ' ').trim() return text.length > MAX_CHAT ? `${text.slice(0, MAX_CHAT - 1)}…` : text } /** * The plugin's memory of recent keys, keyed off the post: its id when core's * news path gives one, else what it says — `core.announce` hands a leg a post * with no id. Either way a retried leg never says the same line twice. */ function chatKey(post, line) { if (post && post.id !== undefined && post.id !== null) return `news:${post.id}` return `news:${crypto.createHash('sha1').update(line).digest('hex')}` } const LEG = { leg: 'rust.chat', label: 'Rust in-game chat', /** * Say a post in the chat of every server whose switch is on. Never throws, as * every leg client must not. The answer is one outcome per switched-on * server, for `classify`. */ async dispatch(post) { try { const line = chatLine(post) if (!line) return { ok: false, empty: true, outcomes: [] } const list = (await servers.listForPolling()).filter((s) => s.announceNews) const key = chatKey(post, line) const outcomes = await Promise.all( list.map(async (server) => ({ server: server.name || server.id, ...(await sayOn(server, { key, message: line })) })), ) return { ok: true, outcomes } } catch (err) { log.warn('news chat leg failed', { error: err.message }) return { ok: false, error: err.message, outcomes: [] } } }, /** * `done` when every switched-on server that is up took the line — or when no * server is switched on, since there is nothing to deliver. `retry` only when * every switched-on server is down. A server that refused is named; one that * was down is skipped, never queued (D104). */ classify(result) { if (!result || (!result.ok && !result.empty && !result.outcomes)) return { outcome: 'retry', error: (result && result.error) || 'no answer' } if (result.empty) return { outcome: 'terminal', error: 'the post has no title or excerpt to say' } if (!result.ok) return { outcome: 'retry', error: result.error || 'the leg failed' } const outcomes = result.outcomes || [] if (!outcomes.length) return { outcome: 'done' } const took = outcomes.filter((o) => o.state === 'said' || o.state === 'repeat') const down = outcomes.filter((o) => o.state === 'down').map((o) => o.server) const refused = outcomes.filter((o) => o.state === 'refused').map((o) => `${o.server}: ${pluginError(o.data, 'refused')}`) if (down.length === outcomes.length) return { outcome: 'retry', error: `every server is down: ${down.join(', ')}` } if (!took.length) return { outcome: 'terminal', error: refused.join('; ') } const notes = [...(down.length ? [`skipped (down): ${down.join(', ')}`] : []), ...refused] return notes.length ? { outcome: 'done', error: notes.join('; ') } : { outcome: 'done' } }, } // ── Option sources ─────────────────────────────────────────────────────────── /** A fixed choice as a dropdown — core has no enum type, so a source is how a field offers words. */ const fixed = (id, label, description, rows) => ({ id, label, description, async resolve() { return rows } }) const OPTION_SOURCES = [ { // Live from each server's Kits, so the form offers only kits that exist. The // label says what a reward of each one gives (R16, D103). id: 'rust.options.kits', label: 'Kits', description: "Each server's Kits, flagged by what a reward of one gives.", searchable: true, async resolve({ q } = {}) { const term = String(q || '').trim().toLowerCase() const answers = await perServer((server) => client.kits(server)) const rows = [] for (const { server, result } of answers) { const data = result.data || {} if (data.kind !== 'kits.list') continue for (const k of data.kits || []) { if (!k || !k.name) continue const value = `${server.id}/${k.name}` if (term && !value.toLowerCase().includes(term)) continue const reward = kitReward(k) const flags = [ reward.permission ? null : 'open to everyone', reward.max > 0 ? `${reward.max} use${reward.max === 1 ? '' : 's'}` : null, reward.rewardsNothing ? 'rewards nothing' : null, ].filter(Boolean) rows.push({ value, label: flags.length ? `${k.name} · ${flags.join(' · ')}` : String(k.name), group: server.name || server.id }) } } return bounded(rows, 'rust.options.kits') }, }, // Free text: the zones a run will open do not exist when it is authored, and // the name is checked when the step runs (D100). Declared so the field is // documented rather than a bare box, and answers nothing. fixed('rust.options.runzones', 'Zones this run opens', 'The name an earlier "Open a zone" step of the same run gave its zone. Type it; it is checked when the step runs.', []), fixed('rust.options.scoremodes', 'Score', 'What earns a place in a tally.', [ { value: 'seconds', label: 'Seconds present' }, { value: 'kills', label: 'Kills' }, { value: 'both', label: 'Both — minutes plus a weight per kill' }, ]), fixed('rust.options.killsof', 'Kills of', 'Whose deaths count as a kill.', [ { value: 'players', label: 'Players' }, { value: 'npcs', label: 'NPCs, animals included' }, { value: 'both', label: 'Players and NPCs' }, ]), fixed('rust.options.recipientmodes', 'Recipients', 'Who a reward goes to (D101).', [ { value: 'everyone', label: 'Everyone who scored' }, { value: 'top', label: 'The top N (ties in)' }, { value: 'minScore', label: 'A score of at least X' }, { value: 'random', label: 'N drawn at random' }, { value: 'topPercent', label: 'The top X per cent (ties in)' }, ]), { id: 'rust.options.chatservers', label: 'Chat servers', description: 'Every enabled server, or * for all of them.', async resolve() { const list = await servers.listForPolling() return [{ value: EVERY_SERVER, label: 'Every server' }, ...list.map((s) => ({ value: s.id, label: s.name || s.id }))] }, }, ] const ACTIONS = [participationOpen, participationCollect, kitEntitle, announce] module.exports = { MAX_RECIPIENTS, MAX_CHAT, EVERY_SERVER, BUDGETS, ACTIONS, LEG, OPTION_SOURCES, pickRecipients, checkCount, splitKit, kitReward, chatLine, chatKey, refParts, }