feat(rust): the kit weekend, linked rewards, reward messages and the kit warning (protocol 14)
All checks were successful
PR Checks / client-build (pull_request) Successful in 3m46s
PR Checks / frozen-manifest (pull_request) Successful in 6m12s
PR Checks / server-tests (pull_request) Successful in 7m30s

PLAN_REDESIGNS §2, the Rust half (D164, D192, D332, D333):
- rust.template.kit_weekend (core registerEventTemplates, MODULE_API 1.13.0):
  inputs kit/minutes/message/delivery; one phase advance after the weekend,
  core.lease rust.group.permission default/<kit permission> + rust.kit.entitle
  linked. Steps only where the kit can use them; validated by the real core.
- rust.kit.entitle: recipients 'linked' (every linked player who has played
  on the server, no tally, refused past the bound), optional message ({kit})
  and delivery, stored per row (rust_perm_run_grants.notice*).
- perm.sync notices: the untold set to the picked account, keyed
  run:step:user, in the digest; the report's noticesTold marks them told.
- rust.options.grouppermissions notes a kit's permission rows via kits.uses
  (cached a minute per server; a failure is no note).
- duration: 'minutes' on rust.zone.open and rust.participation.open; the
  group-permission lease's value example is true.
- protocol 13 -> 14, coreApi ^1.13.0; ci/core-ref.json pinned to website#212's
  head while it is open; engagement-triggers.json regenerated (coreApi line).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-12 02:25:06 -05:00
parent be8c09e6b3
commit d21a9e2c0d
17 changed files with 743 additions and 29 deletions

View File

@@ -57,7 +57,9 @@ const DEFAULT_KILL_WEIGHT = 5
const SCORES = ['seconds', 'kills', 'both']
const KILLS_OF = ['players', 'npcs', 'both']
const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent']
// `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 = '*'
@@ -135,7 +137,7 @@ function refParts(ref) {
* `everyone` takes none.
*/
function checkCount(mode, raw) {
if (mode === 'everyone') return { ok: true, value: null }
if (mode === 'everyone' || mode === 'linked') return { ok: true, value: null }
const value = Number(raw)
if (mode === 'top' || mode === 'random') {
@@ -287,6 +289,28 @@ async function readKit(server, kit) {
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 = {
@@ -312,7 +336,7 @@ const participationOpen = {
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,
{ 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: () => ({}),
@@ -480,7 +504,7 @@ 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.',
'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,
@@ -489,9 +513,14 @@ const kitEntitle = {
{ 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.' },
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.' },
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
@@ -513,6 +542,9 @@ const kitEntitle = {
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
@@ -540,20 +572,40 @@ const kitEntitle = {
}
}
const tally = await readTally(server, runId)
if (!tally.ok) return tally
let picked
let bound
let users
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`,
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
const users = await usersFor(picked.map((p) => p.steamId))
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 = []
@@ -579,6 +631,7 @@ const kitEntitle = {
permission: kit.permission,
kit: kit.kit,
credit: kit.max > 0,
...(notice.text ? { notice: notice.text.replace(/\{kit\}/g, kit.kit), noticeDelivery: notice.delivery } : {}),
})
}
@@ -598,6 +651,7 @@ const kitEntitle = {
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' } : {}),
},
}
@@ -862,6 +916,7 @@ const OPTION_SOURCES = [
{ 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' },
@@ -893,6 +948,8 @@ module.exports = {
checkCount,
splitKit,
kitReward,
readKit,
rewardNotice,
chatLine,
chatKey,
lineBody,