Stage 2 of PLAN_LAST_FIXES (docs#366). - W7/W11: an "is it loaded" refusal is confirmed live before it is given. New liveStatus.js asks the sidecar once (boot.refreshOne, as a poll tick would), at most once per server per 5 s and waiting at most 3 s, then reads the stored status again. Used by Economics (step and staff page), Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes, TruePVE and RunicNPC. - W3: a world call answered `already-out` (the bridge half comes in stage 3) succeeds as called 0, saying a Travelling Vendor is already out. - W6: "A locked crate dropped by a Chinook is being hacked." - W8: a RaidableBases profile's audit row names RaidableBases, not "@data". - W10: the backpack sizes picker lists each size once, naming its servers. - W13: a RaidableBases name both servers describe differently shows each server's own description. - W14: each RaidableBases note after the first starts its own sentence. - W15: a staff deposit refused at Economics' limit answers 409. - W16: a payment's failedNote explains only the reasons that happened. - W17: the sync state counts player grants that did not land (waitingToLand), and the Permissions page says "in sync · 1 waits for their player to join" instead of plain "in sync". - W18: Guarded Crate's kept note drops the "30 minutes" it could not know. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
1442 lines
64 KiB
JavaScript
1442 lines
64 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
|
|
// rust.coins.pay Economics coins for the people a mode picks,
|
|
// kept once paid (protocol 14, D335)
|
|
// rust.backpack.size a bigger Backpacks backpack until the run ends:
|
|
// a kit reward with `backpacks.size.N` as its
|
|
// permission and no kit (protocol 14, D337)
|
|
//
|
|
// …and the announce leg, `rust.chat`, which says a published news post in the
|
|
// chat of every server whose switch is on (D104).
|
|
//
|
|
// Phase 17 gave both a delivery — chat, or a popup through PopupNotifications
|
|
// (D141, D142) — and a VOICE: the style of one permission group, which the
|
|
// plugin says the line in with no player as its sender (D140).
|
|
//
|
|
// ── 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 economics = require('./model/economics/economics.model')
|
|
const backpacks = require('./model/backpacks/backpacks.model')
|
|
const live = require('./liveStatus')
|
|
const voice = require('./model/permissions/voice')
|
|
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']
|
|
// `linked` (PLAN_REDESIGNS.md §2.5) reads no tally: every linked player of the
|
|
// kit's server, one use each — the kit weekend's second step.
|
|
const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent', 'linked']
|
|
|
|
/** The fleet, in `rust.announce`'s `server` param (D105). */
|
|
const EVERY_SERVER = '*'
|
|
|
|
/** Where a line goes (D141). Chat is the default and what every server can do. */
|
|
const DELIVERIES = ['chat', 'popup']
|
|
|
|
/** 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',
|
|
// Protocol 12: a popup on a server without PopupNotifications. Waiting does not
|
|
// install it.
|
|
'popup-unavailable',
|
|
// Protocol 14, Economics (§11.2.2). Nor does it install Economics, and a bad
|
|
// amount or too long a list is the step's, not the moment's.
|
|
'economics-missing',
|
|
'bad-amount',
|
|
'too-large',
|
|
])
|
|
|
|
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.',
|
|
},
|
|
{
|
|
id: 'rust.coins',
|
|
label: 'Coins paid',
|
|
unit: 'coins',
|
|
description:
|
|
'Economics coins an event pays: the amount times the people paid. A mode that is not a count is priced at the most it could pay. Coins paid are kept — nothing takes them back.',
|
|
},
|
|
{
|
|
id: 'rust.backpacks',
|
|
label: 'Backpack sizes',
|
|
unit: 'backpacks',
|
|
description:
|
|
'Bigger backpacks an event lends until it ends: one per recipient. A mode that is not a count is priced at the most it could lend.',
|
|
},
|
|
]
|
|
|
|
/** 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' || mode === 'linked') 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 }
|
|
}
|
|
|
|
/**
|
|
* A reward's message to its player, checked (protocol 14, PLAN_REDESIGNS.md
|
|
* §2.4, D333). `{ ok, text, delivery }`, with `text` null for a step that says
|
|
* nothing — the default, and every step saved before this. Measured with the
|
|
* kit's name in place, against the plugin's line bound, so a long kit name
|
|
* cannot push a message the form accepted past what the game will say.
|
|
*/
|
|
function rewardNotice(params, kitName) {
|
|
const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
|
|
if (!text) return { ok: true, text: null, delivery: null }
|
|
|
|
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
|
|
const delivery = oneOf(rawDelivery, DELIVERIES)
|
|
if (!delivery) return { ok: false, error: `a reward's message goes to chat or to a popup, not to "${rawDelivery}"` }
|
|
|
|
const said = text.replace(/\{kit\}/g, kitName)
|
|
if (said.length > MAX_CHAT) {
|
|
return { ok: false, error: `a reward's message is at most ${MAX_CHAT} characters with the kit's name in it, not ${said.length}` }
|
|
}
|
|
return { ok: true, text, delivery }
|
|
}
|
|
|
|
// ── 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, duration: 'minutes',
|
|
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 — or every linked player of the server — the right to redeem a kit on its server, and one more use of it, and can tell each of them in game. 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, the top X per cent — or every linked player of the server, with no tally at all (a kit weekend).' },
|
|
{ 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 or linked.' },
|
|
// Protocol 14 (PLAN_REDESIGNS.md §2.4, D333): the reward tells its player.
|
|
{ name: 'message', type: 'string', required: false, example: 'You earned kit {kit} — type /kit {kit}',
|
|
description: 'Said once to each player rewarded, in game: at once if they are up, otherwise when they next wake on the server. {kit} is the kit\'s name. Leave it empty to say nothing.' },
|
|
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
|
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
|
|
],
|
|
|
|
// 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 notice = rewardNotice(params, parsed.kit)
|
|
if (!notice.ok) return { ok: false, retry: false, error: notice.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' },
|
|
}
|
|
}
|
|
|
|
let picked
|
|
let bound
|
|
let users
|
|
|
|
if (mode === 'linked') {
|
|
// A kit weekend (§2.5): every linked player of the server, no tally.
|
|
// Read one past the bound so "too many" is a sentence, not a silent cut.
|
|
const linked = await linksDb.linkedOnServer(server.id, kit.maxRecipients + 1)
|
|
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
|
|
bound = kit.maxRecipients
|
|
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
|
|
if (picked.length > bound) {
|
|
return {
|
|
ok: false,
|
|
retry: false,
|
|
error: `${server.name || server.id} has more than ${bound} linked players, and rewards at most ${bound} in one step — raise the plugin's recipient bound (EventsMaxRecipients) or reward from a tally`,
|
|
}
|
|
}
|
|
} else {
|
|
const tally = await readTally(server, runId)
|
|
if (!tally.ok) return tally
|
|
|
|
picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
|
|
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`,
|
|
}
|
|
}
|
|
|
|
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,
|
|
...(notice.text ? { notice: notice.text.replace(/\{kit\}/g, kit.kit), noticeDelivery: notice.delivery } : {}),
|
|
})
|
|
}
|
|
|
|
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,
|
|
...(notice.text && rows.length ? { told: `${rows.length} will be told in game, by ${notice.delivery}` } : {}),
|
|
...(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) }
|
|
},
|
|
}
|
|
|
|
// ── Coins (protocol 14, PLAN_REDESIGNS.md §11.2.2) ──────────────────────────
|
|
|
|
/** The plugin's bounds on one payment's amount (PROTOCOL.md §20.3). */
|
|
const COINS_MIN = economics.AMOUNT_MIN
|
|
const COINS_MAX = economics.AMOUNT_MAX
|
|
|
|
/** The plugin keeps a payment's key in a file; core's key is used as it is unless it is long. */
|
|
const PAYMENT_KEY_MAX = 120
|
|
|
|
/**
|
|
* A payment's message, checked like a kit reward's (D333): `{ ok, text,
|
|
* delivery }`, text null for a step that says nothing. Measured with the amount
|
|
* in place of `{amount}`, against the plugin's line bound. The plugin fills in
|
|
* what each player RECEIVED, which is never more than the amount asked.
|
|
*/
|
|
function coinsNotice(params, amount) {
|
|
const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
|
|
if (!text) return { ok: true, text: null, delivery: null }
|
|
|
|
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
|
|
const delivery = oneOf(rawDelivery, DELIVERIES)
|
|
if (!delivery) return { ok: false, error: `a payment's message goes to chat or to a popup, not to "${rawDelivery}"` }
|
|
|
|
const said = text.replace(/\{amount\}/g, economics.coins(amount))
|
|
if (said.length > MAX_CHAT) {
|
|
return { ok: false, error: `a payment's message is at most ${MAX_CHAT} characters with the amount in it, not ${said.length}` }
|
|
}
|
|
return { ok: true, text, delivery }
|
|
}
|
|
|
|
/** What each reason in a payment's `failed` list means, in the plugin's order. */
|
|
const FAILED_REASONS = Object.freeze({
|
|
'at-limit': "the balance is at Economics' limit",
|
|
refused: 'Economics did not take the deposit',
|
|
unmeasured: 'Economics did not report the deposit',
|
|
})
|
|
|
|
/** A payment's `failedNote`: only the reasons that happened, each explained (W16). */
|
|
function failedNote(failed) {
|
|
const seen = new Set(failed.map((row) => row.reason))
|
|
return [...Object.keys(FAILED_REASONS), ...[...seen].filter((r) => !FAILED_REASONS[r])]
|
|
.filter((reason) => seen.has(reason))
|
|
.map((reason) => (FAILED_REASONS[reason] ? `${reason}: ${FAILED_REASONS[reason]}` : `${reason}: Economics' own reason`))
|
|
.join('; ')
|
|
}
|
|
|
|
/** The key the plugin remembers a payment by: core's, or a digest of it past the plugin's bound. */
|
|
function paymentKey(idempotencyKey, runId, stepId) {
|
|
const key = String(idempotencyKey || `coins:${runId}:${stepId}`)
|
|
return key.length <= PAYMENT_KEY_MAX ? key : `coins:${crypto.createHash('sha256').update(key).digest('hex').slice(0, 40)}`
|
|
}
|
|
|
|
const coinsPay = {
|
|
id: 'rust.coins.pay',
|
|
label: 'Pay coins',
|
|
description:
|
|
'Pays Economics coins to the people a mode picks from this run\'s tally — or to every linked player of the server — and can tell each of them in game. One payment per website account. Coins paid are kept: a run cancelled or aborted afterwards does not take them back.',
|
|
// Irreversible, like Module-uo's `uo.item.grant` (D335): coins in a player's
|
|
// balance may be spent the moment they land, and the platform reverts a
|
|
// ledgered step at the end of EVERY run, a normal finish included — so a
|
|
// ledger here would take a prize back. Staff take coins back by hand.
|
|
risk: 'irreversible',
|
|
reversible: 'none',
|
|
version: 1,
|
|
budgetMs: BUDGET_MS,
|
|
params: [
|
|
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
|
description: 'The server whose Economics pays. It needs Economics installed.' },
|
|
{ name: 'amount', type: 'float', required: true, example: 500,
|
|
description: `How many coins each person receives, ${economics.coins(COINS_MIN)} to ${economics.coins(COINS_MAX)}.` },
|
|
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
|
|
description: 'Who is paid: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
|
|
{ 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 or linked.' },
|
|
{ name: 'message', type: 'string', required: false, example: 'You earned {amount} coins',
|
|
description: 'Said once to each player paid, in game: at once if they are up, otherwise when they next wake on the server. {amount} is what they received. Leave it empty to say nothing.' },
|
|
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
|
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
|
|
],
|
|
|
|
// Priced before the tally is read, so at the most it could pay: the amount
|
|
// times the count for a count, times the recipient bound for every other mode.
|
|
cost: (p) => {
|
|
const mode = oneOf(p.recipients, MODES)
|
|
const n = Math.round(Number(p.count) || 0)
|
|
const people = mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
|
|
return { 'rust.coins': Math.ceil(Math.max(0, Number(p.amount) || 0) * people) }
|
|
},
|
|
|
|
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
|
const amount = Math.round(Number(params.amount) * 100) / 100
|
|
if (!Number.isFinite(amount) || amount < COINS_MIN || amount > COINS_MAX) {
|
|
return { ok: false, retry: false, error: `a payment is ${economics.coins(COINS_MIN)} to ${economics.coins(COINS_MAX)} coins, not "${params.amount}"` }
|
|
}
|
|
|
|
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 notice = coinsNotice(params, amount)
|
|
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
|
|
|
|
const found = await serverFor(String(params.server || '').trim())
|
|
if (!found.ok) return found
|
|
const server = found.server
|
|
const name = server.name || server.id
|
|
|
|
// D199: refused with the reason where Economics is absent, in the dry run
|
|
// too — the author learns it on the form, not at the run's reward.
|
|
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
|
const econ = await live.confirmed(server, () => economics.forServer(server.id), (e) => !e.loaded)
|
|
if (!econ.loaded) return { ok: false, retry: false, error: `${economics.MISSING} (${name})` }
|
|
|
|
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
|
|
return { ok: false, retry: false, error: `${name} pays at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
|
|
}
|
|
|
|
if (verify) return { ok: true }
|
|
|
|
let picked
|
|
let bound = MAX_RECIPIENTS
|
|
let users
|
|
|
|
if (mode === 'linked') {
|
|
// Every linked player of the server, as a kit weekend's (§2.5). One past
|
|
// the bound, so "too many" is a sentence and not a silent cut.
|
|
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
|
|
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
|
|
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
|
|
if (picked.length > bound) {
|
|
return {
|
|
ok: false,
|
|
retry: false,
|
|
error: `${name} has more than ${bound} linked players, and pays at most ${bound} in one step — pay from a tally instead`,
|
|
}
|
|
}
|
|
} else {
|
|
const tally = await readTally(server, runId)
|
|
if (!tally.ok) return tally
|
|
|
|
picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
|
|
bound = Number(tally.data.maxRecipients) || bound
|
|
if (picked.length > bound) {
|
|
return {
|
|
ok: false,
|
|
retry: false,
|
|
error: `${picked.length} people qualify, and ${name} pays at most ${bound} in one step — pick a count-based mode or a higher bar`,
|
|
}
|
|
}
|
|
|
|
users = await usersFor(picked.map((p) => p.steamId))
|
|
}
|
|
|
|
// One payment per website user (D103's rule): of two linked accounts that
|
|
// both took part, the earlier in the list — the higher score — is paid.
|
|
const payees = []
|
|
const byUser = new Set()
|
|
const missed = []
|
|
|
|
for (const p of picked) {
|
|
const userId = users.get(p.steamId)
|
|
if (!userId) {
|
|
missed.push(p.name)
|
|
continue
|
|
}
|
|
if (byUser.has(userId)) continue
|
|
byUser.add(userId)
|
|
payees.push(p)
|
|
}
|
|
|
|
const missedDetail = missed.length
|
|
? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' }
|
|
: {}
|
|
|
|
if (!payees.length) {
|
|
return { ok: true, detail: { server: name, mode, amount, paid: 0, ...missedDetail } }
|
|
}
|
|
|
|
const result = await client.economicsPay(server, {
|
|
key: paymentKey(idempotencyKey, runId, stepId),
|
|
amount,
|
|
steamIds: payees.map((p) => p.steamId),
|
|
...(notice.text ? { message: notice.text, delivery: notice.delivery } : {}),
|
|
})
|
|
// A lost answer is retried with the same key, and the plugin answers it from
|
|
// its first reply: nobody is paid twice.
|
|
if (!result.ok) return transportFailure(server, result, 'Economics')
|
|
const data = result.data || {}
|
|
if (data.kind !== 'economics.paid') return refusal(server, data, 'payment')
|
|
|
|
const named = new Map(payees.map((p) => [p.steamId, p.name]))
|
|
const paid = (data.paid || []).map((row) => ({
|
|
name: named.get(String(row.steamId)) || String(row.steamId),
|
|
received: Math.round((Number(row.after) - Number(row.before)) * 100) / 100,
|
|
}))
|
|
const failed = (data.failed || []).map((row) => ({ name: named.get(String(row.steamId)) || String(row.steamId), reason: row.reason }))
|
|
const total = Math.round(paid.reduce((sum, row) => sum + row.received, 0) * 100) / 100
|
|
|
|
log.info('coins paid', { server: server.id, run: runId, step: stepId, amount, paid: paid.length, failed: failed.length, repeat: Boolean(data.repeat) })
|
|
|
|
return {
|
|
ok: true,
|
|
detail: {
|
|
server: name,
|
|
mode,
|
|
...(count.value === null ? {} : { count: count.value }),
|
|
amount,
|
|
paid: paid.length,
|
|
total,
|
|
received: paid.slice(0, 50),
|
|
...(failed.length ? { failed: failed.slice(0, 50), failedNote: failedNote(failed) } : {}),
|
|
...(notice.text && paid.length ? { told: `${paid.length} will be told in game, by ${notice.delivery}` } : {}),
|
|
...(data.repeat ? { repeat: true, repeatNote: 'answered from the first attempt; nobody was paid twice' } : {}),
|
|
...missedDetail,
|
|
},
|
|
}
|
|
},
|
|
}
|
|
|
|
// ── A bigger backpack (protocol 14, PLAN_REDESIGNS.md §11.3.2, D337, D338) ──
|
|
|
|
/** Said when the author writes nothing: the take-back surprises a player nobody warned (D338). */
|
|
const BACKPACK_NOTICE =
|
|
'Your backpack holds {size} until the event ends. Anything past your normal size then moves to your inventory, or drops at your feet if it is full.'
|
|
|
|
/**
|
|
* A bigger backpack's message, checked like a kit reward's (D333): `{ ok, text,
|
|
* delivery }`. Unlike a kit reward's it is never silent: an empty message is
|
|
* the default warning above. Measured with the size in place of `{size}`.
|
|
*/
|
|
function backpackNotice(params, size) {
|
|
const written = params.message === undefined || params.message === null ? '' : String(params.message).trim()
|
|
const text = written || BACKPACK_NOTICE
|
|
|
|
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
|
|
const delivery = oneOf(rawDelivery, DELIVERIES)
|
|
if (!delivery) return { ok: false, error: `a bigger backpack's message goes to chat or to a popup, not to "${rawDelivery}"` }
|
|
|
|
const said = text.replace(/\{size\}/g, String(size))
|
|
if (said.length > MAX_CHAT) {
|
|
return { ok: false, error: `a bigger backpack's message is at most ${MAX_CHAT} characters with the size in it, not ${said.length}` }
|
|
}
|
|
return { ok: true, text, delivery }
|
|
}
|
|
|
|
const backpackSize = {
|
|
id: 'rust.backpack.size',
|
|
label: 'Bigger backpack',
|
|
description:
|
|
'Lends the people a mode picks from this run\'s tally — or every linked player of the server — a bigger Backpacks backpack until the run ends, and tells each of them in game. Waits for them if they are offline. Someone who already holds a size at least as big gains nothing and loses nothing. At the end, whatever is past their normal size moves to their inventory the next time they open the backpack, or drops at their feet if the inventory is full.',
|
|
// A kit reward with a different permission (D337): the same lease, the same
|
|
// revert, so the same risk.
|
|
risk: 'change',
|
|
reversible: 'ledger',
|
|
version: 1,
|
|
budgetMs: BUDGET_MS,
|
|
params: [
|
|
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
|
description: 'The server whose backpacks grow. It needs Backpacks installed.' },
|
|
{ name: 'size', type: 'int', required: true, example: 48, source: 'rust.options.backpacksizes',
|
|
description: 'How many slots, one of the sizes the server\'s Backpacks offers (its backpacks.size.N permissions) above the smallest.' },
|
|
{ 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, the top X per cent — or every linked player of the server, with no tally at all.' },
|
|
{ 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 or linked.' },
|
|
{ name: 'message', type: 'string', required: false, example: BACKPACK_NOTICE,
|
|
description: 'Said once to each player, in game: at once if they are up, otherwise when they next wake on the server. {size} is the size. Left empty, it is the example, which warns about the end.' },
|
|
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
|
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
|
|
],
|
|
|
|
// Priced as a kit reward is: the count for a count, the recipient bound for
|
|
// every other mode.
|
|
cost: (p) => {
|
|
const mode = oneOf(p.recipients, MODES)
|
|
const n = Math.round(Number(p.count) || 0)
|
|
return { 'rust.backpacks': mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS }
|
|
},
|
|
|
|
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
|
const size = Number(params.size)
|
|
if (!Number.isInteger(size) || size < 1) return { ok: false, retry: false, error: `"${params.size}" is not a backpack size — pick one from the list` }
|
|
|
|
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 notice = backpackNotice(params, size)
|
|
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
|
|
|
|
const found = await serverFor(String(params.server || '').trim())
|
|
if (!found.ok) return found
|
|
const server = found.server
|
|
const name = server.name || server.id
|
|
|
|
// D199: refused with the reason where Backpacks is absent, in the dry run
|
|
// too, and a size the server does not offer is refused by name.
|
|
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
|
const report = await live.confirmed(server, () => backpacks.forServer(server.id), (r) => !r.loaded)
|
|
if (!report.loaded) return { ok: false, retry: false, error: `${backpacks.MISSING} (${name})` }
|
|
const offered = backpacks.leasable(report)
|
|
if (!offered.includes(size)) {
|
|
return {
|
|
ok: false,
|
|
retry: false,
|
|
error: offered.length
|
|
? `${name}'s Backpacks offers ${offered.join(', ')} slots above its smallest size, not ${size}`
|
|
: `${name}'s Backpacks offers no size above its smallest, so a bigger backpack gives nobody anything — add sizes to its "Permission sizes"`,
|
|
}
|
|
}
|
|
|
|
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
|
|
return { ok: false, retry: false, error: `${name} lends at most ${MAX_RECIPIENTS} backpacks in one step, not ${count.value}` }
|
|
}
|
|
|
|
if (verify) return { ok: true }
|
|
|
|
const permission = `backpacks.size.${size}`
|
|
const ref = entitlementRef(server.id, runId, stepId)
|
|
const resource = { kind: 'backpack-size', ref, payload: { serverId: server.id, permission } }
|
|
|
|
// As a kit reward's: the rows ARE the lease, and a repeated key finds them.
|
|
const existing = await permDb.listRunGrantsForStep(runId, stepId)
|
|
if (existing.length) {
|
|
return {
|
|
ok: true,
|
|
resources: [resource],
|
|
detail: { repeat: true, lent: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was lent' },
|
|
}
|
|
}
|
|
|
|
let picked
|
|
let bound = MAX_RECIPIENTS
|
|
let users
|
|
|
|
if (mode === 'linked') {
|
|
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
|
|
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
|
|
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
|
|
if (picked.length > bound) {
|
|
return {
|
|
ok: false,
|
|
retry: false,
|
|
error: `${name} has more than ${bound} linked players, and lends at most ${bound} backpacks in one step — lend from a tally instead`,
|
|
}
|
|
}
|
|
} else {
|
|
const tally = await readTally(server, runId)
|
|
if (!tally.ok) return tally
|
|
|
|
picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
|
|
bound = Number(tally.data.maxRecipients) || bound
|
|
if (picked.length > bound) {
|
|
return {
|
|
ok: false,
|
|
retry: false,
|
|
error: `${picked.length} people qualify, and ${name} lends at most ${bound} backpacks in one step — pick a count-based mode or a higher bar`,
|
|
}
|
|
}
|
|
|
|
users = await usersFor(picked.map((p) => p.steamId))
|
|
}
|
|
|
|
// One lease per website user (D103's rule). The permission reaches every
|
|
// account the user has linked, as a kit reward's does (D28).
|
|
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
|
|
}
|
|
if (byUser.has(userId)) continue
|
|
byUser.add(userId)
|
|
rows.push({
|
|
runId,
|
|
stepId,
|
|
idemKey: idempotencyKey,
|
|
userId,
|
|
serverId: server.id,
|
|
steamId: p.steamId,
|
|
permission,
|
|
kit: '',
|
|
credit: false,
|
|
notice: notice.text.replace(/\{size\}/g, String(size)),
|
|
noticeDelivery: notice.delivery,
|
|
})
|
|
}
|
|
|
|
await permDb.insertRunGrants(rows)
|
|
await permDb.markDirty(server.id)
|
|
|
|
log.info('backpacks lent', { server: server.id, run: runId, step: stepId, size, mode, lent: rows.length, missed: missed.length })
|
|
|
|
return {
|
|
ok: true,
|
|
resources: [resource],
|
|
detail: {
|
|
server: name,
|
|
size,
|
|
permission,
|
|
mode,
|
|
...(count.value === null ? {} : { count: count.value }),
|
|
lent: rows.length,
|
|
...(rows.length ? { told: `${rows.length} will be told in game, by ${notice.delivery}` } : {}),
|
|
...(missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}),
|
|
},
|
|
}
|
|
},
|
|
|
|
// The lease ends as a kit reward's does: the step's rows go and the next sync
|
|
// revokes the permission. Backpacks moves the overflow at the owner's next
|
|
// open (D338).
|
|
revert: (ctx) => kitEntitle.revert(ctx),
|
|
reconcile: (ctx) => kitEntitle.reconcile(ctx),
|
|
}
|
|
|
|
/**
|
|
* The body of a chat line: its delivery, and the voice's format when the line
|
|
* goes to chat and a voice is chosen (D140). A popup is not a chat line and
|
|
* carries no format. Chat, the default, is left off the wire — the shape a
|
|
* protocol-11 caller sent — so a line with nothing new looks exactly as before.
|
|
*/
|
|
function lineBody(base, delivery, format) {
|
|
return {
|
|
...base,
|
|
...(delivery === 'popup' ? { delivery } : {}),
|
|
...(delivery !== 'popup' && format ? { format } : {}),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.` },
|
|
// Optional, and the action stays version 1: a bump would stop every step
|
|
// already written from dispatching until somebody re-saved it (§33.2).
|
|
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
|
description: 'chat (the default), or popup — which needs PopupNotifications on the server, and is refused with a reason where it is missing (D141).' },
|
|
],
|
|
|
|
// 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 rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
|
|
const delivery = oneOf(rawDelivery, DELIVERIES)
|
|
if (!delivery) return { ok: false, retry: false, error: `a line is delivered to chat or to a popup, not to "${rawDelivery}"` }
|
|
|
|
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 format = delivery === 'chat' ? await voice.currentFormat() : null
|
|
const body = lineBody({ key: idempotencyKey || `run:${runId}`, message, event: true }, delivery, format)
|
|
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)
|
|
// Read once for the post, not once per server: every server says it in the
|
|
// same voice (D140). Each server's own delivery decides chat or popup (D142).
|
|
const format = list.some((s) => s.newsDelivery !== 'popup') ? await voice.currentFormat() : null
|
|
const outcomes = await Promise.all(
|
|
list.map(async (server) => ({
|
|
server: server.name || server.id,
|
|
...(await sayOn(server, lineBody({ key, message: line }, server.newsDelivery, format))),
|
|
})),
|
|
)
|
|
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)' },
|
|
{ value: 'linked', label: 'Every linked player of the server (no tally)' },
|
|
]),
|
|
fixed('rust.options.delivery', 'Delivery', 'Where a line goes (D141).', [
|
|
{ value: 'chat', label: 'Chat' },
|
|
{ value: 'popup', label: 'A popup — needs PopupNotifications on the server' },
|
|
]),
|
|
{
|
|
// From each server's last hello: the sizes its Backpacks offers above the
|
|
// smallest (D337). Each size once, naming every server that offers it, as
|
|
// the other pickers merge a value (W10). The step checks the pair it is given.
|
|
id: 'rust.options.backpacksizes',
|
|
label: 'Backpack sizes',
|
|
description: "Each server's Backpacks sizes above its smallest — its backpacks.size.N permissions.",
|
|
async resolve() {
|
|
const [list, reports] = await Promise.all([servers.listForPolling(), backpacks.byServer()])
|
|
const bySize = new Map()
|
|
for (const s of list) {
|
|
for (const n of backpacks.leasable(reports.get(s.id))) {
|
|
if (!bySize.has(n)) bySize.set(n, [])
|
|
bySize.get(n).push(s.name || s.id)
|
|
}
|
|
}
|
|
return [...bySize.keys()]
|
|
.sort((x, y) => x - y)
|
|
.map((n) => ({ value: n, label: `${n} slots — ${bySize.get(n).join(', ')}` }))
|
|
},
|
|
},
|
|
{
|
|
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, coinsPay, backpackSize, announce]
|
|
|
|
module.exports = {
|
|
MAX_RECIPIENTS,
|
|
MAX_CHAT,
|
|
EVERY_SERVER,
|
|
DELIVERIES,
|
|
BUDGETS,
|
|
ACTIONS,
|
|
LEG,
|
|
OPTION_SOURCES,
|
|
MODES,
|
|
PERMANENT,
|
|
oneOf,
|
|
peopleOf,
|
|
usersFor,
|
|
readTally,
|
|
entitlementRef,
|
|
failedNote,
|
|
pickRecipients,
|
|
checkCount,
|
|
splitKit,
|
|
kitReward,
|
|
readKit,
|
|
rewardNotice,
|
|
coinsNotice,
|
|
paymentKey,
|
|
backpackNotice,
|
|
BACKPACK_NOTICE,
|
|
chatLine,
|
|
chatKey,
|
|
lineBody,
|
|
refParts,
|
|
}
|