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

@@ -100,7 +100,7 @@ function pluginError(data, fallback) {
* of them to forget the drift check, which is the one thing §F says a lease must
* not be allowed to skip.
*/
function lease({ id, label, description, type, min, max, family, targetLabel, source, example, wire }) {
function lease({ id, label, description, type, min, max, family, targetLabel, source, example, valueExample, wire }) {
async function resolve(target) {
const { serverId, rest } = splitTarget(target)
const found = await serverFor(serverId)
@@ -130,6 +130,8 @@ function lease({ id, label, description, type, min, max, family, targetLabel, so
type,
...(min === undefined ? {} : { min }),
...(max === undefined ? {} : { max }),
// The VALUE's example (core MODULE_API 1.13.0), beside the target's below.
...(valueExample === undefined ? {} : { example: valueExample }),
maxDurationMs: MAX_LEASE_MS,
target: { label: targetLabel, source, example },
@@ -322,6 +324,7 @@ const LEASES = [
targetLabel: 'Which server, group and permission',
source: 'rust.options.grouppermissions',
example: 'main/default/kits.vip',
valueExample: true,
wire: (rest) => {
const slash = rest.lastIndexOf('/')
if (slash <= 0 || slash >= rest.length - 1) {
@@ -371,6 +374,80 @@ async function familyOptions(family, sourceId) {
return bounded(rows, sourceId)
}
// ── The kit warning (PLAN_REDESIGNS.md §2.6, protocol 14) ────────────────────
//
// "Kit vip: 2 uses, 24 h cooldown — 9 of 41 linked players have used it up."
// Shown under a group-permission target that is a kit's permission, which is
// what a kit weekend borrows: it tells the author that the weekend's extra use
// (step 2) is what lets those players take the kit again.
/** How long one server's kit counts are reused. The editor asks while typing. */
const KIT_NOTE_TTL_MS = 60_000
/** The most kits one server's notes ask about. One `kits.uses` each. */
const KIT_NOTE_MAX_KITS = 25
const kitNoteCache = new Map()
/** "24 h", "90 min", "45 s". */
function durationWords(seconds) {
const s = Math.max(0, Math.round(Number(seconds) || 0))
if (s >= 3600 && s % 3600 === 0) return `${s / 3600} h`
if (s >= 60) return `${Math.round(s / 60)} min`
return `${s} s`
}
/** One kit's note, from its `kits.list` row and its `kits.uses` answer. */
function kitNote(kit, uses) {
const limit = Number(kit.max) > 0 ? `${kit.max} use${Number(kit.max) === 1 ? '' : 's'}` : 'no use limit'
const cooldown = Number(kit.cooldown) > 0 ? `, ${durationWords(kit.cooldown)} cooldown` : ''
const asked = Number(uses.asked) || 0
if (!asked) return `Kit ${kit.name}: ${limit}${cooldown} — no linked player has played on this server yet.`
const used = Number(kit.max) > 0
? `${Number(uses.exhausted) || 0} of ${asked} linked players have used it up`
: `${asked} linked players`
const waiting = Number(uses.onCooldown) > 0 ? `, ${uses.onCooldown} are on its cooldown` : ''
return `Kit ${kit.name}: ${limit}${cooldown} — ${used}${waiting}. A kit weekend's extra use is what lets them take it again.`
}
/**
* Permission → note, for the kits on one server that a permission gates.
* Cached a minute per server; anything that fails is simply no note — a warning
* is advice, and its absence must never break the field it advises.
*/
async function kitNotes(server) {
const cached = kitNoteCache.get(server.id)
if (cached && Date.now() - cached.at < KIT_NOTE_TTL_MS) return cached.notes
const notes = new Map()
try {
const listed = await client.kits(server)
const gated = ((listed.ok && listed.data && listed.data.kits) || [])
.filter((k) => k && k.permission)
.slice(0, KIT_NOTE_MAX_KITS)
if (gated.length) {
// eslint-disable-next-line global-require
const linksDb = require('./model/links/links.db')
const steamIds = (await linksDb.linkedOnServer(server.id)).map((r) => String(r.steamId))
for (const kit of gated) {
const uses = await client.kitsUses(server, kit.name, steamIds)
if (!uses.ok || !uses.data || uses.data.kind !== 'kits.uses') continue
const permission = String(kit.permission).toLowerCase()
// Two kits behind one permission: both are named, one after the other.
const note = kitNote(kit, uses.data)
notes.set(permission, notes.has(permission) ? `${notes.get(permission)} ${note}` : note)
}
}
} catch (err) {
log.warn('kit notes could not be read', { server: server.id, message: err.message })
}
kitNoteCache.set(server.id, { at: Date.now(), notes })
return notes
}
const OPTION_SOURCES = [
{
id: 'rust.options.servers',
@@ -411,13 +488,19 @@ const OPTION_SOURCES = [
for (const { server, result } of answers) {
const data = result.data || {}
const perms = (data.permissions || []).map((p) => String(p).toLowerCase())
// The kit warning (PLAN_REDESIGNS.md §2.6): a row that grants a kit's
// permission says how many of the server's linked players have used it
// up. A kit's permission is whatever its operator typed into Kits, not
// always `kits.*`, so every server is asked — once a minute (cached).
const notes = await kitNotes(server)
for (const g of data.groups || []) {
const group = g && g.name ? String(g.name).toLowerCase() : null
if (!group) continue
for (const perm of perms) {
const value = `${server.id}/${group}/${perm}`
if (term && !value.includes(term)) continue
rows.push({ value, label: `${group} · ${perm}`, group: server.name || server.id })
const note = notes.get(perm)
rows.push({ value, label: `${group} · ${perm}`, group: server.name || server.id, ...(note ? { note } : {}) })
}
}
}
@@ -437,4 +520,7 @@ module.exports = {
pluginError,
perServer,
bounded,
kitNote,
kitNotes,
kitNoteCache,
}