Merge pull request 'feat(rust): the kit weekend, linked rewards, reward messages and the kit warning (protocol 14)' (#38) from feat/protocol-14-step-editor into edge

Reviewed-on: #38
This commit is contained in:
2026-10-12 09:51:57 +00:00
17 changed files with 743 additions and 29 deletions

View File

@@ -37,6 +37,7 @@
"eventLeases.js",
"eventProgress.js",
"eventRewards.js",
"eventTemplates.js",
"eventWorld.js",
"index.js",
"ingest.js",

View File

@@ -1,6 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as.",
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. Pinned to website#212's branch head while that PR is open; re-pin to the sha it merges as.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "f0e7d2aa2a4dbdd848f92648085181611b7418f0",
"refName": "main @ MODULE_API 1.11.0 (website#209 merged)"
"ref": "e58857c133b9cb89664341949f9130fab6aa48c0",
"refName": "feat/event-step-editor @ MODULE_API 1.13.0 (website#212, open)"
}

View File

@@ -1,6 +1,6 @@
{
"_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.",
"coreApi": "^1.11.0",
"coreApi": "^1.13.0",
"triggers": [
{
"id": "rust.base.destroyed",

View File

@@ -2,7 +2,7 @@
"id": "rust",
"name": "Rust",
"version": "0.1.0",
"coreApi": "^1.11.0",
"coreApi": "^1.13.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",

View File

@@ -857,6 +857,16 @@ CREATE TABLE IF NOT EXISTS rust_perm_run_grants (
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The reward's message to its player (protocol 14, PLAN_REDESIGNS.md §2.4,
-- D333): "You earned kit vip — type /kit vip", said once in game, at once or
-- when they next wake. NULL `notice` is a reward that says nothing, which is
-- every row written before this. `notice_told_at` is set when the plugin
-- reports the player was told; until then every sync carries it. A withdrawn
-- reward deletes its row, and its untold message goes with it.
ALTER TABLE rust_perm_run_grants ADD COLUMN IF NOT EXISTS notice VARCHAR(300) NULL;
ALTER TABLE rust_perm_run_grants ADD COLUMN IF NOT EXISTS notice_delivery VARCHAR(8) NULL;
ALTER TABLE rust_perm_run_grants ADD COLUMN IF NOT EXISTS notice_told_at DATETIME NULL;
-- The news switch (D104): whether a published news post is also said in this
-- server's chat. Off, because core enqueues every registered leg for every
-- post, and without it the day this module updates every post would start

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

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,

154
server/eventTemplates.js Normal file
View File

@@ -0,0 +1,154 @@
// ── Event templates (PLAN_REDESIGNS.md §2.5, D164, D192) ────────────────────
//
// A named starting point for a new event, through core's
// `api.registerEventTemplates` (MODULE_API 1.13.0). The author fills in a few
// inputs; `build()` turns them into an ordinary draft — phases, steps, params —
// which the editor opens unsaved. Core validates the plan as a spec and keeps
// nothing about the template, so this file can change without touching a
// single event it was ever used for.
//
// ── The kit weekend ──────────────────────────────────────────────────────────
//
// "Everybody gets kit vip this weekend." One phase that lasts the weekend, and
// two steps in it:
//
// 1. core.lease → rust.group.permission: the `default` group holds the kit's
// permission for the weekend, so every player can see and redeem it. The
// game holds the deadline and gives it back on time whether or not the site
// is up.
// 2. rust.kit.entitle → that kit, every linked player of the server (`linked`),
// one more use each — the use a player who already spent theirs needs to
// take the kit again. Withdrawn at teardown if not yet redeemed (D103).
//
// A kit with no permission is open to everyone already, so its weekend is step
// 2 alone; a kit with no use limit has no "one more use", so its weekend is step
// 1 alone. A kit with neither is refused by `readKit`, because a weekend of it
// gives nobody anything.
const { serverFor } = require('./eventLeases')
const { readKit, splitKit, rewardNotice, MAX_CHAT } = require('./eventRewards')
/** The lease's own ceiling (D77): a weekend longer than seven days is refused by the game. */
const MAX_WEEKEND_MINUTES = 7 * 24 * 60
/** "48 hours", "90 minutes". */
function minutesWords(n) {
if (n >= 120 && n % 60 === 0) return `${n / 60} hours`
return `${n} minute${n === 1 ? '' : 's'}`
}
const kitWeekend = {
id: 'rust.template.kit_weekend',
label: 'Kit weekend',
description:
'A kit for everybody, for a while: the default group holds the kit\'s permission for the length of the weekend, and every linked player of the server gets one more use of it. You can change every part of the plan before saving it.',
inputs: [
{
name: 'kit',
type: 'string',
required: true,
example: 'main/vip',
source: 'rust.options.kits',
description: 'The kit, as server/kit. The weekend happens on that server only.',
},
{
name: 'minutes',
type: 'int',
required: true,
example: 2880,
duration: 'minutes',
description: `How long the weekend lasts, up to ${MAX_WEEKEND_MINUTES} (seven days). 2880 is two days.`,
},
{
name: 'message',
type: 'string',
required: false,
example: 'Kit weekend! One more use of {kit} — type /kit {kit}',
description: `Told once to each linked player in game, at once or when they next wake. {kit} is the kit's name; at most ${MAX_CHAT} characters. Leave it empty to say nothing.`,
},
{
name: 'delivery',
type: 'string',
required: false,
example: 'popup',
source: 'rust.options.delivery',
description: 'Chat, or a popup where the server has PopupNotifications.',
},
],
async build({ inputs }) {
const parsed = splitKit(inputs.kit)
if (!parsed) return { ok: false, error: `"${inputs.kit}" is not a kit — pick one from the list, as server/kit` }
const minutes = Number(inputs.minutes)
if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_WEEKEND_MINUTES) {
return { ok: false, error: `a kit weekend lasts 1 to ${MAX_WEEKEND_MINUTES} minutes, not ${inputs.minutes}` }
}
const notice = rewardNotice({ message: inputs.message, delivery: inputs.delivery }, parsed.kit)
if (!notice.ok) return { ok: false, error: notice.error }
const found = await serverFor(parsed.serverId)
if (!found.ok) return { ok: false, error: found.error }
const server = found.server
const serverName = server.name || server.id
// The kit as its server describes it, now. A server that is down cannot say
// which permission gates the kit, and a plan guessed at would be wrong.
const kit = await readKit(server, parsed.kit)
if (!kit.ok) return { ok: false, error: kit.error }
const steps = []
if (kit.permission) {
steps.push({
actionId: 'core.lease',
label: `Open ${kit.kit} to everyone`,
params: {
lease: 'rust.group.permission',
target: `${server.id}/default/${kit.permission}`,
value: 'true',
minutes,
},
})
}
if (kit.max > 0) {
steps.push({
actionId: 'rust.kit.entitle',
label: 'One more use for every linked player',
params: {
kit: `${server.id}/${kit.kit}`,
recipients: 'linked',
...(notice.text ? { message: notice.text, delivery: notice.delivery } : {}),
},
})
}
const parts = [
kit.permission ? `everybody on ${serverName} may redeem ${kit.kit}` : null,
kit.max > 0 ? 'every linked player gets one more use of it' : null,
].filter(Boolean)
return {
title: `Kit weekend: ${kit.kit} on ${serverName}`,
summary: `For ${minutesWords(minutes)}, ${parts.join(', and ')}.`,
spec: {
schedule: { kind: 'manual' },
phases: [
{
key: 'weekend',
label: 'The weekend',
// The phase IS the weekend: it holds the run open for as long as the
// lease lasts, so teardown gives the permission back on time and not
// a second after the steps finish (§2.2, U-6).
advance: { after: `${minutes}m` },
steps,
},
],
},
}
},
}
const TEMPLATES = [kitWeekend]
module.exports = { TEMPLATES, MAX_WEEKEND_MINUTES, minutesWords }

View File

@@ -696,6 +696,9 @@ const ACTIONS = [
type: 'int',
required: true,
example: 120,
// A timed step (core MODULE_API 1.13.0): the editor keeps the run open
// this long, because teardown erases the zone when the run ends.
duration: 'minutes',
description: `How long it stands, up to ${ZONE_MAX_MINUTES} (seven days). Counted against zone time.`,
},
{ name: 'name', type: 'string', required: false, example: 'Airfield brawl', description: 'What the zone is called.' },

View File

@@ -59,6 +59,7 @@ module.exports = function register(ctx, api) {
const eventLeases = require('./eventLeases')
const eventWorld = require('./eventWorld')
const eventRewards = require('./eventRewards')
const eventTemplates = require('./eventTemplates')
const boot = require('./boot')
const commands = require('./commands')
/* eslint-enable global-require */
@@ -171,6 +172,11 @@ module.exports = function register(ctx, api) {
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS])
// The templates (PLAN_REDESIGNS.md §2.5, MODULE_API 1.13.0): a kit weekend
// as a starting point on core's New event screen. Core keeps nothing about a
// template; what it builds is an ordinary draft.
api.registerEventTemplates(eventTemplates.TEMPLATES)
// News in game chat (D104). Core enqueues every registered leg for every
// published post, so the leg itself sends only to the servers whose switch an
// operator turned on — off by default, and a server that is down is skipped.

View File

@@ -166,6 +166,28 @@ async function userIdsForSteamIds(steamIds) {
)
}
/**
* The linked players of one server: every linked Steam account that has played
* there, in any wipe (PLAN_REDESIGNS.md §2.5, the kit weekend's `linked`).
*
* "Has played there" is a stats row on that server, which every player who ever
* connected has. A link is fleet-wide, so the server is what narrows it: a kit
* weekend on one server rewards that server's players, not every account on the
* site. Bounded at `limit`, ordered by most recently seen, so a caller with a
* cap keeps the people who are actually around.
*/
async function linkedOnServer(serverId, limit = 5000) {
return core.query(
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name AS name, MAX(s.last_seen) AS lastSeen
FROM ${LINKS} l
JOIN rust_player_wipe_stats s ON s.steam_id = l.steam_id AND s.server_id = ?
GROUP BY l.steam_id, l.user_id, l.name
ORDER BY lastSeen DESC, l.steam_id
LIMIT ?`,
[serverId, Number(limit) || 5000],
)
}
/**
* The servers that handed out a link code in the last `windowSec` (PLAN_FIXES F5).
*
@@ -195,4 +217,5 @@ module.exports = {
removeBySteamId,
statsForSteamId,
userIdsForSteamIds,
linkedOnServer,
}

View File

@@ -398,7 +398,8 @@ async function deleteExceptionsFor(holder, grantId) {
async function listRunGrants() {
return core.query(
`SELECT run_id AS runId, step_id AS stepId, user_id AS userId, server_id AS serverId,
steam_id AS steamId, permission, kit, credit
steam_id AS steamId, permission, kit, credit,
notice, notice_delivery AS noticeDelivery, notice_told_at AS noticeToldAt
FROM ${RUN_GRANTS}`,
)
}
@@ -416,8 +417,8 @@ async function insertRunGrants(rows) {
if (!rows.length) return 0
const result = await core.query(
`INSERT IGNORE INTO ${RUN_GRANTS} (run_id, step_id, idem_key, user_id, server_id, steam_id, permission, kit, credit)
VALUES ${placeholders(rows, 9)}`,
`INSERT IGNORE INTO ${RUN_GRANTS} (run_id, step_id, idem_key, user_id, server_id, steam_id, permission, kit, credit, notice, notice_delivery)
VALUES ${placeholders(rows, 11)}`,
rows.flatMap((r) => [
String(r.runId),
String(r.stepId),
@@ -428,12 +429,36 @@ async function insertRunGrants(rows) {
r.permission || '',
r.kit,
r.credit ? 1 : 0,
r.notice || null,
r.notice ? r.noticeDelivery || 'chat' : null,
]),
)
return affected(result)
}
/**
* Mark reward messages told (protocol 14, D333): the plugin listed their keys in
* a sync report. A key is `<runId>:<stepId>:<userId>` (`noticeKey` in the
* model); one that names no untold row — told already, or withdrawn — changes
* nothing.
*/
async function markNoticesTold(serverId, keys) {
let marked = 0
for (const key of keys || []) {
const [runId, stepId, userId] = String(key).split(':')
if (!runId || !stepId || !Number(userId)) continue
const result = await core.query(
`UPDATE ${RUN_GRANTS} SET notice_told_at = NOW()
WHERE server_id = ? AND run_id = ? AND step_id = ? AND user_id = ?
AND notice IS NOT NULL AND notice_told_at IS NULL`,
[serverId, runId, stepId, Number(userId)],
)
marked += affected(result)
}
return marked
}
async function deleteRunGrantsForStep(runId, stepId) {
return deleteRunGrantsWhere('run_id = ? AND step_id = ?', [String(runId), String(stepId)])
}
@@ -858,6 +883,7 @@ module.exports = {
deleteException,
deleteExceptionsFor,
listRunGrants,
markNoticesTold,
listRunGrantsForStep,
insertRunGrants,
deleteRunGrantsForStep,

View File

@@ -365,6 +365,7 @@ function buildDesired(serverId, authored) {
// every account the user has linked (D28). The CREDIT is one extra use on the
// account that took part, while it is still linked to the winner.
const credits = new Map()
const notices = []
for (const row of runGrants) {
if (row.serverId !== serverId) continue
@@ -380,6 +381,19 @@ function buildDesired(serverId, authored) {
const key = `${row.steamId}|${row.kit}`
credits.set(key, (credits.get(key) || 0) + 1)
}
// The reward's message (protocol 14, D333), to the account the reward
// picked, while it is still linked to the winner — and only until the
// plugin reports it said it. A withdrawn reward has no row, so nobody is
// told about a reward that is no longer theirs.
if (row.notice && !row.noticeToldAt && linked.includes(row.steamId)) {
notices.push({
key: noticeKey(row),
steamId: row.steamId,
message: String(row.notice),
delivery: row.noticeDelivery === 'popup' ? 'popup' : 'chat',
})
}
}
// ── A group's BetterChat style (phase 17, D138) ───────────────────────
@@ -415,11 +429,15 @@ function buildDesired(serverId, authored) {
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({ steamId, permissions })),
// Always sent, even empty (D103).
credits: creditRows,
// Always sent, even empty (D333): the whole untold set, so a notice the
// site stops sending is one the plugin may forget.
notices: notices.sort((a, b) => a.key.localeCompare(b.key)),
}
const hashed = [
...rows,
...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
...notices.map((n) => ({ kind: 'notice', subject: n.steamId, object: n.key, value: `${n.delivery}:${n.message}` })),
]
return { payload, rows, hash: hashRows(hashed), sources }
@@ -441,6 +459,15 @@ function isShared(group, serverRows) {
* A digest of the desired set. Sorted before hashing, and a row's VALUE is in
* it (a style field, a group's title, rank and parent): a change to one must push.
*/
/**
* The key of one reward message (protocol 14, D333): `<runId>:<stepId>:<userId>`.
* One reward step tells one person once, whichever of their linked accounts it
* picked; `permissions.db.markNoticesTold` reads it back.
*/
function noticeKey(row) {
return `${row.runId}:${row.stepId}:${row.userId}`
}
function hashRows(rows) {
const canonical = rows
.map((row) => `${row.kind} ${row.subject} ${row.object}${row.value === undefined || row.value === null ? '' : `=${row.value}`}`)
@@ -561,6 +588,7 @@ module.exports = {
buildDesired,
retirements,
hashRows,
noticeKey,
rowKey,
collapseGrants,
shapeSync,

View File

@@ -458,6 +458,11 @@ async function applyReport(server, { desired, hash, retire, styleRetired = [], r
const revocations = await db.listRevocations(server.id)
await db.deleteRevocations(revocations.map((row) => row.id))
// Protocol 14 (D333). The reward messages the plugin has said; they leave the
// next sync's set, which is what lets the plugin forget them.
const noticesTold = Array.isArray(report.noticesTold) ? report.noticesTold : []
const marked = noticesTold.length ? await db.markNoticesTold(server.id, noticesTold) : 0
// What waits for a person: the reconcile's (D161's `adopt`, and an event's
// grant removed in the game), and a style field somebody changed by hand (D138).
await db.replaceDrift(server.id, [
@@ -505,6 +510,7 @@ async function applyReport(server, { desired, hash, retire, styleRetired = [], r
...(report.creditsApplied !== undefined
? { creditsApplied: report.creditsApplied, creditsWithdrawn: report.creditsWithdrawn }
: {}),
...(marked ? { noticesTold: marked } : {}),
})
}

View File

@@ -69,7 +69,10 @@ const TIMEOUT_MS = 12000
* `/map/render` and `/map/live`, its picture and what moves on it (PLAN.md
* §30); **12** adds `/titles`, the chat titles BetterChat shows, and gives a
* permission sync a group's BetterChat style and a chat line a delivery and a
* voice (PLAN.md §33). The bump lands here in the same change as the emitters,
* voice (PLAN.md §33); **13** is the first player walk's fixes and redesigns and
* RunicNPC (PLAN_FIXES.md, PLAN_REDESIGNS.md §1–§6); **14** adds `/kits/uses`
* and a permission sync's `notices` — the step editor's kit warning and reward
* message (PLAN_REDESIGNS.md §2, D332, D333). The bump lands here in the same change as the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
@@ -79,7 +82,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 13
const PROTOCOL_VERSION = 14
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -448,6 +451,15 @@ const tallyClose = (server, body) => request(server, '/tally/close', { method: '
/** The kits this server's Kits plugin has, and the plugin's reward bounds. `kits.error` when Kits is not loaded. */
const kits = (server) => request(server, '/kits')
/**
* Protocol 14 (PLAN_REDESIGNS.md §2.6). Of these players, how many have used `kit`
* up and how many are on its cooldown: `kits.uses` with `{ kit, max, cooldown,
* asked, exhausted, onCooldown }`, counts only. `kits.error` when Kits is not
* loaded or has no such kit.
*/
const kitsUses = (server, kit, steamIds) =>
request(server, '/kits/uses', { method: 'POST', body: { kit, steamIds } })
/**
* Say one line in the server's chat. `chat.ok` carries `said: false` when the
* key was said in the last ten minutes — a retry, answered and not repeated.
@@ -536,6 +548,7 @@ module.exports = {
tallySnapshot,
tallyClose,
kits,
kitsUses,
chat,
titles,
mapInfo,

View File

@@ -160,6 +160,8 @@ function fakeApi() {
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
// MODULE_API 1.13.0 (PLAN_REDESIGNS.md §2.5).
registerEventTemplates(templates) { once('registerEventTemplates'); record.eventTemplates = templates },
// Core's `once()` holds here too: a module's commands are one batch.
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },

View File

@@ -0,0 +1,299 @@
// ── The step editor's Rust half (PLAN_REDESIGNS.md §2, protocol 14) ──────────
//
// `linked` rewards every linked player of the server, reads no tally, and is
// refused past the server's bound rather than trimmed (§2.5)
// a reward's message is checked with the kit's name in it, and a blank one
// says nothing — every step saved before protocol 14 (D333)
// the sync carries every untold message to the account the reward picked,
// and stops once it is told; the key reads back to its row
// the kit warning names how many linked players have used the kit up (§2.6)
// the kit weekend builds the plan the plan describes, and only the steps the
// kit can use (§2.5, D164)
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
require('../core')._reset()
require('../core').init(fakeCtx())
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
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 rewards = require('../eventRewards')
const leases = require('../eventLeases')
const templates = require('../eventTemplates')
const model = require('../model/permissions/permissions.model')
const entitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle')
const ok = (data) => ({ ok: true, status: 'ok', data })
const MAIN = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
const KIT = { name: 'vip', permission: 'kits.vip', max: 2, cooldown: 86400 }
/** Swap one module's members for a test, and put them back after. */
function swap(t, mod, members) {
const saved = {}
for (const k of Object.keys(members)) saved[k] = mod[k]
Object.assign(mod, members)
t.after(() => Object.assign(mod, saved))
}
function rewardStubs(t, { linked = [], kits = [KIT], maxRecipients = 100 } = {}) {
const calls = { grants: [], tally: 0 }
swap(t, serversDb, { getServer: async (id) => (id === 'main' ? MAIN : null) })
swap(t, client, {
kits: async () => ok({ kind: 'kits.list', kits, maxRecipients }),
tallySnapshot: async () => {
calls.tally++
return ok({ kind: 'tally.snapshot', people: [], maxRecipients })
},
})
swap(t, permDb, {
insertRunGrants: async (rows) => calls.grants.push(...rows),
listRunGrantsForStep: async () => [],
markDirty: async () => {},
})
swap(t, linksDb, {
linkedOnServer: async (serverId, limit) => linked.slice(0, limit),
})
swap(t, emit, { entitled: () => 0 })
return calls
}
const envelope = (params) => ({ runId: '7', stepId: '3', idempotencyKey: 'k', params })
// ── linked ──────────────────────────────────────────────────────────────────
test('linked rewards every linked player of the server and reads no tally', async (t) => {
const calls = rewardStubs(t, {
linked: [
{ steamId: '76561190000000001', userId: 11, name: 'Ann' },
{ steamId: '76561190000000002', userId: 12, name: 'Bo' },
],
})
const answer = await entitle.perform(envelope({ kit: 'main/vip', recipients: 'linked' }))
assert.strictEqual(answer.ok, true, answer.error)
assert.strictEqual(calls.tally, 0)
assert.deepStrictEqual(calls.grants.map((r) => [r.userId, r.steamId, r.kit, r.credit]), [
[11, '76561190000000001', 'vip', true],
[12, '76561190000000002', 'vip', true],
])
// No message asked for, so none written: a step from before protocol 14.
assert.ok(calls.grants.every((r) => !('notice' in r)))
assert.strictEqual(answer.detail.mode, 'linked')
})
test('linked past the server\'s bound is refused, never trimmed', async (t) => {
const linked = Array.from({ length: 4 }, (_, i) => ({ steamId: `7656119000000000${i}`, userId: i + 1 }))
rewardStubs(t, { linked, maxRecipients: 3 })
const answer = await entitle.perform(envelope({ kit: 'main/vip', recipients: 'linked' }))
assert.strictEqual(answer.ok, false)
assert.strictEqual(answer.retry, false)
assert.match(answer.error, /more than 3 linked players/)
})
test('linked takes no count and is priced at the most a step can grant', () => {
assert.deepStrictEqual(rewards.checkCount('linked', 5), { ok: true, value: null })
assert.deepStrictEqual(entitle.cost({ recipients: 'linked' }), { 'rust.grants': rewards.MAX_RECIPIENTS })
})
// ── The reward's message (D333) ─────────────────────────────────────────────
test('a message is written per row with the kit\'s name in it, and says how it goes', async (t) => {
const calls = rewardStubs(t, { linked: [{ steamId: '76561190000000001', userId: 11 }] })
const answer = await entitle.perform(envelope({
kit: 'main/vip', recipients: 'linked', message: 'You earned kit {kit} — type /kit {kit}', delivery: 'Popup',
}))
assert.strictEqual(answer.ok, true, answer.error)
assert.strictEqual(calls.grants[0].notice, 'You earned kit vip — type /kit vip')
assert.strictEqual(calls.grants[0].noticeDelivery, 'popup')
assert.match(answer.detail.told, /1 will be told in game, by popup/)
})
test('a message is checked on the form: its length with the kit in it, and where it goes', () => {
assert.deepStrictEqual(rewards.rewardNotice({}, 'vip'), { ok: true, text: null, delivery: null })
assert.deepStrictEqual(rewards.rewardNotice({ message: ' ' }, 'vip'), { ok: true, text: null, delivery: null })
assert.strictEqual(rewards.rewardNotice({ message: 'hi' }, 'vip').delivery, 'chat')
assert.match(rewards.rewardNotice({ message: 'hi', delivery: 'shout' }, 'vip').error, /not to "shout"/)
// 250 characters fit; with a long kit name in place of {kit} they do not.
const text = `${'x'.repeat(245)}{kit}`
assert.strictEqual(rewards.rewardNotice({ message: text }, 'vip').ok, true)
assert.match(rewards.rewardNotice({ message: text }, 'a-very-long-kit-name').error, /at most 256 characters/)
})
// ── The sync's notices ──────────────────────────────────────────────────────
const authored = (runGrants) => ({
groups: [],
groupServers: [],
groupPermissions: [],
members: [],
steamMembers: [],
grants: [],
steamGrants: [],
exceptions: [],
groupChat: [],
steamIdsByUser: new Map([[11, ['76561190000000001']], [12, ['76561190000000002']]]),
runGrants,
})
const grantRow = (extra) => ({
runId: '7', stepId: '3', userId: 11, serverId: 'main', steamId: '76561190000000001',
permission: 'kits.vip', kit: 'vip', credit: 1, notice: null, noticeDelivery: null, noticeToldAt: null, ...extra,
})
test('the sync carries each untold message to the account the reward picked', () => {
const desired = model.buildDesired('main', authored([
grantRow({ notice: 'You earned kit vip', noticeDelivery: 'popup' }),
// Told already: it leaves the set, which is what lets the plugin forget it.
grantRow({ userId: 12, steamId: '76561190000000002', notice: 'x', noticeToldAt: new Date() }),
// No message: a reward that says nothing.
grantRow({ stepId: '4' }),
// Another server's reward is that server's to say.
grantRow({ serverId: 'alt', stepId: '5', notice: 'elsewhere' }),
// Unlinked since: nobody to tell.
grantRow({ stepId: '6', steamId: '76561190000000099', notice: 'gone' }),
]))
assert.deepStrictEqual(desired.payload.notices, [
{ key: '7:3:11', steamId: '76561190000000001', message: 'You earned kit vip', delivery: 'popup' },
])
})
test('a notice moves the digest, so a new one is pushed and a told one is dropped', () => {
const none = model.buildDesired('main', authored([grantRow({})]))
const one = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeDelivery: 'chat' })]))
const told = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeToldAt: new Date() })]))
assert.deepStrictEqual(none.payload.notices, [])
assert.notStrictEqual(none.hash, one.hash)
assert.strictEqual(none.hash, told.hash)
})
test('a told key reads back to its row; a malformed one changes nothing', async (t) => {
const core = require('../core')
const queries = []
swap(t, core, {
query: async (sql, params) => {
queries.push(params)
return { affectedRows: 1 }
},
})
const marked = await permDb.markNoticesTold('main', ['7:3:11', 'nonsense', '7:3:x'])
assert.strictEqual(marked, 1)
assert.deepStrictEqual(queries, [['main', '7', '3', 11]])
})
// ── The kit warning (§2.6) ──────────────────────────────────────────────────
test('the note says the limit, the cooldown, and how many have used the kit up', () => {
assert.strictEqual(
leases.kitNote(KIT, { asked: 41, exhausted: 9, onCooldown: 3 }),
'Kit vip: 2 uses, 24 h cooldown — 9 of 41 linked players have used it up, 3 are on its cooldown. A kit weekend\'s extra use is what lets them take it again.',
)
assert.match(leases.kitNote({ name: 'free', max: 0, cooldown: 0 }, { asked: 5 }), /^Kit free: no use limit — 5 linked players\./)
assert.match(leases.kitNote(KIT, { asked: 0 }), /no linked player has played on this server yet/)
})
test('the group-permission source notes the rows that grant a kit\'s permission', async (t) => {
leases.kitNoteCache.clear()
t.after(() => leases.kitNoteCache.clear())
const asked = []
swap(t, servers, { listForPolling: async () => [{ id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' }] })
swap(t, client, {
permCatalogue: async () => ok({ kind: 'perm.catalogue', permissions: ['kits.vip', 'other.thing'], groups: [{ name: 'default' }] }),
kits: async () => ok({ kind: 'kits.list', kits: [KIT, { name: 'open', permission: '', max: 1 }] }),
kitsUses: async (server, kit, steamIds) => {
asked.push({ kit, steamIds })
return ok({ kind: 'kits.uses', kit, max: 2, cooldown: 86400, asked: steamIds.length, exhausted: 1, onCooldown: 0 })
},
})
swap(t, linksDb, { linkedOnServer: async () => [{ steamId: '76561190000000001' }, { steamId: '76561190000000002' }] })
const source = leases.OPTION_SOURCES.find((s) => s.id === 'rust.options.grouppermissions')
const rows = await source.resolve({})
const vip = rows.find((r) => r.value === 'main/default/kits.vip')
const other = rows.find((r) => r.value === 'main/default/other.thing')
assert.match(vip.note, /^Kit vip: 2 uses, 24 h cooldown — 1 of 2 linked players have used it up\./)
assert.strictEqual('note' in other, false)
// Only the kit a permission gates is asked about, and with the server's linked players.
assert.deepStrictEqual(asked, [{ kit: 'vip', steamIds: ['76561190000000001', '76561190000000002'] }])
// Asked again within the minute: answered from the cache.
await source.resolve({})
assert.strictEqual(asked.length, 1)
})
test('a kit warning that cannot be read is no note, never a broken field', async (t) => {
leases.kitNoteCache.clear()
t.after(() => leases.kitNoteCache.clear())
swap(t, client, {
kits: async () => ok({ kind: 'kits.list', kits: [KIT] }),
kitsUses: async () => ({ ok: false, status: 'timeout', data: null }),
})
swap(t, linksDb, { linkedOnServer: async () => [] })
const notes = await leases.kitNotes({ id: 'main' })
assert.strictEqual(notes.size, 0)
})
// ── The kit weekend (§2.5) ──────────────────────────────────────────────────
const weekend = templates.TEMPLATES.find((x) => x.id === 'rust.template.kit_weekend')
function weekendStubs(t, kits = [KIT]) {
swap(t, serversDb, { getServer: async (id) => (id === 'main' ? MAIN : null) })
swap(t, client, { kits: async () => ok({ kind: 'kits.list', kits, maxRecipients: 100 }) })
}
test('the kit weekend is one phase as long as the weekend, holding both steps', async (t) => {
weekendStubs(t)
const draft = await weekend.build({ inputs: { kit: 'main/vip', minutes: 2880, message: 'Kit weekend! {kit}', delivery: 'popup' } })
assert.strictEqual(draft.title, 'Kit weekend: vip on Main')
assert.strictEqual(draft.summary, 'For 48 hours, everybody on Main may redeem vip, and every linked player gets one more use of it.')
const [phase] = draft.spec.phases
assert.deepStrictEqual(phase.advance, { after: '2880m' })
assert.deepStrictEqual(phase.steps.map((s) => s.actionId), ['core.lease', 'rust.kit.entitle'])
assert.deepStrictEqual(phase.steps[0].params, {
lease: 'rust.group.permission', target: 'main/default/kits.vip', value: 'true', minutes: 2880,
})
assert.deepStrictEqual(phase.steps[1].params, {
kit: 'main/vip', recipients: 'linked', message: 'Kit weekend! {kit}', delivery: 'popup',
})
})
test('a kit with no permission is step 2 alone; one with no use limit is step 1 alone', async (t) => {
weekendStubs(t, [{ name: 'open', permission: '', max: 1 }, { name: 'free', permission: 'kits.free', max: 0 }])
const open = await weekend.build({ inputs: { kit: 'main/open', minutes: 60 } })
assert.deepStrictEqual(open.spec.phases[0].steps.map((s) => s.actionId), ['rust.kit.entitle'])
assert.ok(!('message' in open.spec.phases[0].steps[0].params))
const free = await weekend.build({ inputs: { kit: 'main/free', minutes: 60 } })
assert.deepStrictEqual(free.spec.phases[0].steps.map((s) => s.actionId), ['core.lease'])
})
test('the kit weekend refuses what its steps would refuse, in the author\'s words', async (t) => {
weekendStubs(t)
assert.match((await weekend.build({ inputs: { kit: 'vip', minutes: 60 } })).error, /as server\/kit/)
assert.match((await weekend.build({ inputs: { kit: 'main/vip', minutes: 20000 } })).error, /1 to 10080 minutes/)
assert.match((await weekend.build({ inputs: { kit: 'main/vip', minutes: 60, delivery: 'shout', message: 'x' } })).error, /not to "shout"/)
assert.match((await weekend.build({ inputs: { kit: 'gone/vip', minutes: 60 } })).error, /gone/)
assert.match((await weekend.build({ inputs: { kit: 'main/nope', minutes: 60 } })).error, /no kit called "nope"/)
})
test('the template\'s inputs are params core will accept, and its minutes are a duration', () => {
for (const p of weekend.inputs) {
assert.ok(p.example !== undefined && p.example !== '', `${p.name} needs an example`)
}
assert.strictEqual(weekend.inputs.find((p) => p.name === 'minutes').duration, 'minutes')
assert.ok(/^rust\.[a-z0-9_]+\.[a-z0-9_]+$/.test(weekend.id))
})
test('the module\'s timed params say they are durations', () => {
const zone = require('../eventWorld').ACTIONS.find((a) => a.id === 'rust.zone.open')
const tally = rewards.ACTIONS.find((a) => a.id === 'rust.participation.open')
assert.strictEqual(zone.params.find((p) => p.name === 'minutes').duration, 'minutes')
assert.strictEqual(tally.params.find((p) => p.name === 'minutes').duration, 'minutes')
const groupLease = leases.LEASES.find((l) => l.id === 'rust.group.permission')
assert.strictEqual(groupLease.example, true)
})